@o3r/core
Version:
Core of the Otter Framework
522 lines (504 loc) • 20.5 kB
JavaScript
import { map, filter, EMPTY, from, of, isObservable, identity, BehaviorSubject, merge, observeOn, animationFrameScheduler } from 'rxjs';
import { tap, startWith, pairwise, switchMap, catchError, filter as filter$1, delay, finalize, mergeMap, bufferCount, concatMap, scan } from 'rxjs/operators';
import { v4 } from 'uuid';
/* eslint-disable @typescript-eslint/naming-convention -- exception for `BuildTimeProperties` */
/**
* Library build time default properties
*/
const DEFAULT_BUILD_PROPERTIES = {
DEBUG_MODE: true,
APP_BASE_HREF: '.',
APP_VERSION: '0.0.0',
DEFAULT_LOC_BUNDLE_NAME: '',
DEVTOOL_HISTORY_SIZE: 20,
ENABLE_GHOSTING: false,
ENABLE_WEBSTORAGE: true,
ENVIRONMENT: 'dev',
LOCALIZATION_BUNDLES_OUTPUT: 'localizations/',
USE_MOCKS: false
};
/**
* Private field where Otter component information are stored
*/
const otterComponentInfoPropertyName = '__otter-info__';
/**
* Decorates an Angular component to provide Otter information
* @param info Information to define the Otter component
* @returns the component with the information
*/
// eslint-disable-next-line @typescript-eslint/naming-convention -- decorator should be PascalCase
function O3rComponent(info) {
return (constructor) => {
const componentName = constructor.name;
constructor.prototype[otterComponentInfoPropertyName] = { ...info, componentName };
return constructor;
};
}
/**
* Compute the name of the component with the library's name to generate unique component identifier used in metadata and different modules
* @param componentName Name of the component to get the configuration
* @param libraryName Name of the library the component is coming from
*/
function computeItemIdentifier(componentName, libraryName) {
return (libraryName ? libraryName + '#' : '') + componentName;
}
/** Type of a message exchanged within the Otter Framework */
const otterMessageType = 'otter';
/** Target of a message that should be handled by the application */
const applicationMessageTarget = 'app';
/**
* Determine if a message should be handle by the application
* @param message Message to analyze
*/
const isToAppOtterMessage = (message) => {
return message?.to === applicationMessageTarget;
};
/**
* Determine if a message is emitted by an Otter tool
* @param message Message to analyze
*/
const isOtterMessage = (message) => {
return message?.type === otterMessageType;
};
/**
* Send an Otter Message
* @param dataType Type of the message
* @param content content of the message
* @param preStringify determine if the message should JSON.stringify before being send (will use the default mechanism otherwise)
*/
const sendOtterMessage = (dataType, content, preStringify = true) => {
const message = {
type: otterMessageType,
content: {
...content,
dataType
}
};
return window.postMessage(preStringify ? JSON.stringify(message) : message, '*');
};
/**
* Filter the Otter message that should be handle by the application and returns it content
* @param predicate condition to filter the message
* @returns content of the message
*/
/**
* Operator to get only Otter messages that match the predicate
* @param predicate
*/
function filterMessageContent(predicate) {
return (source$) => {
const obs = source$.pipe(map((event) => {
const data = event.data;
return typeof data === 'string' ? JSON.parse(data) : data;
}), filter(isOtterMessage), filter(isToAppOtterMessage), map((message) => message.content));
if (predicate) {
return obs.pipe(filter(predicate));
}
return obs;
};
}
const asyncStoreItemAdapter = {
addRequest: (item, requestId) => {
return {
...item,
requestIds: [...item.requestIds, requestId],
isFailure: item.requestIds.length === 0 ? false : item.isFailure,
isPending: true
};
},
failRequest: (item, requestId) => {
const requestIds = requestId ? item.requestIds.filter((id) => id !== requestId) : item.requestIds;
return {
...item,
isFailure: true,
isPending: requestIds.length > 0,
requestIds
};
},
resolveRequest: (item, requestId) => {
const requestIds = requestId ? item.requestIds.filter((id) => id !== requestId) : item.requestIds;
return {
...item,
requestIds,
isPending: requestIds.length > 0
};
},
initialize: (entityItem) => {
return {
...entityItem,
requestIds: []
};
},
extractAsyncStoreItem: (entityItem) => {
return {
requestIds: [...entityItem.requestIds],
isFailure: entityItem.isFailure,
isPending: entityItem.isPending
};
},
clearAsyncStoreItem: (entityItem) => {
const { isPending, isFailure, ...newResponse } = { ...entityItem, requestIds: [] };
return newResponse;
},
merge: (...items) => {
return items.reduce((mergedItem, item) => item
? {
requestIds: [...mergedItem.requestIds, ...item.requestIds],
isFailure: mergedItem.isFailure || item.isFailure,
isPending: mergedItem.isPending || item.isPending
}
: mergedItem, asyncStoreItemAdapter.initialize({}));
},
entityStatusAddRequest: (status, subResource, requestId) => {
const currentSubStatus = status[subResource] || asyncStoreItemAdapter.initialize({});
return {
...status,
[subResource]: asyncStoreItemAdapter.addRequest(currentSubStatus, requestId)
};
},
entityStatusResolveRequest: (status, subResource, requestId) => {
const currentSubStatus = status[subResource];
return {
...status,
[subResource]: currentSubStatus ? asyncStoreItemAdapter.resolveRequest(currentSubStatus, requestId) : { requestIds: [] }
};
},
entityStatusFailRequest: (status, subResource, requestId) => {
const currentSubStatus = status[subResource];
return {
...status,
[subResource]: currentSubStatus ? asyncStoreItemAdapter.failRequest(currentSubStatus, requestId) : { requestIds: [], isFailure: true }
};
},
entityStatusResetFailure: (status, subResource) => {
const currentSubStatus = status[subResource];
return {
...status,
[subResource]: currentSubStatus ? asyncStoreItemAdapter.resetFailureStatus(currentSubStatus) : { requestIds: [] }
};
},
resetFailureStatus: (entityItem) => {
return {
...entityItem,
isFailure: false
};
},
setLoadingStatus: (entityItem) => {
return {
...entityItem,
isPending: true
};
}
};
/**
* Create an Asynchronous Request Entity Adapter
* @param adapter Entity Adapter
*/
function createEntityAsyncRequestAdapter(adapter) {
const addRequestOne = (state, id, requestId) => {
const currentEntity = typeof id !== 'undefined' && id !== null && state.entities[id];
if (currentEntity) {
const changes = asyncStoreItemAdapter.addRequest(asyncStoreItemAdapter.extractAsyncStoreItem(currentEntity), requestId);
return adapter.updateOne({ id, changes }, state);
}
return asyncStoreItemAdapter.addRequest(state, requestId);
};
const addRequestMany = (state, ids, requestId) => adapter.updateMany(ids.filter((id) => !!state.entities[id]).map((id) => ({
id,
changes: asyncStoreItemAdapter.addRequest(asyncStoreItemAdapter.extractAsyncStoreItem(state.entities[id]), requestId)
})), state);
const resolveRequestOne = (state, entity, requestId, idProperty = 'id') => {
const currentEntity = state.entities[entity[idProperty]];
const newEntity = currentEntity
? asyncStoreItemAdapter.resolveRequest({ ...entity, ...asyncStoreItemAdapter.extractAsyncStoreItem(currentEntity) }, requestId)
: asyncStoreItemAdapter.initialize(entity);
state = asyncStoreItemAdapter.resolveRequest(state, requestId);
return adapter.upsertOne(newEntity, state);
};
const resolveRequestMany = (state, entities, requestId, idProperty = 'id') => adapter.updateMany(entities.filter((entity) => !!state.entities[entity[idProperty]]).map((entity) => {
const model = { ...entity, ...asyncStoreItemAdapter.extractAsyncStoreItem(state.entities[entity[idProperty]]) };
return { id: entity[idProperty], changes: asyncStoreItemAdapter.resolveRequest(model, requestId) };
}), state);
const failRequestMany = (state, ids = [], requestId) => {
if (ids.length > 0 && !ids.some((id) => state.entities[id] === undefined)) {
return adapter.updateMany(ids.map((id) => ({
id,
changes: asyncStoreItemAdapter.failRequest(asyncStoreItemAdapter.extractAsyncStoreItem(state.entities[id]), requestId)
})), state);
}
return asyncStoreItemAdapter.failRequest(state, requestId);
};
return {
...adapter,
failRequestMany,
addRequestOne,
addRequestMany,
resolveRequestOne,
resolveRequestMany
};
}
/**
* Determine if the action is an AsyncRequest action
* @param action Redux Action
*/
function isCallAction(action) {
if (!action) {
return false;
}
return !!action.call && action.call instanceof Promise;
}
/**
* Determine if the action is an AsyncRequest action with a Request ID
* @param action Redux Action
*/
function isIdentifiedCallAction(action) {
return isCallAction(action) && typeof action.requestId !== 'undefined';
}
/**
* Determine if the given item implements the AsyncRequest interface
* @param item
*/
function isAsyncRequest(item) {
return typeof item.requestId !== 'undefined';
}
/**
* Determine if the given parameter is a Promise
* @param object
*/
const isPromise = (object) => object && typeof object === 'object' && typeof object.then !== 'undefined';
/**
* Custom operator to use instead of SwitchMap with effects based on FromApi actions.
* It makes sure to emit an action when the inner subscription is unsubscribed in order to keep the store up-to-date with pending information.
* @param successHandler function that returns the action to emit in case the FromApi call is a success
* @param errorHandler function that returns the action to emit in case the FromApi call fails
* @param cancelRequestActionFactory function that returns the action to emit in case the FromApi action is 'cancelled' because a new action was received by the switchMap
*/
function fromApiEffectSwitchMap(successHandler, errorHandler, cancelRequestActionFactory) {
const pendingRequestIdsContext = {};
return (source$) => source$.pipe(tap((action) => {
if (isIdentifiedCallAction(action)) {
pendingRequestIdsContext[action.requestId] = true;
}
}), startWith(undefined), pairwise(), switchMap(([previousAction, action]) => {
if (!action) {
return EMPTY;
}
const isPreviousActionStillRunning = isIdentifiedCallAction(previousAction) && pendingRequestIdsContext[previousAction.requestId];
const cleanStack = () => {
if (isIdentifiedCallAction(action)) {
delete pendingRequestIdsContext[action.requestId];
}
};
return from(action.call).pipe(tap(cleanStack), switchMap((result) => {
const success = successHandler(result, action);
return isObservable(success) ? success : (isPromise(success) ? success : of(success));
}), catchError((error) => {
cleanStack();
return errorHandler?.(error, action) || EMPTY;
}), isPreviousActionStillRunning && cancelRequestActionFactory ? startWith(cancelRequestActionFactory({ requestId: previousAction.requestId }, action)) : identity);
}));
}
/**
* Same as {@link fromApiEffectSwitchMap}, instead one inner subscription is kept by id.
* @param successHandler
* @param errorHandler
* @param cancelRequestActionFactory
* @param cleanUpTimer
*/
function fromApiEffectSwitchMapById(successHandler, errorHandler, cancelRequestActionFactory, cleanUpTimer) {
const innerSourcesById = {};
return (source$) => {
return source$.pipe(filter$1((action) => {
if (!isIdentifiedCallAction(action) || !action.id) {
return false;
}
if (isIdentifiedCallAction(action) && innerSourcesById[action.id]) {
innerSourcesById[action.id][0].next(action);
return false;
}
return true;
}), switchMap((action) => {
const newIdSubject = new BehaviorSubject(action);
const newId$ = newIdSubject.pipe(fromApiEffectSwitchMap(successHandler, errorHandler, cancelRequestActionFactory));
innerSourcesById[action.id] = [newIdSubject, newId$];
if (cleanUpTimer !== undefined) {
newIdSubject.pipe(switchMap((myAction) => from(myAction.call).pipe(delay(cleanUpTimer), finalize(() => {
delete innerSourcesById[myAction.id];
newIdSubject.complete();
})))).subscribe();
}
const streams = Object.values(innerSourcesById).map(([_, obs]) => obs);
return merge(...streams);
}));
};
}
/**
* Returns a creator that makes sure that requestId is defined in the action's properties by generating one
* if needed.
*/
const asyncProps = () => {
return (props) => isAsyncRequest(props) ? props : { ...props, requestId: v4() };
};
/**
* Serializer for asynchronous store.
* @param state State of an asynchronous store to serialize
* @returns a plain json object to pass to json.stringify
*/
function asyncSerializer(state) {
return asyncStoreItemAdapter.clearAsyncStoreItem(state);
}
/**
* Serializer for asynchronous entity store.
* @param state State of an asynchronous entity store to serialize
* @returns a plain json object to pass to json.stringify
*/
function asyncEntitySerializer(state) {
const entities = state.ids.reduce((entitiesAcc, entityId) => {
entitiesAcc[entityId] = asyncStoreItemAdapter.clearAsyncStoreItem(state.entities[entityId]);
return entitiesAcc;
}, {});
return { ...asyncStoreItemAdapter.clearAsyncStoreItem(state), entities };
}
/**
* Serializer for asynchronous entity store with status.
* @param state State of an asynchronous entity store with status to serialize
* @returns a plain json object to pass to json.stringify
*/
function asyncEntityWithStatusSerializer(state) {
const entities = state.ids.reduce((entitiesAcc, entityId) => {
entitiesAcc[entityId] = { ...asyncStoreItemAdapter.clearAsyncStoreItem(state.entities[entityId]), status: {} };
return entitiesAcc;
}, {});
return { ...asyncStoreItemAdapter.clearAsyncStoreItem(state), entities };
}
class StateSerializer {
constructor(serializer) {
this.reviver = (_, value) => value;
this.serialize = serializer.serialize || this.serialize;
this.deserialize = serializer.deserialize || this.deserialize;
this.reviver = serializer.reviver || this.reviver;
this.replacer = serializer.replacer || this.replacer;
this.initialState = serializer.initialState || this.initialState;
}
}
/**
* Pad number
* @param val
* @param digits
*/
function padNumber(val, digits = 2) {
const str = `${val}`;
return '0'.repeat(Math.max(0, digits - str.length)) + str;
}
/**
* Returns TRUE if bootstrap config environment is production FALSE otherwise
* @param dataset
* @returns TRUE if bootstrap config environment is production FALSE otherwise
*/
function isProductionEnvironment(dataset) {
const bootstrapConfig = dataset.bootstrapconfig && JSON.parse(dataset.bootstrapconfig);
return bootstrapConfig?.environment === 'prod';
}
const defaultConstruct = (data) => data;
const isDate = (data) => data instanceof Date && !Number.isNaN(data);
/**
* Check if an object is not an array or a date
* @param obj
* @param additionalMappers
*/
function isObject(obj, additionalMappers) {
return obj instanceof Object && !Array.isArray(obj) && !additionalMappers?.some((mapper) => mapper.condition(obj)) && !isDate(obj);
}
/**
* Return a new reference of the given object
* @param obj
* @param additionalMappers
*/
function immutablePrimitive(obj, additionalMappers) {
if (Array.isArray(obj)) {
return obj.slice();
}
const matchingPrimitive = additionalMappers?.find((mapper) => mapper.condition(obj));
const resolvedType = matchingPrimitive && ((matchingPrimitive.construct || defaultConstruct)(obj));
if (resolvedType !== undefined) {
return resolvedType;
}
if (isDate(obj)) {
return new Date(obj);
}
else if (obj instanceof Object) {
return deepFill(obj, obj, additionalMappers);
}
else {
return obj;
}
}
/**
* Deep fill of base object using source
* It will do a deep merge of the objects, overriding arrays
* All properties not present in source, but present in base, will remain
* @param base
* @param source
* @param additionalMappers Map of conditions of type mapper
*/
function deepFill(base, source, additionalMappers) {
if (typeof source === 'undefined') {
return deepFill(base, base, additionalMappers);
}
if (!isObject(base, additionalMappers)) {
return immutablePrimitive(typeof source === 'undefined' ? base : source, additionalMappers);
}
const newObj = { ...base };
for (const key in base) {
if (source[key] === null) {
newObj[key] = immutablePrimitive(null, additionalMappers);
}
else if (key in source && typeof base[key] === typeof source[key]) {
const keyOfSource = source[key];
newObj[key] = typeof keyOfSource === 'undefined' ? immutablePrimitive(base[key], additionalMappers) : deepFill(base[key], keyOfSource, additionalMappers);
}
else {
newObj[key] = immutablePrimitive(base[key], additionalMappers);
}
}
// getting keys present in source and not present in base
for (const key in source) {
if (!(key in newObj)) {
newObj[key] = immutablePrimitive(source[key], additionalMappers);
}
}
return newObj;
}
/**
* Buffers and emits data for lazy/progressive rendering of big lists
* That could solve issues with long-running tasks when trying to render an array
* of similar components.
* @param delayMs Delay between data emits
* @param concurrency Amount of elements that should be emitted at once
*/
function lazyArray(delayMs = 0, concurrency = 2) {
let isFirstEmission = true;
return (source$) => {
return source$.pipe(mergeMap((items) => {
if (!isFirstEmission) {
return of(items);
}
const items$ = from(items);
return items$.pipe(bufferCount(concurrency), concatMap((value, index) => {
return of(value).pipe(observeOn(animationFrameScheduler), delay(index * delayMs));
}), scan((acc, steps) => {
return [...acc, ...steps];
}, []), tap((scannedItems) => {
const scanDidComplete = scannedItems.length === items.length;
if (scanDidComplete) {
isFirstEmission = false;
}
}));
}));
};
}
/**
* Generated bundle index. Do not edit.
*/
export { DEFAULT_BUILD_PROPERTIES, O3rComponent, StateSerializer, applicationMessageTarget, asyncEntitySerializer, asyncEntityWithStatusSerializer, asyncProps, asyncSerializer, asyncStoreItemAdapter, computeItemIdentifier, createEntityAsyncRequestAdapter, deepFill, filterMessageContent, fromApiEffectSwitchMap, fromApiEffectSwitchMapById, immutablePrimitive, isAsyncRequest, isCallAction, isIdentifiedCallAction, isObject, isOtterMessage, isProductionEnvironment, isToAppOtterMessage, lazyArray, otterComponentInfoPropertyName, otterMessageType, padNumber, sendOtterMessage };
//# sourceMappingURL=o3r-core.mjs.map