@igo2/common
Version:
1,672 lines (1,662 loc) • 147 kB
JavaScript
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