diff --git a/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.html b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.html new file mode 100644 index 0000000000..804bb4f891 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.html @@ -0,0 +1,10 @@ +
+ +
+ +
+
diff --git a/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.spec.ts b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.spec.ts new file mode 100644 index 0000000000..c41351f380 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.spec.ts @@ -0,0 +1,84 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { BitstreamAuthorizationsComponent } from './bitstream-authorizations.component'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; + +describe('BitstreamAuthorizationsComponent', () => { + let comp: BitstreamAuthorizationsComponent; + let fixture: ComponentFixture>; + + const bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10000, + metadata: { + 'dc.title': [ + { + value: 'file name', + language: null + } + ] + }, + _links: { + content: { href: 'file-selflink' } + } + }); + + const bitstreamRD = createSuccessfulRemoteDataObject(bitstream); + + const routeStub = { + data: observableOf({ + bitstream: bitstreamRD + }) + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [BitstreamAuthorizationsComponent], + providers: [ + { provide: ActivatedRoute, useValue: routeStub }, + ChangeDetectorRef, + BitstreamAuthorizationsComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamAuthorizationsComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + fixture.destroy(); + }); + + it('should create', () => { + expect(comp).toBeTruthy(); + }); + + it('should init dso remote data properly', (done) => { + const expected = cold('(a|)', { a: bitstreamRD }); + expect(comp.dsoRD$).toBeObservable(expected); + done(); + }); +}); diff --git a/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts new file mode 100644 index 0000000000..adc0638780 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-collection-authorizations', + templateUrl: './bitstream-authorizations.component.html', +}) +/** + * Component that handles the Collection Authorizations + */ +export class BitstreamAuthorizationsComponent implements OnInit { + + /** + * The initial DSO object + */ + public dsoRD$: Observable>; + + /** + * Initialize instance variables + * + * @param {ActivatedRoute} route + */ + constructor( + private route: ActivatedRoute + ) { + } + + /** + * Initialize the component, setting up the collection + */ + ngOnInit(): void { + this.dsoRD$ = this.route.data.pipe(first(), map((data) => data.bitstream)); + } +} diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts index bbbd65f279..284f29f7b4 100644 --- a/src/app/+bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -4,8 +4,14 @@ import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { BitstreamPageResolver } from './bitstream-page.resolver'; import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component'; +import { ResourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { ResourcePolicyCreateComponent } from '../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/resource-policy.resolver'; +import { ResourcePolicyEditComponent } from '../shared/resource-policies/edit/resource-policy-edit.component'; +import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; const EDIT_BITSTREAM_PATH = ':id/edit'; +const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; /** * Routing module to help navigate Bitstream pages @@ -27,6 +33,36 @@ const EDIT_BITSTREAM_PATH = ':id/edit'; bitstream: BitstreamPageResolver }, canActivate: [AuthenticatedGuard] + }, + { + path: EDIT_BITSTREAM_AUTHORIZATIONS_PATH, + + children: [ + { + path: 'create', + resolve: { + resourcePolicyTarget: ResourcePolicyTargetResolver + }, + component: ResourcePolicyCreateComponent, + data: { title: 'resource-policies.create.page.title', showBreadcrumbs: true } + }, + { + path: 'edit', + resolve: { + resourcePolicy: ResourcePolicyResolver + }, + component: ResourcePolicyEditComponent, + data: { title: 'resource-policies.edit.page.title', showBreadcrumbs: true } + }, + { + path: '', + resolve: { + bitstream: BitstreamPageResolver + }, + component: BitstreamAuthorizationsComponent, + data: { title: 'bitstream.edit.authorizations.title', showBreadcrumbs: true } + } + ] } ]) ], diff --git a/src/app/+bitstream-page/bitstream-page.module.ts b/src/app/+bitstream-page/bitstream-page.module.ts index 24b4cd512f..80e5ad14e3 100644 --- a/src/app/+bitstream-page/bitstream-page.module.ts +++ b/src/app/+bitstream-page/bitstream-page.module.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '../shared/shared.module'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; +import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; /** * This module handles all components that are necessary for Bitstream related pages @@ -14,6 +15,7 @@ import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; BitstreamPageRoutingModule ], declarations: [ + BitstreamAuthorizationsComponent, EditBitstreamPageComponent ] }) diff --git a/src/app/+bitstream-page/bitstream-page.resolver.ts b/src/app/+bitstream-page/bitstream-page.resolver.ts index a876b22d5e..fd9d5b351b 100644 --- a/src/app/+bitstream-page/bitstream-page.resolver.ts +++ b/src/app/+bitstream-page/bitstream-page.resolver.ts @@ -35,7 +35,7 @@ export class BitstreamPageResolver implements Resolve> { */ get followLinks(): FollowLinkConfig[] { return [ - followLink('bundle', undefined, true, true, true, followLink('item')), + followLink('bundle', {}, followLink('item')), followLink('format') ]; } diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html index fd13e249a0..cbb587cca4 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -19,7 +19,11 @@ [submitLabel]="'form.save'" (submitForm)="onSubmit()" (cancel)="onCancel()" - (dfChange)="onChange($event)"> + (dfChange)="onChange($event)"> + + diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts index 2e7eb4e1d1..9c2cb3a093 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts @@ -18,12 +18,8 @@ import { hasValue } from '../../shared/empty.util'; import { FormControl, FormGroup } from '@angular/forms'; import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { VarDirective } from '../../shared/utils/var.directive'; -import { - createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ -} from '../../shared/remote-data.utils'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { getEntityEditRoute, getItemEditRoute } from '../../+item-page/item-page-routing-paths'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { getEntityEditRoute } from '../../+item-page/item-page-routing-paths'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { Item } from '../../core/shared/item.model'; @@ -39,7 +35,6 @@ let bitstream: Bitstream; let selectedFormat: BitstreamFormat; let allFormats: BitstreamFormat[]; let router: Router; -let routerStub; describe('EditBitstreamPageComponent', () => { let comp: EditBitstreamPageComponent; @@ -129,10 +124,6 @@ describe('EditBitstreamPageComponent', () => { findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)) }); - const itemPageUrl = `fake-url/some-uuid`; - routerStub = Object.assign(new RouterStub(), { - url: `${itemPageUrl}` - }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule], declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective], @@ -142,7 +133,6 @@ describe('EditBitstreamPageComponent', () => { { provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } }, { provide: BitstreamDataService, useValue: bitstreamService }, { provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, - { provide: Router, useValue: routerStub }, ChangeDetectorRef ], schemas: [NO_ERRORS_SCHEMA] @@ -154,7 +144,8 @@ describe('EditBitstreamPageComponent', () => { fixture = TestBed.createComponent(EditBitstreamPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); - router = (comp as any).router; + router = TestBed.inject(Router); + spyOn(router, 'navigate'); }); describe('on startup', () => { @@ -241,14 +232,14 @@ describe('EditBitstreamPageComponent', () => { it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => { comp.itemId = 'some-uuid1'; comp.navigateToItemEditBitstreams(); - expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']); + expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']); }); }); describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => { it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => { comp.itemId = undefined; comp.navigateToItemEditBitstreams(); - expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']); + expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']); }); }); }); diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts index 8a4d584647..4ad0aac7ef 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -19,10 +19,10 @@ import { cloneDeep } from 'lodash'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { getAllSucceededRemoteDataPayload, - getFirstSucceededRemoteDataPayload, - getRemoteDataPayload, + getFirstCompletedRemoteData, getFirstSucceededRemoteData, - getFirstCompletedRemoteData + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload } from '../../core/shared/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; @@ -131,15 +131,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { rows: 10 }); - /** - * The Dynamic Input Model for the file's embargo (disabled on this page) - */ - embargoModel = new DynamicInputModel({ - id: 'embargo', - name: 'embargo', - disabled: true - }); - /** * The Dynamic Input Model for the selected format */ @@ -159,7 +150,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { /** * All input models in a simple array for easier iterations */ - inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel]; + inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel, this.newFormatModel]; /** * The dynamic form fields used for editing the information of a bitstream @@ -179,12 +170,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { this.descriptionModel ] }), - new DynamicFormGroupModel({ - id: 'embargoContainer', - group: [ - this.embargoModel - ] - }), new DynamicFormGroupModel({ id: 'formatContainer', group: [ @@ -243,11 +228,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { host: 'row' } }, - embargoContainer: { - grid: { - host: 'row' - } - }, formatContainer: { grid: { host: 'row' diff --git a/src/app/+collection-page/collection-page.resolver.ts b/src/app/+collection-page/collection-page.resolver.ts index f6f87f117c..d476a180d3 100644 --- a/src/app/+collection-page/collection-page.resolver.ts +++ b/src/app/+collection-page/collection-page.resolver.ts @@ -14,7 +14,7 @@ import { ResolvedAction } from '../core/resolving/resolver.actions'; * Requesting them as embeds will limit the number of requests */ export const COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('parentCommunity', undefined, true, true, true, + followLink('parentCommunity', {}, followLink('parentCommunity') ), followLink('logo') diff --git a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.html b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.html index 81ba60e51b..4abb149498 100644 --- a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.html +++ b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.html @@ -6,11 +6,12 @@

{{ 'collection.delete.text' | translate:{ dso: dso.name } }}

- -
diff --git a/src/app/+community-page/delete-community-page/delete-community-page.component.html b/src/app/+community-page/delete-community-page/delete-community-page.component.html index 85aa8b1bce..658f3da436 100644 --- a/src/app/+community-page/delete-community-page/delete-community-page.component.html +++ b/src/app/+community-page/delete-community-page/delete-community-page.component.html @@ -6,11 +6,12 @@

{{ 'community.delete.text' | translate:{ dso: dso.name } }}

- -
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 fb193b24d4..76597a135b 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 @@ -4,10 +4,9 @@ import { ActivatedRoute } from '@angular/router'; import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators'; -import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; import { - getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteDataWithNotEmptyPayload + getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataWithNotEmptyPayload, } from '../../../core/shared/operators'; import { Item } from '../../../core/shared/item.model'; import { followLink } from '../../../shared/utils/follow-link-config.model'; @@ -15,7 +14,6 @@ import { LinkService } from '../../../core/cache/builders/link.service'; import { Bundle } from '../../../core/shared/bundle.model'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { Bitstream } from '../../../core/shared/bitstream.model'; -import { FindListOptions } from '../../../core/data/request.models'; /** * Interface for a bundle's bitstream map entry @@ -79,7 +77,7 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { getFirstSucceededRemoteDataWithNotEmptyPayload(), map((item: Item) => this.linkService.resolveLink( item, - followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')) + followLink('bundles', {}, followLink('bitstreams')) )) ) as Observable; diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.html b/src/app/+item-page/edit-item-page/item-move/item-move.component.html index 74ca9aae4e..f68cfb0685 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.html +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.html @@ -5,19 +5,16 @@

{{'item.edit.move.description' | translate}}

- - - +
+
{{'dso-selector.placeholder' | translate: { type: 'collection' } }}
+
+ + +
+
+
@@ -33,16 +30,24 @@
- - +
+
+ + + +
+
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 dd91c65e1e..d200891629 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 @@ -21,6 +21,8 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { RequestService } from '../../../core/data/request.service'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; describe('ItemMoveComponent', () => { let comp: ItemMoveComponent; @@ -47,18 +49,25 @@ describe('ItemMoveComponent', () => { name: 'Test collection 2' }); - const mockItemDataService = jasmine.createSpyObj({ - moveToCollection: createSuccessfulRemoteDataObject$(collection1) + let itemDataService; + + const mockItemDataServiceSuccess = jasmine.createSpyObj({ + moveToCollection: createSuccessfulRemoteDataObject$(collection1), + findById: createSuccessfulRemoteDataObject$(mockItem), }); const mockItemDataServiceFail = jasmine.createSpyObj({ - moveToCollection: createFailedRemoteDataObject$('Internal server error', 500) + moveToCollection: createFailedRemoteDataObject$('Internal server error', 500), + findById: createSuccessfulRemoteDataObject$(mockItem), }); const routeStub = { data: observableOf({ dso: createSuccessfulRemoteDataObject(Object.assign(new Item(), { - id: 'item1' + id: 'item1', + owningCollection: createSuccessfulRemoteDataObject$(Object.assign(new Collection(), { + id: 'originalOwningCollection', + })) })) }) }; @@ -79,43 +88,40 @@ describe('ItemMoveComponent', () => { const notificationsServiceStub = new NotificationsServiceStub(); + const init = (mockItemDataService) => { + itemDataService = mockItemDataService; + + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], + declarations: [ItemMoveComponent], + providers: [ + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: SearchService, useValue: mockSearchService }, + { provide: RequestService, useValue: getMockRequestService() }, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + fixture = TestBed.createComponent(ItemMoveComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }; + describe('ItemMoveComponent success', () => { beforeEach(() => { - TestBed.configureTestingModule({ - imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [ItemMoveComponent], - providers: [ - { provide: ActivatedRoute, useValue: routeStub }, - { provide: Router, useValue: routerStub }, - { provide: ItemDataService, useValue: mockItemDataService }, - { provide: NotificationsService, useValue: notificationsServiceStub }, - { provide: SearchService, useValue: mockSearchService }, - ], schemas: [ - CUSTOM_ELEMENTS_SCHEMA - ] - }).compileComponents(); - fixture = TestBed.createComponent(ItemMoveComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); + init(mockItemDataServiceSuccess); }); - it('should load suggestions', () => { - const expected = [ - collection1, - collection2 - ]; - comp.collectionSearchResults.subscribe((value) => { - expect(value).toEqual(expected); - } - ); - }); it('should get current url ', () => { expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit'); }); - it('should on click select the correct collection name and id', () => { + it('should select the correct collection name and id on click', () => { const data = collection1; - comp.onClick(data); + comp.selectDso(data); expect(comp.selectedCollectionName).toEqual('Test collection 1'); expect(comp.selectedCollection).toEqual(collection1); @@ -128,12 +134,12 @@ describe('ItemMoveComponent', () => { }); comp.selectedCollectionName = 'selected-collection-id'; comp.selectedCollection = collection1; - comp.moveCollection(); + comp.moveToCollection(); - expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1); + expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1); }); it('should call notificationsService success message on success', () => { - comp.moveCollection(); + comp.moveToCollection(); expect(notificationsServiceStub.success).toHaveBeenCalled(); }); @@ -142,26 +148,11 @@ describe('ItemMoveComponent', () => { describe('ItemMoveComponent fail', () => { beforeEach(() => { - TestBed.configureTestingModule({ - imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [ItemMoveComponent], - providers: [ - { provide: ActivatedRoute, useValue: routeStub }, - { provide: Router, useValue: routerStub }, - { provide: ItemDataService, useValue: mockItemDataServiceFail }, - { provide: NotificationsService, useValue: notificationsServiceStub }, - { provide: SearchService, useValue: mockSearchService }, - ], schemas: [ - CUSTOM_ELEMENTS_SCHEMA - ] - }).compileComponents(); - fixture = TestBed.createComponent(ItemMoveComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); + init(mockItemDataServiceFail); }); it('should call notificationsService error message on fail', () => { - comp.moveCollection(); + comp.moveToCollection(); expect(notificationsServiceStub.error).toHaveBeenCalled(); }); 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 b1ed121b40..b7ab761fe5 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 @@ -1,25 +1,21 @@ import { Component, OnInit } from '@angular/core'; -import { first, map } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { RemoteData } from '../../../core/data/remote-data'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { PaginatedList } from '../../../core/data/paginated-list.model'; import { Item } from '../../../core/shared/item.model'; import { ActivatedRoute, Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { - getFirstSucceededRemoteData, - getFirstCompletedRemoteData, getAllSucceededRemoteDataPayload + getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload, } from '../../../core/shared/operators'; import { ItemDataService } from '../../../core/data/item-data.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs'; import { Collection } from '../../../core/shared/collection.model'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SearchService } from '../../../core/shared/search/search.service'; -import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; -import { SearchResult } from '../../../shared/search/search-result.model'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { RequestService } from '../../../core/data/request.service'; @Component({ selector: 'ds-item-move', @@ -38,7 +34,8 @@ export class ItemMoveComponent implements OnInit { inheritPolicies = false; itemRD$: Observable>; - collectionSearchResults: Observable = observableOf([]); + originalCollection: Collection; + selectedCollectionName: string; selectedCollection: Collection; canSubmit = false; @@ -46,23 +43,26 @@ export class ItemMoveComponent implements OnInit { item: Item; processing = false; - pagination = new PaginationComponentOptions(); - /** * Route to the item's page */ itemPageRoute$: Observable; + COLLECTIONS = [DSpaceObjectType.COLLECTION]; + constructor(private route: ActivatedRoute, private router: Router, private notificationsService: NotificationsService, private itemDataService: ItemDataService, private searchService: SearchService, - private translateService: TranslateService) { - } + private translateService: TranslateService, + private requestService: RequestService, + ) {} ngOnInit(): void { - this.itemRD$ = this.route.data.pipe(map((data) => data.dso), getFirstSucceededRemoteData()) as Observable>; + this.itemRD$ = this.route.data.pipe( + map((data) => data.dso), getFirstSucceededRemoteData() + ) as Observable>; this.itemPageRoute$ = this.itemRD$.pipe( getAllSucceededRemoteDataPayload(), map((item) => getItemPageRoute(item)) @@ -71,43 +71,22 @@ export class ItemMoveComponent implements OnInit { this.item = rd.payload; } ); - this.pagination.pageSize = 5; - this.loadSuggestions(''); - } - - /** - * Find suggestions based on entered query - * @param query - Search query - */ - findSuggestions(query): void { - this.loadSuggestions(query); - } - - /** - * Load all available collections to move the item to. - * TODO: When the API support it, only fetch collections where user has ADD rights to. - */ - loadSuggestions(query): void { - this.collectionSearchResults = this.searchService.search(new PaginatedSearchOptions({ - pagination: this.pagination, - dsoTypes: [DSpaceObjectType.COLLECTION], - query: query - })).pipe( - first(), - map((rd: RemoteData>>) => { - return rd.payload.page.map((searchResult) => { - return searchResult.indexableObject; - }); - }) , - ); - + this.itemRD$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((item) => item.owningCollection), + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ).subscribe((collection) => { + this.originalCollection = collection; + }); } /** * Set the collection name and id based on the selected value * @param data - obtained from the ds-input-suggestions component */ - onClick(data: any): void { + selectDso(data: any): void { this.selectedCollection = data; this.selectedCollectionName = data.name; this.canSubmit = true; @@ -123,26 +102,41 @@ export class ItemMoveComponent implements OnInit { /** * Moves the item to a new collection based on the selected collection */ - moveCollection() { + moveToCollection() { this.processing = true; - this.itemDataService.moveToCollection(this.item.id, this.selectedCollection).pipe(getFirstCompletedRemoteData()).subscribe( - (response: RemoteData) => { - this.router.navigate([getItemEditRoute(this.item)]); - if (response.hasSucceeded) { - this.notificationsService.success(this.translateService.get('item.edit.move.success')); - } else { - this.notificationsService.error(this.translateService.get('item.edit.move.error')); - } - this.processing = false; + const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection) + .pipe(getFirstCompletedRemoteData()); + + move$.subscribe((response: RemoteData) => { + if (response.hasSucceeded) { + this.notificationsService.success(this.translateService.get('item.edit.move.success')); + } else { + this.notificationsService.error(this.translateService.get('item.edit.move.error')); } - ); + }); + + move$.pipe( + switchMap(() => this.requestService.setStaleByHrefSubstring(this.item.id)), + switchMap(() => + this.itemDataService.findById( + this.item.id, + false, + true, + followLink('owningCollection') + )), + getFirstCompletedRemoteData() + ).subscribe(() => { + this.processing = false; + this.router.navigate([getItemEditRoute(this.item)]); + }); } - /** - * Resets the can submit when the user changes the content of the input field - * @param data - */ - resetCollection(data: any) { + discard(): void { + this.selectedCollection = null; this.canSubmit = false; } + + get canMove(): boolean { + return this.canSubmit && this.selectedCollection?.id !== this.originalCollection.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 5583de5fd5..2185108c8f 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 @@ -6,21 +6,30 @@ - + - +
+ - -
{{"item.edit.relationships.no-relationships" | translate}}
+ +
+ +
{{"item.edit.relationships.no-relationships" | translate}}
- +
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 90fad00a45..6742234058 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 @@ -16,6 +16,12 @@ import { SharedModule } from '../../../../shared/shared.module'; import { EditRelationshipListComponent } from './edit-relationship-list.component'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { HostWindowService } from '../../../../shared/host-window.service'; +import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; let comp: EditRelationshipListComponent; let fixture: ComponentFixture; @@ -25,6 +31,8 @@ let linkService; let objectUpdatesService; let relationshipService; let selectableListService; +let paginationService; +let hostWindowService; const url = 'http://test-url.com/test-url'; @@ -37,9 +45,21 @@ let fieldUpdate1; let fieldUpdate2; let relationships; let relationshipType; +let paginationOptions; describe('EditRelationshipListComponent', () => { + const resetComponent = () => { + fixture = TestBed.createComponent(EditRelationshipListComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + comp.item = item; + comp.itemType = entityType; + comp.url = url; + comp.relationshipType = relationshipType; + fixture.detectChanges(); + }; + beforeEach(waitForAsync(() => { entityType = Object.assign(new ItemType(), { @@ -63,6 +83,12 @@ describe('EditRelationshipListComponent', () => { rightwardType: 'isPublicationOfAuthor', }); + paginationOptions = Object.assign(new PaginationComponentOptions(), { + id: `er${relationshipType.id}`, + pageSize: 5, + currentPage: 1, + }); + author1 = Object.assign(new Item(), { id: 'author1', uuid: 'author1' @@ -141,6 +167,10 @@ describe('EditRelationshipListComponent', () => { resolveLinks: () => null, }; + paginationService = new PaginationServiceStub(paginationOptions); + + hostWindowService = new HostWindowServiceStub(1200); + TestBed.configureTestingModule({ imports: [SharedModule, TranslateModule.forRoot()], declarations: [EditRelationshipListComponent], @@ -149,22 +179,15 @@ describe('EditRelationshipListComponent', () => { { provide: RelationshipService, useValue: relationshipService }, { provide: SelectableListService, useValue: selectableListService }, { provide: LinkService, useValue: linkService }, + { provide: PaginationService, useValue: paginationService }, + { provide: HostWindowService, useValue: hostWindowService }, ], 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(); - }); + resetComponent(); + })); describe('changeType is REMOVE', () => { beforeEach(() => { @@ -176,4 +199,82 @@ describe('EditRelationshipListComponent', () => { expect(element.classList).toContain('alert-danger'); }); }); + + describe('pagination component', () => { + let paginationComp: PaginationComponent; + + beforeEach(() => { + paginationComp = de.query(By.css('ds-pagination')).componentInstance; + }); + + it('should receive the correct pagination config', () => { + expect(paginationComp.paginationOptions).toEqual(paginationOptions); + }); + + it('should receive correct collection size', () => { + expect(paginationComp.collectionSize).toEqual(relationships.length); + }); + + }); + + describe('relationshipService.getItemRelationshipsByLabel', () => { + it('should receive the correct pagination info', () => { + expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1); + + const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args; + const findListOptions = callArgs[2]; + + expect(findListOptions.elementsPerPage).toEqual(paginationOptions.pageSize); + expect(findListOptions.currentPage).toEqual(paginationOptions.currentPage); + }); + + describe('when the publication is on the left side of the relationship', () => { + beforeEach(() => { + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftType: createSuccessfulRemoteDataObject$(entityType), // publication + rightType: createSuccessfulRemoteDataObject$(relatedEntityType), // author + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor', + }); + relationshipService.getItemRelationshipsByLabel.calls.reset(); + resetComponent(); + }); + + it('should fetch isAuthorOfPublication', () => { + expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1); + + const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args; + const label = callArgs[1]; + + expect(label).toEqual('isAuthorOfPublication'); + }); + }); + + describe('when the publication is on the right side of the relationship', () => { + beforeEach(() => { + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftType: createSuccessfulRemoteDataObject$(relatedEntityType), // author + rightType: createSuccessfulRemoteDataObject$(entityType), // publication + leftwardType: 'isPublicationOfAuthor', + rightwardType: 'isAuthorOfPublication', + }); + relationshipService.getItemRelationshipsByLabel.calls.reset(); + resetComponent(); + }); + + it('should fetch isAuthorOfPublication', () => { + expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1); + + const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args; + const label = callArgs[1]; + + expect(label).toEqual('isAuthorOfPublication'); + }); + }); + }); + }); 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 3f9637cdc9..9f417ab799 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,9 +1,14 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, OnDestroy } 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 { combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + from as observableFrom +} from 'rxjs'; import { FieldUpdate, FieldUpdates, @@ -11,14 +16,24 @@ import { } from '../../../../core/data/object-updates/object-updates.reducer'; import { RelationshipService } from '../../../../core/data/relationship.service'; import { Item } from '../../../../core/shared/item.model'; -import { defaultIfEmpty, map, mergeMap, switchMap, take, startWith } from 'rxjs/operators'; -import { hasValue, hasValueOperator } from '../../../../shared/empty.util'; +import { + defaultIfEmpty, + map, + mergeMap, + switchMap, + take, + startWith, + toArray, + tap +} from 'rxjs/operators'; +import { hasValue, hasValueOperator, hasNoValue } from '../../../../shared/empty.util'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { - getAllSucceededRemoteData, getRemoteDataPayload, - getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, + getAllSucceededRemoteData, } from '../../../../core/shared/operators'; 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'; @@ -30,6 +45,10 @@ import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { Collection } from '../../../../core/shared/collection.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; @Component({ selector: 'ds-edit-relationship-list', @@ -40,7 +59,7 @@ import { Collection } from '../../../../core/shared/collection.model'; * A component creating a list of editable relationships of a certain type * The relationships are rendered as a list of related items */ -export class EditRelationshipListComponent implements OnInit { +export class EditRelationshipListComponent implements OnInit, OnDestroy { /** * The item to display related items for @@ -60,6 +79,17 @@ export class EditRelationshipListComponent implements OnInit { */ @Input() relationshipType: RelationshipType; + /** + * Observable that emits the left and right item type of {@link relationshipType} simultaneously. + */ + private relationshipLeftAndRightType$: Observable<[ItemType, ItemType]>; + + /** + * Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType}, + * false if it is on the right-hand side and undefined in the rare case that it is on neither side. + */ + private currentItemIsLeftItem$: Observable; + private relatedEntityType$: Observable; /** @@ -70,7 +100,38 @@ export class EditRelationshipListComponent implements OnInit { /** * The FieldUpdates for the relationships in question */ - updates$: Observable; + updates$: BehaviorSubject = new BehaviorSubject(undefined); + + /** + * The RemoteData for the relationships + */ + relationshipsRd$: BehaviorSubject>> = new BehaviorSubject(undefined); + + /** + * Whether the current page is the last page + */ + isLastPage$: BehaviorSubject = new BehaviorSubject(true); + + /** + * Whether we're loading + */ + loading$: BehaviorSubject = new BehaviorSubject(true); + + /** + * The number of added fields that haven't been saved yet + */ + nbAddedFields$: BehaviorSubject = new BehaviorSubject(0); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * The pagination config + */ + paginationConfig: PaginationComponentOptions; /** * A reference to the lookup window @@ -82,6 +143,7 @@ export class EditRelationshipListComponent implements OnInit { protected linkService: LinkService, protected relationshipService: RelationshipService, protected modalService: NgbModal, + protected paginationService: PaginationService, protected selectableListService: SelectableListService, ) { } @@ -172,6 +234,10 @@ export class EditRelationshipListComponent implements OnInit { this.objectUpdatesService.saveAddFieldUpdate(this.url, update); }); } + + this.loading$.next(true); + // emit the last page again to trigger a fieldupdates refresh + this.relationshipsRd$.next(this.relationshipsRd$.getValue()); }); }); }; @@ -186,6 +252,10 @@ export class EditRelationshipListComponent implements OnInit { ) ); }); + + this.loading$.next(true); + // emit the last page again to trigger a fieldupdates refresh + this.relationshipsRd$.next(this.relationshipsRd$.getValue()); }; this.relatedEntityType$ .pipe(take(1)) @@ -212,10 +282,10 @@ export class EditRelationshipListComponent implements OnInit { if (field.relationship) { return this.getRelatedItem(field.relationship); } else { - return of(field.relatedItem); + return observableOf(field.relatedItem); } }) - ) : of([]) + ) : observableOf([]) ), take(1), map((items) => items.map((item) => { @@ -267,18 +337,19 @@ export class EditRelationshipListComponent implements OnInit { } ngOnInit(): void { + // store the left and right type of the relationship in a single observable + this.relationshipLeftAndRightType$ = observableCombineLatest([ + this.relationshipType.leftType, + this.relationshipType.rightType, + ].map((type) => type.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ))) as Observable<[ItemType, ItemType]>; - this.relatedEntityType$ = - observableCombineLatest([ - this.relationshipType.leftType, - this.relationshipType.rightType, - ].map((type) => type.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ))).pipe( - map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)), - hasValueOperator() - ); + this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe( + map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)), + hasValueOperator() + ); this.relatedEntityType$.pipe( take(1) @@ -286,65 +357,142 @@ export class EditRelationshipListComponent implements OnInit { (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; + this.currentItemIsLeftItem$ = this.relationshipLeftAndRightType$.pipe( + map(([leftType, rightType]: [ItemType, ItemType]) => { + if (leftType.id === this.itemType.id) { + return true; + } + + if (rightType.id === this.itemType.id) { + return false; + } + + // should never happen... + console.warn(`The item ${this.item.uuid} is not on the right or the left side of relationship type ${this.relationshipType.uuid}`); + return undefined; + }) + ); + + // initialize the pagination options + this.paginationConfig = new PaginationComponentOptions(); + this.paginationConfig.id = `er${this.relationshipType.id}`; + this.paginationConfig.pageSize = 5; + this.paginationConfig.currentPage = 1; + + // get the pagination params from the route + const currentPagination$ = this.paginationService.getCurrentPagination( + this.paginationConfig.id, + this.paginationConfig + ).pipe( + tap(() => this.loading$.next(true)) + ); + + this.subs.push( + observableCombineLatest([ + currentPagination$, + this.currentItemIsLeftItem$, + ]).pipe( + switchMap(([currentPagination, currentItemIsLeftItem]: [PaginationComponentOptions, boolean]) => + // get the relationships for the current item, relationshiptype and page + this.relationshipService.getItemRelationshipsByLabel( + this.item, + currentItemIsLeftItem ? this.relationshipType.leftwardType : this.relationshipType.rightwardType, + { + elementsPerPage: currentPagination.pageSize, + currentPage: currentPagination.currentPage, + }, + false, + true, + followLink('leftItem'), + followLink('rightItem'), + )), + ).subscribe((rd: RemoteData>) => { + this.relationshipsRd$.next(rd); + }) + ); + + // keep isLastPage$ up to date based on relationshipsRd$ + this.subs.push(this.relationshipsRd$.pipe( + hasValueOperator(), + getAllSucceededRemoteData() + ).subscribe((rd: RemoteData>) => { + this.isLastPage$.next(hasNoValue(rd.payload._links.next)); + })); + + this.subs.push(this.relationshipsRd$.pipe( + hasValueOperator(), + getAllSucceededRemoteData(), + switchMap((rd: RemoteData>) => + // emit each relationship in the page separately + observableFrom(rd.payload.page).pipe( + mergeMap((relationship: Relationship) => + // check for each relationship whether it's the left item + this.relationshipService.isLeftItem(relationship, this.item).pipe( + // emit an array containing both the relationship and whether it's the left item, + // as we'll need both + map((isLeftItem: boolean) => [relationship, isLeftItem]) + ) + ), + map(([relationship, isLeftItem]: [Relationship, boolean]) => { + // turn it into a RelationshipIdentifiable, an + 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) => { - if (hasValue(fieldUpdates[uuid])) { - const field = fieldUpdates[uuid].field; - if ((field as RelationshipIdentifiable).type.id === this.relationshipType.id) { - fieldUpdatesFiltered[uuid] = fieldUpdates[uuid]; - } - } - }); - return fieldUpdatesFiltered; - }), + }), + // wait until all relationships have been processed, and emit them all as a single array + toArray(), + // if the pipe above completes without emitting anything, emit an empty array instead + defaultIfEmpty([]) )), + switchMap((nextFields: RelationshipIdentifiable[]) => { + // Get a list that contains the unsaved changes for the page, as well as the page of + // RelationshipIdentifiables, as a single list of FieldUpdates + return this.objectUpdatesService.getFieldUpdates(this.url, nextFields).pipe( + map((fieldUpdates: FieldUpdates) => { + const fieldUpdatesFiltered: FieldUpdates = {}; + this.nbAddedFields$.next(0); + // iterate over the fieldupdates and filter out the ones that pertain to this + // relationshiptype + Object.keys(fieldUpdates).forEach((uuid) => { + if (hasValue(fieldUpdates[uuid])) { + const field = fieldUpdates[uuid].field as RelationshipIdentifiable; + // only include fieldupdates regarding this RelationshipType + if (field.type.id === this.relationshipType.id) { + // if it's a newly added relationship + if (fieldUpdates[uuid].changeType === FieldChangeType.ADD) { + // increase the counter that tracks new relationships + this.nbAddedFields$.next(this.nbAddedFields$.getValue() + 1); + if (this.isLastPage$.getValue() === true) { + // only include newly added relationships to the output if we're on the last + // page + fieldUpdatesFiltered[uuid] = fieldUpdates[uuid]; + } + } else { + // include all others + fieldUpdatesFiltered[uuid] = fieldUpdates[uuid]; + } + } + } + }); + return fieldUpdatesFiltered; + }), + ); + }), startWith({}), - ); + ).subscribe((updates: FieldUpdates) => { + this.loading$.next(false); + this.updates$.next(updates); + })); } - private getItemRelationships() { - this.linkService.resolveLink(this.item, - followLink('relationships', undefined, true, true, true, - followLink('relationshipType'), - followLink('leftItem'), - followLink('rightItem'), - )); - return this.item.relationships.pipe( - getAllSucceededRemoteData(), - map((relationships: RemoteData>) => relationships.payload.page.filter((relationship: Relationship) => hasValue(relationship))), - switchMap((itemRelationships: Relationship[]) => - observableCombineLatest( - itemRelationships - .map((relationship) => relationship.relationshipType.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - )) - ).pipe( - defaultIfEmpty([]), - map((relationshipTypes) => itemRelationships.filter( - (relationship, index) => relationshipTypes[index].id === this.relationshipType.id) - ), - ) - ), - ); + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); } } diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index 1c5ed3c02b..e22ad8ddcb 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -227,7 +227,6 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { * Sends all initial values of this item to the object updates service */ public initializeOriginalFields() { - console.log('init'); return this.relationshipService.getRelatedItems(this.item).pipe( take(1), ).subscribe((items: Item[]) => { diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts index 214484120e..e21c1a32eb 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts @@ -68,7 +68,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On {elementsPerPage: options.pageSize, currentPage: options.currentPage}, true, true, - followLink('format') + followLink('format'), + followLink('thumbnail'), )), tap((rd: RemoteData>) => { if (hasValue(rd.errorMessage)) { @@ -85,7 +86,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On {elementsPerPage: options.pageSize, currentPage: options.currentPage}, true, true, - followLink('format') + followLink('format'), + followLink('thumbnail'), )), tap((rd: RemoteData>) => { if (hasValue(rd.errorMessage)) { diff --git a/src/app/+item-page/item.resolver.ts b/src/app/+item-page/item.resolver.ts index 99b96511fe..ca6a6c5958 100644 --- a/src/app/+item-page/item.resolver.ts +++ b/src/app/+item-page/item.resolver.ts @@ -5,7 +5,6 @@ import { RemoteData } from '../core/data/remote-data'; import { ItemDataService } from '../core/data/item-data.service'; import { Item } from '../core/shared/item.model'; import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; -import { FindListOptions } from '../core/data/request.models'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { Store } from '@ngrx/store'; import { ResolvedAction } from '../core/resolving/resolver.actions'; @@ -15,13 +14,13 @@ import { ResolvedAction } from '../core/resolving/resolver.actions'; * Requesting them as embeds will limit the number of requests */ export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('owningCollection', undefined, true, true, true, - followLink('parentCommunity', undefined, true, true, true, + followLink('owningCollection', {}, + followLink('parentCommunity', {}, followLink('parentCommunity')) ), - followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')), followLink('relationships'), - followLink('version', undefined, true, true, true, followLink('versionhistory')), + followLink('version', {}, followLink('versionhistory')), + followLink('thumbnail') ]; /** diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.html b/src/app/+item-page/simple/item-types/publication/publication.component.html index a5282bfa7f..9e61f00e48 100644 --- a/src/app/+item-page/simple/item-types/publication/publication.component.html +++ b/src/app/+item-page/simple/item-types/publication/publication.component.html @@ -10,7 +10,7 @@
- + diff --git a/src/app/+item-page/simple/item-types/shared/item.component.ts b/src/app/+item-page/simple/item-types/shared/item.component.ts index 130f67edc7..793af180c9 100644 --- a/src/app/+item-page/simple/item-types/shared/item.component.ts +++ b/src/app/+item-page/simple/item-types/shared/item.component.ts @@ -1,12 +1,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { environment } from '../../../../../environments/environment'; -import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; -import { takeUntilCompletedRemoteData } from '../../../../core/shared/operators'; import { getItemPageRoute } from '../../../item-page-routing-paths'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; -import { RemoteData } from '../../../../core/data/remote-data'; @Component({ selector: 'ds-item', @@ -18,28 +13,14 @@ import { RemoteData } from '../../../../core/data/remote-data'; export class ItemComponent implements OnInit { @Input() object: Item; - /** - * The Item's thumbnail - */ - thumbnail$: BehaviorSubject>; - /** * Route to the item page */ itemPageRoute: string; - mediaViewer = environment.mediaViewer; - constructor(protected bitstreamDataService: BitstreamDataService) { - } + mediaViewer = environment.mediaViewer; ngOnInit(): void { this.itemPageRoute = getItemPageRoute(this.object); - - this.thumbnail$ = new BehaviorSubject>(undefined); - this.bitstreamDataService.getThumbnailFor(this.object).pipe( - takeUntilCompletedRemoteData(), - ).subscribe((rd: RemoteData) => { - this.thumbnail$.next(rd); - }); } } diff --git a/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html index bacffbf526..b0157dcfee 100644 --- a/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -10,7 +10,7 @@
- + diff --git a/src/app/+search-page/search.component.ts b/src/app/+search-page/search.component.ts index b817c82a57..c1caf27b9a 100644 --- a/src/app/+search-page/search.component.ts +++ b/src/app/+search-page/search.component.ts @@ -16,9 +16,11 @@ import { SearchResult } from '../shared/search/search-result.model'; import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; import { SearchService } from '../core/shared/search/search.service'; import { currentPath } from '../shared/utils/route.utils'; -import { Router} from '@angular/router'; +import { Router } from '@angular/router'; import { Context } from '../core/shared/context.model'; import { SortOptions } from '../core/cache/models/sort-options.model'; +import { followLink } from '../shared/utils/follow-link-config.model'; +import { Item } from '../core/shared/item.model'; @Component({ selector: 'ds-search', @@ -128,8 +130,11 @@ export class SearchComponent implements OnInit { this.searchLink = this.getSearchLink(); this.searchOptions$ = this.getSearchOptions(); this.sub = this.searchOptions$.pipe( - switchMap((options) => this.service.search(options).pipe(getFirstSucceededRemoteData(), startWith(undefined)))) - .subscribe((results) => { + switchMap((options) => this.service.search( + options, undefined, true, true, followLink('thumbnail', { isOptional: true }) + ).pipe(getFirstSucceededRemoteData(), startWith(undefined)) + ) + ).subscribe((results) => { this.resultsRD$.next(results); }); this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index cbf70ca39a..e882ae5902 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -174,8 +174,8 @@ export class CommunityListService { direction: options.sort.direction } }, - followLink('subcommunities', this.configOnePage, true, true), - followLink('collections', this.configOnePage, true, true)) + followLink('subcommunities', { findListOptions: this.configOnePage }), + followLink('collections', { findListOptions: this.configOnePage })) .pipe( getFirstSucceededRemoteData(), map((results) => results.payload), @@ -242,8 +242,8 @@ export class CommunityListService { elementsPerPage: MAX_COMCOLS_PER_PAGE, currentPage: i }, - followLink('subcommunities', this.configOnePage, true, true), - followLink('collections', this.configOnePage, true, true)) + followLink('subcommunities', { findListOptions: this.configOnePage }), + followLink('collections', { findListOptions: this.configOnePage })) .pipe( getFirstCompletedRemoteData(), switchMap((rd: RemoteData>) => { diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index f80be89034..e2cef3562f 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -292,10 +292,13 @@ export class ResetAuthenticationMessagesAction implements Action { export class RetrieveAuthMethodsAction implements Action { public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS; - payload: AuthStatus; + payload: { + status: AuthStatus; + blocking: boolean; + }; - constructor(authStatus: AuthStatus) { - this.payload = authStatus; + constructor(status: AuthStatus, blocking: boolean) { + this.payload = { status, blocking }; } } @@ -306,10 +309,14 @@ export class RetrieveAuthMethodsAction implements Action { */ export class RetrieveAuthMethodsSuccessAction implements Action { public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS; - payload: AuthMethod[]; - constructor(authMethods: AuthMethod[] ) { - this.payload = authMethods; + payload: { + authMethods: AuthMethod[]; + blocking: boolean; + }; + + constructor(authMethods: AuthMethod[], blocking: boolean ) { + this.payload = { authMethods, blocking }; } } @@ -320,6 +327,12 @@ export class RetrieveAuthMethodsSuccessAction implements Action { */ export class RetrieveAuthMethodsErrorAction implements Action { public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR; + + payload: boolean; + + constructor(blocking: boolean) { + this.payload = blocking; + } } /** diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 5d530f39a9..cd4f456b44 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -43,10 +43,12 @@ describe('AuthEffects', () => { let initialState; let token; let store: MockStore; + let authStatus; function init() { authServiceStub = new AuthServiceStub(); token = authServiceStub.getToken(); + authStatus = Object.assign(new AuthStatus(), {}); initialState = { core: { auth: { @@ -217,16 +219,38 @@ describe('AuthEffects', () => { expect(authEffects.checkTokenCookie$).toBeObservable(expected); }); - it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { - spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( - observableOf( - { authenticated: false }) - ); - actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + describe('on CSR', () => { + it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { authenticated: false }) + ); + spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue( + new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); - const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) }); + const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false) }); - expect(authEffects.checkTokenCookie$).toBeObservable(expected); + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + }); + + describe('on SSR', () => { + it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { authenticated: false }) + ); + spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue( + new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true) }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); }); }); @@ -359,27 +383,74 @@ describe('AuthEffects', () => { describe('retrieveMethods$', () => { - describe('when retrieve authentication methods succeeded', () => { - it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { - actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + describe('on CSR', () => { + describe('when retrieve authentication methods succeeded', () => { + it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { + actions = hot('--a-', { a: + { + type: AuthActionTypes.RETRIEVE_AUTH_METHODS, + payload: { status: authStatus, blocking: false} + } + }); - const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) }); + const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, false) }); - expect(authEffects.retrieveMethods$).toBeObservable(expected); + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }); + + describe('when retrieve authentication methods failed', () => { + it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { + spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); + + actions = hot('--a-', { a: + { + type: AuthActionTypes.RETRIEVE_AUTH_METHODS, + payload: { status: authStatus, blocking: false} + } + }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(false) }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); }); }); - describe('when retrieve authentication methods failed', () => { - it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { - spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); + describe('on SSR', () => { + describe('when retrieve authentication methods succeeded', () => { + it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { + actions = hot('--a-', { a: + { + type: AuthActionTypes.RETRIEVE_AUTH_METHODS, + payload: { status: authStatus, blocking: true} + } + }); - actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, true) }); - const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() }); + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }); - expect(authEffects.retrieveMethods$).toBeObservable(expected); + describe('when retrieve authentication methods failed', () => { + it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { + spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); + + actions = hot('--a-', { a: + { + type: AuthActionTypes.RETRIEVE_AUTH_METHODS, + payload: { status: authStatus, blocking: true} + } + }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(true) }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); }); }); + }); describe('clearInvalidTokenOnRehydrate$', () => { diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 9452af1fb8..2ef90dd76c 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -145,7 +145,7 @@ export class AuthEffects { if (response.authenticated) { return new RetrieveTokenAction(); } else { - return new RetrieveAuthMethodsAction(response); + return this.authService.getRetrieveAuthMethodsAction(response); } }), catchError((error) => observableOf(new AuthenticatedErrorAction(error))) @@ -234,10 +234,10 @@ export class AuthEffects { .pipe( ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), switchMap((action: RetrieveAuthMethodsAction) => { - return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) + return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload.status) .pipe( - map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), - catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) + map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels, action.payload.blocking)), + catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction(action.payload.blocking))) ); }) ); diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 4c6f1e2a25..914a1a152d 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -512,7 +512,7 @@ describe('authReducer', () => { loading: false, authMethods: [] }; - const action = new RetrieveAuthMethodsAction(new AuthStatus()); + const action = new RetrieveAuthMethodsAction(new AuthStatus(), true); const newState = authReducer(initialState, action); state = { authenticated: false, @@ -536,7 +536,7 @@ describe('authReducer', () => { new AuthMethod(AuthMethodType.Password), new AuthMethod(AuthMethodType.Shibboleth, 'location') ]; - const action = new RetrieveAuthMethodsSuccessAction(authMethods); + const action = new RetrieveAuthMethodsSuccessAction(authMethods, false); const newState = authReducer(initialState, action); state = { authenticated: false, @@ -548,7 +548,31 @@ describe('authReducer', () => { expect(newState).toEqual(state); }); - it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => { + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action with blocking as true', () => { + initialState = { + authenticated: false, + loaded: false, + blocking: true, + loading: true, + authMethods: [] + }; + const authMethods = [ + new AuthMethod(AuthMethodType.Password), + new AuthMethod(AuthMethodType.Shibboleth, 'location') + ]; + const action = new RetrieveAuthMethodsSuccessAction(authMethods, true); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + blocking: true, + loading: false, + authMethods: authMethods + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action ', () => { initialState = { authenticated: false, loaded: false, @@ -557,7 +581,7 @@ describe('authReducer', () => { authMethods: [] }; - const action = new RetrieveAuthMethodsErrorAction(); + const action = new RetrieveAuthMethodsErrorAction(false); const newState = authReducer(initialState, action); state = { authenticated: false, @@ -568,4 +592,25 @@ describe('authReducer', () => { }; expect(newState).toEqual(state); }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action with blocking as true', () => { + initialState = { + authenticated: false, + loaded: false, + blocking: true, + loading: true, + authMethods: [] + }; + + const action = new RetrieveAuthMethodsErrorAction(true); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + blocking: true, + loading: false, + authMethods: [new AuthMethod(AuthMethodType.Password)] + }; + expect(newState).toEqual(state); + }); }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 6d5635f263..dfe29a3ef2 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -10,6 +10,7 @@ import { RedirectWhenTokenExpiredAction, RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, SetRedirectUrlAction } from './auth.actions'; @@ -211,14 +212,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: return Object.assign({}, state, { loading: false, - blocking: false, - authMethods: (action as RetrieveAuthMethodsSuccessAction).payload + blocking: (action as RetrieveAuthMethodsSuccessAction).payload.blocking, + authMethods: (action as RetrieveAuthMethodsSuccessAction).payload.authMethods }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: return Object.assign({}, state, { loading: false, - blocking: false, + blocking: (action as RetrieveAuthMethodsErrorAction).payload, authMethods: [new AuthMethod(AuthMethodType.Password)] }); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index fa29f1bc36..ed4fca615c 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -35,6 +35,7 @@ import { AppState } from '../../app.reducer'; import { CheckAuthenticationTokenAction, ResetAuthenticationMessagesAction, + RetrieveAuthMethodsAction, SetRedirectUrlAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; @@ -518,4 +519,13 @@ export class AuthService { ); } + /** + * Return a new instance of RetrieveAuthMethodsAction + * + * @param authStatus The auth status + */ + getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction { + return new RetrieveAuthMethodsAction(authStatus, false); + } + } diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 9840b22267..cccc1490f8 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -10,6 +10,7 @@ import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { RemoteData } from '../data/remote-data'; +import { RetrieveAuthMethodsAction } from './auth.actions'; /** * The auth service. @@ -60,4 +61,13 @@ export class ServerAuthService extends AuthService { map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) ); } + + /** + * Return a new instance of RetrieveAuthMethodsAction + * + * @param authStatus The auth status + */ + getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction { + return new RetrieveAuthMethodsAction(authStatus, true); + } } diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index a6d9c59492..f567c39314 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -102,7 +102,7 @@ describe('LinkService', () => { describe('resolveLink', () => { describe(`when the linkdefinition concerns a single object`, () => { beforeEach(() => { - service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); + service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); }); it('should call dataservice.findByHref with the correct href and nested links', () => { expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor')); @@ -116,7 +116,7 @@ describe('LinkService', () => { propertyName: 'predecessor', isList: true }); - service.resolveLink(testModel, followLink('predecessor', { some: 'options ' } as any, true, true, true, followLink('successor'))); + service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); }); it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); @@ -124,7 +124,7 @@ describe('LinkService', () => { }); describe('either way', () => { beforeEach(() => { - result = service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); + result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); }); it('should call getLinkDefinition with the correct model and link', () => { @@ -149,7 +149,7 @@ describe('LinkService', () => { }); it('should throw an error', () => { expect(() => { - service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); + service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); }).toThrow(); }); }); @@ -160,7 +160,7 @@ describe('LinkService', () => { }); it('should throw an error', () => { expect(() => { - service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); + service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))); }).toThrow(); }); }); diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index 29f8633da5..66f91dbbd6 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -39,7 +39,7 @@ export class LinkService { */ public resolveLinks(model: T, ...linksToFollow: FollowLinkConfig[]): T { linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { - this.resolveLink(model, linkToFollow); + this.resolveLink(model, linkToFollow); }); return model; } @@ -55,9 +55,7 @@ export class LinkService { public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable> { const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); - if (hasNoValue(matchingLinkDef)) { - throw new Error(`followLink('${linkToFollow.name}') was used for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`); - } else { + if (hasValue(matchingLinkDef)) { const provider = this.getDataServiceFor(matchingLinkDef.resourceType); if (hasNoValue(provider)) { @@ -84,7 +82,10 @@ export class LinkService { throw e; } } + } else if (!linkToFollow.isOptional) { + throw new Error(`followLink('${linkToFollow.name}') was used as a required link for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`); } + return EMPTY; } diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index cb193724a7..0cb45733a6 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -523,7 +523,7 @@ describe('RemoteDataBuildService', () => { let paginatedLinksToFollow; beforeEach(() => { paginatedLinksToFollow = [ - followLink('page', undefined, true, true, true, ...linksToFollow), + followLink('page', {}, ...linksToFollow), ...linksToFollow ]; }); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 11815c133b..6b67549f2d 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -271,7 +271,7 @@ export class RemoteDataBuildService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ buildList(href$: string | Observable, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.buildFromHref>(href$, followLink('page', undefined, false, true, true, ...linksToFollow)); + return this.buildFromHref>(href$, followLink('page', { shouldEmbed: false }, ...linksToFollow)); } /** diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 615c2b3977..3c34e5ec35 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -162,6 +162,7 @@ import { UsageReport } from './statistics/models/usage-report.model'; import { RootDataService } from './data/root-data.service'; import { Root } from './data/root.model'; import { SearchConfig } from './shared/search/search-filters/search-config.model'; +import { SequenceService } from './shared/sequence.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -282,7 +283,8 @@ const PROVIDERS = [ FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory }, VocabularyService, - VocabularyTreeviewService + VocabularyTreeviewService, + SequenceService, ]; /** diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 1a16abc47f..3890f4e663 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { map, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; @@ -18,7 +18,7 @@ import { Item } from '../shared/item.model'; import { BundleDataService } from './bundle-data.service'; import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; +import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { FindListOptions, PutRequest } from './request.models'; import { RequestService } from './request.service'; @@ -28,7 +28,6 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { sendRequest } from '../shared/operators'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PageInfo } from '../shared/page-info.model'; -import { RequestEntryState } from './request.reducer'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -75,92 +74,6 @@ export class BitstreamDataService extends DataService { return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } - /** - * Retrieves the thumbnail for the given item - * @returns {Observable>} the first bitstream in the THUMBNAIL bundle - */ - // TODO should be implemented rest side. {@link Item} should get a thumbnail link - public getThumbnailFor(item: Item): Observable> { - return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( - switchMap((bundleRD: RemoteData) => { - if (isNotEmpty(bundleRD.payload)) { - return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe( - map((bitstreamRD: RemoteData>) => { - if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) { - return new RemoteData( - bitstreamRD.timeCompleted, - bitstreamRD.msToLive, - bitstreamRD.lastUpdated, - bitstreamRD.state, - bitstreamRD.errorMessage, - bitstreamRD.payload.page[0], - bitstreamRD.statusCode - ); - } else { - return bitstreamRD as any; - } - }) - ); - } else { - return [bundleRD as any]; - } - }) - ); - } - - /** - * Retrieve the matching thumbnail for a {@link Bitstream}. - * - * The {@link Item} is technically redundant, but is available - * in all current use cases, and having it simplifies this method - * - * @param item The {@link Item} the {@link Bitstream} and its thumbnail are a part of - * @param bitstreamInOriginal The original {@link Bitstream} to find the thumbnail for - */ - // TODO should be implemented rest side - public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable> { - return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( - switchMap((bundleRD: RemoteData) => { - if (isNotEmpty(bundleRD.payload)) { - return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 9999 }).pipe( - map((bitstreamRD: RemoteData>) => { - if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) { - const matchingThumbnail = bitstreamRD.payload.page.find((thumbnail: Bitstream) => - thumbnail.name.startsWith(bitstreamInOriginal.name) - ); - if (hasValue(matchingThumbnail)) { - return new RemoteData( - bitstreamRD.timeCompleted, - bitstreamRD.msToLive, - bitstreamRD.lastUpdated, - bitstreamRD.state, - bitstreamRD.errorMessage, - matchingThumbnail, - bitstreamRD.statusCode - ); - } else { - return new RemoteData( - bitstreamRD.timeCompleted, - bitstreamRD.msToLive, - bitstreamRD.lastUpdated, - RequestEntryState.Error, - 'No matching thumbnail found', - undefined, - 404 - ); - } - } else { - return bitstreamRD as any; - } - }) - ); - } else { - return [bundleRD as any]; - } - }) - ); - } - /** * Retrieve all {@link Bitstream}s in a certain {@link Bundle}. * diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 88b15754af..5bc7423824 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -233,7 +233,7 @@ describe('DataService', () => { const config: FindListOptions = Object.assign(new FindListOptions(), { elementsPerPage: 5 }); - (service as any).getFindAllHref({}, null, followLink('bundles', config, true, true, true)).subscribe((value) => { + (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -253,7 +253,7 @@ describe('DataService', () => { elementsPerPage: 2 }); - (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', config, true, true, true), followLink('templateItemOf')).subscribe((value) => { + (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -261,7 +261,13 @@ describe('DataService', () => { it('should not include linksToFollow with shouldEmbed = false', () => { const expected = `${endpoint}?embed=templateItemOf`; - (service as any).getFindAllHref({}, null, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')).subscribe((value) => { + (service as any).getFindAllHref( + {}, + null, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf') + ).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -269,7 +275,7 @@ describe('DataService', () => { it('should include nested linksToFollow 3lvl', () => { const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; - (service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))).subscribe((value) => { + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -279,7 +285,7 @@ describe('DataService', () => { const config: FindListOptions = Object.assign(new FindListOptions(), { elementsPerPage: 4 }); - (service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', config, true, true, true))).subscribe((value) => { + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -308,13 +314,19 @@ describe('DataService', () => { it('should not include linksToFollow with shouldEmbed = false', () => { const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')); + const result = (service as any).getIDHref( + endpointMock, + resourceIdMock, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf') + ); expect(result).toEqual(expected); }); it('should include nested linksToFollow 3lvl', () => { const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', undefined, true, true, true,followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))); + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))); expect(result).toEqual(expected); }); }); diff --git a/src/app/core/data/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect-data.service.spec.ts index d64f37ad78..bcd25487c2 100644 --- a/src/app/core/data/dso-redirect-data.service.spec.ts +++ b/src/app/core/data/dso-redirect-data.service.spec.ts @@ -119,7 +119,7 @@ describe('DsoRedirectDataService', () => { }); it('should navigate to entities route with the corresponding entity type', () => { remoteData.payload.type = 'item'; - remoteData.payload.metadata = { + remoteData.payload.metadata = { 'dspace.entity.type': [ { language: 'en_US', @@ -174,13 +174,29 @@ describe('DsoRedirectDataService', () => { it('should not include linksToFollow with shouldEmbed = false', () => { const expected = `${requestUUIDURL}&embed=templateItemOf`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')); + const result = (service as any).getIDHref( + pidLink, + dsoUUID, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf') + ); expect(result).toEqual(expected); }); it('should include nested linksToFollow 3lvl', () => { const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))); + const result = (service as any).getIDHref( + pidLink, + dsoUUID, + followLink('owningCollection', + {}, + followLink('itemtemplate', + {}, + followLink('relationships') + ) + ) + ); expect(result).toEqual(expected); }); }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index e56f9f2b0c..7a0116fe86 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -23,14 +23,7 @@ import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { - DeleteRequest, - FindListOptions, - GetRequest, - PostRequest, - PutRequest, - RestRequest -} from './request.models'; +import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models'; import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { Bundle } from '../shared/bundle.model'; @@ -38,6 +31,9 @@ import { MetadataMap } from '../shared/metadata.models'; import { BundleDataService } from './bundle-data.service'; import { Operation } from 'fast-json-patch'; import { NoContent } from '../shared/NoContent.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { ResponseParsingService } from './parsing.service'; +import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; @Injectable() @dataService(ITEM) @@ -229,7 +225,7 @@ export class ItemDataService extends DataService { * @param itemId * @param collection */ - public moveToCollection(itemId: string, collection: Collection): Observable> { + public moveToCollection(itemId: string, collection: Collection): Observable> { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'text/uri-list'); @@ -242,9 +238,17 @@ export class ItemDataService extends DataService { find((href: string) => hasValue(href)), map((href: string) => { const request = new PutRequest(requestId, href, collection._links.self.href, options); - this.requestService.send(request); + Object.assign(request, { + // TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code + getResponseParser(): GenericConstructor { + return StatusCodeOnlyResponseParsingService; + } + }); + return request; }) - ).subscribe(); + ).subscribe((request) => { + this.requestService.send(request); + }); return this.rdbService.buildFromRequestUUID(requestId); } diff --git a/src/app/core/json-patch/json-patch-operations.actions.ts b/src/app/core/json-patch/json-patch-operations.actions.ts index 4c26703472..91fc6cb0da 100644 --- a/src/app/core/json-patch/json-patch-operations.actions.ts +++ b/src/app/core/json-patch/json-patch-operations.actions.ts @@ -20,6 +20,7 @@ export const JsonPatchOperationsActionTypes = { ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'), FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'), START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'), + DELETE_PENDING_JSON_PATCH_OPERATIONS: type('dspace/core/patch/DELETE_PENDING_JSON_PATCH_OPERATIONS'), }; /* tslint:disable:max-classes-per-file */ @@ -261,6 +262,13 @@ export class NewPatchReplaceOperationAction implements Action { } } +/** + * An ngrx action to delete all pending JSON Patch Operations. + */ +export class DeletePendingJsonPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS; +} + /* tslint:enable:max-classes-per-file */ /** @@ -276,4 +284,5 @@ export type PatchOperationsActions | NewPatchRemoveOperationAction | NewPatchReplaceOperationAction | RollbacktPatchOperationsAction - | StartTransactionPatchOperationsAction; + | StartTransactionPatchOperationsAction + | DeletePendingJsonPatchOperationsAction; diff --git a/src/app/core/json-patch/json-patch-operations.reducer.spec.ts b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts index 34378b819b..1f98cf0920 100644 --- a/src/app/core/json-patch/json-patch-operations.reducer.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts @@ -1,7 +1,7 @@ import * as deepFreeze from 'deep-freeze'; import { - CommitPatchOperationsAction, + CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction, FlushPatchOperationsAction, NewPatchAddOperationAction, NewPatchRemoveOperationAction, @@ -323,4 +323,19 @@ describe('jsonPatchOperationsReducer test suite', () => { }); + describe('When DeletePendingJsonPatchOperationsAction has been dispatched', () => { + it('should set set the JsonPatchOperationsState to null ', () => { + const action = new DeletePendingJsonPatchOperationsAction(); + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + transactionStartTime: startTimestamp, + commitPending: true + }) + }); + const newState = jsonPatchOperationsReducer(initState, action); + + expect(newState).toBeNull(); + }); + }); + }); diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts index 8d630dbfa1..5e00027edb 100644 --- a/src/app/core/json-patch/json-patch-operations.reducer.ts +++ b/src/app/core/json-patch/json-patch-operations.reducer.ts @@ -11,7 +11,8 @@ import { NewPatchReplaceOperationAction, CommitPatchOperationsAction, StartTransactionPatchOperationsAction, - RollbacktPatchOperationsAction + RollbacktPatchOperationsAction, + DeletePendingJsonPatchOperationsAction } from './json-patch-operations.actions'; import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model'; @@ -101,6 +102,10 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction); } + case JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS: { + return deletePendingOperations(state, action as DeletePendingJsonPatchOperationsAction); + } + default: { return state; } @@ -178,6 +183,20 @@ function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPa } } +/** + * Set the JsonPatchOperationsState to its initial value. + * + * @param state + * the current state + * @param action + * an DeletePendingJsonPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function deletePendingOperations(state: JsonPatchOperationsState, action: DeletePendingJsonPatchOperationsAction): JsonPatchOperationsState { + return null; +} + /** * Add new JSON patch operation list. * diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts index 97aba52e56..d1b2948777 100644 --- a/src/app/core/json-patch/json-patch-operations.service.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -17,6 +17,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; import { CommitPatchOperationsAction, + DeletePendingJsonPatchOperationsAction, RollbacktPatchOperationsAction, StartTransactionPatchOperationsAction } from './json-patch-operations.actions'; @@ -288,4 +289,19 @@ describe('JsonPatchOperationsService test suite', () => { }); }); + describe('deletePendingJsonPatchOperations', () => { + beforeEach(() => { + store.dispatch.and.callFake(() => { /* */ }); + }); + + it('should dispatch a new DeletePendingJsonPatchOperationsAction', () => { + + const expectedAction = new DeletePendingJsonPatchOperationsAction(); + scheduler.schedule(() => service.deletePendingJsonPatchOperations()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + }); diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts index 84d946daba..c3363f4db4 100644 --- a/src/app/core/json-patch/json-patch-operations.service.ts +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -10,7 +10,7 @@ import { CoreState } from '../core.reducers'; import { jsonPatchOperationsByResourceType } from './selectors'; import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; import { - CommitPatchOperationsAction, + CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction, RollbacktPatchOperationsAction, StartTransactionPatchOperationsAction } from './json-patch-operations.actions'; @@ -105,6 +105,13 @@ export abstract class JsonPatchOperationsService>; /** diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 53eb5e3ce2..d98c22225e 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, autoserializeAs, deserialize, deserializeAs, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; import { isEmpty } from '../../shared/empty.util'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; @@ -19,6 +19,8 @@ import { ITEM } from './item.resource-type'; import { ChildHALResource } from './child-hal-resource.model'; import { Version } from './version.model'; import { VERSION } from './version.resource-type'; +import { BITSTREAM } from './bitstream.resource-type'; +import { Bitstream } from './bitstream.model'; /** * Class representing a DSpace Item @@ -37,7 +39,7 @@ export class Item extends DSpaceObject implements ChildHALResource { /** * The Date of the last modification of this Item */ - @deserialize + @deserializeAs(Date) lastModified: Date; /** @@ -69,6 +71,7 @@ export class Item extends DSpaceObject implements ChildHALResource { owningCollection: HALLink; templateItemOf: HALLink; version: HALLink; + thumbnail: HALLink; self: HALLink; }; @@ -100,6 +103,13 @@ export class Item extends DSpaceObject implements ChildHALResource { @link(RELATIONSHIP, true) relationships?: Observable>>; + /** + * The thumbnail for this Item + * Will be undefined unless the thumbnail {@link HALLink} has been resolved. + */ + @link(BITSTREAM, false, 'thumbnail') + thumbnail?: Observable>; + /** * Method that returns as which type of object this object should be rendered */ diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 9cb284db32..75723366bc 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -41,6 +41,43 @@ import { SearchConfig } from './search-filters/search-config.model'; import { PaginationService } from '../../pagination/pagination.service'; import { SearchConfigurationService } from './search-configuration.service'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { DataService } from '../../data/data.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DSOChangeAnalyzer } from '../../data/dso-change-analyzer.service'; + +/* tslint:disable:max-classes-per-file */ +/** + * A class that lets us delegate some methods to DataService + */ +class DataServiceImpl extends DataService { + protected linkPath = 'discover'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } + + /** + * Adds the embed options to the link for the request + * @param href The href the params are to be added to + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + public addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig[]) { + return super.addEmbedParams(href, args, ...linksToFollow); + } +} /** * Service that performs all general actions that have to do with the search page @@ -78,6 +115,11 @@ export class SearchService implements OnDestroy { */ private sub; + /** + * Instance of DataServiceImpl that lets us delegate some methods to DataService + */ + private searchDataService: DataServiceImpl; + constructor(private router: Router, private routeService: RouteService, protected requestService: RequestService, @@ -89,6 +131,16 @@ export class SearchService implements OnDestroy { private paginationService: PaginationService, private searchConfigurationService: SearchConfigurationService ) { + this.searchDataService = new DataServiceImpl( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); } /** @@ -131,7 +183,17 @@ export class SearchService implements OnDestroy { search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { const href$ = this.getEndpoint(searchOptions); - href$.pipe(take(1)).subscribe((url: string) => { + href$.pipe( + take(1), + map((href: string) => { + const args = this.searchDataService.addEmbedParams(href, [], ...linksToFollow); + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + }) + ).subscribe((url: string) => { const request = new this.request(this.requestService.generateRequestId(), url); const getResponseParserFn: () => GenericConstructor = () => { @@ -152,7 +214,7 @@ export class SearchService implements OnDestroy { ); return this.directlyAttachIndexableObjects(sqr$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } + } /** * Method to retrieve request entries for search results from the server @@ -399,9 +461,9 @@ export class SearchService implements OnDestroy { let pageParams = { page: 1 }; const queryParams = { view: viewMode }; if (viewMode === ViewMode.DetailedListElement) { - pageParams = Object.assign(pageParams, {pageSize: 1}); + pageParams = Object.assign(pageParams, { pageSize: 1 }); } else if (config.pageSize === 1) { - pageParams = Object.assign(pageParams, {pageSize: 10}); + pageParams = Object.assign(pageParams, { pageSize: 10 }); } this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams); }); @@ -413,7 +475,7 @@ export class SearchService implements OnDestroy { * @param {string} configurationName the name of the configuration * @returns {Observable>} The found configuration */ - getSearchConfigurationFor(scope?: string, configurationName?: string ): Observable> { + getSearchConfigurationFor(scope?: string, configurationName?: string): Observable> { const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe( map((url: string) => this.getConfigUrl(url, scope, configurationName)), ); diff --git a/src/app/core/shared/sequence.service.spec.ts b/src/app/core/shared/sequence.service.spec.ts new file mode 100644 index 0000000000..e48ad3efcc --- /dev/null +++ b/src/app/core/shared/sequence.service.spec.ts @@ -0,0 +1,22 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { SequenceService } from './sequence.service'; + +let service: SequenceService; + +describe('SequenceService', () => { + beforeEach(() => { + service = new SequenceService(); + }); + + it('should return sequential numbers on next(), starting with 1', () => { + const NUMBERS = [1,2,3,4,5]; + const sequence = NUMBERS.map(() => service.next()); + expect(sequence).toEqual(NUMBERS); + }); +}); diff --git a/src/app/core/shared/sequence.service.ts b/src/app/core/shared/sequence.service.ts new file mode 100644 index 0000000000..2340ffb259 --- /dev/null +++ b/src/app/core/shared/sequence.service.ts @@ -0,0 +1,24 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Injectable } from '@angular/core'; + +@Injectable() +/** + * Provides unique sequential numbers + */ +export class SequenceService { + private value: number; + + constructor() { + this.value = 0; + } + + public next(): number { + return ++this.value; + } +} diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index d82ef01087..da58512441 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -173,7 +173,7 @@ export class VocabularyService { ); // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved - return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( + return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe( getFirstSucceededRemoteDataPayload(), switchMap((vocabulary: Vocabulary) => vocabulary.entries), ); @@ -200,7 +200,7 @@ export class VocabularyService { ); // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved - return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( + return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe( getFirstSucceededRemoteDataPayload(), switchMap((vocabulary: Vocabulary) => vocabulary.entries), ); @@ -249,7 +249,7 @@ export class VocabularyService { ); // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved - return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( + return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe( getFirstSucceededRemoteDataPayload(), switchMap((vocabulary: Vocabulary) => vocabulary.entries), getFirstSucceededRemoteListPayload(), diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html index feb282d3a7..028876b3d0 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html @@ -8,13 +8,13 @@ rel="noopener noreferrer" [routerLink]="[itemPageRoute]" class="card-img-top full-width">
- +
- +
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html index aa2352b284..65ff75a731 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html @@ -8,13 +8,13 @@ rel="noopener noreferrer" [routerLink]="[itemPageRoute]" class="card-img-top full-width">
- +
- +
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html index 8fdad59827..0c5824c6d6 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html @@ -8,13 +8,13 @@ rel="noopener noreferrer" [routerLink]="[itemPageRoute]" class="card-img-top full-width">
- +
- +
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 8e357140d8..5847be7dd2 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 @@ -9,7 +9,7 @@
- +
- +
- +
- +
- +
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html index 23de8b134f..680a9909bc 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html @@ -8,13 +8,13 @@ rel="noopener noreferrer" [routerLink]="[itemPageRoute]" class="card-img-top full-width">
- +
- +
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html index 88498a4d67..204f8fc8cb 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html @@ -8,13 +8,13 @@ rel="noopener noreferrer" [routerLink]="[itemPageRoute]" class="card-img-top full-width">
- +
- +
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 d328e93b15..c9ea8fb549 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 @@ -9,7 +9,7 @@
-
- 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 181a8dc44e..8f2ff6adcd 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 @@ -10,7 +10,7 @@
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html index 063e1393cc..13787bb925 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html @@ -1,6 +1,6 @@
- +
{ - return this.bitstreamDataService.getThumbnailFor(this.dso).pipe( - getFirstSucceededRemoteDataPayload() - ); - } } diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html index e177b2b561..87a422e7db 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html @@ -21,4 +21,4 @@
- \ No newline at end of file + 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 64cf73cfb9..13de40e015 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 @@ -1,8 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; -import { Bitstream } from '../../../../../core/shared/bitstream.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @@ -108,11 +105,4 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu modalComp.value = value; return modalRef.result; } - - // TODO refactor to return RemoteData, and thumbnail template to deal with loading - getThumbnail(): Observable { - return this.bitstreamDataService.getThumbnailFor(this.dso).pipe( - getFirstSucceededRemoteDataPayload() - ); - } } 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 ad3cf9f9fa..8579becc83 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,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; import { first, map } from 'rxjs/operators'; @@ -29,6 +29,12 @@ export class DeleteComColPageComponent i */ public dsoRD$: Observable>; + /** + * A boolean representing if a delete operation is pending + * @type {BehaviorSubject} + */ + public processing$: BehaviorSubject = new BehaviorSubject(false); + public constructor( protected dsoDataService: ComColDataService, protected router: Router, @@ -48,6 +54,7 @@ export class DeleteComColPageComponent i * Deletes an existing DSO and redirects to the home page afterwards, showing a notification that states whether or not the deletion was successful */ onConfirm(dso: TDomain) { + this.processing$.next(true); this.dsoDataService.delete(dso.id) .pipe(getFirstCompletedRemoteData()) .subscribe((response: RemoteData) => { 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 122f37b031..ab2ea6cd8b 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 @@ -14,12 +14,12 @@ [infiniteScrollContainer]="'.scrollable-menu'" [fromRoot]="true" (scrolled)="onScrollDown()"> - + -
+
+ class="search-filter-wrapper" [ngClass]="{ 'closed' : closed, 'notab': notab }"> diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss index 518e7c9d5f..7e2631b55f 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss @@ -1,10 +1,36 @@ :host .facet-filter { - border: 1px solid var(--bs-light); - cursor: pointer; - .search-filter-wrapper.closed { - overflow: hidden; + border: 1px solid var(--bs-light); + cursor: pointer; + line-height: 0; + + .search-filter-wrapper { + line-height: var(--bs-line-height-base); + &.closed { + overflow: hidden; } - .filter-toggle { - line-height: var(--bs-line-height-base); + &.notab { + visibility: hidden; } + } + + .filter-toggle { + line-height: var(--bs-line-height-base); + text-align: right; + position: relative; + top: -0.125rem; // Fix weird outline shape in Chrome + } + + > button { + appearance: none; + border: 0; + padding: 0; + background: transparent; + width: 100%; + outline: none !important; + } + + &.focus { + outline: none; + box-shadow: var(--bs-input-btn-focus-box-shadow); + } } diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts index 228eef9a20..5e0077e11d 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts @@ -12,6 +12,7 @@ import { SearchFilterConfig } from '../../search-filter-config.model'; import { FilterType } from '../../filter-type.model'; import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { SequenceService } from '../../../../core/shared/sequence.service'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -50,12 +51,15 @@ describe('SearchFilterComponent', () => { }; let filterService; + let sequenceService; const mockResults = observableOf(['test', 'data']); const searchServiceStub = { getFacetValuesFor: (filter) => mockResults }; beforeEach(waitForAsync(() => { + sequenceService = jasmine.createSpyObj('sequenceService', { next: 17 }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], declarations: [SearchFilterComponent], @@ -65,7 +69,8 @@ describe('SearchFilterComponent', () => { provide: SearchFilterService, useValue: mockFilterService }, - { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + { provide: SequenceService, useValue: sequenceService }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchFilterComponent, { @@ -81,6 +86,12 @@ describe('SearchFilterComponent', () => { filterService = (comp as any).filterService; }); + it('should generate unique IDs', () => { + expect(sequenceService.next).toHaveBeenCalled(); + expect(comp.toggleId).toContain('17'); + expect(comp.regionId).toContain('17'); + }); + describe('when the toggle method is triggered', () => { beforeEach(() => { spyOn(filterService, 'toggle'); diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts index 31ace10a7d..0f7f763b45 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts @@ -10,6 +10,7 @@ import { isNotEmpty } from '../../../empty.util'; import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { SequenceService } from '../../../../core/shared/sequence.service'; @Component({ selector: 'ds-search-filter', @@ -37,6 +38,16 @@ export class SearchFilterComponent implements OnInit { */ closed: boolean; + /** + * True when the filter controls should be hidden & removed from the tablist + */ + notab: boolean; + + /** + * True when the filter toggle button is focused + */ + focusBox = false; + /** * Emits true when the filter is currently collapsed in the store */ @@ -52,10 +63,15 @@ export class SearchFilterComponent implements OnInit { */ active$: Observable; + private readonly sequenceId: number; + constructor( private filterService: SearchFilterService, private searchService: SearchService, - @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService, + private sequenceService: SequenceService, + ) { + this.sequenceId = this.sequenceService.next(); } /** @@ -112,6 +128,9 @@ export class SearchFilterComponent implements OnInit { if (event.fromState === 'collapsed') { this.closed = false; } + if (event.toState === 'collapsed') { + this.notab = true; + } } /** @@ -122,6 +141,17 @@ export class SearchFilterComponent implements OnInit { if (event.toState === 'collapsed') { this.closed = true; } + if (event.fromState === 'collapsed') { + this.notab = false; + } + } + + get regionId(): string { + return `search-filter-region-${this.sequenceId}`; + } + + get toggleId(): string { + return `search-filter-toggle-${this.sequenceId}`; } /** diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html index 06b60b5ecd..49ca6fe3fd 100644 --- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html @@ -8,15 +8,18 @@
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
-
- +
+
- +
- + - + [dsDebounce]="250" (onDebounce)="onSubmit()" + (keydown)="startKeyboardControl()" (keyup)="stopKeyboardControl()" + [(ngModel)]="range" ngDefaultControl> +
diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss index 2c98280e7f..f26806abfb 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -21,6 +21,7 @@ } &:focus { outline: none; + box-shadow: var(--bs-input-btn-focus-box-shadow); } } diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index 62b1cb98a6..b23a2d8224 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -68,6 +68,12 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple */ sub: Subscription; + /** + * Whether the sider is being controlled by the keyboard. + * Supresses any changes until the key is released. + */ + keyboardControl: boolean; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected router: Router, @@ -104,6 +110,10 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple * Submits new custom range values to the range filter from the widget */ onSubmit() { + if (this.keyboardControl) { + return; // don't submit if a key is being held down + } + const newMin = this.range[0] !== this.min ? [this.range[0]] : null; const newMax = this.range[1] !== this.max ? [this.range[1]] : null; this.router.navigate(this.getSearchLinkParts(), { @@ -117,6 +127,14 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple this.filter = ''; } + startKeyboardControl(): void { + this.keyboardControl = true; + } + + stopKeyboardControl(): void { + this.keyboardControl = false; + } + /** * TODO when upgrading nouislider, verify that this check is still needed. * Prevents AoT bug diff --git a/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html index 43134014e1..fdf154bc04 100644 --- a/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html @@ -8,15 +8,18 @@
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
{ * Defaults to true */ reRequestOnStale? = true; + + /** + * If this is false an error will be thrown if the link doesn't exist on the model it is used on + * Defaults to false + */ + isOptional? = false; } /** @@ -57,23 +64,35 @@ export class FollowLinkConfig { * no valid cached version. Defaults * @param reRequestOnStale: Whether or not the link should automatically be re-requested after the * response becomes stale + * @param isOptional: Whether or not to fail if the link doesn't exist * @param linksToFollow: a list of {@link FollowLinkConfig}s to * use on the retrieved object. */ export const followLink = ( linkName: keyof R['_links'], - findListOptions?: FindListOptions, - shouldEmbed = true, - useCachedVersionIfAvailable = true, - reRequestOnStale = true, - ...linksToFollow: FollowLinkConfig[] -): FollowLinkConfig => { - return { - name: linkName, + { findListOptions, shouldEmbed, useCachedVersionIfAvailable, reRequestOnStale, + isOptional + }: { + findListOptions?: FindListOptions, + shouldEmbed?: boolean, + useCachedVersionIfAvailable?: boolean, + reRequestOnStale?: boolean, + isOptional?: boolean, + } = {}, + ...linksToFollow: FollowLinkConfig[] +): FollowLinkConfig => { + const followLinkConfig = { + name: linkName, + findListOptions: hasValue(findListOptions) ? findListOptions : new FindListOptions(), + shouldEmbed: hasValue(shouldEmbed) ? shouldEmbed : true, + useCachedVersionIfAvailable: hasValue(useCachedVersionIfAvailable) ? useCachedVersionIfAvailable : true, + reRequestOnStale: hasValue(reRequestOnStale) ? reRequestOnStale : true, + isOptional: hasValue(isOptional) ? isOptional : false, linksToFollow }; + return followLinkConfig; }; diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index 7b730b7a73..8013162d85 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -18,6 +18,8 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { mockSubmissionObject } from '../../shared/mocks/submission.mock'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { ItemDataService } from '../../core/data/item-data.service'; +import { SubmissionJsonPatchOperationsServiceStub } from '../../shared/testing/submission-json-patch-operations-service.stub'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; describe('SubmissionEditComponent Component', () => { @@ -25,6 +27,7 @@ describe('SubmissionEditComponent Component', () => { let fixture: ComponentFixture; let submissionServiceStub: SubmissionServiceStub; let itemDataService: ItemDataService; + let submissionJsonPatchOperationsServiceStub: SubmissionJsonPatchOperationsServiceStub; let router: RouterStub; const submissionId = '826'; @@ -46,6 +49,7 @@ describe('SubmissionEditComponent Component', () => { providers: [ { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: SubmissionJsonPatchOperationsService, useClass: SubmissionJsonPatchOperationsServiceStub }, { provide: ItemDataService, useValue: itemDataService }, { provide: TranslateService, useValue: getMockTranslateService() }, { provide: Router, useValue: new RouterStub() }, @@ -60,6 +64,7 @@ describe('SubmissionEditComponent Component', () => { fixture = TestBed.createComponent(SubmissionEditComponent); comp = fixture.componentInstance; submissionServiceStub = TestBed.inject(SubmissionService as any); + submissionJsonPatchOperationsServiceStub = TestBed.inject(SubmissionJsonPatchOperationsService as any); router = TestBed.inject(Router as any); }); @@ -112,4 +117,16 @@ describe('SubmissionEditComponent Component', () => { expect(comp.submissionDefinition).toBeUndefined(); }); + describe('ngOnDestroy', () => { + it('should call delete pending json patch operations', fakeAsync(() => { + + submissionJsonPatchOperationsServiceStub.deletePendingJsonPatchOperations.and.callFake(() => { /* */ }); + comp.ngOnDestroy(); + + expect(submissionJsonPatchOperationsServiceStub.deletePendingJsonPatchOperations).toHaveBeenCalled(); + })); + + }); + + }); diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 60ef656822..bd9876da8e 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -17,6 +17,7 @@ import { Item } from '../../core/shared/item.model'; import { getAllSucceededRemoteData } from '../../core/shared/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; import { SubmissionError } from '../objects/submission-objects.reducer'; import parseSectionErrors from '../utils/parseSectionErrors'; @@ -93,6 +94,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { * @param {ItemDataService} itemDataService * @param {SubmissionService} submissionService * @param {TranslateService} translate + * @param {SubmissionJsonPatchOperationsService} submissionJsonPatchOperationsService */ constructor(private changeDetectorRef: ChangeDetectorRef, private notificationsService: NotificationsService, @@ -100,7 +102,8 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { private router: Router, private itemDataService: ItemDataService, private submissionService: SubmissionService, - private translate: TranslateService) { + private translate: TranslateService, + private submissionJsonPatchOperationsService: SubmissionJsonPatchOperationsService) { } /** @@ -159,5 +162,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.subs .filter((sub) => hasValue(sub)) .forEach((sub) => sub.unsubscribe()); + + this.submissionJsonPatchOperationsService.deletePendingJsonPatchOperations(); } } diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index b4ff31aaf6..14dfcef864 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -261,9 +261,10 @@ describe('SubmissionFormCollectionComponent Component', () => { expect(comp.toggled).toHaveBeenCalled(); }); - it('should ', () => { + it('should change collection properly', () => { spyOn(comp.collectionChange, 'emit').and.callThrough(); jsonPatchOpServiceStub.jsonPatchByResourceID.and.returnValue(of(submissionRestResponse)); + submissionServiceStub.retrieveSubmission.and.returnValue(createSuccessfulRemoteDataObject$(submissionRestResponse[0])); comp.ngOnInit(); comp.onSelect(mockCollectionList[1]); fixture.detectChanges(); diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 854a2f1db0..ba7eb88c6f 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -13,7 +13,7 @@ import { import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { find, - map + map, mergeMap } from 'rxjs/operators'; import { Collection } from '../../../core/shared/collection.model'; @@ -27,6 +27,7 @@ import { SubmissionJsonPatchOperationsService } from '../../../core/submission/s import { CollectionDataService } from '../../../core/data/collection-data.service'; import { CollectionDropdownComponent } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { SectionsService } from '../../sections/sections.service'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; /** * This component allows to show the current collection the submission belonging to and to change it. @@ -164,11 +165,17 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { this.submissionService.getSubmissionObjectLinkName(), this.submissionId, 'sections', - 'collection') - .subscribe((submissionObject: SubmissionObject[]) => { + 'collection').pipe( + mergeMap((submissionObject: SubmissionObject[]) => { + // retrieve the full submission object with embeds + return this.submissionService.retrieveSubmission(submissionObject[0].id).pipe( + getFirstSucceededRemoteDataPayload() + ); + }) + ).subscribe((submissionObject: SubmissionObject) => { this.selectedCollectionId = event.collection.id; this.selectedCollectionName$ = observableOf(event.collection.name); - this.collectionChange.emit(submissionObject[0]); + this.collectionChange.emit(submissionObject); this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id); this.processingChange$.next(false); this.cdr.detectChanges(); diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index bc9d159750..eea585f9f8 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -78,6 +78,7 @@ describe('ThumbnailComponent', () => { bundle: { href: 'bundle.url' }, format: { href: 'format.url' }, content: { href: 'content.url' }, + thumbnail: undefined, }; }); @@ -126,6 +127,7 @@ describe('ThumbnailComponent', () => { bundle: { href: 'bundle.url' }, format: { href: 'format.url' }, content: { href: 'content.url' }, + thumbnail: undefined, }; thumbnail = createSuccessfulRemoteDataObject(bitstream); }); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 160201d9e7..5fc2063474 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -532,6 +532,12 @@ + "bitstream.edit.authorizations.link": "Edit bitstream's Policies", + + "bitstream.edit.authorizations.title": "Edit bitstream's Policies", + + "bitstream.edit.return": "Back", + "bitstream.edit.bitstream": "Bitstream: ", "bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"Main article\" or \"Experiment data readings\".", @@ -663,6 +669,8 @@ "collection.delete.confirm": "Confirm", + "collection.delete.processing": "Deleting", + "collection.delete.head": "Delete Collection", "collection.delete.notification.fail": "Collection could not be deleted", @@ -901,6 +909,8 @@ "community.delete.confirm": "Confirm", + "community.delete.processing": "Deleting...", + "community.delete.head": "Delete Community", "community.delete.notification.fail": "Community could not be deleted", @@ -1625,7 +1635,11 @@ - "item.edit.move.cancel": "Cancel", + "item.edit.move.cancel": "Back", + + "item.edit.move.save-button": "Save", + + "item.edit.move.discard-button": "Discard", "item.edit.move.description": "Select the collection you wish to move this item to. To narrow down the list of displayed collections, you can enter a search query in the box.", @@ -1819,8 +1833,6 @@ "item.page.description": "Description", - "item.page.edit": "Edit this item", - "item.page.journal-issn": "Journal ISSN", "item.page.journal-title": "Journal Title", @@ -2882,38 +2894,56 @@ "search.filters.filter.author.placeholder": "Author name", + "search.filters.filter.author.label": "Search author name", + "search.filters.filter.birthDate.head": "Birth Date", "search.filters.filter.birthDate.placeholder": "Birth Date", + "search.filters.filter.birthDate.label": "Search birth date", + "search.filters.filter.collapse": "Collapse filter", "search.filters.filter.creativeDatePublished.head": "Date Published", "search.filters.filter.creativeDatePublished.placeholder": "Date Published", + "search.filters.filter.creativeDatePublished.label": "Search date published", + "search.filters.filter.creativeWorkEditor.head": "Editor", "search.filters.filter.creativeWorkEditor.placeholder": "Editor", + "search.filters.filter.creativeWorkEditor.label": "Search editor", + "search.filters.filter.creativeWorkKeywords.head": "Subject", "search.filters.filter.creativeWorkKeywords.placeholder": "Subject", + "search.filters.filter.creativeWorkKeywords.label": "Search subject", + "search.filters.filter.creativeWorkPublisher.head": "Publisher", "search.filters.filter.creativeWorkPublisher.placeholder": "Publisher", + "search.filters.filter.creativeWorkPublisher.label": "Search publisher", + "search.filters.filter.dateIssued.head": "Date", - "search.filters.filter.dateIssued.max.placeholder": "Minimum Date", + "search.filters.filter.dateIssued.max.placeholder": "Maximum Date", - "search.filters.filter.dateIssued.min.placeholder": "Maximum Date", + "search.filters.filter.dateIssued.max.label": "End", + + "search.filters.filter.dateIssued.min.placeholder": "Minimum Date", + + "search.filters.filter.dateIssued.min.label": "Start", "search.filters.filter.dateSubmitted.head": "Date submitted", "search.filters.filter.dateSubmitted.placeholder": "Date submitted", + "search.filters.filter.dateSubmitted.label": "Search date submitted", + "search.filters.filter.discoverable.head": "Private", "search.filters.filter.withdrawn.head": "Withdrawn", @@ -2922,6 +2952,8 @@ "search.filters.filter.entityType.placeholder": "Item Type", + "search.filters.filter.entityType.label": "Search item type", + "search.filters.filter.expand": "Expand filter", "search.filters.filter.has_content_in_original_bundle.head": "Has files", @@ -2930,38 +2962,56 @@ "search.filters.filter.itemtype.placeholder": "Type", + "search.filters.filter.itemtype.label": "Search type", + "search.filters.filter.jobTitle.head": "Job Title", "search.filters.filter.jobTitle.placeholder": "Job Title", + "search.filters.filter.jobTitle.label": "Search job title", + "search.filters.filter.knowsLanguage.head": "Known language", "search.filters.filter.knowsLanguage.placeholder": "Known language", + "search.filters.filter.knowsLanguage.label": "Search known language", + "search.filters.filter.namedresourcetype.head": "Status", "search.filters.filter.namedresourcetype.placeholder": "Status", + "search.filters.filter.namedresourcetype.label": "Search status", + "search.filters.filter.objectpeople.head": "People", "search.filters.filter.objectpeople.placeholder": "People", + "search.filters.filter.objectpeople.label": "Search people", + "search.filters.filter.organizationAddressCountry.head": "Country", "search.filters.filter.organizationAddressCountry.placeholder": "Country", + "search.filters.filter.organizationAddressCountry.label": "Search country", + "search.filters.filter.organizationAddressLocality.head": "City", "search.filters.filter.organizationAddressLocality.placeholder": "City", + "search.filters.filter.organizationAddressLocality.label": "Search city", + "search.filters.filter.organizationFoundingDate.head": "Date Founded", "search.filters.filter.organizationFoundingDate.placeholder": "Date Founded", + "search.filters.filter.organizationFoundingDate.label": "Search date founded", + "search.filters.filter.scope.head": "Scope", "search.filters.filter.scope.placeholder": "Scope filter", + "search.filters.filter.scope.label": "Search scope filter", + "search.filters.filter.show-less": "Collapse", "search.filters.filter.show-more": "Show more", @@ -2970,10 +3020,14 @@ "search.filters.filter.subject.placeholder": "Subject", + "search.filters.filter.subject.label": "Search subject", + "search.filters.filter.submitter.head": "Submitter", "search.filters.filter.submitter.placeholder": "Submitter", + "search.filters.filter.submitter.label": "Search submitter", + "search.filters.entityType.JournalIssue": "Journal Issue", @@ -2999,6 +3053,8 @@ "search.filters.reset": "Reset filters", + "search.filters.search.submit": "Submit", + "search.form.search": "Search", diff --git a/src/styles/_truncatable-part.component.scss b/src/styles/_truncatable-part.component.scss new file mode 100644 index 0000000000..b938f3a199 --- /dev/null +++ b/src/styles/_truncatable-part.component.scss @@ -0,0 +1,80 @@ +@mixin clamp($lines, $bg, $size-factor: 1, $line-height: $line-height-base) { + $height: $line-height * $font-size-base * $size-factor; + &.fixedHeight { + height: $lines * $height; + } + .content { + max-height: $lines * $height; + position: relative; + overflow: hidden; + line-height: $line-height; + overflow-wrap: break-word; + &:after { + content: ""; + position: absolute; + padding-right: 15px; + top: ($lines - 1) * $height; + right: 0; + width: 30%; + min-width: 75px; + max-width: 150px; + height: $height; + background: linear-gradient(to right, rgba(255, 255, 255, 0), $bg 70%); + pointer-events: none; + } + } + +} + +@mixin min($lines, $size-factor: 1, $line-height: $line-height-base) { + $height: $line-height * $font-size-base * $size-factor; + min-height: $lines * $height; +} + +$h4-factor: strip-unit($h4-font-size); + +@mixin clamp-with-titles($i, $bg) { + transition: height 1s; + @include clamp($i, $bg); + &.title { + @include clamp($i, $bg, 1.25); + } + &.h4 { + @include clamp($i, $bg, $h4-factor, $headings-line-height); + } +} + +@for $i from 1 through 15 { + .clamp-default-#{$i} { + @include clamp-with-titles($i, $body-bg); + } + :focus .clamp-default-#{$i}, + .ds-hover .clamp-default-#{$i} { + @include clamp-with-titles($i, $list-group-hover-bg); + } + + .clamp-primary-#{$i} { + @include clamp-with-titles($i, $primary); + } + + :focus .clamp-primary-#{$i}, + .ds-hover .clamp-primary-#{$i} { + @include clamp-with-titles($i, darken($primary, 10%)); + } +} + +.clamp-none { + overflow: hidden; + @for $i from 1 through 15 { + &.fixedHeight.min-#{$i} { + transition: height 1s; + @include min($i); + &.title { + @include min($i, 1.25); + } + &.h4 { + @include min($i, $h4-factor, $headings-line-height); + } + } + } +} diff --git a/src/styles/base-theme.scss b/src/styles/base-theme.scss index 068c2ece26..bde50bcfd7 100644 --- a/src/styles/base-theme.scss +++ b/src/styles/base-theme.scss @@ -3,4 +3,5 @@ @import '../../node_modules/nouislider/distribute/nouislider.min'; @import './_custom_variables.scss'; @import './bootstrap_variables_mapping.scss'; +@import './_truncatable-part.component.scss'; @import './_global-styles.scss'; diff --git a/src/themes/custom/styles/theme.scss b/src/themes/custom/styles/theme.scss index e4cc9e45ed..35810b15a6 100644 --- a/src/themes/custom/styles/theme.scss +++ b/src/themes/custom/styles/theme.scss @@ -9,4 +9,5 @@ @import '../../../styles/_custom_variables.scss'; @import './_theme_css_variable_overrides.scss'; @import '../../../styles/bootstrap_variables_mapping.scss'; +@import '../../../styles/_truncatable-part.component.scss'; @import './_global-styles.scss'; diff --git a/src/themes/dspace/styles/theme.scss b/src/themes/dspace/styles/theme.scss index e4cc9e45ed..35810b15a6 100644 --- a/src/themes/dspace/styles/theme.scss +++ b/src/themes/dspace/styles/theme.scss @@ -9,4 +9,5 @@ @import '../../../styles/_custom_variables.scss'; @import './_theme_css_variable_overrides.scss'; @import '../../../styles/bootstrap_variables_mapping.scss'; +@import '../../../styles/_truncatable-part.component.scss'; @import './_global-styles.scss';