fix issue where the menu wouldn't update if an option was added after the initial render

This commit is contained in:
Art Lowel
2020-07-07 15:21:20 +02:00
committed by Marie Verdonck
parent d2523ade9b
commit 2f946ba2a3
10 changed files with 186 additions and 133 deletions

View File

@@ -5,7 +5,7 @@
<div class="sidebar-collapsible"> <div class="sidebar-collapsible">
<span class="section-header-text"> <span class="section-header-text">
<ng-container <ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span> </span>
</div> </div>
</li> </li>

View File

@@ -25,7 +25,7 @@
<ng-container *ngFor="let section of (sections | async)"> <ng-container *ngFor="let section of (sections | async)">
<ng-container <ng-container
*ngComponentOutlet="sectionComponents.get(section.id); injector: sectionInjectors.get(section.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</ng-container> </ng-container>
</ul> </ul>
</div> </div>
@@ -49,4 +49,4 @@
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -1,9 +1,9 @@
import { Component, Injector, OnInit } from '@angular/core'; import { Component, Injector, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { combineLatest as combineLatestObservable } from 'rxjs'; import { combineLatest as observableCombineLatest } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { of } from 'rxjs/internal/observable/of'; import { of } from 'rxjs/internal/observable/of';
import { first, map, take } from 'rxjs/operators'; import { first, map, take, tap, filter } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { ProcessDataService } from '../../core/data/processes/process-data.service'; import { ProcessDataService } from '../../core/data/processes/process-data.service';
import { ScriptDataService } from '../../core/data/processes/script-data.service'; import { ScriptDataService } from '../../core/data/processes/script-data.service';
@@ -99,7 +99,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
this.sidebarOpen = !collapsed; this.sidebarOpen = !collapsed;
this.sidebarClosed = collapsed; this.sidebarClosed = collapsed;
}); });
this.sidebarExpanded = combineLatestObservable(this.menuCollapsed, this.menuPreviewCollapsed) this.sidebarExpanded = observableCombineLatest(this.menuCollapsed, this.menuPreviewCollapsed)
.pipe( .pipe(
map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed)) map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed))
); );
@@ -323,78 +323,81 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
* the export scripts exist and the current user is allowed to execute them * the export scripts exist and the current user is allowed to execute them
*/ */
createExportMenuSections() { createExportMenuSections() {
const isAuthorized$: Observable<boolean> = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); const menuList = [
isAuthorized$.subscribe((authorized: boolean) => { /* Export */
if (authorized) { {
const metadataExportScriptExists$ = this.scriptDataService.scripWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME); id: 'export',
metadataExportScriptExists$.subscribe((metadataExportScriptExists: boolean) => { active: false,
const menuList = [ visible: true,
/* Export */ model: {
{ type: MenuItemType.TEXT,
id: 'export', text: 'menu.section.export'
active: false, } as TextMenuItemModel,
visible: true, icon: 'sign-out-alt',
model: { index: 3,
type: MenuItemType.TEXT, shouldPersistOnRouteChange: true
text: 'menu.section.export' },
} as TextMenuItemModel, {
icon: 'sign-out-alt', id: 'export_community',
index: 3 parentID: 'export',
}, active: false,
{ visible: true,
id: 'export_community', model: {
parentID: 'export', type: MenuItemType.LINK,
active: false, text: 'menu.section.export_community',
visible: true, link: ''
model: { } as LinkMenuItemModel,
type: MenuItemType.LINK, shouldPersistOnRouteChange: true
text: 'menu.section.export_community', },
link: '' {
} as LinkMenuItemModel, id: 'export_collection',
}, parentID: 'export',
{ active: false,
id: 'export_collection', visible: true,
parentID: 'export', model: {
active: false, type: MenuItemType.LINK,
visible: true, text: 'menu.section.export_collection',
model: { link: ''
type: MenuItemType.LINK, } as LinkMenuItemModel,
text: 'menu.section.export_collection', shouldPersistOnRouteChange: true
link: '' },
} as LinkMenuItemModel, {
}, id: 'export_item',
{ parentID: 'export',
id: 'export_item', active: false,
parentID: 'export', visible: true,
active: false, model: {
visible: true, type: MenuItemType.LINK,
model: { text: 'menu.section.export_item',
type: MenuItemType.LINK, link: ''
text: 'menu.section.export_item', } as LinkMenuItemModel,
link: '' shouldPersistOnRouteChange: true
} as LinkMenuItemModel, },
}, ];
{ menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection));
id: 'export_metadata',
parentID: 'export',
active: true,
visible: (authorized && metadataExportScriptExists),
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_metadata',
function: () => {
this.modalService.open(ExportMetadataSelectorComponent);
}
} as OnClickMenuItemModel,
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
});
observableCombineLatest(
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME)
).pipe(
filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists),
take(1)
).subscribe(() => {
this.menuService.addSection(this.menuID, {
id: 'export_metadata',
parentID: 'export',
active: true,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_metadata',
function: () => {
this.modalService.open(ExportMetadataSelectorComponent);
}
} as OnClickMenuItemModel,
shouldPersistOnRouteChange: true
});
});
} }
/** /**

View File

@@ -12,16 +12,16 @@
(click)="toggleSection($event)"> (click)="toggleSection($event)">
<span class="section-header-text"> <span class="section-header-text">
<ng-container <ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span> </span>
<i class="fas fa-chevron-right fa-pull-right" <i class="fas fa-chevron-right fa-pull-right"
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'" [title]="('menu.section.toggle.' + section.id) | translate"></i> [@rotate]="(expanded | async) ? 'expanded' : 'collapsed'" [title]="('menu.section.toggle.' + section.id) | translate"></i>
</a> </a>
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)"> <ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
<li *ngFor="let subSection of (subSections | async)"> <li *ngFor="let subSection of (subSections$ | async)">
<ng-container <ng-container
*ngComponentOutlet="itemComponents.get(subSection.id); injector: itemInjectors.get(subSection.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</li> </li>
</ul> </ul>
</div> </div>
</li> </li>

View File

@@ -1,5 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { getSucceededRemoteData } from '../../shared/operators'; import {
getSucceededRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../shared/operators';
import { DataService } from '../data.service'; import { DataService } from '../data.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -11,7 +14,7 @@ import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../default-change-analyzer.service'; import { DefaultChangeAnalyzer } from '../default-change-analyzer.service';
import { Script } from '../../../process-page/scripts/script.model'; import { Script } from '../../../process-page/scripts/script.model';
import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; import { ProcessParameter } from '../../../process-page/processes/process-parameter.model';
import { find, map, switchMap } from 'rxjs/operators'; import { find, map, switchMap, filter } from 'rxjs/operators';
import { URLCombiner } from '../../url-combiner/url-combiner'; import { URLCombiner } from '../../url-combiner/url-combiner';
import { RemoteData } from '../remote-data'; import { RemoteData } from '../remote-data';
import { MultipartPostRequest, RestRequest } from '../request.models'; import { MultipartPostRequest, RestRequest } from '../request.models';
@@ -20,6 +23,7 @@ import { Observable } from 'rxjs';
import { RequestEntry } from '../request.reducer'; import { RequestEntry } from '../request.reducer';
import { dataService } from '../../cache/builders/build-decorators'; import { dataService } from '../../cache/builders/build-decorators';
import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; import { SCRIPT } from '../../../process-page/scripts/script.resource-type';
import { hasValue } from '../../../shared/empty.util';
@Injectable() @Injectable()
@dataService(SCRIPT) @dataService(SCRIPT)
@@ -65,11 +69,11 @@ export class ScriptDataService extends DataService<Script> {
* Check whether a script with given name exist; user needs to be allowed to execute script for this to to not throw a 401 Unauthorized * Check whether a script with given name exist; user needs to be allowed to execute script for this to to not throw a 401 Unauthorized
* @param scriptName script we want to check exists (and we can execute) * @param scriptName script we want to check exists (and we can execute)
*/ */
public scripWithNameExistsAndCanExecute(scriptName: string): Observable<boolean> { public scriptWithNameExistsAndCanExecute(scriptName: string): Observable<boolean> {
return this.findById(scriptName).pipe( return this.findById(scriptName).pipe(
getSucceededRemoteData(), find((rd: RemoteData<Script>) => hasValue(rd.payload) || hasValue(rd.error)),
map((scriptRD: RemoteData<Script>) => { map((rd: RemoteData<Script>) => {
return (scriptRD.hasSucceeded); return hasValue(rd.payload);
})); }));
} }
} }

View File

@@ -5,13 +5,13 @@
id="browseDropdown" (click)="toggleSection($event)" id="browseDropdown" (click)="toggleSection($event)"
data-toggle="dropdown"> data-toggle="dropdown">
<ng-container <ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</a> </a>
<ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)" <ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)"
class="m-0 shadow-none border-top-0 dropdown-menu show"> class="m-0 shadow-none border-top-0 dropdown-menu show">
<ng-container *ngFor="let subSection of (subSections | async)"> <ng-container *ngFor="let subSection of (subSections$ | async)">
<ng-container <ng-container
*ngComponentOutlet="itemComponents.get(subSection.id); injector: itemInjectors.get(subSection.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</ng-container> </ng-container>
</ul> </ul>
</li> </li>

View File

@@ -1,4 +1,4 @@
<li class="nav-item"> <li class="nav-item">
<ng-container <ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</li> </li>

View File

@@ -7,7 +7,7 @@
<ul class="navbar-nav mr-auto shadow-none"> <ul class="navbar-nav mr-auto shadow-none">
<ng-container *ngFor="let section of (sections | async)"> <ng-container *ngFor="let section of (sections | async)">
<ng-container <ng-container
*ngComponentOutlet="sectionComponents.get(section.id); injector: sectionInjectors.get(section.id);"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</ng-container> </ng-container>
</ul> </ul>
</div> </div>

View File

@@ -1,13 +1,15 @@
import { Component, Injector } from '@angular/core'; import { Component, Injector, OnInit, OnDestroy } from '@angular/core';
import { MenuService } from '../menu.service'; import { MenuService } from '../menu.service';
import { MenuSection } from '../menu.reducer'; import { MenuSection } from '../menu.reducer';
import { getComponentForMenuItemType } from '../menu-item.decorator'; import { getComponentForMenuItemType } from '../menu-item.decorator';
import { MenuID, MenuItemType } from '../initial-menus-state'; import { MenuID, MenuItemType } from '../initial-menus-state';
import { hasNoValue } from '../../empty.util'; import { hasNoValue, hasValue } from '../../empty.util';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { MenuItemModel } from '../menu-item/models/menu-item.model'; import { MenuItemModel } from '../menu-item/models/menu-item.model';
import { distinctUntilChanged } from 'rxjs/operators'; import { distinctUntilChanged, switchMap } from 'rxjs/operators';
import { GenericConstructor } from '../../../core/shared/generic-constructor'; import { GenericConstructor } from '../../../core/shared/generic-constructor';
import { Subscription } from 'rxjs/internal/Subscription';
import { BehaviorSubject } from 'rxjs';
/** /**
* A basic implementation of a menu section's component * A basic implementation of a menu section's component
@@ -16,7 +18,7 @@ import { GenericConstructor } from '../../../core/shared/generic-constructor';
selector: 'ds-menu-section', selector: 'ds-menu-section',
template: '' template: ''
}) })
export class MenuSectionComponent { export class MenuSectionComponent implements OnInit, OnDestroy{
/** /**
* Observable that emits whether or not this section is currently active * Observable that emits whether or not this section is currently active
@@ -28,20 +30,25 @@ export class MenuSectionComponent {
*/ */
menuID: MenuID; menuID: MenuID;
/**
* List of Injectors for each dynamically rendered menu item of this section
*/
itemInjectors: Map<string, Injector> = new Map<string, Injector>();
/**
* List of child Components for each dynamically rendered menu item of this section
*/
itemComponents: Map<string, GenericConstructor<MenuSectionComponent>> = new Map<string, GenericConstructor<MenuSectionComponent>>();
/** /**
* List of available subsections in this section * List of available subsections in this section
*/ */
subSections: Observable<MenuSection[]>; subSections$: Observable<MenuSection[]>;
/**
* Map of components and injectors for each dynamically rendered menu section
*/
sectionMap$: BehaviorSubject<Map<string, {
injector: Injector,
component: GenericConstructor<MenuSectionComponent>
}>> = new BehaviorSubject(new Map());
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
subs: Subscription[] = [];
constructor(public section: MenuSection, protected menuService: MenuService, protected injector: Injector) { constructor(public section: MenuSection, protected menuService: MenuService, protected injector: Injector) {
} }
@@ -85,15 +92,34 @@ export class MenuSectionComponent {
* Method for initializing all injectors and component constructors for the menu items in this section * Method for initializing all injectors and component constructors for the menu items in this section
*/ */
private initializeInjectorData() { private initializeInjectorData() {
this.itemInjectors.set(this.section.id, this.getItemModelInjector(this.section.model)); this.updateSectionMap(
this.itemComponents.set(this.section.id, this.getMenuItemComponent(this.section.model)); this.section.id,
this.subSections = this.menuService.getSubSectionsByParentID(this.menuID, this.section.id); this.getItemModelInjector(this.section.model),
this.subSections.subscribe((sections: MenuSection[]) => { this.getMenuItemComponent(this.section.model)
sections.forEach((section: MenuSection) => { );
this.itemInjectors.set(section.id, this.getItemModelInjector(section.model)); this.subSections$ = this.menuService.getSubSectionsByParentID(this.menuID, this.section.id);
this.itemComponents.set(section.id, this.getMenuItemComponent(section.model)); this.subs.push(
this.subSections$.pipe(
// if you return an array from a switchMap it will emit each element as a separate event.
// So this switchMap is equivalent to a subscribe with a forEach inside
switchMap((sections: MenuSection[]) => sections)
).subscribe((section: MenuSection) => {
this.updateSectionMap(
section.id,
this.getItemModelInjector(section.model),
this.getMenuItemComponent(section.model)
)
}) })
}) );
}
/**
* Update the sectionMap
*/
private updateSectionMap(id, injector, component) {
const nextMap = this.sectionMap$.getValue();
nextMap.set(id, { injector, component });
this.sectionMap$.next(nextMap);
} }
/** /**
@@ -124,4 +150,12 @@ export class MenuSectionComponent {
}); });
} }
/**
* Unsubscribe from open subscriptions
*/
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
} }

View File

@@ -3,12 +3,14 @@ import { Observable } from 'rxjs/internal/Observable';
import { MenuService } from './menu.service'; import { MenuService } from './menu.service';
import { MenuID } from './initial-menus-state'; import { MenuID } from './initial-menus-state';
import { MenuSection } from './menu.reducer'; import { MenuSection } from './menu.reducer';
import { distinctUntilChanged, first, map } from 'rxjs/operators'; import { distinctUntilChanged, first, map, tap, switchMap, take } from 'rxjs/operators';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
import { hasValue } from '../empty.util'; import { hasValue } from '../empty.util';
import { MenuSectionComponent } from './menu-section/menu-section.component'; import { MenuSectionComponent } from './menu-section/menu-section.component';
import { getComponentForMenu } from './menu-section.decorator'; import { getComponentForMenu } from './menu-section.decorator';
import { Subscription } from 'rxjs/internal/Subscription'; import { Subscription } from 'rxjs/internal/Subscription';
import { BehaviorSubject } from 'rxjs';
import { compareArraysUsingIds } from '../../+item-page/simple/item-types/shared/item-relationships-utils';
/** /**
* A basic implementation of a MenuComponent * A basic implementation of a MenuComponent
@@ -44,14 +46,12 @@ export class MenuComponent implements OnInit, OnDestroy {
sections: Observable<MenuSection[]>; sections: Observable<MenuSection[]>;
/** /**
* List of Injectors for each dynamically rendered menu section * Map of components and injectors for each dynamically rendered menu section
*/ */
sectionInjectors: Map<string, Injector> = new Map<string, Injector>(); sectionMap$: BehaviorSubject<Map<string, {
injector: Injector,
/** component: GenericConstructor<MenuSectionComponent>
* List of child Components for each dynamically rendered menu section }>> = new BehaviorSubject(new Map());
*/
sectionComponents: Map<string, GenericConstructor<MenuSectionComponent>> = new Map<string, GenericConstructor<MenuSectionComponent>>();
/** /**
* Prevent unnecessary rerendering * Prevent unnecessary rerendering
@@ -79,13 +79,25 @@ export class MenuComponent implements OnInit, OnDestroy {
this.menuCollapsed = this.menuService.isMenuCollapsed(this.menuID); this.menuCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.menuPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID); this.menuPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.menuVisible = this.menuService.isMenuVisible(this.menuID); this.menuVisible = this.menuService.isMenuVisible(this.menuID);
this.sections = this.menuService.getMenuTopSections(this.menuID).pipe(distinctUntilChanged((x, y) => JSON.stringify(x) === JSON.stringify(y))); this.sections = this.menuService.getMenuTopSections(this.menuID).pipe(distinctUntilChanged(compareArraysUsingIds()));
this.subs.push(this.sections.subscribe((sections: MenuSection[]) => { this.subs.push(
sections.forEach((section: MenuSection) => { this.sections.pipe(
this.sectionInjectors.set(section.id, this.getSectionDataInjector(section)); // if you return an array from a switchMap it will emit each element as a separate event.
this.getSectionComponent(section).pipe(first()).subscribe((constr) => this.sectionComponents.set(section.id, constr)); // So this switchMap is equivalent to a subscribe with a forEach inside
}); switchMap((sections: MenuSection[]) => sections),
})); switchMap((section: MenuSection) => this.getSectionComponent(section).pipe(
map((component: GenericConstructor<MenuSectionComponent>) => ({ section, component }))
)),
distinctUntilChanged((x,y) => x.section.id === y.section.id)
).subscribe(({ section, component}) => {
const nextMap = this.sectionMap$.getValue();
nextMap.set(section.id, {
injector: this.getSectionDataInjector(section),
component
});
this.sectionMap$.next(nextMap);
})
);
} }
/** /**