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">
<span class="section-header-text">
<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>
</div>
</li>

View File

@@ -25,7 +25,7 @@
<ng-container *ngFor="let section of (sections | async)">
<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>
</ul>
</div>

View File

@@ -1,9 +1,9 @@
import { Component, Injector, OnInit } from '@angular/core';
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 { 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 { ProcessDataService } from '../../core/data/processes/process-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.sidebarClosed = collapsed;
});
this.sidebarExpanded = combineLatestObservable(this.menuCollapsed, this.menuPreviewCollapsed)
this.sidebarExpanded = observableCombineLatest(this.menuCollapsed, this.menuPreviewCollapsed)
.pipe(
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
*/
createExportMenuSections() {
const isAuthorized$: Observable<boolean> = this.authorizationService.isAuthorized(FeatureID.AdministratorOf);
isAuthorized$.subscribe((authorized: boolean) => {
if (authorized) {
const metadataExportScriptExists$ = this.scriptDataService.scripWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME);
metadataExportScriptExists$.subscribe((metadataExportScriptExists: boolean) => {
const menuList = [
/* Export */
{
id: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export'
} as TextMenuItemModel,
icon: 'sign-out-alt',
index: 3
},
{
id: 'export_community',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_community',
link: ''
} as LinkMenuItemModel,
},
{
id: 'export_collection',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_collection',
link: ''
} as LinkMenuItemModel,
},
{
id: 'export_item',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_item',
link: ''
} as LinkMenuItemModel,
},
{
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
})));
});
}
});
const menuList = [
/* Export */
{
id: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export'
} as TextMenuItemModel,
icon: 'sign-out-alt',
index: 3,
shouldPersistOnRouteChange: true
},
{
id: 'export_community',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_community',
link: ''
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
},
{
id: 'export_collection',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_collection',
link: ''
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
},
{
id: 'export_item',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_item',
link: ''
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection));
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,15 +12,15 @@
(click)="toggleSection($event)">
<span class="section-header-text">
<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>
<i class="fas fa-chevron-right fa-pull-right"
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'" [title]="('menu.section.toggle.' + section.id) | translate"></i>
</a>
<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
*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>
</ul>
</div>

View File

@@ -1,5 +1,8 @@
import { Injectable } from '@angular/core';
import { getSucceededRemoteData } from '../../shared/operators';
import {
getSucceededRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../shared/operators';
import { DataService } from '../data.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
@@ -11,7 +14,7 @@ import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../default-change-analyzer.service';
import { Script } from '../../../process-page/scripts/script.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 { RemoteData } from '../remote-data';
import { MultipartPostRequest, RestRequest } from '../request.models';
@@ -20,6 +23,7 @@ import { Observable } from 'rxjs';
import { RequestEntry } from '../request.reducer';
import { dataService } from '../../cache/builders/build-decorators';
import { SCRIPT } from '../../../process-page/scripts/script.resource-type';
import { hasValue } from '../../../shared/empty.util';
@Injectable()
@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
* @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(
getSucceededRemoteData(),
map((scriptRD: RemoteData<Script>) => {
return (scriptRD.hasSucceeded);
find((rd: RemoteData<Script>) => hasValue(rd.payload) || hasValue(rd.error)),
map((rd: RemoteData<Script>) => {
return hasValue(rd.payload);
}));
}
}

View File

@@ -5,13 +5,13 @@
id="browseDropdown" (click)="toggleSection($event)"
data-toggle="dropdown">
<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>
<ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)"
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
*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>
</ul>
</li>

View File

@@ -1,4 +1,4 @@
<li class="nav-item">
<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>

View File

@@ -7,7 +7,7 @@
<ul class="navbar-nav mr-auto shadow-none">
<ng-container *ngFor="let section of (sections | async)">
<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>
</ul>
</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 { MenuSection } from '../menu.reducer';
import { getComponentForMenuItemType } from '../menu-item.decorator';
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 { 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 { Subscription } from 'rxjs/internal/Subscription';
import { BehaviorSubject } from 'rxjs';
/**
* A basic implementation of a menu section's component
@@ -16,7 +18,7 @@ import { GenericConstructor } from '../../../core/shared/generic-constructor';
selector: 'ds-menu-section',
template: ''
})
export class MenuSectionComponent {
export class MenuSectionComponent implements OnInit, OnDestroy{
/**
* Observable that emits whether or not this section is currently active
@@ -28,20 +30,25 @@ export class MenuSectionComponent {
*/
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
*/
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) {
}
@@ -85,15 +92,34 @@ export class MenuSectionComponent {
* Method for initializing all injectors and component constructors for the menu items in this section
*/
private initializeInjectorData() {
this.itemInjectors.set(this.section.id, this.getItemModelInjector(this.section.model));
this.itemComponents.set(this.section.id, this.getMenuItemComponent(this.section.model));
this.subSections = this.menuService.getSubSectionsByParentID(this.menuID, this.section.id);
this.subSections.subscribe((sections: MenuSection[]) => {
sections.forEach((section: MenuSection) => {
this.itemInjectors.set(section.id, this.getItemModelInjector(section.model));
this.itemComponents.set(section.id, this.getMenuItemComponent(section.model));
this.updateSectionMap(
this.section.id,
this.getItemModelInjector(this.section.model),
this.getMenuItemComponent(this.section.model)
);
this.subSections$ = this.menuService.getSubSectionsByParentID(this.menuID, this.section.id);
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 { MenuID } from './initial-menus-state';
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 { hasValue } from '../empty.util';
import { MenuSectionComponent } from './menu-section/menu-section.component';
import { getComponentForMenu } from './menu-section.decorator';
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
@@ -44,14 +46,12 @@ export class MenuComponent implements OnInit, OnDestroy {
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>();
/**
* List of child Components for each dynamically rendered menu section
*/
sectionComponents: Map<string, GenericConstructor<MenuSectionComponent>> = new Map<string, GenericConstructor<MenuSectionComponent>>();
sectionMap$: BehaviorSubject<Map<string, {
injector: Injector,
component: GenericConstructor<MenuSectionComponent>
}>> = new BehaviorSubject(new Map());
/**
* Prevent unnecessary rerendering
@@ -79,13 +79,25 @@ export class MenuComponent implements OnInit, OnDestroy {
this.menuCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.menuPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(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.subs.push(this.sections.subscribe((sections: MenuSection[]) => {
sections.forEach((section: MenuSection) => {
this.sectionInjectors.set(section.id, this.getSectionDataInjector(section));
this.getSectionComponent(section).pipe(first()).subscribe((constr) => this.sectionComponents.set(section.id, constr));
});
}));
this.sections = this.menuService.getMenuTopSections(this.menuID).pipe(distinctUntilChanged(compareArraysUsingIds()));
this.subs.push(
this.sections.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),
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);
})
);
}
/**