diff --git a/docker/README.md b/docker/README.md index 809a150d86..747db22143 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,5 +1,9 @@ # Docker Compose files +*** +:warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario. +*** + ## docker directory - docker-compose.yml - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index e172f9717b..53a9ecb2ab 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -531,14 +531,17 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { * Create menu sections dependent on whether or not the current user can manage access control groups */ createAccessControlMenuSections() { - this.authorizationService.isAuthorized(FeatureID.CanManageGroups).subscribe((authorized) => { + observableCombineLatest( + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + this.authorizationService.isAuthorized(FeatureID.CanManageGroups) + ).subscribe(([isSiteAdmin, canManageGroups]) => { const menuList = [ /* Access Control */ { id: 'access_control_people', parentID: 'access_control', active: false, - visible: authorized, + visible: isSiteAdmin, model: { type: MenuItemType.LINK, text: 'menu.section.access_control_people', @@ -549,7 +552,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { id: 'access_control_groups', parentID: 'access_control', active: false, - visible: authorized, + visible: canManageGroups, model: { type: MenuItemType.LINK, text: 'menu.section.access_control_groups', @@ -571,7 +574,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { { id: 'access_control', active: false, - visible: authorized, + visible: canManageGroups || isSiteAdmin, model: { type: MenuItemType.TEXT, text: 'menu.section.access_control' 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.component.ts b/src/app/+collection-page/collection-page.component.ts index 9eba2e4ab2..366e1da7b1 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,6 +1,11 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Observable, + Subject +} from 'rxjs'; import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; import { SearchService } from '../core/shared/search/search.service'; @@ -8,8 +13,6 @@ import { SortDirection, SortOptions } from '../core/cache/models/sort-options.mo import { CollectionDataService } from '../core/data/collection-data.service'; import { PaginatedList } from '../core/data/paginated-list.model'; import { RemoteData } from '../core/data/remote-data'; - -import { MetadataService } from '../core/metadata/metadata.service'; import { Bitstream } from '../core/shared/bitstream.model'; import { Collection } from '../core/shared/collection.model'; @@ -65,7 +68,6 @@ export class CollectionPageComponent implements OnInit { constructor( private collectionDataService: CollectionDataService, private searchService: SearchService, - private metadata: MetadataService, private route: ActivatedRoute, private router: Router, private authService: AuthService, @@ -122,10 +124,6 @@ export class CollectionPageComponent implements OnInit { getAllSucceededRemoteDataPayload(), map((collection) => getCollectionPageRoute(collection.id)) ); - - this.route.queryParams.pipe(take(1)).subscribe((params) => { - this.metadata.processRemoteData(this.collectionRD$); - }); } isNotEmpty(object: any) { 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/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html index c791cec600..d69f87883b 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html @@ -1,4 +1,4 @@ -
+
{{ label }}
diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts index 9c62b80cad..e17e5397b7 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component'; @@ -6,35 +6,34 @@ import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.componen /* tslint:disable:max-classes-per-file */ @Component({ selector: 'ds-component-without-content', - template: '\n' + + template: '\n' + '' }) -class NoContentComponent {} +class NoContentComponent { + public hideIfNoTextContent = true; +} @Component({ selector: 'ds-component-with-empty-spans', - template: '\n' + + template: '\n' + ' \n' + ' \n' + '' }) -class SpanContentComponent {} +class SpanContentComponent { + @Input() hideIfNoTextContent = true; +} @Component({ selector: 'ds-component-with-text', - template: '\n' + + template: '\n' + ' The quick brown fox jumps over the lazy dog\n' + '' }) -class TextContentComponent {} +class TextContentComponent { + @Input() hideIfNoTextContent = true; +} -@Component({ - selector: 'ds-component-with-image', - template: '\n' + - ' an alt text\n' + - '' -}) -class ImgContentComponent {} /* tslint:enable:max-classes-per-file */ describe('MetadataFieldWrapperComponent', () => { @@ -43,7 +42,7 @@ describe('MetadataFieldWrapperComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent] + declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent] }).compileComponents(); })); @@ -58,38 +57,60 @@ describe('MetadataFieldWrapperComponent', () => { expect(component).toBeDefined(); }); - it('should not show the component when there is no content', () => { - const parentFixture = TestBed.createComponent(NoContentComponent); - parentFixture.detectChanges(); - const parentNative = parentFixture.nativeElement; - const nativeWrapper = parentNative.querySelector(wrapperSelector); - expect(nativeWrapper.classList.contains('d-none')).toBe(true); + describe('with hideIfNoTextContent=true', () => { + it('should not show the component when there is no content', () => { + const parentFixture = TestBed.createComponent(NoContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + expect(nativeWrapper.classList.contains('d-none')).toBe(true); + }); + + it('should not show the component when there is no text content', () => { + const parentFixture = TestBed.createComponent(SpanContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + expect(nativeWrapper.classList.contains('d-none')).toBe(true); + }); + + it('should show the component when there is text content', () => { + const parentFixture = TestBed.createComponent(TextContentComponent); + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + parentFixture.detectChanges(); + expect(nativeWrapper.classList.contains('d-none')).toBe(false); + }); }); - it('should not show the component when there is DOM content but not text or an image', () => { - const parentFixture = TestBed.createComponent(SpanContentComponent); - parentFixture.detectChanges(); - const parentNative = parentFixture.nativeElement; - const nativeWrapper = parentNative.querySelector(wrapperSelector); - expect(nativeWrapper.classList.contains('d-none')).toBe(true); - }); + describe('with hideIfNoTextContent=false', () => { + it('should show the component when there is no content', () => { + const parentFixture = TestBed.createComponent(NoContentComponent); + parentFixture.componentInstance.hideIfNoTextContent = false; + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + expect(nativeWrapper.classList.contains('d-none')).toBe(false); + }); - it('should show the component when there is text content', () => { - const parentFixture = TestBed.createComponent(TextContentComponent); - parentFixture.detectChanges(); - const parentNative = parentFixture.nativeElement; - const nativeWrapper = parentNative.querySelector(wrapperSelector); - parentFixture.detectChanges(); - expect(nativeWrapper.classList.contains('d-none')).toBe(false); - }); + it('should show the component when there is no text content', () => { + const parentFixture = TestBed.createComponent(SpanContentComponent); + parentFixture.componentInstance.hideIfNoTextContent = false; + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + expect(nativeWrapper.classList.contains('d-none')).toBe(false); + }); - it('should show the component when there is img content', () => { - const parentFixture = TestBed.createComponent(ImgContentComponent); - parentFixture.detectChanges(); - const parentNative = parentFixture.nativeElement; - const nativeWrapper = parentNative.querySelector(wrapperSelector); - parentFixture.detectChanges(); - expect(nativeWrapper.classList.contains('d-none')).toBe(false); + it('should show the component when there is text content', () => { + const parentFixture = TestBed.createComponent(TextContentComponent); + parentFixture.componentInstance.hideIfNoTextContent = false; + parentFixture.detectChanges(); + const parentNative = parentFixture.nativeElement; + const nativeWrapper = parentNative.querySelector(wrapperSelector); + parentFixture.detectChanges(); + expect(nativeWrapper.classList.contains('d-none')).toBe(false); + }); }); - }); diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts index 8af108cceb..5c6b99248f 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.ts @@ -1,5 +1,4 @@ import { Component, Input } from '@angular/core'; -import { hasNoValue } from '../../../shared/empty.util'; /** * This component renders any content inside this wrapper. @@ -17,10 +16,5 @@ export class MetadataFieldWrapperComponent { */ @Input() label: string; - /** - * Make hasNoValue() available in the template - */ - hasNoValue(o: any): boolean { - return hasNoValue(o); - } + @Input() hideIfNoTextContent = true; } diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index bc1c63cc32..c5393055df 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -11,7 +11,7 @@ [retainScrollPosition]="true"> -
+
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/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index cb6a1ccf60..5e1bec7656 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -1,4 +1,4 @@ -import {filter, map} from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Data, Router } from '@angular/router'; @@ -11,8 +11,6 @@ import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { MetadataService } from '../../core/metadata/metadata.service'; - import { fadeInOut } from '../../shared/animations/fade'; import { hasValue } from '../../shared/empty.util'; import { AuthService } from '../../core/auth/auth.service'; 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-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index c647647339..cc23ba86d5 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -8,8 +8,6 @@ import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { MetadataService } from '../../core/metadata/metadata.service'; - import { fadeInOut } from '../../shared/animations/fade'; import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; @@ -54,7 +52,6 @@ export class ItemPageComponent implements OnInit { protected route: ActivatedRoute, private router: Router, private items: ItemDataService, - private metadataService: MetadataService, private authService: AuthService, ) { } @@ -66,7 +63,6 @@ export class ItemPageComponent implements OnInit { map((data) => data.dso as RemoteData), redirectOn4xx(this.router, this.authService) ); - this.metadataService.processRemoteData(this.itemRD$); this.itemPageRoute$ = this.itemRD$.pipe( getAllSucceededRemoteDataPayload(), map((item) => getItemPageRoute(item)) 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 27f5ff43a1..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 @@ -9,8 +9,8 @@
- - + + 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 120eda930f..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,10 +1,6 @@ import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; 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 { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; import { getItemPageRoute } from '../../../item-page-routing-paths'; @Component({ @@ -21,19 +17,10 @@ export class ItemComponent implements OnInit { * Route to the item page */ itemPageRoute: string; - mediaViewer = environment.mediaViewer; - constructor(protected bitstreamDataService: BitstreamDataService) { - } + mediaViewer = environment.mediaViewer; ngOnInit(): void { this.itemPageRoute = getItemPageRoute(this.object); } - - // TODO refactor to return RemoteData, and thumbnail template to deal with loading - getThumbnail(): Observable { - return this.bitstreamDataService.getThumbnailFor(this.object).pipe( - getFirstSucceededRemoteDataPayload() - ); - } } 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 89e6e7a490..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 @@ -9,8 +9,8 @@
- - + + 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/access-control/access-control-routing-paths.ts b/src/app/access-control/access-control-routing-paths.ts index d229d12bd2..259aa311e7 100644 --- a/src/app/access-control/access-control-routing-paths.ts +++ b/src/app/access-control/access-control-routing-paths.ts @@ -3,6 +3,10 @@ import { getAccessControlModuleRoute } from '../app-routing-paths'; export const GROUP_EDIT_PATH = 'groups'; +export function getGroupsRoute() { + return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString(); +} + export function getGroupEditRoute(id: string) { return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); } diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts index cac49938a9..e64b0d170a 100644 --- a/src/app/access-control/access-control-routing.module.ts +++ b/src/app/access-control/access-control-routing.module.ts @@ -5,6 +5,9 @@ import { GroupFormComponent } from './group-registry/group-form/group-form.compo import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; import { GROUP_EDIT_PATH } from './access-control-routing-paths'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { GroupPageGuard } from './group-registry/group-page.guard'; +import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; @NgModule({ imports: [ @@ -15,7 +18,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso resolve: { breadcrumb: I18nBreadcrumbResolver }, - data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' } + data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }, + canActivate: [SiteAdministratorGuard] }, { path: GROUP_EDIT_PATH, @@ -23,7 +27,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso resolve: { breadcrumb: I18nBreadcrumbResolver }, - data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' } + data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }, + canActivate: [GroupAdministratorGuard] }, { path: `${GROUP_EDIT_PATH}/newGroup`, @@ -31,7 +36,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso resolve: { breadcrumb: I18nBreadcrumbResolver }, - data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' } + data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' }, + canActivate: [GroupAdministratorGuard] }, { path: `${GROUP_EDIT_PATH}/:groupId`, @@ -39,7 +45,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso resolve: { breadcrumb: I18nBreadcrumbResolver }, - data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' } + data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }, + canActivate: [GroupPageGuard] } ]) ] diff --git a/src/app/access-control/group-registry/group-page.guard.spec.ts b/src/app/access-control/group-registry/group-page.guard.spec.ts new file mode 100644 index 0000000000..48fa124c07 --- /dev/null +++ b/src/app/access-control/group-registry/group-page.guard.spec.ts @@ -0,0 +1,83 @@ +import { GroupPageGuard } from './group-page.guard'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { AuthService } from '../../core/auth/auth.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; + +describe('GroupPageGuard', () => { + const groupsEndpointUrl = 'https://test.org/api/eperson/groups'; + const groupUuid = '0d6f89df-f95a-4829-943c-f21f434fb892'; + const groupEndpointUrl = `${groupsEndpointUrl}/${groupUuid}`; + const routeSnapshotWithGroupId = { + params: { + groupId: groupUuid, + } + } as unknown as ActivatedRouteSnapshot; + + let guard: GroupPageGuard; + let halEndpointService: HALEndpointService; + let authorizationService: AuthorizationDataService; + let router: Router; + let authService: AuthService; + + beforeEach(() => { + halEndpointService = jasmine.createSpyObj(['getEndpoint']); + (halEndpointService as any).getEndpoint.and.returnValue(observableOf(groupsEndpointUrl)); + + authorizationService = jasmine.createSpyObj(['isAuthorized']); + // NOTE: value is set in beforeEach + + router = jasmine.createSpyObj(['parseUrl']); + (router as any).parseUrl.and.returnValue = {}; + + authService = jasmine.createSpyObj(['isAuthenticated']); + (authService as any).isAuthenticated.and.returnValue(observableOf(true)); + + guard = new GroupPageGuard(halEndpointService, authorizationService, router, authService); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); + + describe('canActivate', () => { + describe('when the current user can manage the group', () => { + beforeEach(() => { + (authorizationService as any).isAuthorized.and.returnValue(observableOf(true)); + }); + + it('should return true', (done) => { + guard.canActivate( + routeSnapshotWithGroupId, { url: 'current-url'} as any + ).subscribe((result) => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith( + FeatureID.CanManageGroup, groupEndpointUrl, undefined + ); + expect(result).toBeTrue(); + done(); + }); + }); + }); + + describe('when the current user can not manage the group', () => { + beforeEach(() => { + (authorizationService as any).isAuthorized.and.returnValue(observableOf(false)); + }); + + it('should not return true', (done) => { + guard.canActivate( + routeSnapshotWithGroupId, { url: 'current-url'} as any + ).subscribe((result) => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith( + FeatureID.CanManageGroup, groupEndpointUrl, undefined + ); + expect(result).not.toBeTrue(); + done(); + }); + }); + }); + }); + +}); diff --git a/src/app/access-control/group-registry/group-page.guard.ts b/src/app/access-control/group-registry/group-page.guard.ts new file mode 100644 index 0000000000..057f67ddeb --- /dev/null +++ b/src/app/access-control/group-registry/group-page.guard.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable, of as observableOf } from 'rxjs'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class GroupPageGuard extends SomeFeatureAuthorizationGuard { + + protected groupsEndpoint = 'groups'; + + constructor(protected halEndpointService: HALEndpointService, + protected authorizationService: AuthorizationDataService, + protected router: Router, + protected authService: AuthService) { + super(authorizationService, router, authService); + } + + getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf([FeatureID.CanManageGroup]); + } + + getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe( + map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`) + ); + } + +} diff --git a/src/app/access-control/group-registry/groups-registry.component.html b/src/app/access-control/group-registry/groups-registry.component.html index e5e25ae944..4135f4cc95 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -33,9 +33,9 @@
- + {{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}
- + + + + -
+
+ 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 34fdcba104..154e73a765 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'; /** * This component allows to edit an existing workspaceitem/workflowitem. @@ -92,7 +93,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) { } /** @@ -149,5 +151,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/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 8df0ab1658..6d4ddb4ca0 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -122,7 +122,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * Initialize all instance variables and retrieve form configuration */ ngOnChanges(changes: SimpleChanges) { - if (this.collectionId && this.submissionId) { + if ((changes.collectionId && this.collectionId) && (changes.submissionId && this.submissionId)) { this.isActive = true; // retrieve submission's section list diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index c76a15abcb..d5798b82c8 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -4,7 +4,8 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DYNAMIC_FORM_CONTROL_TYPE_GROUP, - DynamicFormControlEvent + DynamicFormControlEvent, + DynamicInputModel } from '@ng-dynamic-forms/core'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; @@ -28,6 +29,7 @@ import { } from '../../../shared/mocks/form-models.mock'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { DynamicRowArrayModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; describe('SectionFormOperationsService test suite', () => { let formBuilderService: any; @@ -83,6 +85,11 @@ describe('SectionFormOperationsService test suite', () => { formBuilderService = TestBed.inject(FormBuilderService); }); + afterEach(() => { + jsonPatchOpBuilder.add.calls.reset(); + jsonPatchOpBuilder.remove.calls.reset(); + }); + describe('dispatchOperationsFromEvent', () => { it('should call dispatchOperationsFromRemoveEvent on remove event', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); @@ -567,7 +574,7 @@ describe('SectionFormOperationsService test suite', () => { }); it('should dispatch a json-path remove operation when has a stored value', () => { - const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + let previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { parent: mockRowGroupModel @@ -590,6 +597,7 @@ describe('SectionFormOperationsService test suite', () => { spyIndex.and.returnValue(1); spyPath.and.returnValue('path/1'); + previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path/1')); @@ -620,6 +628,32 @@ describe('SectionFormOperationsService test suite', () => { new FormFieldMetadataValueObject('test')); }); + it('should dispatch a json-path add operation when has a stored value but previous value is empty', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], null); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); + spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + new FormFieldMetadataValueObject('test'), + true); + }); + it('should dispatch a json-path add operation when has a value and field index is zero or undefined', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { @@ -760,4 +794,86 @@ describe('SectionFormOperationsService test suite', () => { }); }); + describe('handleArrayGroupPatch', () => { + let arrayModel; + let previousValue; + beforeEach(() => { + arrayModel = new DynamicRowArrayModel( + { + id: 'testFormRowArray', + initialCount: 5, + notRepeatable: false, + relationshipConfig: undefined, + submissionId: '1234', + isDraggable: true, + groupFactory: () => { + return [ + new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }) + ]; + }, + required: false, + metadataKey: 'dc.contributor.author', + metadataFields: ['dc.contributor.author'], + hasSelectableMetadata: true + } + ); + spyOn(serviceAsAny, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + previousValue = new FormFieldPreviousValueObject(['path'], null); + }); + + it('should not dispatch a json-path operation when a array value is empty', () => { + formBuilderService.getValueFromModel.and.returnValue({}); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); + expect(jsonPatchOpBuilder.remove).not.toHaveBeenCalled(); + }); + + it('should dispatch a json-path add operation when a array value is not empty', () => { + const pathValue = [ + new FormFieldMetadataValueObject('test'), + new FormFieldMetadataValueObject('test two') + ]; + formBuilderService.getValueFromModel.and.returnValue({ + path:pathValue + }); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + pathValue, + false + ); + expect(jsonPatchOpBuilder.remove).not.toHaveBeenCalled(); + }); + + it('should dispatch a json-path remove operation when a array value is empty and has previous value', () => { + formBuilderService.getValueFromModel.and.returnValue({}); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path')); + }); + }); }); diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index a1bb99e3cd..adba46bf3a 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -6,7 +6,8 @@ import { DYNAMIC_FORM_CONTROL_TYPE_GROUP, DynamicFormArrayGroupModel, DynamicFormControlEvent, - DynamicFormControlModel, isDynamicFormControlEvent + DynamicFormControlModel, + isDynamicFormControlEvent } from '@ng-dynamic-forms/core'; import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isNull, isUndefined } from '../../../shared/empty.util'; @@ -297,17 +298,14 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject): void { - if (event.context && event.context instanceof DynamicFormArrayGroupModel) { - // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context); - return; - } - const path = this.getFieldPathFromEvent(event); const value = this.getFieldValueFromChangeEvent(event); console.log(value); if (this.formBuilder.isQualdropGroup(event.model as DynamicFormControlModel)) { this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue); + } else if (event.context && event.context instanceof DynamicFormArrayGroupModel) { + // Model is a DynamicRowArrayModel + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); } else if ((isNotEmpty(value) && typeof value === 'string') || (isNotEmpty(value) && value instanceof FormFieldMetadataValueObject && value.hasValue())) { this.operationsBuilder.remove(pathCombiner.getPath(path)); } @@ -368,7 +366,7 @@ export class SectionFormOperationsService { if (event.context && event.context instanceof DynamicFormArrayGroupModel) { // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context); + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); return; } @@ -388,7 +386,7 @@ export class SectionFormOperationsService { this.operationsBuilder.add( pathCombiner.getPath(segmentedPath), value, true); - } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || hasStoredValue) { + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || (hasStoredValue && isNotEmpty(previousValue.value)) ) { // Here model has a previous value changed or stored in the server if (hasValue(event.$event) && hasValue(event.$event.previousIndex)) { if (event.$event.previousIndex < 0) { @@ -421,7 +419,7 @@ export class SectionFormOperationsService { previousValue.delete(); } else if (value.hasValue()) { // Here model has no previous value but a new one - if (isUndefined(this.getArrayIndexFromEvent(event)) || this.getArrayIndexFromEvent(event) === 0) { + if (isUndefined(this.getArrayIndexFromEvent(event)) || this.getArrayIndexFromEvent(event) === 0) { // Model is single field or is part of an array model but is the first item, // so dispatch an add operation that initialize the values of a specific metadata this.operationsBuilder.add( @@ -498,23 +496,37 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject) { - return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel); + return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel, previousValue); } /** * Specific patch handler for a DynamicRowArrayModel. * Configure a Patch ADD with the current array value. * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation * @param event + * the [[DynamicFormControlEvent]] for the specified operation * @param model + * the [[DynamicRowArrayModel]] model + * @param previousValue + * the [[FormFieldPreviousValueObject]] for the specified operation */ private handleArrayGroupPatch(pathCombiner: JsonPatchOperationPathCombiner, event, - model: DynamicRowArrayModel) { + model: DynamicRowArrayModel, + previousValue: FormFieldPreviousValueObject) { + const arrayValue = this.formBuilder.getValueFromModel([model]); - const segmentedPath2 = this.getFieldPathSegmentedFromChangeEvent(event); - this.operationsBuilder.add( - pathCombiner.getPath(segmentedPath2), - arrayValue[segmentedPath2], false); + const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); + if (isNotEmpty(arrayValue)) { + this.operationsBuilder.add( + pathCombiner.getPath(segmentedPath), + arrayValue[segmentedPath], + false + ); + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model))) { + this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); + } + } } diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss new file mode 100644 index 0000000000..b443db711b --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss @@ -0,0 +1,6 @@ + +::ng-deep .access-condition-group { + position: relative; + top: -2.3rem; + margin-bottom: -2.3rem; +} diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts index 512453d84e..3275787984 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -18,6 +18,8 @@ import { import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service'; import { + BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG, + BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, @@ -43,6 +45,7 @@ import { FormComponent } from '../../../../../shared/form/form.component'; */ @Component({ selector: 'ds-submission-section-upload-file-edit', + styleUrls: ['./section-upload-file-edit.component.scss'], templateUrl: './section-upload-file-edit.component.html', }) export class SubmissionSectionUploadFileEditComponent implements OnChanges { @@ -209,8 +212,9 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); - - return [type, startDate, endDate]; + const accessConditionGroupConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG); + accessConditionGroupConfig.group = [type, startDate, endDate]; + return [new DynamicFormGroupModel(accessConditionGroupConfig, BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT)]; }; // Number of access conditions blocks in form @@ -233,7 +237,7 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { public initModelData(formModel: DynamicFormControlModel[]) { this.fileData.accessConditions.forEach((accessCondition, index) => { Array.of('name', 'startDate', 'endDate') - .filter((key) => accessCondition.hasOwnProperty(key)) + .filter((key) => accessCondition.hasOwnProperty(key) && isNotEmpty(accessCondition[key])) .forEach((key) => { const metadataModel: any = this.formBuilderService.findById(key, formModel, index); if (metadataModel) { diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts index 096954659e..300a4b461f 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts @@ -15,12 +15,24 @@ export const BITSTREAM_METADATA_FORM_GROUP_CONFIG: DynamicFormGroupModelConfig = export const BITSTREAM_METADATA_FORM_GROUP_LAYOUT: DynamicFormControlLayout = { element: { container: 'form-group', - label: 'col-form-label' + label: 'col-form-label' }, grid: { label: 'col-sm-3' } }; +export const BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG: DynamicFormGroupModelConfig = { + id: 'accessConditionGroup', + group: [] +}; + +export const BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT: DynamicFormControlLayout = { + element: { + host: 'form-group flex-fill access-condition-group', + container: 'pl-1 pr-1', + control: 'form-row ' + } +}; export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = { id: 'accessConditions', @@ -28,7 +40,7 @@ export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayMode }; export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLayout = { grid: { - group: 'form-row' + group: 'form-row pt-4', } }; @@ -39,11 +51,8 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConf }; export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', - label: 'col-form-label' - }, - grid: { - host: 'col-md-10' + host: 'col-12', + label: 'col-form-label name-label' } }; @@ -70,11 +79,10 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePicke }; export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', label: 'col-form-label' }, grid: { - host: 'col-md-4' + host: 'col-6' } }; @@ -101,10 +109,9 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerM }; export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', label: 'col-form-label' }, grid: { - host: 'col-md-4' + host: 'col-6' } }; diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html index 64df1155bf..221d396a39 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.html +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -10,8 +10,9 @@
- - + + +