src/frontend/packages/core/src/shared/components/list/list.component.ts
import { animate, style, transition, trigger } from '@angular/animations';
import {
AfterViewInit,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
NgZone,
OnChanges,
OnDestroy,
OnInit,
Optional,
Output,
SimpleChanges,
TemplateRef,
ViewChild,
} from '@angular/core';
import { NgForm, NgModel } from '@angular/forms';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { SortDirection } from '@angular/material/sort';
import { Store } from '@ngrx/store';
import {
asapScheduler,
BehaviorSubject,
combineLatest as observableCombineLatest,
isObservable,
Observable,
of as observableOf,
Subscription,
} from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
first,
map,
pairwise,
publishReplay,
refCount,
startWith,
subscribeOn,
takeWhile,
tap,
withLatestFrom,
} from 'rxjs/operators';
import {
ListFilter,
ListPagination,
ListSort,
ListView,
SetListViewAction,
} from '../../../../../store/src/actions/list.actions';
import {
ResetPagination,
ResetPaginationSortFilter,
SetClientFilterKey,
SetPage,
} from '../../../../../store/src/actions/pagination.actions';
import { GeneralAppState } from '../../../../../store/src/app-state';
import { entityCatalog } from '../../../../../store/src/entity-catalog/entity-catalog';
import { EntityCatalogEntityConfig } from '../../../../../store/src/entity-catalog/entity-catalog.types';
import { ActionState } from '../../../../../store/src/reducers/api-request-reducer/types';
import { getListStateObservables } from '../../../../../store/src/reducers/list.reducer';
import { PaginatedAction } from '../../../../../store/src/types/pagination.types';
import { safeUnsubscribe } from '../../../core/utils.service';
import {
EntitySelectConfig,
getDefaultRowState,
IListDataSource,
RowState,
} from './data-sources-controllers/list-data-source-types';
import { IListPaginationController, ListPaginationController } from './data-sources-controllers/list-pagination-controller';
import { ITableColumn } from './list-table/table.types';
import {
defaultPaginationPageSizeOptionsCards,
defaultPaginationPageSizeOptionsTable,
IGlobalListAction,
IListConfig,
IListFilter,
IMultiListAction,
IOptionalAction,
ListConfig,
ListViewTypes,
MultiFilterManager,
} from './list.component.types';
@Component({
selector: 'app-list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.scss'],
animations: [
trigger('list', [
transition('* => in', [
style({ opacity: '0', transform: 'translateY(-10px)' }),
animate('350ms ease-out', style({ opacity: '1', transform: 'translateY(0)' }))
]),
transition('* => left, * => repeatLeft', [
style({ opacity: '0', transform: 'translateX(-2%)' }),
animate('350ms ease-out', style({ opacity: '1', transform: 'translateX(0)' })),
]),
transition('* => right, * => repeatRight', [
style({ opacity: '0', transform: 'translateX(2%)' }),
animate('350ms ease-out', style({ opacity: '1', transform: 'translateX(0)' })),
])
])
]
})
export class ListComponent<T> implements OnInit, OnChanges, OnDestroy, AfterViewInit {
private uberSub: Subscription;
public entitySelectConfig: EntitySelectConfig;
@Input() addForm: NgForm;
@Input() customFilters: TemplateRef<any>;
@Input() noEntries: TemplateRef<any>;
@Input() noEntriesForCurrentFilter: TemplateRef<any>;
// List config when supplied as an attribute rather than a dependency
@Input() listConfig: ListConfig<T>;
entitySelectValue = new BehaviorSubject(undefined);
entitySelectValue$: Observable<number> = this.entitySelectValue.asObservable();
pPaginator: MatPaginator;
private filterString: string;
@ViewChild(MatPaginator) set setPaginator(paginator: MatPaginator) {
if (!paginator || this.paginationWidgetToStore) {
return;
}
this.pPaginator = paginator;
// The paginator component can do some smarts underneath (change page when page size changes). For non-local lists this means
// multiple requests are made and stale data is added to the store. To prevent this only have one subscriber to the page change
// event which handles either page or pageSize changes.
this.paginationWidgetToStore = paginator.page.pipe(
startWith(this.initialPageEvent),
pairwise(),
).subscribe(([oldV, newV]) => {
const pageSizeChanged = oldV.pageSize !== newV.pageSize;
const pageChanged = oldV.pageIndex !== newV.pageIndex;
if (pageSizeChanged) {
this.paginationController.pageSize(newV.pageSize);
if (this.dataSource.isLocal) {
this.paginationController.page(0);
}
} else if (pageChanged) {
this.paginationController.page(newV.pageIndex);
}
});
}
@ViewChild('filter') set setFilter(filterValue: NgModel) {
if (!filterValue || this.filterWidgetToStore) {
return;
}
this.filterWidgetToStore = filterValue.valueChanges.pipe(
debounceTime(this.dataSource.isLocal ? 150 : 250),
distinctUntilChanged(),
map(value => value as string),
tap(filterString => {
return this.paginationController.filterByString(filterString);
})).subscribe();
}
@Output() initialised = new EventEmitter<boolean>();
private componentInitialised = false;
private initialPageEvent: PageEvent;
private paginatorSettings: {
pageSizeOptions: number[],
pageSize: number,
pageIndex: number,
length: number;
} = {
pageSizeOptions: null,
pageSize: null,
pageIndex: null,
length: null
};
private headerSort: {
direction: SortDirection,
value: string;
} = {
direction: null,
value: null
};
private sortColumns: ITableColumn<T>[];
private filterColumns: IListFilter[];
private filterSelected: IListFilter;
private paginationWidgetToStore: Subscription;
private filterWidgetToStore: Subscription;
private multiFilterChangesSub: Subscription;
globalActions: IGlobalListAction<T>[];
multiActions: IMultiListAction<T>[];
haveMultiActions = new BehaviorSubject(false);
hasSingleActions: boolean;
columns: ITableColumn<T>[];
dataSource: IListDataSource<T>;
multiFilterManagers: MultiFilterManager<T>[];
paginationController: IListPaginationController<T>;
private multiFilterWidgetObservables = new Array<Subscription>();
view$: Observable<ListView>;
isAddingOrSelecting$: Observable<boolean>;
hasRows$: Observable<boolean>;
noRowsHaveFilter$: Observable<boolean>;
disableActions$: Observable<boolean>;
hasRowsOrIsFiltering$: Observable<boolean>;
isFiltering$: Observable<boolean>;
noRowsNotFiltering$: Observable<boolean>;
showProgressBar$: Observable<boolean>;
isRefreshing$: Observable<boolean>;
// Observable which allows you to determine if the paginator control should be hidden
hidePaginator$: Observable<boolean>;
listViewKey: string;
// Observable which allows you to determine if the top control bar should be shown
hasControls$: Observable<boolean>;
pageState$: Observable<string>;
initialised$: Observable<boolean>;
pendingActions: Map<Observable<ActionState>, Subscription> = new Map<Observable<ActionState>, Subscription>();
subs: Subscription[] = [];
public safeAddForm() {
// Something strange is afoot. When using addform in [disabled] it thinks this is null, even when initialised
// When applying the question mark (addForm?) it's value is ignored by [disabled]
return this.addForm || {};
}
constructor(
private store: Store<GeneralAppState>,
private cd: ChangeDetectorRef,
@Optional() public config: ListConfig<T>,
private ngZone: NgZone,
) { }
ngOnInit() {
// null list means we have list bound but no value available yet
if (this.listConfig === null) {
// We will watch for changes to the list value
return;
} else if (this.listConfig) {
// A value for the list is already available
this.config = this.listConfig;
}
// Otherwise, do we have a value from the config?
if (this.config) {
if (this.config.getInitialised) {
this.initialised$ = this.config.getInitialised().pipe(
filter(initialised => initialised),
first(),
tap(() => this.initialise()),
publishReplay(1), refCount()
);
} else {
this.initialise();
this.initialised$ = observableOf(true);
}
}
}
// If the list changes, update to use the new value
ngOnChanges(changes: SimpleChanges) {
const listChanges = changes.list;
if (!!listChanges && listChanges.currentValue) {
this.ngOnDestroy();
// ngOnInit will pick up the new value and use it
this.ngOnInit();
}
}
private getMultiFilterManagers() {
const configs = this.config.getMultiFiltersConfigs();
if (!configs) {
return null;
}
return configs.map(config => new MultiFilterManager<T>(config, this.dataSource));
}
// TODO: This needs tidying up - NJ
private initialise() {
this.globalActions = this.setupActionsDefaultObservables(
this.config.getGlobalActions()
);
this.multiActions = this.setupActionsDefaultObservables(
this.config.getMultiActions()
);
this.hasSingleActions = (this.config.getSingleActions() || []).length > 0;
this.columns = this.config.getColumns();
this.dataSource = this.config.getDataSource();
this.entitySelectConfig = this.dataSource.entitySelectConfig;
this.dataSource.pagination$.pipe(
first(),
).subscribe(pag => {
this.entitySelectValue.next(pag.forcedLocalPage);
});
if (this.dataSource.rowsState) {
this.dataSource.getRowState = this.getRowStateFromRowsState;
} else if (!this.dataSource.getRowState) {
this.dataSource.getRowState = this.getRowStateGeneratorFromEntityMonitor(this.dataSource.sourceScheme, this.dataSource);
}
this.multiFilterManagers = this.getMultiFilterManagers();
// Create convenience observables that make the html clearer
this.isAddingOrSelecting$ = observableCombineLatest(
this.dataSource.isAdding$,
this.dataSource.isSelecting$
).pipe(
map(([isAdding, isSelecting]) => isAdding || isSelecting)
);
// Set up an observable containing the current view (card/table)
this.listViewKey = this.dataSource.entityKey + '-' + this.dataSource.paginationKey;
const { view, } = getListStateObservables(this.store, this.listViewKey);
this.view$ = view.pipe(
map(listView => {
if (this.config.viewType === ListViewTypes.CARD_ONLY) {
return 'cards';
}
if (this.config.viewType === ListViewTypes.TABLE_ONLY) {
return 'table';
}
return listView;
})
);
// If this is the first time the user has used this list then set the view to the default
this.view$.pipe(first()).subscribe(listView => {
if (!listView) {
this.updateListView(this.getDefaultListView(this.config));
}
});
// Determine if this list view needs the control header bar at the top
this.hasControls$ = this.view$.pipe(map((viewType) => {
return !!(
this.config.viewType === 'both' ||
this.config.text && this.config.text.title ||
this.addForm ||
this.globalActions && this.globalActions.length ||
this.multiActions && this.multiActions.length ||
viewType === 'cards' && this.sortColumns && this.sortColumns.length ||
this.multiFilterManagers && this.multiFilterManagers.length ||
this.config.enableTextFilter
);
}));
this.paginationController = new ListPaginationController(this.store, this.dataSource, this.ngZone);
this.multiFilterChangesSub = this.paginationController.multiFilterChanges$.subscribe();
const hasPages$ = this.dataSource.page$.pipe(
map(pag => !!(pag && pag.length)),
distinctUntilChanged()
);
this.hasRows$ = observableCombineLatest(hasPages$, this.dataSource.maxedResults$).pipe(
map(([hasPages, maxedResults]) => !maxedResults && hasPages),
startWith(false)
);
// Determine if we should hide the paginator
this.hidePaginator$ = observableCombineLatest(this.hasRows$, this.dataSource.pagination$).pipe(
map(([hasRows, pagination]) => {
const minPageSize = (
this.paginatorSettings.pageSizeOptions && this.paginatorSettings.pageSizeOptions.length ?
this.paginatorSettings.pageSizeOptions[0] : -1
);
return !hasRows ||
pagination && (pagination.totalResults <= minPageSize);
}));
this.paginatorSettings.pageSizeOptions = this.config.pageSizeOptions ||
(this.config.viewType === ListViewTypes.TABLE_ONLY ? defaultPaginationPageSizeOptionsTable : defaultPaginationPageSizeOptionsCards);
// Ensure we set a pageSize that's relevant to the configured set of page sizes. The default is 9 and in some cases is not a valid
// pageSize
this.paginationController.pagination$.pipe(first()).subscribe(pagination => {
this.initialPageEvent = new PageEvent();
this.initialPageEvent.pageIndex = pagination.pageIndex - 1;
this.initialPageEvent.pageSize = pagination.pageSize;
if (this.paginatorSettings.pageSizeOptions.findIndex(pageSize => pageSize === pagination.pageSize) < 0) {
this.initialPageEvent.pageSize = this.paginatorSettings.pageSizeOptions[0];
this.paginationController.pageSize(this.paginatorSettings.pageSizeOptions[0]);
}
});
const paginationStoreToWidget = this.paginationController.pagination$.pipe(tap((pagination: ListPagination) => {
this.paginatorSettings.length = pagination.totalResults;
this.paginatorSettings.pageIndex = pagination.pageIndex - 1;
this.paginatorSettings.pageSize = pagination.pageSize;
}));
this.sortColumns = this.columns.filter((column: ITableColumn<T>) => {
return column.sort;
});
const sortStoreToWidget = this.paginationController.sort$.pipe(tap((sort: ListSort) => {
this.headerSort.value = sort.field;
this.headerSort.direction = sort.direction;
}));
this.filterColumns = this.config.getFilters ? this.config.getFilters() : [];
const filterStoreToWidget = this.paginationController.filter$.pipe(
distinctUntilChanged(),
tap((paginationFilter: ListFilter) => {
this.filterString = paginationFilter.string;
const filterKey = paginationFilter.filterKey;
if (filterKey) {
this.filterSelected = this.filterColumns.find(filterConfig => {
return filterConfig.key === filterKey;
});
} else if (this.filterColumns) {
this.filterSelected = this.filterColumns.find(filterConfig => filterConfig.default);
if (this.filterSelected) {
this.updateListFilter(this.filterSelected);
}
}
// Pipe store values to filter managers. This ensures any changes such as automatically selected orgs/spaces are shown in the drop
// downs (change org to one with one space results in that space being selected)
Object.values(this.multiFilterManagers).forEach((filterManager: MultiFilterManager<T>, index: number) => {
// If this is NOT the first... and we have the value to apply
if (index !== 0 || filterManager.hasValue(paginationFilter.items)) {
filterManager.applyValue(paginationFilter.items);
return;
}
// If we're the first drop down filter and there are other drop downs... select the first one
if (index === 0 && this.multiFilterManagers.length > 1) {
filterManager.filterItems$.pipe(
first()
).subscribe(list => {
if (list && list.length === 1) {
filterManager.selectItem(list[0].value);
} else {
filterManager.applyValue(paginationFilter.items);
}
});
return;
}
filterManager.applyValue(paginationFilter.items);
});
}),
);
// Multi filters (e.g. cf/org/space)
// - Pass any multi filter changes made by the user to the pagination controller and thus the store
// - If the first multi filter has one value it's not shown, ensure it's automatically selected to ensure other filters are correct
this.multiFilterWidgetObservables = new Array<Subscription>();
this.paginationController.filter$.pipe(
first(),
tap(() => {
Object.values(this.multiFilterManagers).forEach((filterManager: MultiFilterManager<T>, index: number) => {
// Pipe changes in the widgets to the store
const sub = filterManager.multiFilterConfig.select.asObservable().pipe(tap((filterItem: string) => {
this.paginationController.multiFilter(filterManager.multiFilterConfig, filterItem);
}));
this.multiFilterWidgetObservables.push(sub.subscribe());
});
})
).subscribe();
this.isFiltering$ = this.paginationController.filter$.pipe(
map((f: ListFilter) => {
const isFilteringByString = f.string ? !!f.string.length : false;
const isFilteringByItems = Object.values(f.items).filter(value => !!value).length > 0;
return isFilteringByString || isFilteringByItems;
})
);
this.noRowsHaveFilter$ = observableCombineLatest(this.hasRows$, this.isFiltering$, this.dataSource.maxedResults$).pipe(
map(([hasRows, isFiltering, maxedResults]) => !hasRows && isFiltering && !maxedResults)
);
this.noRowsNotFiltering$ = observableCombineLatest(this.hasRows$, this.isFiltering$, this.dataSource.maxedResults$).pipe(
map(([hasRows, isFiltering, maxedResults]) => !hasRows && !isFiltering && !maxedResults)
);
this.hasRowsOrIsFiltering$ = observableCombineLatest(this.hasRows$, this.isFiltering$).pipe(
map(([hasRows, isFiltering]) => hasRows || isFiltering)
);
this.disableActions$ = observableCombineLatest(this.dataSource.isLoadingPage$, this.noRowsHaveFilter$).pipe(
map(([isLoading, noRowsHaveFilter]) => isLoading || noRowsHaveFilter)
);
// Multi actions can be a list of actions that aren't visible. For those case, in effect, we don't have multi actions
const visibles$ = (this.multiActions || []).map(multiAction => multiAction.visible$);
const haveMultiActions = observableCombineLatest(visibles$).pipe(
map(visibles => visibles.some(visible => visible)),
tap(allowSelection => {
this.haveMultiActions.next(allowSelection);
})
);
this.uberSub = observableCombineLatest(
paginationStoreToWidget,
sortStoreToWidget,
haveMultiActions
).subscribe();
const initialisedSub = observableCombineLatest([
filterStoreToWidget // Should not be unsubbed until destroy
]).subscribe(() => {
// When this fires the first time we're initialised
if (!this.componentInitialised) {
this.componentInitialised = true;
this.initialised.next(true);
}
});
this.subs.push(initialisedSub);
this.pageState$ = observableCombineLatest(
this.paginationController.pagination$,
this.dataSource.isLoadingPage$,
this.view$
)
.pipe(
filter(([pagination, busy, viewType]) => viewType !== 'table'),
map(([pagination, busy, viewType]) => ({ pageIndex: pagination.pageIndex, busy, viewType })),
distinctUntilChanged((x, y) => x.pageIndex === y.pageIndex && x.busy === y.busy && x.viewType === y.viewType),
pairwise(),
map(([oldVal, newVal]) => {
if (oldVal.viewType !== oldVal.viewType) {
return 'none';
}
if (oldVal.pageIndex > newVal.pageIndex) {
return 'left';
} else if (oldVal.pageIndex < newVal.pageIndex) {
return 'right';
} else if (oldVal.busy && !newVal.busy) {
return 'in';
}
return 'none';
}),
startWith('none'),
pairwise(),
map(([oldVal, newVal]) => {
if (oldVal === newVal) {
if (oldVal === 'left') {
return 'repeatLeft';
}
if (oldVal === 'right') {
return 'repeatRight';
}
}
return newVal;
})
);
const loadingHasChanged$ = this.dataSource.isLoadingPage$.pipe(
distinctUntilChanged(),
pairwise(),
map(([oldLoading, newLoading]) =>
oldLoading !== newLoading
),
startWith(true)
);
const hasFilterChangedSinceLastLoading$ = loadingHasChanged$.pipe(
filter(hasChanged => hasChanged),
withLatestFrom(this.dataSource.pagination$),
pairwise(),
map(([oldData, newData]) => [oldData[1], newData[1]]),
map(([oldPage, newPage]) =>
oldPage.currentPage !== newPage.currentPage ||
oldPage.clientPagination.filter !== newPage.clientPagination.filter ||
oldPage.params !== newPage.params
),
startWith(true)
);
this.isRefreshing$ = observableCombineLatest(hasFilterChangedSinceLastLoading$, this.dataSource.isLoadingPage$).pipe(
map(([hasChanged, loading]) => !hasChanged && loading),
startWith(false)
);
this.showProgressBar$ = this.dataSource.isLoadingPage$.pipe(
withLatestFrom(this.isRefreshing$),
map(([loading, isRefreshing]) => !isRefreshing && loading),
distinctUntilChanged(),
startWith(true),
);
}
ngAfterViewInit() {
this.cd.detectChanges();
}
ngOnDestroy() {
safeUnsubscribe(
...this.multiFilterWidgetObservables,
this.paginationWidgetToStore,
this.filterWidgetToStore,
this.uberSub,
this.multiFilterChangesSub,
...this.subs,
);
if (this.dataSource) {
this.dataSource.destroy();
}
this.pendingActions.forEach(sub => sub.unsubscribe());
}
private getDefaultListView(config: IListConfig<T>) {
switch (config.viewType) {
case ListViewTypes.TABLE_ONLY:
return 'table';
case ListViewTypes.CARD_ONLY:
return 'cards';
default:
return this.config.defaultView || 'table';
}
}
public resetFilteringAndSort() {
/* tslint:disable-next-line:no-string-literal */
const pAction: PaginatedAction = this.dataSource.action['length'] ? this.dataSource.action[0] : this.dataSource.action;
this.store.dispatch(new ResetPaginationSortFilter(pAction));
if (!this.dataSource.isLocal) {
this.store.dispatch(new ResetPagination(pAction, pAction.paginationKey));
}
// Reset the multi-entity filter
this.entitySelectValue.next(undefined);
this.setEntityPage(undefined);
}
updateListView(listView: ListView) {
this.store.dispatch(new SetListViewAction(this.listViewKey, listView));
}
updateListSort(field: string, direction: SortDirection) {
this.headerSort.value = field;
this.headerSort.direction = direction;
this.paginationController.sort({
direction,
field
});
}
updateListFilter(filterSelected: IListFilter) {
this.store.dispatch(new SetClientFilterKey(
this.dataSource,
this.dataSource.paginationKey,
filterSelected.key
));
}
executeActionMultiple(listActionConfig: IMultiListAction<T>) {
const result = listActionConfig.action(Array.from(this.dataSource.selectedRows.values()));
if (isObservable(result)) {
const sub = this.getActionSub(result);
this.pendingActions.set(result, sub);
} else {
this.dataSource.selectClear();
}
}
private getActionSub(result: Observable<ActionState>) {
const sub = result.pipe(
subscribeOn(asapScheduler)
).subscribe(done => {
if (!done.busy) {
this.pendingActions.delete(result);
sub.unsubscribe();
if (!done.error) {
this.dataSource.selectClear();
}
}
});
return sub;
}
executeActionGlobal(listActionConfig: IGlobalListAction<T>) {
listActionConfig.action();
}
public refresh() {
if (this.dataSource.refresh) {
this.dataSource.refresh();
this.dataSource.isLoadingPage$.pipe(
tap(isLoading => {
if (!isLoading) {
this.paginationController.page(0);
}
}),
takeWhile(isLoading => isLoading)
);
}
}
// Used by multi-entity lists
public setEntityPage(page: number) {
this.pPaginator.firstPage();
this.store.dispatch(new SetPage(
this.dataSource,
this.dataSource.paginationKey,
page,
true,
true
));
}
private setupActionsDefaultObservables<Y extends IOptionalAction<T>>(actions: Y[]) {
if (Array.isArray(actions)) {
return actions.map(action => {
if (!action.visible$) {
action.visible$ = observableOf(true);
}
if (!action.enabled$) {
action.enabled$ = observableOf(true);
}
return action;
});
}
return actions;
}
private getRowStateGeneratorFromEntityMonitor(entityConfig: EntityCatalogEntityConfig, dataSource: IListDataSource<T>) {
return (row) => {
if (!entityConfig || !row) {
return observableOf(getDefaultRowState());
}
const catalogEntity = entityCatalog.getEntity(entityConfig);
const entityMonitor = catalogEntity.store.getEntityMonitor(
dataSource.getRowUniqueId(row),
{
schemaKey: entityConfig.schemaKey
}
);
return entityMonitor.entityRequest$.pipe(
distinctUntilChanged(),
map(requestInfo => ({
deleting: requestInfo.deleting.busy,
error: requestInfo.deleting.error,
message: requestInfo.deleting.error ? requestInfo.deleting.message || `Sorry, deletion failed` : null
}))
);
};
}
private getRowStateFromRowsState = (row: T): Observable<RowState> =>
this.dataSource.rowsState.pipe(map(state => state[this.dataSource.getRowUniqueId(row)] || getDefaultRowState()));
public showAllAfterMax() {
this.dataSource.showAllAfterMax();
}
}