common-ui/src/lib/listing/services/listing.service.ts
2023-02-08 17:20:13 +02:00

218 lines
8.2 KiB
TypeScript

import { Injectable } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { FilterService, getFilteredEntities } from '../../filtering';
import { SearchService } from '../../search';
import { Id, IListable } from '../models';
import { EntitiesService } from './entities.service';
import { any, getLength, shareDistinctLast, shareLast } from '../../utils';
import { SortingService } from '../../sorting';
@Injectable()
export class ListingService<Class extends IListable<PrimaryKey>, PrimaryKey extends Id = Class['id']> {
readonly displayed$: Observable<Class[]>;
readonly displayedLength$: Observable<number>;
readonly areAllSelected$: Observable<boolean>;
readonly areSomeSelected$: Observable<boolean>;
readonly notAllSelected$: Observable<boolean>;
readonly selected$: Observable<PrimaryKey[]>;
readonly selectedEntities$: Observable<Class[]>;
readonly selectedLength$: Observable<number>;
readonly sortedDisplayedEntities$: Observable<Class[]>;
private _displayed: Class[] = [];
private readonly _selected$ = new BehaviorSubject<PrimaryKey[]>([]);
private _anchor: Class | undefined = undefined;
private _focus: Class | undefined = undefined;
private _sortedDisplayed: Class[] = [];
constructor(
protected readonly _filterService: FilterService,
protected readonly _searchService: SearchService<Class>,
protected readonly _entitiesService: EntitiesService<Class, Class>,
protected readonly _sortingService: SortingService<Class>,
) {
this.displayed$ = this._getDisplayed$;
this.displayedLength$ = this.displayed$.pipe(getLength, shareDistinctLast());
this.selected$ = this._selected$.asObservable().pipe(shareDistinctLast());
this.selectedEntities$ = combineLatest([this._selected$.asObservable(), this._entitiesService.all$]).pipe(
map(([selectedIds, all]) => all.filter(a => selectedIds.includes(a.id))),
shareLast(),
);
this.selectedLength$ = this._selected$.pipe(getLength, shareDistinctLast());
this.areAllSelected$ = this._areAllSelected$;
this.areSomeSelected$ = this._areSomeSelected$;
this.notAllSelected$ = this._notAllSelected$;
this.sortedDisplayedEntities$ = this.#getSortedDisplayedEntities$();
}
get selected(): Class[] {
const selectedIds = this.selectedIds;
return this._entitiesService.all.filter(a => selectedIds.includes(a.id));
}
get selectedIds(): PrimaryKey[] {
return this._selected$.getValue();
}
private get _getDisplayed$(): Observable<Class[]> {
const { filterGroups$ } = this._filterService;
const { valueChanges$ } = this._searchService;
return combineLatest([this._entitiesService.all$, filterGroups$, valueChanges$]).pipe(
map(([entities, filterGroups]) => getFilteredEntities(entities, filterGroups)),
map(entities => this._searchService.searchIn(entities)),
tap(displayed => {
this._displayed = displayed;
this._updateSelection();
}),
shareLast(),
);
}
private get _areAllSelected$(): Observable<boolean> {
return combineLatest([this.displayedLength$, this.selectedLength$]).pipe(
map(([displayedLength, selectedLength]) => !!displayedLength && displayedLength === selectedLength),
shareDistinctLast(),
);
}
private get _areSomeSelected$(): Observable<boolean> {
return this.selectedLength$.pipe(
map(length => !!length),
shareDistinctLast(),
);
}
private get _notAllSelected$(): Observable<boolean> {
return combineLatest([this.areAllSelected$, this.areSomeSelected$]).pipe(
map(([allAreSelected, someAreSelected]) => !allAreSelected && someAreSelected),
shareDistinctLast(),
);
}
private get _allSelected() {
return this._displayed.length !== 0 && this._displayed.length === this.selected.length;
}
setSelected(newEntities: Class[]): void {
const selectedIds = newEntities.map(e => e.id);
this._selected$.next(selectedIds);
if (this._anchor && !newEntities.includes(this._anchor)) {
this._anchor = undefined;
}
}
isSelected(entity: Class): boolean {
return this.selectedIds.indexOf(entity.id) !== -1;
}
isSelected$(entity: Class): Observable<boolean> {
return this._selected$.pipe(
any(selectedId => selectedId === entity.id),
shareLast(),
);
}
selectAll(): void {
if (this._allSelected) {
return this.setSelected([]);
}
this.setSelected(this._displayed);
}
select(entity: Class, withShift = false): void {
const currentlySelected = this.selected;
const currentEntityIdx = currentlySelected.indexOf(entity);
const isCurrentlySelected = currentEntityIdx !== -1;
if (!withShift) {
if (!isCurrentlySelected) {
this.setSelected([...currentlySelected, entity]);
this._anchor = entity;
this._focus = entity;
} else {
// Entity is previously selected, deselect it
this.setSelected(currentlySelected.slice(0, currentEntityIdx).concat(currentlySelected.slice(currentEntityIdx + 1)));
this.#moveAnchorAfterDeselect(entity);
}
} else {
this.#shiftClick(entity);
}
}
deselect(entities: Class | Class[]) {
const _entities = Array.isArray(entities) ? entities : [entities];
const entitiesIds = _entities.map(e => e.id);
this.setSelected(this.selected.filter(el => !entitiesIds.includes(el.id)));
}
/** Move anchor & focus to next selected, or previous selected, or undefined */
#moveAnchorAfterDeselect(entity: Class): void {
const entityIdx = this._sortedDisplayed.indexOf(entity);
let newAnchorIdx = entityIdx + 1;
let increment = 1;
do {
if (this.isSelected(this._sortedDisplayed[newAnchorIdx])) {
break;
}
newAnchorIdx += increment;
if (newAnchorIdx === this._sortedDisplayed.length) {
newAnchorIdx = entityIdx - 1;
increment = -1;
}
} while (newAnchorIdx > 0);
this._anchor = this._sortedDisplayed[newAnchorIdx];
this._focus = this._sortedDisplayed[newAnchorIdx];
}
#shiftClick(entity: Class): void {
const entityIdx = this._sortedDisplayed.indexOf(entity);
if (!this._anchor || !this._focus) {
this._anchor = this._sortedDisplayed[0];
this._focus = this._sortedDisplayed[0];
}
const anchorIdx = this._sortedDisplayed.indexOf(this._anchor);
const focusIdx = this._sortedDisplayed.indexOf(this._focus);
// Deselect entities between anchor and previous focus
const remove = this._sortedDisplayed.slice(Math.min(anchorIdx, focusIdx), Math.max(anchorIdx, focusIdx) + 1);
// Update focus
this._focus = entity;
// Select entities between anchor and new focus
const intervalEntities = this._sortedDisplayed.slice(Math.min(anchorIdx, entityIdx), Math.max(anchorIdx, entityIdx) + 1);
const newSelected = [...intervalEntities, ...this.selected.filter(e => !intervalEntities.includes(e) && !remove.includes(e))];
this.setSelected(newSelected);
}
private _updateSelection(): void {
const items = this._displayed.filter(item => this.selected.includes(item));
this.setSelected(items);
}
#getSortedDisplayedEntities$(): Observable<Class[]> {
const sort = (entities: Class[]) => this._sortingService.defaultSort(entities);
const sortedEntities$ = this.displayed$.pipe(map(sort));
return this._sortingService.sortingOption$.pipe(
switchMap(() => sortedEntities$),
tap(sortedEntities => {
this._sortedDisplayed = sortedEntities;
}),
shareDistinctLast(),
);
}
}