UNPKG

@igo2/common

Version:
1,672 lines (1,662 loc) 147 kB
import { t } from 'typy'; import { ReplaySubject, BehaviorSubject, combineLatest, of, Observable } from 'rxjs'; import { uuid, ObjectUtils, StringUtils } from '@igo2/utils'; import { map, skip, debounceTime, catchError, tap } from 'rxjs/operators'; import * as i0 from '@angular/core'; import { EventEmitter, Output, Input, ChangeDetectionStrategy, Component, NgModule, ViewChild, HostListener, Directive, Optional, Self } from '@angular/core'; import { NgIf, NgFor, AsyncPipe, NgClass, NgStyle } from '@angular/common'; import * as i3$1 from '@angular/material/core'; import { MatOptionModule, MatNativeDateModule } from '@angular/material/core'; import * as i1 from '@angular/material/form-field'; import { MatFormFieldModule, MatFormFieldControl } from '@angular/material/form-field'; import * as i2 from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select'; import * as i2$2 from '@angular/cdk/a11y'; import * as i1$2 from '@angular/forms'; import { UntypedFormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { provideMomentDateAdapter } from '@angular/material-moment-adapter'; import * as i4 from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import * as i6 from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button'; import * as i7 from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox'; import * as i8 from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker'; import * as i10 from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon'; import * as i11 from '@angular/material/input'; import { MatInputModule } from '@angular/material/input'; import * as i3 from '@angular/material/paginator'; import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; import * as i12 from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort'; import * as i13 from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import * as i14 from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip'; import { SanitizeHtmlPipe } from '@igo2/common/custom-html'; import { ImageErrorDirective, SecureImagePipe } from '@igo2/common/image'; import { StopPropagationDirective } from '@igo2/common/stop-propagation'; import * as i1$1 from '@igo2/core/language'; import { IgoLanguageModule } from '@igo2/core/language'; import moment from 'moment'; import * as i2$1 from '@igo2/core/media'; import scrollIntoView from 'scroll-into-view-if-needed'; import * as i15 from '@ngx-translate/core'; var EntityOperationType; (function (EntityOperationType) { EntityOperationType["Insert"] = "Insert"; EntityOperationType["Update"] = "Update"; EntityOperationType["Delete"] = "Delete"; })(EntityOperationType || (EntityOperationType = {})); var EntityTableColumnRenderer; (function (EntityTableColumnRenderer) { EntityTableColumnRenderer["Default"] = "Default"; EntityTableColumnRenderer["HTML"] = "HTML"; EntityTableColumnRenderer["UnsanitizedHTML"] = "UnsanitizedHTML"; EntityTableColumnRenderer["Editable"] = "Editable"; EntityTableColumnRenderer["Icon"] = "Icon"; EntityTableColumnRenderer["ButtonGroup"] = "ButtonGroup"; })(EntityTableColumnRenderer || (EntityTableColumnRenderer = {})); var EntityTableScrollBehavior; (function (EntityTableScrollBehavior) { EntityTableScrollBehavior["Auto"] = "auto"; EntityTableScrollBehavior["Instant"] = "instant"; EntityTableScrollBehavior["Smooth"] = "smooth"; })(EntityTableScrollBehavior || (EntityTableScrollBehavior = {})); var EntityTableSelectionState; (function (EntityTableSelectionState) { EntityTableSelectionState["None"] = "None"; EntityTableSelectionState["All"] = "All"; EntityTableSelectionState["Some"] = "Some"; })(EntityTableSelectionState || (EntityTableSelectionState = {})); /** * Get an entity's named property. Nested properties are supported * with the dotted notation. (i.e 'author.name') * * Note: this method is a 'best attempt' at getting an entity's property. * It fits the most common cases but you might need to explicitely define * a property getter when using an EntityStore, for example. * @param entity Entity * @param property Property name * @returns Property value */ function getEntityProperty(entity, property) { return t(entity, property).safeObject; } /** * Get an entity's id. An entity's id can be one of: * 'entity.meta.id', 'entity.meta.idProperty' or 'entity.id'. * * Note: See the note in the 'getEntityProperty' documentation. * @param entity Entity * @returns Entity id */ function getEntityId(entity) { const meta = entity.meta || {}; return meta.id ? meta.id : getEntityProperty(entity, meta.idProperty || 'id'); } /** * Get an entity's title. An entity's title can be one of: * 'entity.meta.title', 'entity.meta.titleProperty' or 'entity.title'. * @param entity Entity * @returns Entity title */ function getEntityTitle(entity) { const meta = entity.meta || {}; return meta.title ? meta.title : getEntityProperty(entity, meta.titleProperty || 'title'); } /** * Get an entity's HTML title. An entity's HTML title can be one of: * 'entity.meta.titleHtml', 'entity.meta.titleHtmlProperty' or 'entity.titleHtml'. * @param entity Entity * @returns Entity HTML title */ function getEntityTitleHtml(entity) { const meta = entity.meta || {}; return meta.titleHtml ? meta.titleHtml : getEntityProperty(entity, meta.titleHtmlProperty || 'titleHtml'); } /** * Get an entity's icon. An entity's icon can be one of: * 'entity.meta.icon', 'entity.meta.iconProperty' or 'entity.icon'. * @param entity Entity * @returns Entity icon */ function getEntityIcon(entity) { const meta = entity.meta || {}; return meta.icon ? meta.icon : getEntityProperty(entity, meta.iconProperty || 'icon'); } /** * Get an entity's revision. * @param entity Entity * @returns Entity revision */ function getEntityRevision(entity) { const meta = entity.meta || {}; return meta.revision || 0; } /** * This class is used to track a store's entities state */ class EntityStateManager { options; /** * State index */ index = new Map(); /** * Change emitter */ change$ = new ReplaySubject(1); /** * Method to get an entity's id */ getKey; constructor(options = {}) { this.options = options; this.getKey = options.getKey ? options.getKey : getEntityId; this.next(); } /** * Clear state */ clear() { if (this.index.size > 0) { this.index.clear(); this.next(); } } /** * Get an entity's state * @param entity Entity * @returns State */ get(entity) { return (this.index.get(this.getKey(entity)) || {}); } /** * Set an entity's state * @param entity Entity * @param state State */ set(entity, state) { this.setMany([entity], state); } /** * Set many entitie's state * @param entitie Entities * @param state State */ setMany(entities, state) { entities.forEach((entity) => { this.index.set(this.getKey(entity), Object.assign({}, state)); }); this.next(); } /** * Set state of all entities that already have a state. This is not * the same as setting the state of all the store's entities. * @param state State */ setAll(state) { Array.from(this.index.keys()).forEach((key) => { this.index.set(key, Object.assign({}, state)); }); this.next(); } /** * Update an entity's state * @param entity Entity * @param changes State changes */ update(entity, changes, exclusive = false) { this.updateMany([entity], changes, exclusive); } /** * Update many entitie's state * @param entitie Entities * @param changes State changes */ updateMany(entities, changes, exclusive = false) { if (exclusive === true) { return this.updateManyExclusive(entities, changes); } entities.forEach((entity) => { const state = Object.assign({}, this.get(entity), changes); this.index.set(this.getKey(entity), state); }); this.next(); } /** * Reversee an entity's state * @param entity Entity * @param keys State keys to reverse */ reverse(entity, keys) { this.reverseMany([entity], keys); } /** * Reverse many entitie's state * @param entitie Entities * @param keys State keys to reverse */ reverseMany(entities, keys) { entities.forEach((entity) => { const currentState = this.get(entity); const changes = keys.reduce((acc, key) => { acc[key] = currentState[key] || false; return acc; }, {}); const reversedChanges = this.reverseChanges(changes); const state = Object.assign({}, currentState, reversedChanges); this.index.set(this.getKey(entity), state); }); this.next(); } /** * Update state of all entities that already have a state. This is not * the same as updating the state of all the store's entities. * @param changes State */ updateAll(changes) { const allKeys = this.getAllKeys(); Array.from(allKeys).forEach((key) => { const state = Object.assign({}, this.index.get(key), changes); this.index.set(key, state); }); this.next(); } /** * When some state changes are flagged as 'exclusive', reverse * the state of all other entities. Changes are reversable when * they are boolean. * @param entitie Entities * @param changes State changes */ updateManyExclusive(entities, changes) { const reverseChanges = this.reverseChanges(changes); const keys = entities.map((entity) => this.getKey(entity)); const allKeys = new Set(keys.concat(Array.from(this.getAllKeys()))); allKeys.forEach((key) => { const state = this.index.get(key) || {}; if (keys.indexOf(key) >= 0) { this.index.set(key, Object.assign({}, state, changes)); } else { // Update only if the reverse changes would modify // a key already present in the current state const shouldUpdate = Object.keys(reverseChanges).some((changeKey) => { return (state[changeKey] !== undefined && state[changeKey] !== reverseChanges[changeKey]); }); if (shouldUpdate === true) { this.index.set(key, Object.assign({}, state, reverseChanges)); } } }); this.next(); } /** * Compute a 'reversed' version of some state changes. * Changes are reversable when they are boolean. * @param changes State changes * @returns Reversed state changes */ reverseChanges(changes) { return Object.entries(changes).reduce((reverseChanges, bunch) => { const [changeKey, value] = bunch; if (typeof value === typeof true) { reverseChanges[changeKey] = !value; } return reverseChanges; }, {}); } /** * Return all the keys in that state and in the store it's bound to, if any. * @returns Set of keys */ getAllKeys() { const storeKeys = Array.from(this.options?.index?.keys() ?? []); return new Set(Array.from(this.index.keys()).concat(storeKeys)); } /** * Emit 'change' event */ next() { this.change$.next(); } } /** * An entity view streams entities from an observable source. These entities * can be filtered or sorted without affecting the source. A view can also * combine data from multiple sources, joined together. */ class EntityView { source$; /** * Observable stream of values */ values$ = new BehaviorSubject([]); /** * Subscription to the source (and joined sources) values */ values$$; /** * Whether this view has been lifted */ lifted = false; /** * Join clauses */ joins = []; /** * Observable of a filter clause */ filter$ = new BehaviorSubject(undefined); /** * Observable of filter clauses */ filters$ = new BehaviorSubject([]); /** * Filters index */ filterIndex = new Map(); /** * Observable of a sort clause */ sort$ = new BehaviorSubject(undefined); /** * Method for indexing */ get getKey() { return this.getKey$.value; } getKey$ = new BehaviorSubject(undefined); /** * Number of entities */ count$ = new BehaviorSubject(0); get count() { return this.count$.value; } /** * Whether the store is empty */ empty$ = new BehaviorSubject(true); get empty() { return this.empty$.value; } /** * Store index */ get index() { return this._index; } _index; constructor(source$) { this.source$ = source$; } /** * Get a value from the view by key * @param key Key * @returns Value */ get(key) { if (this._index === undefined) { throw new Error('This view has no index, therefore, this method is unavailable.'); } return this.index.get(key); } /** * Get all the values * @returns Array of values */ all() { return this.values$.value; } /** * Observe all the values * @returns Observable of values */ all$() { return this.values$; } /** * Get the first value that respects a criteria * @returns A value */ firstBy(clause) { return this.values$.value.find(clause); } /** * Observe the first value that respects a criteria * @returns Observable of a value */ firstBy$(clause) { return this.values$.pipe(map((values) => values.find(clause))); } /** * Get all the values that respect a criteria * @returns Array of values */ manyBy(clause) { return this.values$.value.filter(clause); } /** * Observe all the values that respect a criteria * @returns Observable of values */ manyBy$(clause) { return this.values$.pipe(map((values) => values.filter(clause))); } /** * Clear the filter and sort and unsubscribe from the source */ clear() { this.filter(undefined); this.sort(undefined); } destroy() { if (this.values$$ !== undefined) { this.values$$.unsubscribe(); } this.clear(); } /** * Create an index * @param getKey Method to get a value's id * @returns The view */ createIndex(getKey) { this._index = new Map(); this.getKey$.next(getKey); return this; } /** * Join another source to the stream (chainable) * @param clause Join clause * @returns The view */ join(clause) { if (this.lifted === true) { throw new Error('This view has already been lifted, therefore, no join is allowed.'); } this.joins.push(clause); return this; } /** * Filter values (chainable) * @param clause Filter clause * @returns The view */ filter(clause) { this.filter$.next(clause); return this; } /** * @param clause Filter clause * @returns The filter id */ addFilter(clause) { const id = uuid(); this.filterIndex.set(id, clause); this.filters$.next(Array.from(this.filterIndex.values())); return id; } /** * Remove a filter by id * @param clause Filter clause */ removeFilter(id) { this.filterIndex.delete(id); this.filters$.next(Array.from(this.filterIndex.values())); } /** * Sort values (chainable) * @param clauseSort clause * @returns The view */ sort(clause) { this.sort$.next(clause); return this; } /** * Create the final observable * @returns Observable */ lift() { this.lifted = true; const source$ = this.joins.length > 0 ? this.liftJoinedSource() : this.liftSource(); const observables$ = [ source$, this.filters$, this.filter$, this.sort$, this.getKey$ ]; this.values$$ = combineLatest(observables$) .pipe(skip(1), debounceTime(5)) .subscribe((bunch) => { const [_values, filters, filter, sort, getKey] = bunch; const values = this.processValues(_values, filters, filter, sort); const generateIndex = getKey !== undefined; this.setValues(values, generateIndex); }); } /** * Create the source observable when no joins are defined * @returns Observable */ liftSource() { return this.source$; } /** * Create the source observable when joins are defined * @returns Observable */ liftJoinedSource() { const sources$ = [ this.source$, combineLatest(this.joins.map((join) => join.source)) ]; return combineLatest(sources$).pipe(map((bunch) => { const [entities, joinData] = bunch; return entities.reduce((values, entity) => { const value = this.computeJoinedValue(entity, joinData); if (value !== undefined) { values.push(value); } return values; }, []); })); } /** * Apply joins to a source's entity and return the final value * @returns Final value */ computeJoinedValue(entity, joinData) { let value = entity; let joinIndex = 0; while (value !== undefined && joinIndex < this.joins.length) { value = this.joins[joinIndex].reduce(value, joinData[joinIndex]); joinIndex += 1; } return value; } /** * Filter and sort values before streaming them * @param values Values * @param filters Filter clauses * @param filter Filter clause * @param sort Sort clause * @returns Filtered and sorted values */ processValues(values, filters, filter, sort) { values = values.slice(0); values = this.filterValues(values, filters.concat([filter])); values = this.sortValues(values, sort); return values; } /** * Filter values * @param values Values * @param filters Filter clauses * @returns Filtered values */ filterValues(values, clauses) { if (clauses.length === 0) { return values; } return values.filter((value) => { return clauses .filter((clause) => clause !== undefined) .every((clause) => clause(value)); }); } /** * Sort values * @param values Values * @param sort Sort clause * @returns Sorted values */ sortValues(values, clause) { if (clause === undefined) { return values; } return values.sort((v1, v2) => { return ObjectUtils.naturalCompare(clause.valueAccessor(v1), clause.valueAccessor(v2), clause.direction, clause.nullsFirst); }); } /** * Set value and optionally generate an index * @param values Values * @param generateIndex boolean */ setValues(values, generateIndex) { if (generateIndex === true) { this._index = this.generateIndex(values); } this.values$.next(values); const count = values.length; const empty = count === 0; this.count$.next(count); this.empty$.next(empty); } /** * Generate a complete index of all the values * @param entities Entities * @returns Index */ generateIndex(values) { const entries = values.map((value) => [this.getKey(value), value]); return new Map(entries); } } /** * An entity store class holds any number of entities * as well as their state. It can be observed, filtered and sorted and * provides methods to insert, update or delete entities. */ class EntityStore { /** * Observable of the raw entities */ entities$ = new BehaviorSubject([]); /** * Number of entities */ count$ = new BehaviorSubject(0); get count() { return this.count$.value; } /** * Whether the store is empty */ empty$ = new BehaviorSubject(true); get empty() { return this.empty$.value; } /** * Entity store state */ state; /** * View of all the entities */ view; /** * View of all the entities and their state */ stateView; /** * Method to get an entity's id */ getKey; /** * Method to get an entity's named property */ getProperty; /** * Store index */ get index() { return this._index; } _index; /** * Store index */ get pristine() { return this._pristine; } _pristine = true; /** * Strategies */ strategies = []; constructor(entities, options = {}) { this.getKey = options.getKey ? options.getKey : getEntityId; this.getProperty = options.getProperty ? options.getProperty : getEntityProperty; this.state = this.createStateManager(); this.view = this.createDataView(); this.stateView = this.createStateView(); this.view.lift(); this.stateView.lift(); if (entities.length > 0) { this.load(entities); } else { this._index = this.generateIndex(entities); } } /** * Get an entity from the store by key * @param key Key * @returns Entity */ get(key) { return this.index.get(key); } /** * Get all entities in the store * @returns Array of entities */ all() { return this.entities$.value; } /** * Set this store's entities * @param entities Entities */ load(entities, pristine = true) { this._index = this.generateIndex(entities); this._pristine = pristine; this.next(); } /** * Clear the store's entities but keep the state and views intact. * Views won't return any data but future data will be subject to the * current views filter and sort */ softClear() { if (this.index && this.index.size > 0) { this.index.clear(); this._pristine = true; this.next(); } else if (this.index) { this.updateCount(); } } /** * Clear the store's entities, state and views */ clear() { this.stateView.clear(); this.view.clear(); this.state.clear(); this.softClear(); } destroy() { this.stateView.destroy(); this.view.destroy(); this.clear(); } /** * Insert an entity into the store * @param entity Entity */ insert(entity) { this.insertMany([entity]); } /** * Insert many entities into the store * @param entities Entities */ insertMany(entities) { entities.forEach((entity) => this.index.set(this.getKey(entity), entity)); this._pristine = false; this.next(); } /** * Update or insert an entity into the store * @param entity Entity */ update(entity) { this.updateMany([entity]); } /** * Update or insert many entities into the store * @param entities Entities */ updateMany(entities) { entities.forEach((entity) => this.index.set(this.getKey(entity), entity)); this._pristine = false; this.next(); } /** * Add a strategy to this store * @param strategy Entity store strategy * @returns Entity store */ addStrategy(strategy, activate = false) { const existingStrategy = this.strategies.find((_strategy) => { return strategy.constructor === _strategy.constructor; }); if (existingStrategy !== undefined) { throw new Error('A strategy of this type already exists on that EntityStore.'); } this.strategies.push(strategy); strategy.bindStore(this); if (activate === true) { strategy.activate(); } return this; } /** * Remove a strategy from this store * @param strategy Entity store strategy * @returns Entity store */ removeStrategy(strategy) { const index = this.strategies.indexOf(strategy); if (index >= 0) { this.strategies.splice(index, 1); strategy.unbindStore(this); } return this; } /** * Return strategies of a given type * @param type Entity store strategy class * @returns Strategies */ getStrategyOfType(type) { return this.strategies.find((strategy) => { return strategy instanceof type; }); } /** * Activate strategies of a given type * @param type Entity store strategy class */ activateStrategyOfType(type) { const strategy = this.getStrategyOfType(type); if (strategy !== undefined) { strategy.activate(); } } /** * Deactivate strategies of a given type * @param type Entity store strategy class */ deactivateStrategyOfType(type) { const strategy = this.getStrategyOfType(type); if (strategy !== undefined) { strategy.deactivate(); } } /** * Delete an entity from the store * @param entity Entity */ delete(entity) { this.deleteMany([entity]); } /** * Delete many entities from the store * @param entities Entities */ deleteMany(entities) { entities.forEach((entity) => this.index.delete(this.getKey(entity))); this._pristine = false; this.next(); } /** * Generate a complete index of all the entities * @param entities Entities * @returns Index */ generateIndex(entities) { const entries = entities.map((entity) => [this.getKey(entity), entity]); return new Map(entries); } /** * Push the index's entities into the entities$ observable */ next() { this.entities$.next(Array.from(this.index.values())); this.updateCount(); } /** * Update the store's count and empty */ updateCount() { const count = this.index.size; const empty = count === 0; this.count$.next(count); this.empty$.next(empty); } /** * Create the entity state manager * @returns EntityStateManager */ createStateManager() { return new EntityStateManager({ getKey: this.getKey, index: this.index }); } /** * Create the data view * @returns EntityView<E> */ createDataView() { return new EntityView(this.entities$); } /** * Create the state view * @returns EntityView<EntityRecord<E>> */ createStateView() { return new EntityView(this.view.all$()) .join({ source: this.state.change$, reduce: (entity) => { const key = this.getKey(entity); const state = this.state.get(entity); const currentRecord = this.stateView.get(key); if (currentRecord !== undefined && currentRecord.entity === entity && this.statesAreTheSame(currentRecord.state, state)) { return currentRecord; } const revision = currentRecord ? currentRecord.revision + 1 : 1; const ref = `${key}-${revision}`; return { entity, state, revision, ref }; } }) .createIndex((record) => this.getKey(record.entity)); } statesAreTheSame(currentState, newState) { if (currentState === newState) { return true; } const currentStateIsEmpty = Object.keys(currentState).length === 0; const newStateIsEmpty = Object.keys(newState).length === 0; return currentStateIsEmpty && newStateIsEmpty; } } /** * This class is used to synchronize a component's changes * detection with an EntityStore changes. For example, it is frequent * to have a component subscribe to a store's selected entity and, at the same time, * this component provides a way to select an entity with, let's say, a click. * * This class automatically handles those case and triggers the compoent's * change detection when needed. * * Note: If the component observes the store's stateView, a workspace is * probably not required because the stateView catches any changes to the * entities and their state. */ class EntityStoreWatcher { /** * Component change detector */ cdRef; /** * Entity store */ store; /** * Component inner state */ innerStateIndex = new Map(); /** * Subscription to the store's entities */ entities$$; /** * Subscription to the store's state */ state$$; constructor(store, cdRef) { this.setChangeDetector(cdRef); this.setStore(store); } destroy() { this.setChangeDetector(undefined); this.setStore(undefined); } /** * Bind this workspace to a store and start watching for changes * @param store Entity store */ setStore(store) { if (store === undefined) { this.teardownObservers(); this.innerStateIndex.clear(); this.store = undefined; return; } this.setStore(undefined); this.store = store; this.setupObservers(); this.detectChanges(); } /** * Bind this workspace to a component's change detector * @param cdRef Change detector */ setChangeDetector(cdRef) { this.cdRef = cdRef; } /** * Set up observers on a store's entities and their state * @param store Entity store */ setupObservers() { this.teardownObservers(); this.entities$$ = this.store.entities$.subscribe(() => this.onEntitiesChange()); this.state$$ = this.store.state.change$ .pipe(skip(1)) .subscribe(() => this.onStateChange()); } /** * Teardown store observers */ teardownObservers() { if (this.entities$$ !== undefined) { this.entities$$.unsubscribe(); } if (this.state$$ !== undefined) { this.state$$.unsubscribe(); } this.entities$$ = undefined; this.state$$ = undefined; } /** * When the entities change, always trigger the changes detection */ onEntitiesChange() { this.detectChanges(); } /** * When the entities state change, trigger the change detection * only if the component has not handled these changes yet. For example, * the component might have initiated thoses changes itself. */ onStateChange() { let changesDetected = false; const storeIndex = this.store.state.index; const innerIndex = this.innerStateIndex; if (storeIndex.size !== innerIndex.size) { changesDetected = this.detectChanges(); } const storeKeys = Array.from(storeIndex.keys()); for (const key of storeKeys) { const storeValue = storeIndex.get(key); const innerValue = innerIndex.get(key); if (changesDetected === false) { if (innerValue === undefined) { changesDetected = this.detectChanges(); } else if (!ObjectUtils.objectsAreEquivalent(storeValue, innerValue)) { changesDetected = this.detectChanges(); } } this.innerStateIndex.set(key, Object.assign({}, storeValue)); } } /** * Trigger the change detection of the workspace is bound to a change detector */ detectChanges() { if (this.cdRef !== undefined) { this.cdRef.detectChanges(); } return true; } } /** * This class holds a reference to the insert, update and delete * operations performed on a store. This is useful to commit * these operations in a single pass or to cancel them. */ class EntityTransaction { /** * Store holding the operations on another store */ operations; /** * Method to get an entity's id */ getKey; /** * Whether there are pending operations */ get empty$() { return this.operations.empty$; } /** * Whether there are pending operations */ get empty() { return this.empty$.value; } /** * Whether thise store is in commit phase */ get inCommitPhase() { return this.inCommitPhase$.value; } inCommitPhase$ = new BehaviorSubject(false); constructor(options = {}) { this.getKey = options.getKey ? options.getKey : getEntityId; this.operations = new EntityStore([], { getKey: (operation) => operation.key }); } destroy() { this.operations.destroy(); } /** * Insert an entity into a store. If no store is specified, an insert * operation is still created but the transaction won't add the new * entity to the store. * @param current The entity to insert * @param store Optional: The store to insert the entity into * @param meta Optional: Any metadata on the operation */ insert(current, store, meta) { const existingOperation = this.getOperationByEntity(current); if (existingOperation !== undefined) { this.removeOperation(existingOperation); } this.doInsert(current, store, meta); } /** * Update an entity in a store. If no store is specified, an update * operation is still created but the transaction won't update the * entity into the store. * @param previous The entity before update * @param current The entity after update * @param store Optional: The store to update the entity into * @param meta Optional: Any metadata on the operation */ update(previous, current, store, meta) { const existingOperation = this.getOperationByEntity(current); if (existingOperation !== undefined) { this.removeOperation(existingOperation); if (existingOperation.type === EntityOperationType.Insert) { this.doInsert(current, store, meta); return; } else if (existingOperation.type === EntityOperationType.Update) { previous = existingOperation.previous; } } this.doUpdate(previous, current, store, meta); } /** * Delete an entity from a store. If no store is specified, a delete * operation is still created but the transaction won't remove the * entity from the store. * @param previous The entity before delete * @param store Optional: The store to delete the entity from * @param meta Optional: Any metadata on the operation */ delete(previous, store, meta) { const existingOperation = this.getOperationByEntity(previous); if (existingOperation !== undefined) { this.removeOperation(existingOperation); if (existingOperation.type === EntityOperationType.Insert) { if (store !== undefined) { store.delete(previous); } return; } } this.doDelete(previous, store, meta); } /** * Commit operations the transaction. This method doesn't do much * in itself. The handler it receives does the hard work and it's * implementation is left to the caller. This method simply wraps * the handler into an error catching mechanism to update * the transaction afterward. The caller needs to subscribe to this * method's output (observable) for the commit to be performed. * @param operations Operations to commit * @param handler Function that handles the commit operation * @returns The handler output (observable) */ commit(operations, handler) { this.inCommitPhase$.next(true); return handler(this, operations).pipe(catchError(() => of(new Error())), tap((result) => { if (result instanceof Error) { this.onCommitError(); } else { this.onCommitSuccess(operations); } })); } /** * Commit all the operations of the transaction. * @param handler Function that handles the commit operation * @returns The handler output (observable) */ commitAll(handler) { const operations = this.getOperationsInCommit(); return this.commit(operations, handler); } /** * Rollback this transaction */ rollback() { this.rollbackOperations(this.operations.all()); } /** * Rollback specific operations */ rollbackOperations(operations) { this.checkInCommitPhase(); const operationsFactory = () => new Map([ [EntityOperationType.Delete, []], [EntityOperationType.Update, []], [EntityOperationType.Insert, []] ]); const storesOperations = new Map(); // Group operations by store and by operation type. // Grouping operations allows us to revert them in bacth, thus, triggering // observables only one per operation type. for (const operation of operations) { const store = operation.store; if (operation.store === undefined) { continue; } let storeOperations = storesOperations.get(store); if (storeOperations === undefined) { storeOperations = operationsFactory(); storesOperations.set(store, storeOperations); } storeOperations.get(operation.type).push(operation); } Array.from(storesOperations.keys()).forEach((store) => { const storeOperations = storesOperations.get(store); const deletes = storeOperations.get(EntityOperationType.Delete); store.insertMany(deletes.map((_delete) => _delete.previous)); const updates = storeOperations.get(EntityOperationType.Update); store.updateMany(updates.map((_update) => _update.previous)); const inserts = storeOperations.get(EntityOperationType.Insert); store.deleteMany(inserts.map((_insert) => _insert.current)); }); this.operations.deleteMany(operations); this.inCommitPhase$.next(false); } /** * Clear this transaction * @todo Raise event and synchronize stores? */ clear() { this.operations.clear(); this.inCommitPhase$.next(false); } /** * Get any existing operation on an entity * @param entity Entity * @returns Either an insert, update or delete operation */ getOperationByEntity(entity) { return this.operations.get(this.getKey(entity)); } /** * Merge another transaction in this one * @param transaction Another transaction */ mergeTransaction(transaction) { this.checkInCommitPhase(); const operations = transaction.operations.all(); operations.forEach((operation) => { this.addOperation(operation); }); } /** * Create an insert operation and add an entity to the store * @param current The entity to insert * @param store Optional: The store to insert the entity into * @param meta Optional: Any metadata on the operation */ doInsert(current, store, meta) { this.addOperation({ key: this.getKey(current), type: EntityOperationType.Insert, previous: undefined, current, store, meta }); if (store !== undefined) { store.insert(current); } } /** * Create an update operation and update an entity into the store * @param previous The entity before update * @param current The entity after update * @param store Optional: The store to update the entity into * @param meta Optional: Any metadata on the operation */ doUpdate(previous, current, store, meta) { this.addOperation({ key: this.getKey(current), type: EntityOperationType.Update, previous, current, store, meta }); if (store !== undefined) { store.update(current); } } /** * Create a delete operation and delete an entity from the store * @param previous The entity before delete * @param store Optional: The store to delete the entity from * @param meta Optional: Any metadata on the operation */ doDelete(previous, store, meta) { this.addOperation({ key: this.getKey(previous), type: EntityOperationType.Delete, previous, current: undefined, store, meta }); if (store !== undefined) { store.delete(previous); } } /** * Remove committed operations from store * @param operations Commited operations * @todo Raise event and synchronize stores? */ resolveOperations(operations) { this.operations.deleteMany(operations); } /** * On commit success, resolve commited operations and exit commit phase * @param operations Commited operations */ onCommitSuccess(operations) { this.resolveOperations(operations); this.inCommitPhase$.next(false); } /** * On commit error, abort transaction * @param operations Commited operations */ onCommitError() { this.inCommitPhase$.next(false); } /** * Add an operation to the operations store * @param operation Operation to add */ addOperation(operation) { this.checkInCommitPhase(); this.operations.insert(operation); this.operations.state.update(operation, { added: true }); } /** * Remove an operation from the operations store * @param operation Operation to remove */ removeOperation(operation) { this.checkInCommitPhase(); this.operations.delete(operation); this.operations.state.update(operation, { added: false }); } /** * Get all the operations to commit * @returns Operations to commit */ getOperationsInCommit() { return this.operations.stateView .manyBy((value) => { return value.state.added === true; }) .map((value) => value.entity); } /** * Check if the transaction is in the commit phase and throw an error if it is */ checkInCommitPhase() { if (this.inCommitPhase === true) { throw new Error('This transaction is in the commit phase. Cannot complete this operation.'); } } } /** * Entity store strategies. They can do pretty much anything during a store's * lifetime. For example, they may act as triggers when something happens. * Sharing a strategy is a good idea when multiple strategies would have * on cancelling effect on each other. * * At creation, strategy is inactive and needs to be manually activated. */ class EntityStoreStrategy { options; /** * Feature store * @internal */ stores = []; /** * Whether this strategy is active * @internal */ get active() { return this.active$.value; } active$ = new BehaviorSubject(false); constructor(options = {}) { this.options = options; this.options = options; } /** * Activate the strategy. If it's already active, it'll be deactivated * and activated again. */ activate() { if (this.active === true) { this.doDeactivate(); } this.active$.next(true); this.doActivate(); } /** * Activate the strategy. If it's already active, it'll be deactivated * and activated again. */ deactivate() { this.active$.next(false); this.doDeactivate(); } /** * Bind this strategy to a store * @param store Feature store */ bindStore(store) { if (this.stores.indexOf(store) < 0) { this.stores.push(store); } } /** * Unbind this strategy from store * @param store Feature store */ unbindStore(store) { const index = this.stores.indexOf(store); if (index >= 0) { this.stores.splice(index, 1); } } /** * Do the stataegy activation * @internal */ doActivate() { // empty } /** * Do the strategy deactivation * @internal */ doDeactivate() { // empty } } /** * When active, this strategy filters a store's stateView to return * selected entities only. */ class EntityStoreFilterCustomFuncStrategy extends EntityStoreStrategy { options; constructor(options) { super(options); this.options = options; } /** * Store / filter ids map */ filters = new Map(); /** * Bind this strategy to a store and start filtering it * @param store Entity store */ bindStore(store) { super.bindStore(store); if (this.active === true) { this.filterStore(store); } } /** * Unbind this strategy from a store and stop filtering it * @param store Entity store */ unbindStore(store) { super.unbindStore(store); if (this.active === true) { this.unfilterStore(store); } } /** * Start filtering all stores * @internal */ doActivate() { this.filterAll(); } /** * Stop filtering all stores * @internal */ doDeactivate() { this.unfilterAll(); } /** * Filter all stores */ filterAll() { this.stores.forEach((store) => this.filterStore(store)); } /** * Unfilter all stores */ unfilterAll() { this.stores.forEach((store) => this.unfilterStore(store)); } /** * Filter a store and add it to the filters map */ filterStore(store) { this.filters.set(store, store.stateView.addFilter(this.options.filterClauseFunc)); } /** * Unfilter a store and delete it from the filters map */ unfilterStore(store) { const filterId = this.filters.get(store); if (filterId === undefined) { return; } store.stateView.removeFilter(filterId); this.filters.delete(store); } } /** * When active, this strategy filters a store's stateView to return * selected entities only. */ class EntityStoreFilterSelectionStrategy extends EntityStoreStrategy { /** * Store / filter ids map */ filters = new Map(); /** * Bind this strategy to a store and start filtering it * @param store Entity store */ bindStore(store) { super.bindStore(store); if (this.active === true) { this.filterStore(store); } } /** * Unbind this strategy from a store and stop filtering it * @param store Entity store */ unbindStore(store) { super.unbindStore(store); if (this.active === true) { this.unfilterStore(store); } } /** * Start filtering all stores * @internal */ doActivate() { this.filterAll(); } /** * Stop filtering all stores * @internal */ doDeactivate() { this.unfilterAll(); } /** * Filter all stores */ filterAll() { this.stores.forEach((store) => this.filterStore(store)); } /** * Unfilter all stores */ unfilterAll() { this.stores.forEach((store) => this.unfilterStore(store)); } /** * Filter a store and add it to the filters map */ filterStore(store) { if (this.filters.has(store)) { return; } const filter = (record) => { return record.state.selected === true; }; this.filters.set(store, store.stateView.addFilter(filter)); } /** * Unfilter a store and delete it from the filters map */ unfilterStore(store) { con