From 40ae081b062a521b890493426061d28aa2941a2a Mon Sep 17 00:00:00 2001 From: "Gantner, Florian Klaus" Date: Fri, 1 Sep 2023 18:19:52 +0200 Subject: [PATCH 1/3] associate item page list and service basic functionality to associate two items. own page with search and list preview with action buttons, services to trigger. associateitemmodes are being used to check the access to the page and to show the context-menu button. In the majority some adoption of the edititem, edititem-mode and edit-relationship functionality. --- src/app/app-routing.module.ts | 5 + src/app/app.reducer.ts | 8 +- .../associate-item-page.component.html | 41 ++ .../associate-item-page.component.scss | 3 + .../associate-item-page.component.spec.ts | 106 +++++ .../associate-item-page.component.ts | 405 ++++++++++++++++++ .../associate-item-routing.module.ts | 32 ++ .../associate-item/associate-item.actions.ts | 13 + .../associate-item/associate-item.module.ts | 18 + .../associate-item/associate-item.reducer.ts | 17 + .../associate-item/associate-item.resolver.ts | 60 +++ .../associate-item.selectors.ts | 24 ++ .../guards/associate-item.guard.ts | 50 +++ .../associateitem/associateitem.service.ts | 146 +++++++ .../associateitemmode-data.service.ts | 109 +++++ .../models/associateitem-mode.model.ts | 66 +++ src/app/core/core.module.ts | 8 + src/app/core/shared/context.model.ts | 3 +- .../associate-item-menu.component.html | 7 + .../associate-item-menu.component.spec.ts | 11 + .../associate-item-menu.component.ts | 110 +++++ .../context-menu/context-menu-entry-type.ts | 1 + .../context-menu/context-menu.module.ts | 3 + .../associateitems-list.component.html | 4 + .../associateitems-list.component.scss | 0 .../associateitems-list.component.spec.ts | 98 +++++ .../associateitems-list.component.ts | 40 ++ .../associate-items-actions.component.html | 20 + .../associate-items-actions.component.scss | 0 .../associate-items-actions.component.spec.ts | 52 +++ .../associate-items-actions.component.ts | 138 ++++++ ...ssociate-items-list-preview.component.html | 58 +++ ...ssociate-items-list-preview.component.scss | 5 + ...ciate-items-list-preview.component.spec.ts | 25 ++ .../associate-items-list-preview.component.ts | 51 +++ src/app/shared/shared.module.ts | 8 +- src/assets/i18n/en.json5 | 36 +- 37 files changed, 1777 insertions(+), 4 deletions(-) create mode 100644 src/app/associate-item/associate-item-page.component.html create mode 100644 src/app/associate-item/associate-item-page.component.scss create mode 100644 src/app/associate-item/associate-item-page.component.spec.ts create mode 100644 src/app/associate-item/associate-item-page.component.ts create mode 100644 src/app/associate-item/associate-item-routing.module.ts create mode 100644 src/app/associate-item/associate-item.actions.ts create mode 100644 src/app/associate-item/associate-item.module.ts create mode 100644 src/app/associate-item/associate-item.reducer.ts create mode 100644 src/app/associate-item/associate-item.resolver.ts create mode 100644 src/app/associate-item/associate-item.selectors.ts create mode 100644 src/app/associate-item/guards/associate-item.guard.ts create mode 100644 src/app/core/associateitem/associateitem.service.ts create mode 100644 src/app/core/associateitem/associateitemmode-data.service.ts create mode 100644 src/app/core/associateitem/models/associateitem-mode.model.ts create mode 100644 src/app/shared/context-menu/associate-item/associate-item-menu.component.html create mode 100644 src/app/shared/context-menu/associate-item/associate-item-menu.component.spec.ts create mode 100644 src/app/shared/context-menu/associate-item/associate-item-menu.component.ts create mode 100644 src/app/shared/object-list/associate-item-list/associateitems-list.component.html create mode 100644 src/app/shared/object-list/associate-item-list/associateitems-list.component.scss create mode 100644 src/app/shared/object-list/associate-item-list/associateitems-list.component.spec.ts create mode 100644 src/app/shared/object-list/associate-item-list/associateitems-list.component.ts create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.html create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.scss create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.spec.ts create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.ts create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.scss create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.spec.ts create mode 100644 src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index d585d737437..1e4a9a24554 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -206,6 +206,11 @@ import { RedirectService } from './redirect/redirect.service'; .then((m) => m.EditItemModule), canActivate: [EndUserAgreementCurrentUserGuard] }, + { + path: 'associate-item', + loadChildren: () => import('./associate-item/associate-item.module') + .then((m) => m.AssociateItemModule), + }, { path: PROFILE_MODULE_PATH, loadChildren: () => import('./profile-page/profile-page.module') diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 95d7c385ae1..cbb64231606 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -53,6 +53,10 @@ import { editItemRelationshipsReducer, EditItemRelationshipsState } from './edit-item-relationships/edit-item-relationships.reducer'; +import { + associateItemReducer, + AssociateItemState +} from './associate-item/associate-item.reducer'; export interface AppState { router: RouterReducerState; @@ -76,6 +80,7 @@ export interface AppState { correlationId: string; contextHelp: ContextHelpState; editItemRelationships: EditItemRelationshipsState; + associateItem: AssociateItemState; } export const appReducers: ActionReducerMap = { @@ -99,7 +104,8 @@ export const appReducers: ActionReducerMap = { correlationId: correlationIdReducer, contextHelp: contextHelpReducer, statistics: StatisticsReducer, - editItemRelationships: editItemRelationshipsReducer + editItemRelationships: editItemRelationshipsReducer, + associateItem: associateItemReducer, }; export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/associate-item/associate-item-page.component.html b/src/app/associate-item/associate-item-page.component.html new file mode 100644 index 00000000000..afbebaaba5e --- /dev/null +++ b/src/app/associate-item/associate-item-page.component.html @@ -0,0 +1,41 @@ +
+
+ +
+
+
+

{{'associate.item.' + mode.modename | translate}}

+
+
+
+
+
+ + + +
+
+ +
+
+ + +
+
+
diff --git a/src/app/associate-item/associate-item-page.component.scss b/src/app/associate-item/associate-item-page.component.scss new file mode 100644 index 00000000000..2161239e425 --- /dev/null +++ b/src/app/associate-item/associate-item-page.component.scss @@ -0,0 +1,3 @@ +.btnsamesize { + min-width: var(--ds-edit-item-button-min-width); +} diff --git a/src/app/associate-item/associate-item-page.component.spec.ts b/src/app/associate-item/associate-item-page.component.spec.ts new file mode 100644 index 00000000000..bd2c5ca831d --- /dev/null +++ b/src/app/associate-item/associate-item-page.component.spec.ts @@ -0,0 +1,106 @@ +/* eslint-disable max-classes-per-file */ +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { AssociateItemPageComponent } from './associate-item-page.component'; +import { Observable, of as observableOf } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; +import { Item } from '../core/shared/item.model'; + +describe('AssociateItemPageComponent', () => { + let comp: AssociateItemPageComponent; + let fixture: ComponentFixture; + + class AcceptAllGuard implements CanActivate { + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | Promise | boolean | UrlTree { + return observableOf(true); + } + } + + class AcceptNoneGuard implements CanActivate { + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | Promise | boolean | UrlTree { + return observableOf(false); + } + } + + const accesiblePages = ['accessible']; + const inaccesiblePages = ['inaccessible', 'inaccessibleDoubleGuard']; + const mockRoute = { + snapshot: { + firstChild: { + routeConfig: { + path: accesiblePages[0] + } + }, + routerState: { + snapshot: undefined + } + }, + routeConfig: { + children: [ + { + path: accesiblePages[0], + canActivate: [AcceptAllGuard] + }, { + path: inaccesiblePages[0], + canActivate: [AcceptNoneGuard] + }, { + path: inaccesiblePages[1], + canActivate: [AcceptAllGuard, AcceptNoneGuard] + }, + ] + }, + data: observableOf({dso: createSuccessfulRemoteDataObject(new Item())}) + }; + + const mockRouter = { + routerState: { + snapshot: undefined + }, + events: observableOf(undefined) + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [AssociateItemPageComponent], + providers: [ + { provide: ActivatedRoute, useValue: mockRoute }, + { provide: Router, useValue: mockRouter }, + AcceptAllGuard, + AcceptNoneGuard, + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(AssociateItemPageComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(AssociateItemPageComponent); + comp = fixture.componentInstance; + spyOn((comp as any).injector, 'get').and.callFake((a) => new a()); + fixture.detectChanges(); + })); + + describe('ngOnInit', () => { + it('should enable tabs that the user can activate', fakeAsync(() => { + const enabledItems = fixture.debugElement.queryAll(By.css('a.nav-link')); + expect(enabledItems.length).toBe(accesiblePages.length); + })); + + it('should disable tabs that the user can not activate', () => { + const disabledItems = fixture.debugElement.queryAll(By.css('button.nav-link.disabled')); + expect(disabledItems.length).toBe(inaccesiblePages.length); + }); + }); +}); diff --git a/src/app/associate-item/associate-item-page.component.ts b/src/app/associate-item/associate-item-page.component.ts new file mode 100644 index 00000000000..a4bf1303294 --- /dev/null +++ b/src/app/associate-item/associate-item-page.component.ts @@ -0,0 +1,405 @@ +import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Title } from '@angular/platform-browser'; + +import { BehaviorSubject, EMPTY, Observable, Subscription, } from 'rxjs'; +import { map, switchMap, take, tap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { Store } from '@ngrx/store'; + +import { hasValue } from '../shared/empty.util'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getRemoteDataPayload +} from '../core/shared/operators'; +import { RemoteData } from '../core/data/remote-data'; +import { Item } from '../core/shared/item.model'; +import { EntityTypeDataService } from '../core/data/entity-type-data.service'; +import { Context } from '../core/shared/context.model'; +import { HostWindowService } from '../shared/host-window.service'; +import { getItemPageRoute } from '../item-page/item-page-routing-paths'; +import { AppState } from '../app.reducer'; +import { AssociateItemActionTypes } from './associate-item.actions'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { AssociateItemModeDataService } from '../core/associateitem/associateitemmode-data.service'; +import { AssociateItemMode } from '../core/associateitem/models/associateitem-mode.model'; +import { AssociateItemService } from '../core/associateitem/associateitem.service'; +import { ViewMode } from '../core/shared/view-mode.model'; +import {ConfigurationSearchPageComponent} from '../search-page/configuration-search-page.component'; +import {currentPath} from '../shared/utils/route.utils'; +import {SearchService} from '../core/shared/search/search.service'; + +export enum ManageAssociationEventType { + associate = 'associate', + disassociate = 'disassociate', +} + +export interface ManageAssociationEvent { + action: ManageAssociationEventType; + item: Item; +} + +export interface ManageAssociationCustomData { + metadatafield: string; + updateStatusByItemId$: BehaviorSubject; + targetid: string; +} + +@Component({ + selector: 'ds-associate-item-page', + templateUrl: './associate-item-page.component.html', + styleUrls: ['./associate-item-page.component.scss'], +}) +/** + * Component for displaying the introduction text and search results to associate items with each other + * */ +export class AssociateItemPageComponent implements OnInit, OnDestroy { + + @ViewChild(ConfigurationSearchPageComponent) search: ConfigurationSearchPageComponent; + + /** + * A boolean representing if component is active + * @type {boolean} + */ + isActive: boolean; + + inPlaceSearch = true; + + /** + * Item as observable Remote Data + */ + itemRD$: Observable>; + + /** + * Item that is being checked for relationships + */ + item: Item; + + /** + * The associate mode from the path + */ + routemode: string; + + /** + * The resolved mode + */ + mode: AssociateItemMode; + + /** + * The resolved mode + */ + mode$: Observable; + /** + * The discovery configuration + */ + configuration: string; + /** + * The metadatafield for the associated item + */ + metadatafield: string; + + /** + * The current context of this page: associateItem + */ + context: Context = Context.AssociateItem; + + /** + * The emitter that updates the state of the items. + * If null or undefined then updates all items in the view. + */ + updateStatusByItemId$: BehaviorSubject = new BehaviorSubject(null); + + /** + * The relationship configuration + */ + searchFilter: string; + + /** + * Emits true if were on a small screen + */ + isXsOrSm$: Observable; + + /** + * Description Text for the Page + */ + description: string; + + /** + * Emits true when a relationship is being added, deleted, or updated + */ + private processing$ = new BehaviorSubject(false); + + /** + * Representing if any action is processing in the page result list + */ + pendingChanges$: Observable; + + /** + * Available View Modes + */ + viewModes: ViewMode[] = [ViewMode.ListElement]; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + private subs: Subscription[] = []; + + constructor(private route: ActivatedRoute, + private router: Router, + protected entityTypeService: EntityTypeDataService, + private windowService: HostWindowService, + private translate: TranslateService, + private title: Title, + protected store: Store, + protected notification: NotificationsService, + protected associateItemModeDataService: AssociateItemModeDataService, + protected associateItemService: AssociateItemService, + private searchService: SearchService, + ) { + this.routemode = this.route.snapshot.params.type; + this.isXsOrSm$ = this.windowService.isXsOrSm(); + this.description = this.translate.instant('associate.item.' + this.routemode + '.description') || ''; + } + + + /** + * On component initialization get the item object. + * After getting object get all its relationships & relationsihp types + * Get all results of the relation to manage + */ + ngOnInit() { + + this.itemRD$ = this.route.data.pipe( + map((data) => data.info), + getFirstSucceededRemoteData() + ) as Observable>; + + this.getInfo(); + this.mode$ = this.associateItemModeDataService.getAssociateModeByIdAndType(this.item.id, this.routemode); + this.mode$.subscribe((value: AssociateItemMode) => { + this.mode = value; + this.metadatafield = value.metadatafield; + this.configuration = value.discovery; + } + ); + + this.pendingChanges$ = this.processing$.asObservable().pipe( + tap((res) => { + this.store.dispatch(AssociateItemActionTypes.PENDING_CHANGES({ pendingChanges: res })); + }) + ); + + } + + /** + * Get Info about the current object + * */ + getInfo() { + this.subs.push( + this.itemRD$.pipe( + getRemoteDataPayload(), + take(1) + ).subscribe((item: Item) => { + this.item = item; + + const modeTranslated = this.translate.instant(this.routemode + '.search.results.head'); + + this.title.setTitle(modeTranslated); + this.searchFilter = ``; + this.isActive = true; + }) + ); + } + + /** + * When an action is performed manage the association of the item + * @param event the event from which comes an action type + */ + manageAction(event: ManageAssociationEvent): void { + if (event.action === ManageAssociationEventType.associate) { + this.createAssociation(this.routemode, event.item, event, this.item).subscribe(); + } else if (event.action === ManageAssociationEventType.disassociate) { + this.deleteAssociation(this.routemode, event.item, event, this.item).subscribe(); + } else { + console.warn(`Unhandled action ${event.action}`); + } + } + + /** + * Create Association using the specific service + */ + createAssociation(mode: string, objectItem: Item, action: ManageAssociationEvent, targetItem: Item): Observable { + this.processing$.next(true); + return this.associateItemService.createAssociation(objectItem.id, targetItem.id, mode).pipe(getFirstSucceededRemoteData()).pipe( + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notification.success(undefined, this.getSuccessMsgByAction(action.action)); + this.processing$.next(false); + this.updateStatusByItemId$.next(objectItem.id); + return new Observable(); + } else { + this.processing$.next(false); + this.updateStatusByItemId$.next(objectItem.id); + this.notification.error(undefined, this.getErrMsgByAction(action.action)); + return EMPTY; + } + })); + } + + /** + * Delete Association using the specific service + */ + deleteAssociation(mode: string, objectItem: Item, action: ManageAssociationEvent, targetItem: Item): Observable { + this.processing$.next(true); + return this.associateItemService.deleteAssociation(objectItem.id, targetItem.id, mode).pipe(getFirstCompletedRemoteData()).pipe( + switchMap((rd: any) => { + if (rd.hasSucceeded) { + this.notification.success(undefined, this.getSuccessMsgByAction(action.action)); + this.processing$.next(false); + this.updateStatusByItemId$.next(objectItem.id); + return new Observable(); + } else { + this.processing$.next(false); + this.updateStatusByItemId$.next(objectItem.id); + this.notification.error(undefined, this.getErrMsgByAction(action.action)); + return EMPTY; + } + })); + } + + /** + * When return button is pressed go to previous location + */ + public onReturn() { + this.router.navigateByUrl(getItemPageRoute(this.item)); + } + + public onSearchConnected($event: Event) { + let q = (this.search.searchConfigService.paginatedSearchOptions as any).value.query || ''; + if (q.includes('-' + this.metadatafield + '_authority:' + this.item.id)) { + q = q.replace('-' + this.metadatafield + '_authority:' + this.item.id, this.metadatafield + '_authority:' + this.item.id); + } else if (!q.includes(this.metadatafield + '_authority:' + this.item.id)) { + q += ' ' + this.metadatafield + '_authority:' + this.item.id; + } + q = q.replace(/^ */, ''); + q = q.replace(/ *$/, ''); + q = q.replace(/ +/g, ' '); + this.search.searchOptions$.next(Object.assign((this.search.searchConfigService.paginatedSearchOptions as any).value, {query: q})); + this.updateSearch((this.search.searchConfigService.paginatedSearchOptions as any).value); + $event.preventDefault(); + } + + public onSearchnotConnected($event: Event) { + let q = (this.search.searchConfigService.paginatedSearchOptions as any).value.query || ''; + if (!q.includes('-' + this.metadatafield + '_authority:' + this.item.id) && q.includes(this.metadatafield + '_authority:' + this.item.id)) { + q = q.replace(this.metadatafield + '_authority:' + this.item.id, '-' + this.metadatafield + '_authority:' + this.item.id); + } else if (!q.includes('-' + this.metadatafield + '_authority:' + this.item.id)) { + q += ' -' + this.metadatafield + '_authority:' + this.item.id; + } + q = q.replace(/^ */, ''); + q = q.replace(/ *$/, ''); + q = q.replace(/ +/g, ' '); + this.search.searchOptions$.next(Object.assign((this.search.searchConfigService.paginatedSearchOptions as any).value, {query: q})); + this.updateSearch((this.search.searchConfigService.paginatedSearchOptions as any).value); + $event.preventDefault(); + } + + public onSearchAll($event: Event) { + let q = (this.search.searchConfigService.paginatedSearchOptions as any).value.query || '*'; + if (q.includes('-' + this.metadatafield + '_authority:' + this.item.id)) { + q = q.replace('-' + this.metadatafield + '_authority:' + this.item.id, ''); + } else if (q.includes(this.metadatafield + '_authority:' + this.item.id)) { + q = q.replace(this.metadatafield + '_authority:' + this.item.id, ''); + } + if (!q.includes('*')) { + q += ' *'; + } + + this.search.searchOptions$.next(Object.assign((this.search.searchConfigService.paginatedSearchOptions as any).value, {query: q})); + this.updateSearch((this.search.searchConfigService.paginatedSearchOptions as any).value); + $event.preventDefault(); + } + + /** + * Updates the search URL + * @param data Updated parameters + */ + updateSearch(data: any) { + const queryParams = Object.assign({}, data); + + this.router.navigate(this.getSearchLinkParts(), { + queryParams: queryParams, + queryParamsHandling: 'merge' + }); + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + public getSearchLink(): string { + if (this.inPlaceSearch) { + return currentPath(this.router); + } + return this.searchService.getSearchLink(); + } + + /** + * @returns {string[]} The base path to the search page, or the current page when inPlaceSearch is true, split in separate pieces + */ + public getSearchLinkParts(): string[] { + if (this.inPlaceSearch) { + return []; + } + return this.getSearchLink().split('/'); + } + + /** + * return the i18n error message label according to the action type + * @param action + * @private + */ + private getErrMsgByAction(action: ManageAssociationEventType): string { + let label; + switch (action) { + case ManageAssociationEventType.associate: + label = 'manage.associateitem.error.associate'; + break; + case ManageAssociationEventType.disassociate: + label = 'manage.associateitem.error.disassociate'; + break; + } + + return this.translate.instant(label); + } + + /** + * return the i18n success message label according to the action type + * @param action + * @private + */ + private getSuccessMsgByAction(action: ManageAssociationEventType): string { + let label; + switch (action) { + case ManageAssociationEventType.associate: + label = 'manage.associateitem.success.associate'; + break; + case ManageAssociationEventType.disassociate: + label = 'manage.associateitem.success.disassociate'; + break; + } + + return this.translate.instant(label); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.isActive = false; + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/associate-item/associate-item-routing.module.ts b/src/app/associate-item/associate-item-routing.module.ts new file mode 100644 index 00000000000..b807acc625a --- /dev/null +++ b/src/app/associate-item/associate-item-routing.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { AssociateItemResolver } from './associate-item.resolver'; +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { DsoContextBreadcrumbResolver } from '../core/breadcrumbs/dso-context-breadcrumb.resolver'; +import { AssociateItemPageComponent } from './associate-item-page.component'; +import { AssociateItemGuard } from './guards/associate-item.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: ':id/:type', + component: AssociateItemPageComponent, + resolve: { + info: AssociateItemResolver, + breadcrumb: DsoContextBreadcrumbResolver + }, + data: { + breadcrumbKey: 'associate.item', + }, + canActivate: [AuthenticatedGuard, AssociateItemGuard], + } + ] + ) + ], + providers: [ + AssociateItemResolver, + ] +}) +export class AssociateItemRoutingModule { +} diff --git a/src/app/associate-item/associate-item.actions.ts b/src/app/associate-item/associate-item.actions.ts new file mode 100644 index 00000000000..e2511ee11fe --- /dev/null +++ b/src/app/associate-item/associate-item.actions.ts @@ -0,0 +1,13 @@ +import { createAction, props } from '@ngrx/store'; + +export interface AssociateItemState { + pendingChanges: boolean; +} + +export const AssociateItemActionTypes = { + PENDING_CHANGES: createAction( + 'dspace/associate-item-page/PENDING_CHANGES', + props<{ pendingChanges: boolean }>() + ), +}; + diff --git a/src/app/associate-item/associate-item.module.ts b/src/app/associate-item/associate-item.module.ts new file mode 100644 index 00000000000..11d5db290a9 --- /dev/null +++ b/src/app/associate-item/associate-item.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { AssociateItemRoutingModule } from './associate-item-routing.module'; +import { SearchModule } from '../shared/search/search.module'; +import { AssociateItemPageComponent } from './associate-item-page.component'; + + +@NgModule({ + declarations: [AssociateItemPageComponent], + imports: [ + CommonModule, + SharedModule.withEntryComponents(), + AssociateItemRoutingModule, + SearchModule.withEntryComponents() + ] +}) +export class AssociateItemModule { } diff --git a/src/app/associate-item/associate-item.reducer.ts b/src/app/associate-item/associate-item.reducer.ts new file mode 100644 index 00000000000..550f927a1c2 --- /dev/null +++ b/src/app/associate-item/associate-item.reducer.ts @@ -0,0 +1,17 @@ +import { AssociateItemActionTypes, AssociateItemState } from './associate-item.actions'; +import { createReducer, on } from '@ngrx/store'; + + +export const associateItemReducer = createReducer( + { pendingChanges: false }, + + on(AssociateItemActionTypes.PENDING_CHANGES, (state, action): AssociateItemState => { + return { + ...state, + pendingChanges: action.pendingChanges, + }; + }), + +); + +export { AssociateItemState }; diff --git a/src/app/associate-item/associate-item.resolver.ts b/src/app/associate-item/associate-item.resolver.ts new file mode 100644 index 00000000000..a8db61e447f --- /dev/null +++ b/src/app/associate-item/associate-item.resolver.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../core/data/remote-data'; +import { ItemDataService } from '../core/data/item-data.service'; +import { Item } from '../core/shared/item.model'; +import { FollowLinkConfig } from '../shared/utils/follow-link-config.model'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { Store } from '@ngrx/store'; +import { ResolvedAction } from '../core/resolving/resolver.actions'; + +/** + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ +export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + //followLink('owningCollection', {}, + //followLink('parentCommunity', {}, + //followLink('parentCommunity')) + //), + //followLink('bundles', {}, followLink('bitstreams')), + //followLink('relationships'), + //followLink('version', {}, followLink('versionhistory')), + //followLink('metrics') +]; + +/** + * This class represents a resolver that requests a specific item before the route is activated + */ +@Injectable() +export class AssociateItemResolver implements Resolve> { + constructor( + private itemService: ItemDataService, + private store: Store + ) { + } + + /** + * Method for resolving an item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const itemRD$ = this.itemService.findById(route.params.id, + true, + false, + ...ITEM_PAGE_LINKS_TO_FOLLOW + ).pipe( + getFirstCompletedRemoteData(), + ); + + itemRD$.subscribe((itemRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, itemRD.payload)); + }); + + return itemRD$; + } +} diff --git a/src/app/associate-item/associate-item.selectors.ts b/src/app/associate-item/associate-item.selectors.ts new file mode 100644 index 00000000000..968ddd87fa5 --- /dev/null +++ b/src/app/associate-item/associate-item.selectors.ts @@ -0,0 +1,24 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { AssociateItemState } from './associate-item.actions'; + +/** + * Base selector to select the core state from the store + */ +export const associateItemSelector = createFeatureSelector('associateItem'); + +/** + * Returns the pending status. + * @function _getPendingStatus + * @param {AssociateItemState} state + * @returns {boolean} reportId + */ +const _getPendingStatus = (state: AssociateItemState) => state.pendingChanges; + +/** + * Returns the pending status. + * @function getPendingStatus + * @param {AssociateItemState} state + * @param {any} props + * @return {boolean} + */ +export const getPendingStatus = createSelector(associateItemSelector, _getPendingStatus); diff --git a/src/app/associate-item/guards/associate-item.guard.ts b/src/app/associate-item/guards/associate-item.guard.ts new file mode 100644 index 00000000000..32217ca0f5e --- /dev/null +++ b/src/app/associate-item/guards/associate-item.guard.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import {AssociateItemModeDataService} from '../../core/associateitem/associateitemmode-data.service'; + +/** + * Prevent unauthorized activating and loading of routes + * @class AuthenticatedGuard + */ +@Injectable() +export class AssociateItemGuard implements CanActivate { + + /** + * @constructor + */ + constructor(private router: Router, + private associateItemModeService: AssociateItemModeDataService) { + } + + /** + * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + const url = state.url; + return this.handleEditable(route.params.id, route.params.type, url); + } + + /** + * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated + * @method canActivateChild + */ + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.canActivate(route, state); + } + + private handleEditable(itemId: string, mode: string, url: string): Observable { + // redirect to sign in page if user is not authenticated + return this.associateItemModeService.checkAssociateModeByIdAndType(itemId,mode).pipe( + map((result) => { + return result; + }), + ); + } +} diff --git a/src/app/core/associateitem/associateitem.service.ts b/src/app/core/associateitem/associateitem.service.ts new file mode 100644 index 00000000000..204a93997d1 --- /dev/null +++ b/src/app/core/associateitem/associateitem.service.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import {filter, map, mergeMap, switchMap, toArray} from 'rxjs/operators'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import {AsyncSubject, combineLatest, from as observableFrom, Observable} from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { DeleteRequest, PutRequest } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { NoContent } from '../shared/NoContent.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +/** + * A service that provides methods to make REST requests with AssociateItemMode endpoint. No real HAL-Service. + * Basic functionality (param building) was copied from other services, mainly data-service. + */ + +@Injectable() +export class AssociateItemService { + + protected linkPath = 'associateitem'; + protected responseMsToLive = 10 * 1000; + + constructor(private requestService: RequestService, + protected rdbService: RemoteDataBuildService, + private objectCache: ObjectCacheService, + protected halService: HALEndpointService){ + // + this.halService.getEndpoint(this.linkPath).subscribe(value => { + this.linkPath = value; + }); + } + + /** + * PUT Request on /create endpoint to create associateItem + * */ + public createAssociation(sourceitem: string, targetitem: string, mode: string): Observable> { + //create object + let reps: RequestParam[] = []; + reps.push(new RequestParam('sourceuuid', sourceitem)); + reps.push(new RequestParam('targetuuid', targetitem)); + reps.push(new RequestParam('modename', mode)); + + let href = this.buildHrefWithParams(this.linkPath + '/create', reps); + + const requestId = this.requestService.generateRequestId(); + const request = new PutRequest(requestId, href, undefined); + + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + + this.requestService.send(request); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * DELETE Request on /delete endpoint to remove associateItem + * */ + public deleteAssociation(sourceitem: string, targetitem: string, mode: string): Observable> { + + let reps: RequestParam[] = []; + reps.push(new RequestParam('sourceuuid', sourceitem)); + reps.push(new RequestParam('targetuuid', targetitem)); + reps.push(new RequestParam('modename', mode)); + + let href = this.buildHrefWithParams(this.linkPath + '/delete', reps); + + const requestId = this.requestService.generateRequestId(); + + const request = new DeleteRequest(requestId, href); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.invalidateByHref(href); + return response$; + } else { + return response$; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return combineLatest([response$, invalidated$]).pipe( + filter(([_, invalidated]) => invalidated), + map(([response, _]) => response), + ); + } + + protected buildHrefWithParams(href: string, params: RequestParam[]): string { + + let args = []; + if (hasValue(params)) { + params.forEach((param: RequestParam) => { + args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); + }); + } + + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } + + protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] { + if (href.includes(newArg) || currentArgs.includes(newArg)) { + return [...currentArgs]; + } else { + return [...currentArgs, newArg]; + } + } + + protected invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + )), + ).subscribe(() => { + done$.next(true); + done$.complete(); + }); + + return done$; + } + +} diff --git a/src/app/core/associateitem/associateitemmode-data.service.ts b/src/app/core/associateitem/associateitemmode-data.service.ts new file mode 100644 index 00000000000..6d56fd6e8a4 --- /dev/null +++ b/src/app/core/associateitem/associateitemmode-data.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../data/base/data-service.decorator'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { map } from 'rxjs/operators'; +import { getAllSucceededRemoteDataPayload, getPaginatedListPayload } from '../shared/operators'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list.model'; +import { AssociateItemMode } from './models/associateitem-mode.model'; +import { SearchDataImpl } from '../data/base/search-data'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import {FindListOptions} from '../data/find-list-options.model'; + +/** + * A service that provides methods to make REST requests with AssociateItemMode endpoint. + */ + +@Injectable() +@dataService(AssociateItemMode.type) +export class AssociateItemModeDataService extends IdentifiableDataService { + protected linkPath = 'associateitemmodes'; + protected searchById = 'findModesById'; + private searchData: SearchDataImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService){ + super('associateitems', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Search for AssociateItemMode from the Item id + * + * @param id string id of associate item + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to false + * @return Paginated list of associate item modes + */ + searchAssociateModesById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable>> { + const options = new FindListOptions(); + options.searchParams = [ + { + fieldName: 'uuid', + fieldValue: id + }, + ]; + return this.searchData.searchBy(this.searchById, options, useCachedVersionIfAvailable, reRequestOnStale); + } + /*const hrefObs = this.getSearchByHref( + 'findModesById', { + searchParams: [ + { + fieldName: 'uuid', + fieldValue: id + }, + ] + }); + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.send(request, useCachedVersionIfAvailable); + }); + return this.rdbService.buildList(hrefObs);*( + } + */ + + /** + * Check if associateMode with id is part of the associate item with id + * + * @param id string id of associate item + * @param associateModeId string id of associate item + * @return boolean + */ + checkAssociateModeByIdAndType(id: string, associateModeId: string) { + return this.searchAssociateModesById(id).pipe( + getAllSucceededRemoteDataPayload(), + getPaginatedListPayload(), + map((editModes: AssociateItemMode[]) => { + return !!editModes.find(editMode => editMode.modename === associateModeId); + })); + } + + getAssociateModeByIdAndType(id: string, associateModeId: string) { + return this.searchAssociateModesById(id).pipe( + getAllSucceededRemoteDataPayload(), + getPaginatedListPayload(), + map((editModes: AssociateItemMode[]) => { + return editModes.find(editMode => editMode.modename === associateModeId); + })); + } + + /** + * Invalidate the cache of the associateMode + * @param id + */ + invalidateItemCache(id: string) { + this.requestService.setStaleByHrefSubstring('findModesById?uuid=' + id); + } + +} diff --git a/src/app/core/associateitem/models/associateitem-mode.model.ts b/src/app/core/associateitem/models/associateitem-mode.model.ts new file mode 100644 index 00000000000..ee3a4fd8aaa --- /dev/null +++ b/src/app/core/associateitem/models/associateitem-mode.model.ts @@ -0,0 +1,66 @@ +import { typedObject } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { autoserialize, autoserializeAs, deserialize, deserializeAs } from 'cerialize'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { HALLink } from '../../shared/hal-link.model'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * Describes a AssociateItemMode mode + */ +@typedObject +export class AssociateItemMode extends DSpaceObject { + + static type = new ResourceType('associateitemmode'); + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The universally unique identifier of this Item + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer(AssociateItemMode.type.value), 'name') + uuid: string; + + /** + * Name of the EditItem Mode + */ + // @autoserialize + // name: string; + + @autoserializeAs('name') + modename: string; + + /** + * Label used for i18n + */ + @autoserialize + label: string; + + /** + * Name of the discovery + */ + @autoserialize + discovery: string; + + /** + * Name of the metadatafield + */ + @autoserialize + metadatafield: string; + + /** + * The {@link HALLink}s for this AssociateItemMode + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index f57eb72e9c5..53ba39a30cb 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -159,6 +159,9 @@ import { Section } from './layout/models/section.model'; import { EditItem } from './submission/models/edititem.model'; import { EditItemDataService } from './submission/edititem-data.service'; import { EditItemMode } from './submission/models/edititem-mode.model'; +import { AssociateItemMode } from './associateitem/models/associateitem-mode.model'; +import { AssociateItemModeDataService } from './associateitem/associateitemmode-data.service'; +import { AssociateItemService } from './associateitem/associateitem.service'; import { AuditDataService } from './audit/audit-data.service'; import { Audit } from './audit/model/audit.model'; import { ItemExportFormat } from './itemexportformat/model/item-export-format.model'; @@ -177,6 +180,7 @@ import { StatisticsCategory } from './statistics/models/statistics-category.mode import { RootDataService } from './data/root-data.service'; import { SearchConfig } from '../shared/search/search-filters/search-config.model'; import { EditItemRelationsGuard } from '../edit-item-relationships/guards/edit-item-relationships.guard'; +import { AssociateItemGuard } from '../associate-item/guards/associate-item.guard'; import { SequenceService } from './shared/sequence.service'; import { CoreState } from './core-state.model'; import { GroupDataService } from './eperson/group-data.service'; @@ -355,7 +359,10 @@ const PROVIDERS = [ ItemExportFormatService, SectionDataService, EditItemDataService, + AssociateItemModeDataService, + AssociateItemService, EditItemRelationsGuard, + AssociateItemGuard, SequenceService, GroupDataService, FeedbackDataService, @@ -450,6 +457,7 @@ export const models = Section, EditItem, EditItemMode, + AssociateItemMode, OpenaireBrokerTopicObject, OpenaireBrokerEventObject, OpenaireSuggestion, diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index e9e8ec747c3..55aad190e25 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -18,5 +18,6 @@ export enum Context { SideBarSearchModal = 'sideBarSearchModal', SideBarSearchModalCurrent = 'sideBarSearchModalCurrent', RelationshipItem = 'relationshipItem', - BrowseMostElements = 'browseMostElements' + BrowseMostElements = 'browseMostElements', + AssociateItem = 'associateitem', } diff --git a/src/app/shared/context-menu/associate-item/associate-item-menu.component.html b/src/app/shared/context-menu/associate-item/associate-item-menu.component.html new file mode 100644 index 00000000000..ceff5c7b1bd --- /dev/null +++ b/src/app/shared/context-menu/associate-item/associate-item-menu.component.html @@ -0,0 +1,7 @@ + + + diff --git a/src/app/shared/context-menu/associate-item/associate-item-menu.component.spec.ts b/src/app/shared/context-menu/associate-item/associate-item-menu.component.spec.ts new file mode 100644 index 00000000000..75c559fb451 --- /dev/null +++ b/src/app/shared/context-menu/associate-item/associate-item-menu.component.spec.ts @@ -0,0 +1,11 @@ +import { ComponentFixture } from '@angular/core/testing'; +import { AssociateItemMenuComponent } from './associate-item-menu.component'; + +describe('AssociateItemMenuComponent', () => { + let component: AssociateItemMenuComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + + + +}); diff --git a/src/app/shared/context-menu/associate-item/associate-item-menu.component.ts b/src/app/shared/context-menu/associate-item/associate-item-menu.component.ts new file mode 100644 index 00000000000..e3ce9360801 --- /dev/null +++ b/src/app/shared/context-menu/associate-item/associate-item-menu.component.ts @@ -0,0 +1,110 @@ +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; + +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { hasValue, isNotEmpty } from '../../empty.util'; +import { getAllSucceededRemoteDataPayload, getPaginatedListPayload } from '../../../core/shared/operators'; +import { rendersContextMenuEntriesForType } from '../context-menu.decorator'; +import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; +import { ContextMenuEntryComponent } from '../context-menu-entry.component'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { ContextMenuEntryType } from '../context-menu-entry-type'; +import { AssociateItemMode } from '../../../core/associateitem/models/associateitem-mode.model'; +import { AssociateItemModeDataService } from '../../../core/associateitem/associateitemmode-data.service'; + +/** + * This component renders a context menu option that provides the links to associate item page. + */ +@Component({ + selector: 'ds-context-menu-associate-item', + templateUrl: './associate-item-menu.component.html' +}) +@rendersContextMenuEntriesForType(DSpaceObjectType.ITEM) +export class AssociateItemMenuComponent extends ContextMenuEntryComponent implements OnInit, OnDestroy { + + /** + * The menu entry type + */ + public static menuEntryType: ContextMenuEntryType = ContextMenuEntryType.AssociateItem; + + /** + * A boolean representing if a request operation is pending + * @type {BehaviorSubject} + */ + public processing$ = new BehaviorSubject(false); + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + /** + * List of Associate Modes available on this item + * for the current user + */ + private associateModes$: BehaviorSubject = new BehaviorSubject([]); + + /** + * Variable to track subscription and unsubscribe it onDestroy + */ + private sub: Subscription; + + + /** + * Initialize instance variables + * + * @param {DSpaceObject} injectedContextMenuObject + * @param {DSpaceObjectType} injectedContextMenuObjectType + * @param notificationServices + */ + constructor( + @Inject('contextMenuObjectProvider') protected injectedContextMenuObject: DSpaceObject, + @Inject('contextMenuObjectTypeProvider') protected injectedContextMenuObjectType: DSpaceObjectType, + private associateItemService: AssociateItemModeDataService, + public notificationService: NotificationsService + ) { + super(injectedContextMenuObject, injectedContextMenuObjectType, ContextMenuEntryType.AssociateItem); + } + + ngOnInit(): void { + this.notificationService.claimedProfile.subscribe(() => { + this.getData(); + }); + } + + /** + * Check if associate mode is available + */ + getAssociateModes(): Observable { + return this.associateModes$; + } + + /** + * Check if associate mode is available + */ + isEditAvailable(): Observable { + return this.associateModes$.asObservable().pipe( + map((associateModes) => isNotEmpty(associateModes) && associateModes.length > 0) + ); + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } + getData(): void { + this.sub = this.associateItemService.searchAssociateModesById(this.contextMenuObject.id).pipe( + getAllSucceededRemoteDataPayload(), + getPaginatedListPayload(), + startWith([]) + ).subscribe((associateModes: AssociateItemMode[]) => { + this.associateModes$.next(associateModes); + }); + } +} diff --git a/src/app/shared/context-menu/context-menu-entry-type.ts b/src/app/shared/context-menu/context-menu-entry-type.ts index 238aa14617b..ee67a49a26c 100644 --- a/src/app/shared/context-menu/context-menu-entry-type.ts +++ b/src/app/shared/context-menu/context-menu-entry-type.ts @@ -1,5 +1,6 @@ export enum ContextMenuEntryType { Audit = 'audit', + AssociateItem = 'associateitem', BulkImport = 'bulkimport', Claim = 'claim', EditDSO = 'editdso', diff --git a/src/app/shared/context-menu/context-menu.module.ts b/src/app/shared/context-menu/context-menu.module.ts index f0e6949ef78..807bbf99874 100644 --- a/src/app/shared/context-menu/context-menu.module.ts +++ b/src/app/shared/context-menu/context-menu.module.ts @@ -8,6 +8,7 @@ import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { ContextMenuComponent } from './context-menu.component'; import { RequestCorrectionMenuComponent } from './request-correction/request-correction-menu.component'; import { EditItemMenuComponent } from './edit-item/edit-item-menu.component'; +import { AssociateItemMenuComponent } from './associate-item/associate-item-menu.component'; import { ExportItemMenuComponent } from './export-item/export-item-menu.component'; import { AuditItemMenuComponent } from './audit-item/audit-item-menu.component'; import { DsoPageEditMenuComponent } from './dso-page-edit/dso-page-edit-menu.component'; @@ -29,6 +30,7 @@ const COMPONENTS = [ AuditItemMenuComponent, ContextMenuComponent, EditItemMenuComponent, + AssociateItemMenuComponent, ExportItemMenuComponent, ExportCollectionMenuComponent, EditItemRelationshipsMenuComponent, @@ -46,6 +48,7 @@ const ENTRY_COMPONENTS = [ DsoPageEditMenuComponent, AuditItemMenuComponent, EditItemMenuComponent, + AssociateItemMenuComponent, ExportItemMenuComponent, ExportCollectionMenuComponent, EditItemRelationshipsMenuComponent, diff --git a/src/app/shared/object-list/associate-item-list/associateitems-list.component.html b/src/app/shared/object-list/associate-item-list/associateitems-list.component.html new file mode 100644 index 00000000000..e1717a16bd1 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/associateitems-list.component.html @@ -0,0 +1,4 @@ + + + diff --git a/src/app/shared/object-list/associate-item-list/associateitems-list.component.scss b/src/app/shared/object-list/associate-item-list/associateitems-list.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/object-list/associate-item-list/associateitems-list.component.spec.ts b/src/app/shared/object-list/associate-item-list/associateitems-list.component.spec.ts new file mode 100644 index 00000000000..a92f2937902 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/associateitems-list.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AssociateItemListComponent } from './associateitems-list.component'; +import { TruncatableService } from '../../truncatable/truncatable.service'; +import { mockTruncatableService } from '../../mocks/mock-trucatable.service'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { DSONameServiceMock } from '../../mocks/dso-name.service.mock'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { SharedModule } from '../../shared.module'; +import { ItemInfo } from '../../testing/relationships-mocks'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Item } from '../../../core/shared/item.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../app.reducer'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; + + +describe('AssociateItemListComponent', () => { + let component: AssociateItemListComponent; + let fixture: ComponentFixture; + let store: Store; + let mockStore: MockStore; + + const initialState = { + editItemRelationships: { + pendingChanges: true + } + }; + + let de: DebugElement; + + const item = Object.assign( new Item(), ItemInfo.payload); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AssociateItemListComponent ], + imports : [ + NoopAnimationsModule, + SharedModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + providers: [ + provideMockStore({ initialState }), + { provide: TruncatableService, useValue: mockTruncatableService }, + { provide: DSONameService, useClass: DSONameServiceMock }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + store = TestBed.inject(Store); + mockStore = store as MockStore; + fixture = TestBed.createComponent(AssociateItemListComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should be empty buttons', () => { + expect(de.query(By.css('.action-buttons'))).toBeNull(); + }); + + it('should be empty items', () => { + expect(de.query(By.css('.item-details-preview'))).toBeNull(); + }); + + describe('After inserting item', () => { + + beforeEach(() => { + component.dso = item; + component.object = item; + fixture.detectChanges(); + }); + + it('should be with buttons', () => { + expect(de.query(By.css('.item-details-preview'))).toBeTruthy(); + }); + + it('should be with item', () => { + expect(de.query(By.css('.item-details-preview'))).toBeTruthy(); + }); + }); + +}); diff --git a/src/app/shared/object-list/associate-item-list/associateitems-list.component.ts b/src/app/shared/object-list/associate-item-list/associateitems-list.component.ts new file mode 100644 index 00000000000..3acd6b7cfc6 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/associateitems-list.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../object-collection/shared/listable-object/listable-object.decorator'; +import { Context } from '../../../core/shared/context.model'; +import { ItemSearchResult } from '../../object-collection/shared/item-search-result.model'; +import { SearchResultListElementComponent } from '../search-result-list-element/search-result-list-element.component'; + +@Component({ + selector: 'ds-associateitems-list', + templateUrl: './associateitems-list.component.html', + styleUrls: ['./associateitems-list.component.scss'] +}) + +@listableObjectComponent(ItemSearchResult, ViewMode.ListElement, Context.AssociateItem) +export class AssociateItemListComponent extends SearchResultListElementComponent implements OnInit { + + + /** + * Emit custom event for listable object custom actions. + */ + @Output() customEvent = new EventEmitter(); + + /** + * Pass custom data to the component for custom utilization + */ + @Input() customData: any; + + /** + * Display thumbnails if required by configuration + */ + showThumbnails: boolean; + + ngOnInit() { + super.ngOnInit(); + this.showThumbnails = this.appConfig.browseBy.showThumbnails; + } + +} + diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.html b/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.html new file mode 100644 index 00000000000..08ff2d9d542 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.html @@ -0,0 +1,20 @@ +
+
+ + +
+
diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.scss b/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.spec.ts b/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.spec.ts new file mode 100644 index 00000000000..88887b78754 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AssociateItemsActionsComponent } from './associate-items-actions.component'; + +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../app.reducer'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; + +describe('AssociateItemsActionsComponent', () => { + let component: AssociateItemsActionsComponent; + let fixture: ComponentFixture; + let store: Store; + let mockStore: MockStore; + + const initialState = { + editItemRelationships: { + pendingChanges: true + } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AssociateItemsActionsComponent ], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + providers: [ + provideMockStore({ initialState }), + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + store = TestBed.inject(Store); + mockStore = store as MockStore; + fixture = TestBed.createComponent(AssociateItemsActionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.ts b/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.ts new file mode 100644 index 00000000000..e01e44d6050 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/relationships-items-actions/associate-items-actions.component.ts @@ -0,0 +1,138 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; + +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { select, Store } from '@ngrx/store'; + +import { Item } from '../../../../core/shared/item.model'; +import { AppState } from '../../../../app.reducer'; +import { getPendingStatus } from '../../../../associate-item/associate-item.selectors'; +import { hasValue } from '../../../empty.util'; +import { ManageAssociationCustomData, ManageAssociationEvent, ManageAssociationEventType } from '../../../../associate-item/associate-item-page.component'; + +@Component({ + selector: 'ds-associate-items-actions', + templateUrl: './associate-items-actions.component.html', + styleUrls: ['./associate-items-actions.component.scss'] +}) +export class AssociateItemsActionsComponent implements OnInit, OnDestroy { + + /** + * The Item object + */ + @Input() object: Item; + + /** + * Pass custom data to the component for custom utilization + */ + @Input() customData: ManageAssociationCustomData; + /** + * consider thumbnails if required by configuration + */ + @Input() showThumbnails: boolean; + + /** + * If this item is already associated with the targetitem + */ + isAssociated: BehaviorSubject = new BehaviorSubject(null); + + /** + * The subscription list to be unsubscribed + */ + subs: Subscription[] = []; + + /** + * Representing if a association action is processing + */ + isProcessingAssociation: BehaviorSubject = new BehaviorSubject(false); + + /** + * Representing if a disassociation action is processing + */ + isProcessingDisAssociation: BehaviorSubject = new BehaviorSubject(false); + + /** + * Representing if any action is processing in the page result list + */ + pendingChanges$: Observable; + + /** + * Emit when one of the listed object has changed. + */ + @Output() processCompleted = new EventEmitter(); + + constructor( + protected store: Store, + ) { + } + + /** + * Subscribe to the relationships list + */ + ngOnInit(): void { + this.pendingChanges$ = this.store.pipe( + select(getPendingStatus), + map(pendingChanges => + pendingChanges || + this.isProcessingAssociation.value || + this.isProcessingDisAssociation.value + ) + ); + + if (!!this.customData) { + if (!!this.customData.metadatafield && !!this.customData.targetid) { + if (this.object.hasMetadata(this.customData.metadatafield)) { + if (this.object.allMetadata(this.customData.metadatafield).filter(value => (value.authority && value.authority === this.customData.targetid)) + .length > 0) { + this.isAssociated.next(true); + } + } + } + if (!!this.customData.updateStatusByItemId$) { + this.subs.push( + this.customData.updateStatusByItemId$.subscribe((itemId?: string) => { + if (itemId && this.object.id === itemId) { + // Simple Workaround based on current operation. More elegant would be to reload the item and check, if the metadata exist + if (this.isProcessingAssociation.value) { + this.isAssociated.next(true); + } else if (this.isProcessingDisAssociation.value) { + this.isAssociated.next(false); + } + this.isProcessingAssociation.next(false); + this.isProcessingDisAssociation.next(false); + } + }) + ); + } + } + } + + /** + * When a button is clicked emit the event in the parent components + */ + emitAction(action): void { + this.setProcessing(action); + this.processCompleted.emit({ action, item: this.object }); + } + + private setProcessing(action: ManageAssociationEventType): void { + switch (action) { + case ManageAssociationEventType.associate: + this.isProcessingAssociation.next(true); + break; + case ManageAssociationEventType.disassociate: + this.isProcessingDisAssociation.next(true); + break; + } + } + + /** + * On destroy unsubscribe + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html new file mode 100644 index 00000000000..73cf190298d --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html @@ -0,0 +1,58 @@ +
+
+
+ + +
+
+
+
+
+ + +
+ +

+ +

+
+ + + + ( + ) + + + + + + ; + + + + + +
+ +
+
+
+ + + + + +
+ +
+
+
+
+
+
diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.scss b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.scss new file mode 100644 index 00000000000..4a4c9fb15a1 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.scss @@ -0,0 +1,5 @@ +.drag-handle { + i{ + cursor: move; + } +} diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.spec.ts b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.spec.ts new file mode 100644 index 00000000000..729e3d2847b --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AssociateItemsListPreviewComponent } from './associate-items-list-preview.component'; + +describe('AssociateItemsListPreviewComponent', () => { + let component: AssociateItemsListPreviewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AssociateItemsListPreviewComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AssociateItemsListPreviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.ts b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.ts new file mode 100644 index 00000000000..d21d5bf9b71 --- /dev/null +++ b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.ts @@ -0,0 +1,51 @@ +import {Component, Input, OnInit,} from '@angular/core'; +import { fadeInOut } from '../../../animations/fade'; +import { Item } from '../../../../core/shared/item.model'; +import { getItemPageRoute } from '../../../../item-page/item-page-routing-paths'; +import {DSONameService} from '../../../../core/breadcrumbs/dso-name.service'; + +@Component({ + selector: 'ds-associate-items-list-preview', + templateUrl: './associate-items-list-preview.component.html', + styleUrls: ['./associate-items-list-preview.component.scss'], + animations: [fadeInOut] +}) +export class AssociateItemsListPreviewComponent implements OnInit{ + + /** + * The item to display + */ + @Input() item: Item; + + /** + * The custom information object + */ + @Input() customData: any; + + /** + * A string used for specifying the type of view which the component is being used for + */ + @Input() viewConfig = 'default'; + + @Input() showLabel = false; + + @Input() showThumbnails = false; + + processing = false; + + dsoTitle: string; + + /** + * Route to the item's page + */ + itemPageRoute: string; + + public constructor( protected dsoNameService: DSONameService) { + // + } + + ngOnInit(): void { + this.itemPageRoute = getItemPageRoute(this.item); + this.dsoTitle = this.dsoNameService.getName(this.item); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 4f07cfdeefd..983c8aedd45 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -329,6 +329,9 @@ import { } from './search/search-charts/search-chart/search-chart-bar-horizontal/search-chart-bar-horizontal.component'; import { ThumbnailService } from './thumbnail/thumbnail.service'; import { EntityIconDirective } from './entity-icon/entity-icon.directive'; +import { AssociateItemListComponent } from './object-list/associate-item-list/associateitems-list.component'; +import { AssociateItemsActionsComponent } from './object-list/associate-item-list/relationships-items-actions/associate-items-actions.component'; +import { AssociateItemsListPreviewComponent } from './object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component'; import { AdditionalMetadataComponent } from './object-list/search-result-list-element/additional-metadata/additional-metadata.component'; @@ -469,7 +472,10 @@ const COMPONENTS = [ MetadataLinkViewComponent, ExportExcelSelectorComponent, ThemedBrowseMostElementsComponent, - SearchChartBarHorizontalComponent + SearchChartBarHorizontalComponent, + AssociateItemListComponent, + AssociateItemsActionsComponent, + AssociateItemsListPreviewComponent, ]; const ENTRY_COMPONENTS = [ diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index ca1ee2fd1c8..284d010596c 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -813,7 +813,21 @@ "auth.messages.token-refresh-failed": "Refreshing your session token failed. Please log in again.", + "associate.item": "Connect Item", + "associate.item.PROJECTPUBLICATION": "Manage Publications", + + "associate.item.PROJECTPUBLICATION.description": "You can first search for the relevant publications and connect one entry to the project with the button \"Connect\" . To connect further publications, this process must be repeated.\nVia the search you can also display entries that are already connected with the project. The \"Remove\" button can be used to remove the connection.", + + "associate.item.search.placeholder": "Search for Items to connect....", + + "associate.item.page.button.return": "Return", + + "associate.item.page.button.search.all": "Show all items", + + "associate.item.page.button.search.connected": "Show already connected items", + + "associate.item.page.button.search.notconnected": "Show not connected items", "bitstream.download.page": "Now downloading {{bitstream}}..." , @@ -1568,6 +1582,10 @@ "context-menu.actions": "Actions", + "context-menu.actions.associate-item.btn.action" : "Connect", + + "context-menu.actions.associate-item.btn.PROJECTPUBLICATION" : "Connect Publications", + "context-menu.actions.audit-item.btn": "Audit", "context-menu.actions.bulk-import.btn": "Import items into collection", @@ -3657,6 +3675,22 @@ "logout.title": "Logout", + "manage.associateitem.disassociate": "Remove", + + "manage.associateitem.disassociate.tooltip": "Click here to remove the connection", + + "manage.associateitem.associate": "Connect", + + "manage.associateitem.associate.tooltip": "Click here to establish the connectioon", + + "manage.associateitem.error.disassociate" : "An unexpected error occurs while removing the connection.", + + "manage.associateitem.error.associate" : "An unexpected error occurs while establishing the connection.", + + "manage.associateitem.success.disassociate" : "Connection removed.", + + "manage.associateitem.success.associate" : "Connection established.", + "manage.relationships.select": "Select", @@ -4712,7 +4746,7 @@ "project-relationships.search.results.head": "Project Search Results", - + "PROJECTPUBLICATION.search.results.head": "Connect Publication", "publication.listelement.badge": "Publication", From 93c8e3b6864183aecf4e7783a7b27c365d2fb106 Mon Sep 17 00:00:00 2001 From: "Gantner, Florian Klaus" Date: Tue, 20 Feb 2024 17:47:06 +0100 Subject: [PATCH 2/3] fix badge shown on access-status-page --- .../associate-items-list-preview.component.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html index 73cf190298d..9cc7eeabcda 100644 --- a/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html +++ b/src/app/shared/object-list/associate-item-list/relationships-items-list-preview/associate-items-list-preview.component.html @@ -8,8 +8,7 @@
- - +

From ddd11721b41b9e8e6dacb549b62d6e5f4d05a996 Mon Sep 17 00:00:00 2001 From: "Gantner, Florian Klaus" Date: Thu, 25 Apr 2024 14:18:57 +0200 Subject: [PATCH 3/3] update search when pressing (not)connected button on associate-item-page --- .../associate-item-page.component.ts | 22 +++++++++++-------- src/app/shared/search/search.component.ts | 10 +++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/app/associate-item/associate-item-page.component.ts b/src/app/associate-item/associate-item-page.component.ts index a4bf1303294..51ffd0e01ba 100644 --- a/src/app/associate-item/associate-item-page.component.ts +++ b/src/app/associate-item/associate-item-page.component.ts @@ -285,8 +285,9 @@ export class AssociateItemPageComponent implements OnInit, OnDestroy { q = q.replace(/^ */, ''); q = q.replace(/ *$/, ''); q = q.replace(/ +/g, ' '); - this.search.searchOptions$.next(Object.assign((this.search.searchConfigService.paginatedSearchOptions as any).value, {query: q})); - this.updateSearch((this.search.searchConfigService.paginatedSearchOptions as any).value); + + this.updateSearch(q); + this.search.retrieveResults(); $event.preventDefault(); } @@ -300,8 +301,9 @@ export class AssociateItemPageComponent implements OnInit, OnDestroy { q = q.replace(/^ */, ''); q = q.replace(/ *$/, ''); q = q.replace(/ +/g, ' '); - this.search.searchOptions$.next(Object.assign((this.search.searchConfigService.paginatedSearchOptions as any).value, {query: q})); - this.updateSearch((this.search.searchConfigService.paginatedSearchOptions as any).value); + + this.updateSearch(q); + this.search.retrieveResults(); $event.preventDefault(); } @@ -316,20 +318,22 @@ export class AssociateItemPageComponent implements OnInit, OnDestroy { q += ' *'; } - this.search.searchOptions$.next(Object.assign((this.search.searchConfigService.paginatedSearchOptions as any).value, {query: q})); - this.updateSearch((this.search.searchConfigService.paginatedSearchOptions as any).value); + this.updateSearch(q); + this.search.retrieveResults(); $event.preventDefault(); } /** - * Updates the search URL + * Updates the search URL query and jump to page 1 * @param data Updated parameters */ updateSearch(data: any) { - const queryParams = Object.assign({}, data); this.router.navigate(this.getSearchLinkParts(), { - queryParams: queryParams, + queryParams: { + query: data as string, + [this.search.paginationId + '.page']: 1 + }, queryParamsHandling: 'merge' }); } diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index 26c9d68e0ab..3234d7c5b26 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -662,4 +662,14 @@ export class SearchComponent implements OnInit, OnDestroy { this.sidebarService.toggle(); } + /** + * Experimental to use: Retrieve the results from the current options. + * Might be called when the query has been changed in the search configuration service by some button or so and configuration or context have not changed. + */ + public retrieveResults(){ + this.refreshFilters.next(true); + this.retrieveFilters(this.searchOptions$.value); + this.retrieveSearchResults(this.searchOptions$.value); + } + }