UNPKG

@ngxs-labs/entity-state

Version:

<p align="center"> <img src="https://raw.githubusercontent.com/ngxs-labs/emitter/master/docs/assets/logo.png"> </p>

881 lines (859 loc) 31.4 kB
import { createSelector, ofAction, ofActionDispatched, ofActionSuccessful, ofActionErrored, ofActionCompleted } from '@ngxs/store'; import { patch, compose } from '@ngxs/store/operators'; class EntityStateError extends Error { constructor(message) { super(message); } } class NoActiveEntityError extends EntityStateError { constructor(additionalInformation = '') { super(('No active entity to affect. ' + additionalInformation).trim()); } } class NoSuchEntityError extends EntityStateError { constructor(id) { super(`No entity for ID ${id}`); } } class InvalidIdError extends EntityStateError { constructor(id) { super(`Invalid ID: ${id}`); } } class InvalidIdOfError extends EntityStateError { constructor() { super(`idOf returned undefined`); } } class UpdateFailedError extends EntityStateError { constructor(cause) { super(`Updating entity failed.\n\tCause: ${cause}`); } } class UnableToGenerateIdError extends EntityStateError { constructor(cause) { super(`Unable to generate an ID.\n\tCause: ${cause}`); } } class InvalidEntitySelectorError extends EntityStateError { constructor(invalidSelector) { super(`Cannot use ${invalidSelector} as EntitySelector`); } } const NGXS_META_KEY = 'NGXS_META'; /** * This function generates a new object for the ngxs Action with the given fn name * @param fn The name of the Action to simulate, e.g. "Remove" or "Update" * @param store The class of the targeted entity state, e.g. ZooState * @param payload The payload for the created action object */ function generateActionObject(fn, store, payload) { const name = store[NGXS_META_KEY].path; const ReflectedAction = function (data) { this.payload = data; }; const obj = new ReflectedAction(payload); Reflect.getPrototypeOf(obj).constructor['type'] = `[${name}] ${fn}`; return obj; } /** * Utility function that returns the active entity of the given state * @param state the state of an entity state */ function getActive(state) { return state.entities[state.active]; } /** * Returns the active entity. If none is present an error will be thrown. * @param state The state to act on */ function mustGetActive(state) { const active = getActive(state); if (active === undefined) { throw new NoActiveEntityError(); } return { id: state.active, active }; } /** * Undefined-safe function to access the property given by path parameter * @param object The object to read from * @param path The path to the property */ function elvis(object, path) { return path ? path.split('.').reduce((value, key) => value && value[key], object) : object; } /** * Returns input as an array if it isn't one already * @param input The input to make an array if necessary */ function asArray(input) { return Array.isArray(input) ? input : [input]; } /** * Limits a number to the given boundaries * @param value The input value * @param min The minimum value * @param max The maximum value */ function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } /** * Uses the clamp function is wrap is false. * Else it wrap to the max or min value respectively. * @param wrap Flag to indicate if value should be wrapped * @param value The input value * @param min The minimum value * @param max The maximum value */ function wrapOrClamp(wrap, value, min, max) { if (!wrap) { return clamp(value, min, max); } else if (value < min) { return max; } else if (value > max) { return min; } else { return value; } } /** * Enum that contains all existing Actions for the Entity State adapter. */ var EntityActionType; (function (EntityActionType) { EntityActionType["Add"] = "add"; EntityActionType["CreateOrReplace"] = "createOrReplace"; EntityActionType["Update"] = "update"; EntityActionType["UpdateAll"] = "updateAll"; EntityActionType["UpdateActive"] = "updateActive"; EntityActionType["Remove"] = "remove"; EntityActionType["RemoveAll"] = "removeAll"; EntityActionType["RemoveActive"] = "removeActive"; EntityActionType["SetLoading"] = "setLoading"; EntityActionType["SetError"] = "setError"; EntityActionType["SetActive"] = "setActive"; EntityActionType["ClearActive"] = "clearActive"; EntityActionType["Reset"] = "reset"; EntityActionType["GoToPage"] = "goToPage"; EntityActionType["SetPageSize"] = "setPageSize"; })(EntityActionType || (EntityActionType = {})); class Add { /** * Generates an action that will add the given entities to the state. * The entities given by the payload will be added. * For certain ID strategies this might fail, if it provides an existing ID. * In all other cases it will overwrite the ID value in the entity with the calculated ID. * @param target The targeted state class * @param payload An entity or an array of entities to be added * @see CreateOrReplace#constructor */ constructor(target, payload) { return generateActionObject(EntityActionType.Add, target, payload); } } class CreateOrReplace { /** * Generates an action that will add the given entities to the state. * If an entity with the ID already exists, it will be overridden. * In all cases it will overwrite the ID value in the entity with the calculated ID. * @param target The targeted state class * @param payload An entity or an array of entities to be added * @see Add#constructor */ constructor(target, payload) { return generateActionObject(EntityActionType.CreateOrReplace, target, payload); } } class Remove { /** * Generates an action that will remove the given entities from the state. * @param target The targeted state class * @param payload An EntitySelector payload * @see EntitySelector * @see RemoveAll */ constructor(target, payload) { return generateActionObject(EntityActionType.Remove, target, payload); } } class RemoveAll { /** * Generates an action that will remove all entities from the state. * @param target The targeted state class */ constructor(target) { return generateActionObject(EntityActionType.RemoveAll, target); } } class Update { /** * Generates an action that will update all entities, specified by the given selector. * @param target The targeted state class * @param selector An EntitySelector that determines the entities to update * @param data An Updater that will be applied to the selected entities * @see EntitySelector * @see Updater */ constructor(target, selector, data) { return generateActionObject(EntityActionType.Update, target, { selector, data }); } } class UpdateAll { /** * Generates an action that will update all entities. * If no entity is active a runtime error will be thrown. * @param target The targeted state class * @param data An Updater that will be applied to all entities * @see EntitySelector * @see Updater */ constructor(target, data) { return generateActionObject(EntityActionType.UpdateAll, target, { data }); } } class SetLoading { /** * Generates an action that will set the loading state for the given state. * @param target The targeted state class * @param loading The loading state */ constructor(target, loading) { return generateActionObject(EntityActionType.SetLoading, target, loading); } } class SetActive { /** * Generates an action that sets an ID that identifies the active entity * @param target The targeted state class * @param id The ID that identifies the active entity */ constructor(target, id) { return generateActionObject(EntityActionType.SetActive, target, id); } } class ClearActive { /** * Generates an action that clears the active entity in the given state * @param target The targeted state class */ constructor(target) { return generateActionObject(EntityActionType.ClearActive, target); } } class RemoveActive { /** * Generates an action that removes the active entity from the state and clears the active ID. * @param target The targeted state class */ constructor(target) { return generateActionObject(EntityActionType.RemoveActive, target); } } class UpdateActive { /** * Generates an action that will update the current active entity. * If no entity is active a runtime error will be thrown. * @param target The targeted state class * @param payload An Updater payload * @see Updater */ constructor(target, payload) { return generateActionObject(EntityActionType.UpdateActive, target, payload); } } class SetError { /** * Generates an action that will set the error state for the given state. * Put undefined to clear the error state. * @param target The targeted state class * @param error The error that describes the error state */ constructor(target, error) { return generateActionObject(EntityActionType.SetError, target, error); } } class Reset { /** * Resets the targeted store to the default state: no entities, loading is false, error is undefined, active is undefined. * @param target The targeted state class * @see defaultEntityState */ constructor(target) { return generateActionObject(EntityActionType.Reset, target); } } class GoToPage { /** * Generates an action that changes the page index for pagination. * Page index starts at 0. * @param target The targeted state class * @param payload Payload to change the page index */ constructor(target, payload) { return generateActionObject(EntityActionType.GoToPage, target, Object.assign({ wrap: false }, payload)); } } class SetPageSize { /** * Generates an action that changes the page size * @param target The targeted state class * @param payload The page size */ constructor(target, payload) { return generateActionObject(EntityActionType.SetPageSize, target, payload); } } /** * Adds or replaces the given entities to the state. * For each entity an ID will be calculated, based on the given provider. * This operator ensures that the calculated ID is added to the entity, at the specified id-field. * The `lastUpdated` timestamp will be updated. * @param entities the new entities to add or replace * @param idKey key of the id-field of an entity * @param idProvider function to provide an ID for the given entity */ function addOrReplace(entities, idKey, idProvider) { return (state) => { const nextEntities = Object.assign({}, state.entities); const nextIds = [...state.ids]; let nextState = state; // will be reassigned while looping over new entities asArray(entities).forEach(entity => { const id = idProvider(entity, nextState); let updatedEntity = entity; if (entity[idKey] !== id) { // ensure ID is in the entity updatedEntity = Object.assign(Object.assign({}, entity), { [idKey]: id }); } nextEntities[id] = updatedEntity; if (!nextIds.includes(id)) { nextIds.push(id); } nextState = Object.assign(Object.assign({}, nextState), { entities: nextEntities, ids: nextIds }); }); return Object.assign(Object.assign({}, nextState), { entities: nextEntities, ids: nextIds, lastUpdated: Date.now() }); }; } function updateTimestamp() { return patch({ lastUpdated: Date.now() }); } function alsoUpdateTimestamp(operator) { return compose(updateTimestamp(), operator); } /** * Removes all entities, clears the active entity and updates the `lastUpdated` timestamp. */ function removeAllEntities() { return (state) => { return Object.assign(Object.assign({}, state), { entities: {}, ids: [], active: undefined, lastUpdated: Date.now() }); }; } /** * Removes the entities specified by the given IDs. * The active entity will be cleared if included in the given IDs. * Updates the `lastUpdated` timestamp. * @param ids IDs to remove */ function removeEntities(ids) { const entityRemoval = patch({ entities: removeEntitiesFromDictionary(ids), ids: removeEntitiesFromArray(ids) }); return compose(entityRemoval, clearActiveIfRemoved(ids), updateTimestamp()); } /** * Only clears the `active` entity, if it's included in the given array. * All other fields will remain untouched in any case. * @param idsForRemoval the IDs to be removed */ function clearActiveIfRemoved(idsForRemoval) { return (state) => { return Object.assign(Object.assign({}, state), { active: idsForRemoval.includes(state.active) ? undefined : state.active }); }; } /** * Removes the given items from the existing items, based on equality. * @param forRemoval items to remove */ function removeEntitiesFromArray(forRemoval) { return (existing) => { return existing.filter(value => !forRemoval.includes(value)); }; } /** * Removes items from the dictionary, based on the given keys. * @param keysForRemoval the keys to be removed */ function removeEntitiesFromDictionary(keysForRemoval) { return (existing) => { const clone = Object.assign({}, existing); keysForRemoval.forEach(key => delete clone[key]); return clone; }; } /** * Updates the given entities in the state. * Entities will be merged with the given `onUpdate` function. * @param payload the updated entities * @param idKey key of the id-field of an entity * @param onUpdate update function to call on each entity */ function update(payload, idKey, onUpdate) { return (state) => { let entities = Object.assign({}, state.entities); // create copy const affected = getAffectedValues(Object.values(entities), payload.selector, idKey); if (typeof payload.data === 'function') { affected.forEach(entity => { entities = updateDictionary(entities, payload.data(entity), entity[idKey], onUpdate); }); } else { affected.forEach(entity => { entities = updateDictionary(entities, payload.data, entity[idKey], onUpdate); }); } return Object.assign(Object.assign({}, state), { entities, lastUpdated: Date.now() }); }; } function updateActive(payload, idKey, onUpdate) { return (state) => { const { id: activeId, active } = mustGetActive(state); const { entities } = state; if (typeof payload === 'function') { return Object.assign(Object.assign({}, state), { entities: updateDictionary(entities, payload(active), activeId, onUpdate), lastUpdated: Date.now() }); } else { return Object.assign(Object.assign({}, state), { entities: updateDictionary(entities, payload, activeId, onUpdate), lastUpdated: Date.now() }); } }; } function updateDictionary(entities, entity, id, onUpdate) { if (id === undefined) { throw new UpdateFailedError(new InvalidIdError(id)); } const current = entities[id]; if (current === undefined) { throw new UpdateFailedError(new NoSuchEntityError(id)); } const updated = onUpdate(current, entity); return Object.assign(Object.assign({}, entities), { [id]: updated }); } function getAffectedValues(entities, selector, idKey) { if (selector === null) { return entities; } else if (typeof selector === 'function') { return entities.filter(entity => selector(entity)); } else { const ids = asArray(selector); return entities.filter(entity => ids.includes(entity[idKey])); } } /** * Returns a new object which serves as the default state. * No entities, loading is false, error is undefined, active is undefined. * pageSize is 10 and pageIndex is 0. */ function defaultEntityState(defaults = {}) { return Object.assign({ entities: {}, ids: [], loading: false, error: undefined, active: undefined, pageSize: 10, pageIndex: 0, lastUpdated: Date.now() }, defaults); } // tslint:disable:member-ordering // @dynamic class EntityState { constructor(storeClass, _idKey, idStrategy) { this.idKey = _idKey; this.storePath = storeClass[NGXS_META_KEY].path; this.idGenerator = new idStrategy(_idKey); this.setup(storeClass, Object.values(EntityActionType)); } /** * This function is called every time an entity is updated. * It receives the current entity and a partial entity that was either passed directly or generated with a function. * The default implementation uses the spread operator to create a new entity. * You must override this method if your entity type does not support the spread operator. * @see Updater * @param current The current entity, readonly * @param updated The new data as a partial entity * @example * // default behavior * onUpdate(current: Readonly<T updated: Partial<T>): T { return {...current, ...updated}; } */ onUpdate(current, updated) { return Object.assign(Object.assign({}, current), updated); } // ------------------- SELECTORS ------------------- /** * Returns a selector for the activeId */ static get activeId() { return createSelector([this], state => state.active); } /** * Returns a selector for the active entity */ static get active() { return createSelector([this], state => getActive(state)); } /** * Returns a selector for the keys of all entities */ static get keys() { return createSelector([this], state => { return Object.keys(state.entities); }); } /** * Returns a selector for all entities, sorted by insertion order */ static get entities() { return createSelector([this], state => { return state.ids.map(id => state.entities[id]); }); } /** * Returns a selector for the nth entity, sorted by insertion order */ static nthEntity(index) { return createSelector([this], state => { const id = state.ids[index]; return state.entities[id]; }); } /** * Returns a selector for paginated entities, sorted by insertion order */ static get paginatedEntities() { return createSelector([this], state => { const { ids, pageIndex, pageSize } = state; return ids .slice(pageIndex * pageSize, (pageIndex + 1) * pageSize) .map(id => state.entities[id]); }); } /** * Returns a selector for the map of entities */ static get entitiesMap() { return createSelector([this], state => { return state.entities; }); } /** * Returns a selector for the size of the entity map */ static get size() { return createSelector([this], state => { return Object.keys(state.entities).length; }); } /** * Returns a selector for the error */ static get error() { return createSelector([this], state => { return state.error; }); } /** * Returns a selector for the loading state */ static get loading() { return createSelector([this], state => { return state.loading; }); } /** * Returns a selector for the latest added entity */ static get latest() { return createSelector([this], state => { const latestId = state.ids[state.ids.length - 1]; return state.entities[latestId]; }); } /** * Returns a selector for the latest added entity id */ static get latestId() { return createSelector([this], state => { return state.ids[state.ids.length - 1]; }); } /** * Returns a selector for the update timestamp */ static get lastUpdated() { return createSelector([this], state => { return new Date(state.lastUpdated); }); } /** * Returns a selector for age, based on the update timestamp */ static get age() { return createSelector([this], state => { return Date.now() - state.lastUpdated; }); } // ------------------- ACTION HANDLERS ------------------- /** * The entities given by the payload will be added. * For certain ID strategies this might fail, if it provides an existing ID. * In all cases it will overwrite the ID value in the entity with the calculated ID. */ add({ setState }, { payload }) { setState(addOrReplace(payload, this.idKey, (entity, state) => this.idGenerator.generateId(entity, state))); } /** * The entities given by the payload will be added. * It first checks if the ID provided by each entity does exist. * If it does the current entity will be replaced. * In all cases it will overwrite the ID value in the entity with the calculated ID. */ createOrReplace({ setState }, { payload }) { setState(addOrReplace(payload, this.idKey, (entity, state) => this.idGenerator.getPresentIdOrGenerate(entity, state))); } update({ setState }, { payload }) { if (payload.selector == null) { throw new InvalidEntitySelectorError(payload); } setState(update(payload, this.idKey, (current, updated) => this.onUpdate(current, updated))); } updateAll({ setState }, { payload }) { setState(update(Object.assign(Object.assign({}, payload), { selector: null }), this.idKey, (current, updated) => this.onUpdate(current, updated))); } updateActive({ setState }, { payload }) { setState(updateActive(payload, this.idKey, (current, updated) => this.onUpdate(current, updated))); } removeActive({ getState, setState }) { const { active } = getState(); setState(removeEntities([active])); } remove({ getState, setState, patchState }, { payload }) { if (payload === null) { throw new InvalidEntitySelectorError(payload); } else { const deleteIds = typeof payload === 'function' ? Object.values(getState().entities) .filter(entity => payload(entity)) .map(entity => this.idOf(entity)) : asArray(payload); // can't pass in predicate as you need IDs and thus EntityState#idOf setState(removeEntities(deleteIds)); } } removeAll({ setState }) { setState(removeAllEntities()); } reset({ setState }) { setState(defaultEntityState()); } setLoading({ patchState }, { payload }) { patchState({ loading: payload }); } setActive({ patchState }, { payload }) { patchState({ active: payload }); } clearActive({ patchState }) { patchState({ active: undefined }); } setError({ patchState }, { payload }) { patchState({ error: payload }); } goToPage({ getState, patchState }, { payload }) { if ('page' in payload) { patchState({ pageIndex: payload.page }); return; } else if (payload['first']) { patchState({ pageIndex: 0 }); return; } const { pageSize, pageIndex, ids } = getState(); const totalSize = ids.length; const maxIndex = Math.floor(totalSize / pageSize); if ('last' in payload) { patchState({ pageIndex: maxIndex }); } else { const step = payload['prev'] ? -1 : 1; let index = pageIndex + step; index = wrapOrClamp(payload.wrap, index, 0, maxIndex); patchState({ pageIndex: index }); } } setPageSize({ patchState }, { payload }) { patchState({ pageSize: payload }); } // ------------------- UTILITY ------------------- setup(storeClass, actions) { // validation if a matching action handler exists has moved to reflection-validation tests actions.forEach(fn => { const actionName = `[${this.storePath}] ${fn}`; storeClass[NGXS_META_KEY].actions[actionName] = [ { fn: fn, options: {}, type: actionName } ]; }); } /** * Returns the id of the given entity, based on the defined idKey. * This methods allows Partial entities and thus might return undefined. * Other methods calling this one have to handle this case themselves. * @param data a partial entity */ idOf(data) { return data[this.idKey]; } } var IdStrategy; (function (IdStrategy) { class IdGenerator { constructor(idKey) { this.idKey = idKey; } /** * Checks if the given id is in the state's ID array * @param id the ID to check * @param state the current state */ isIdInState(id, state) { return state.ids.includes(id); } /** * This function tries to get the present ID of the given entity with #getIdOf. * If it's undefined the #generateId function will be used. * @param entity The entity to get the ID from * @param state The current state * @see getIdOf * @see generateId */ getPresentIdOrGenerate(entity, state) { const presentId = this.getIdOf(entity); return presentId === undefined ? this.generateId(entity, state) : presentId; } /** * A wrapper for #getIdOf. If the function returns undefined an error will be thrown. * @param entity The entity to get the ID from * @see getIdOf * @see InvalidIdOfError */ mustGetIdOf(entity) { const id = this.getIdOf(entity); if (id === undefined) { throw new InvalidIdOfError(); } return id; } /** * Returns the ID for the given entity. Can return undefined. * @param entity The entity to get the ID from */ getIdOf(entity) { return entity[this.idKey]; } } IdStrategy.IdGenerator = IdGenerator; class IncrementingIdGenerator extends IdGenerator { constructor(idKey) { super(idKey); } generateId(entity, state) { const max = Math.max(-1, ...state.ids.map(id => parseInt(id, 10))); return (max + 1).toString(10); } } IdStrategy.IncrementingIdGenerator = IncrementingIdGenerator; class UUIDGenerator extends IdGenerator { constructor(idKey) { super(idKey); } generateId(entity, state) { let nextId; do { nextId = this.uuidv4(); } while (this.isIdInState(nextId, state)); return nextId; } uuidv4() { // https://stackoverflow.com/a/2117523 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; // tslint:disable-line const v = c === 'x' ? r : (r & 0x3) | 0x8; // tslint:disable-line return v.toString(16); }); } } IdStrategy.UUIDGenerator = UUIDGenerator; class EntityIdGenerator extends IdGenerator { constructor(idKey) { super(idKey); } generateId(entity, state) { const id = this.mustGetIdOf(entity); if (this.isIdInState(id, state)) { throw new UnableToGenerateIdError(`The provided ID already exists: ${id}`); } return id; } } IdStrategy.EntityIdGenerator = EntityIdGenerator; })(IdStrategy || (IdStrategy = {})); const ofEntityAction = (state, actionType) => { const statePath = state[NGXS_META_KEY].path; const type = `[${statePath}] ${actionType}`; return ofAction({ type: type }); }; const ofEntityActionDispatched = (state, actionType) => { const statePath = state[NGXS_META_KEY].path; const type = `[${statePath}] ${actionType}`; return ofActionDispatched({ type: type }); }; const ofEntityActionSuccessful = (state, actionType) => { const statePath = state[NGXS_META_KEY].path; const type = `[${statePath}] ${actionType}`; return ofActionSuccessful({ type: type }); }; const ofEntityActionErrored = (state, actionType) => { const statePath = state[NGXS_META_KEY].path; const type = `[${statePath}] ${actionType}`; return ofActionErrored({ type: type }); }; const ofEntityActionCompleted = (state, actionType) => { const statePath = state[NGXS_META_KEY].path; const type = `[${statePath}] ${actionType}`; return ofActionCompleted({ type: type }); }; // there are no cancelable actions, thus there is no need for a ofEntityActionCanceled action handler /** * Generated bundle index. Do not edit. */ export { Add, ClearActive, CreateOrReplace, EntityActionType, EntityState, EntityStateError, GoToPage, IdStrategy, InvalidEntitySelectorError, InvalidIdError, InvalidIdOfError, NoActiveEntityError, NoSuchEntityError, Remove, RemoveActive, RemoveAll, Reset, SetActive, SetError, SetLoading, SetPageSize, UnableToGenerateIdError, Update, UpdateActive, UpdateAll, UpdateFailedError, addOrReplace, alsoUpdateTimestamp, clearActiveIfRemoved, defaultEntityState, ofEntityAction, ofEntityActionCompleted, ofEntityActionDispatched, ofEntityActionErrored, ofEntityActionSuccessful, removeAllEntities, removeEntities, removeEntitiesFromArray, removeEntitiesFromDictionary, update, updateActive, updateDictionary, updateTimestamp }; //# sourceMappingURL=ngxs-labs-entity-state.js.map