import uniqid from 'uniqid' import { asArray, equals, merge, defaultTo, deepMerge, either, isNil, defer, isNotNil, clone, removeObjNils, } from './func' import { throttle } from './throttle' import { Func, Predicate, Primitive } from './types' import { obsDispEvents } from './events/obsDispEvents' import { IEvent, isFullEvent, TEventTarget } from './events' import { __findTopLevelObsParent, __getAllChildren } from './traverse' import { ICreateObsDispAPIParams, TGetObserversMap } from './api' import { TObserverByIdMap, __addOD, __createObserversMap } from './obsMap' import { is } from 'ramda' export const ON_CREATE_OBS_FIELD = 'obs' export const __removeAllObservers = (API: ICreateObsDispAPIParams, getObserversMap: TGetObserversMap) => () => { const arr = Array.from(getObserversMap().values()) const map = getObserversMap() const checkExists = __checkObsExists(() => map) const remove = __removeObs(API, () => map) arr.forEach((obs) => checkExists(obs) && remove(obs)) } export interface IObserverTreeNode extends IObserverOptions { children: IObserverTreeNode[] } export interface IObserverOptions { /** Optional id (must be unique); if not passed, it is autogenerated */ id?: string /** * Optional name; it's an arbitrary key, not used by the library internally; can be * useful for debugging / display purposes or when dispatching events (target filter); * does not need to be unique * */ name?: string /** * Arbitrary meta data for the given observer; can be * useful for debugging / display purposes or when dispatching events (target filter); * does not need to be unique */ meta?: Record } export interface IObserver { handleEvent: (event: IEvent) => void options?: IObserverOptions parentId?: string forChildren: (mapper: (obs: IObserver, childrenMap: TObserverByIdMap) => void) => void /** Adds a child observer */ addOD: (obs: IObserver) => IObserver dispatchEvent: ( ...args: Parameters> ) => ReturnType> /** * Removes the child observer that is passed * @param obs * @param skipChildren If passed as `true`, this will not automatically remove child observers */ removeOD: (obs: IObserver, skipChildren: boolean) => void } export type TEventDispatchOptions = { payload?: T extends IEvent ? T['payload'] : {} target?: TEventTarget | TEventTarget[] // MAYBE LATER: | { observers: IObserver[], filter: Predicate } } const isObserver = (o: any): o is IObserver => { return o && 'dispatchEvent' in o } type TEventInternalTarget = { observers: IObserver[]; originalTarget: TEventTarget } /** * Dispatches an obs-disp event. * Note: do not use self-referencing objects (expect exceptions) for payload * */ export const __dispatchEvent = (API: ICreateObsDispAPIParams, getObserversMap: TGetObserversMap) => ( event: Partial | IEvent['name'], options: TEventDispatchOptions = {}, ) => { if (!event) { API.onWarn && API.onWarn({ msg: `Dispached event is undefined` }) return } const _event = (isFullEvent(event as any) ? event : ({ name: event } as T)) as T if (options.payload) { _event.payload = merge(_event.payload, options.payload) } API.onEvent && API.onEvent(_event) const optionsTarget = defaultTo({}, options.target) const useInternalTarget = 'observers' in (optionsTarget as TEventInternalTarget) const target = !useInternalTarget ? optionsTarget : (optionsTarget as TEventInternalTarget).originalTarget const useDefaultGlobalTarget = either(equals('all'), isNil)(options.target as string) const usePredicateTarget = is(Function, target) const targets = !useInternalTarget ? !useDefaultGlobalTarget && asArray(target) : !useDefaultGlobalTarget && asArray((optionsTarget as any as TEventInternalTarget).observers) const useTargetObservers = isObserver(targets[0]) if (useDefaultGlobalTarget) { // ignore such calls / safety if (_event.name === obsDispEvents.OBS_CREATE) return getObserversMap().forEach((observer, _id) => { observer.handleEvent && observer.handleEvent(_event) }) } else if (useTargetObservers) { if (!usePredicateTarget) { ;(targets as IObserver[]).forEach( (observer) => observer.handleEvent && observer.handleEvent(_event), ) } else { const checkPasses = target as Predicate ;(targets as IObserver[]).forEach( (observer) => observer.handleEvent && checkPasses(observer.options) && observer.handleEvent(_event), ) } } else if (usePredicateTarget) { const checkPasses = target as Predicate getObserversMap().forEach((observer, _id) => { observer.handleEvent && checkPasses(observer.options) && observer.handleEvent(_event) }) } } export type TDispatchEventType = ( event: Partial | IEvent['name'], options?: TEventDispatchOptions, ) => void /** * Same as dispatchEvent but can be throttled via throttleMs * @param ev The default event (can be overriden when calling the function) * @param throttleMs default 200 * @returns A throttled function that dispatches an event, e.g. as a generic func `dispatchThrottledEv = createThrottledDispatch(undefined, 200)` * ... later in the code ... `dispatchThrottledEv({ name: 'SOME_EVENT', payload: {}})` * * Or as a more specific throttled event func: * `dispatchSomeThrottledEv = createThrottledDispatch({ name: 'SOME_EVENT', 200)` * ... later in the code ... or overriding the payload if needed * `dispatchSomeThrottledEv({ payload: { somePayloadProp: 1 }})` */ export const __createThrottledDispatch = (API: ICreateObsDispAPIParams, getObserversMap: TGetObserversMap) => (ev: IEvent, opts: { throttleMs?: number; debugLog?: string } = {}) => { const throttleMs = defaultTo(200, opts.throttleMs) return throttle((event?: Partial) => { __dispatchEvent(API, getObserversMap)('all', deepMerge(ev || {}, event || {})) }, throttleMs) } export interface IObserverEventHandlers extends Record> {} /** * Adds (creates) a new global Observer (Observable Dispatcher or obs-disp) * which starts tracking events. * * Will generate a unique ID or use the provided ID (only one OD can exists with the same id) * * When an observer with the same id already exists (and replaceExisting is not passed or is `false`) * then a warning is thrown (can be handled via the `onWarn` handler on the API) and the existing observer is returned * @param eventHandlersOrFunc Either a map of event-name-to-handler or a function that returns such () => ({ ... }) * @param options Common options available to each observer (like `id`) * @param props Initial properties which would be passed (convenient when not using this directly but * when using obsDispCreator((props: Props) => {...})) * @returns */ export const __addObsDisp = (API: ICreateObsDispAPIParams, getObserversMap: TGetObserversMap) => ( eventHandlersOrFunc: Func | IObserverEventHandlers, options: IObserver['options'] = {} as any, props?: T, ): IObserver => { const map = getObserversMap() const { id, meta } = { ...options } const alreadyExistingObs = id && map.get(id) if (id && Boolean(alreadyExistingObs)) { const obsOpts = clone(alreadyExistingObs.options) API.onWarn && API.onWarn({ msg: `Trying to add an observer with existing ID, will dispatch OBS_EXISTING_RE event`, params: { obs: obsOpts }, }) defer( () => alreadyExistingObs.handleEvent && alreadyExistingObs.handleEvent({ name: obsDispEvents.OBS_EXISTING_RE, payload: { [ON_CREATE_OBS_FIELD]: obsOpts }, }), ) return alreadyExistingObs } const eventHandlers = typeof eventHandlersOrFunc === 'function' ? eventHandlersOrFunc(props) : eventHandlersOrFunc const handleEvent = (ev: IEvent) => { const fn = { ...eventHandlers, }[ev.name] const res = fn && fn(ev) return res } const _children: TObserverByIdMap = __createObserversMap() const observer: IObserver = { handleEvent, options: Object.freeze({ id: defaultTo(uniqid('obs-'), id), ...(() => removeObjNils({ meta: meta && clone(meta), name: options.name, }))(), }), forChildren: (fn) => { _children.forEach((ch) => fn(ch, _children)) }, /** Adds a child observer to this one */ addOD: (obs: IObserver) => { __addOD(_children, obs, observer) return obs }, dispatchEvent: (ev, evOpts) => { const topLevelParent = defaultTo(observer, __findTopLevelObsParent(map, observer.parentId)) const observersInTree = [ topLevelParent, ...__getAllChildren(topLevelParent), ].filter(isNotNil) const internalTarget: TEventInternalTarget = { observers: observersInTree, originalTarget: evOpts?.target as TEventTarget, } __dispatchEvent(API, getObserversMap)(ev, { ...evOpts, target: internalTarget as any }) }, removeOD: (obs: IObserver, skipChildren = false) => { skipChildren ? _detatchObsFromMap(_children, obs) : _removeObs(API, _children, obs) }, } if (!alreadyExistingObs) { __addOD(map, observer) ////// CREATION EVENT DISPATCHED (single-time, the next tick after the creation // (reason to do that next tick, not immediately -> due to things still "in computation", like the `parentId` attrib)): defer(() => { handleEvent({ name: obsDispEvents.OBS_CREATE, payload: { [ON_CREATE_OBS_FIELD]: { ...observer.options, instance: observer }, }, }) API.onObsCreated && API.onObsCreated(clone(observer.options)) }) return observer } } // used only to derive correct types const __getSampleAddObsDispFn = () => __addObsDisp({}, () => new Map()) export interface IAddObsDispType extends ReturnType> {} export interface IAddObsDispParameters extends Parameters> {} export interface IAddObsDispReturnType extends ReturnType> {} /** * The initial parameters (props) when creating an obs-disp - a plain object or nothing (void) * */ export type TObsCreateParams = Record | void export const __obsDispCreator: ( API: ICreateObsDispAPIParams, getObserversMap: TGetObserversMap, ) => ( ///////////////////////////////////////////////////////////////////////-> // magic! :: this typing of args allows for // 1. obsDispCreator(...)() // OK // 2. obsDispCreator<{ param: number }>(...)() // TS error // 3. obsDispCreator<{ param: number }>(...)({ param: 5 }) // OK error ...args: [IAddObsDispParameters[0], IAddObsDispParameters[1]?, IAddObsDispParameters[2]?] ) => ///////////////////////////////////////////////////////////////////////<- (props: T) => IObserver = (API, getMap) => (...args) => { if (args.length < 2) { /// this makes sure we don't need to pass obsOptions explicitly -> // this is fine: obsDispCreator(getMap)(() => {...}) // just as this: obsDispCreator(getMap)(() => {...}, { ...obsOptions }) args.push({}) } return __addObsDisp(API, getMap).bind(null, ...args) } const _removeObs = ( API: ICreateObsDispAPIParams, map: TObserverByIdMap, obs: IObserver, skipRelativesCheck = false, ) => { const exists = map.get(obs?.options?.id) if (!exists) { API.onWarn && API.onWarn({ msg: `No observer found to remove`, params: { obs } }) return } _handleObsRemove(API, map, obs, skipRelativesCheck) } /** * Removes the observer (along with all of its children). * You can disable child removal by passing skipChildren as false * @param obs * @param skipChildren If passed as `true`, this will not automatically remove child observers */ export const __removeObs = (API: ICreateObsDispAPIParams, getObserversMap: TGetObserversMap) => (observer: IObserver, skipChildren?: boolean) => { _removeObs(API, getObserversMap(), observer, skipChildren) } export const __removeObsById = (API: ICreateObsDispAPIParams, getObserversMap: TGetObserversMap) => (id: string) => { // debugLog('>>> removeObsById ID', id) const exists = getObserversMap().get(id) if (!exists) { API.onWarn && API.onWarn({ msg: `Remove obs: no observer with id`, params: { id } }) return } const map = getObserversMap() const existingObs = map.get(id) _handleObsRemove(API, map, existingObs) } export const __checkObsExists = (getObserversMap: TGetObserversMap) => (obs: IObserver) => Boolean(getObserversMap().get(obs.options.id)) export const __checkObsExistsById = (getObserversMap: TGetObserversMap) => (id: string) => Boolean(getObserversMap().get(id)) const _handleObsRemove = ( API: ICreateObsDispAPIParams, map: TObserverByIdMap, obs: IObserver, skipChildren: boolean = false, ) => { const existingObs = map.get(obs?.options?.id) // branch: if has parentId -> remove it from the parent's children !!! if (obs?.parentId) { const parentObs = map.get(obs.parentId) parentObs?.removeOD(obs, true) } // remove any children !skipChildren && obs.forChildren((childObs, map) => { _handleObsRemove(API, map, childObs) _removeObsCleanup(map, childObs) // remove from its parent map }) if (existingObs?.handleEvent) { existingObs.handleEvent({ name: obsDispEvents.OBS_REMOVE, payload: existingObs.options }) API.onObsRemoved && API.onObsRemoved(clone(existingObs.options)) } // clean up _removeObsCleanup(map, existingObs) } const _detatchObsFromMap = (map: TObserverByIdMap, obs: IObserver) => { map.delete(obs.options.id) } const _removeObsCleanup = (map: TObserverByIdMap, obs: IObserver) => { _detatchObsFromMap(map, obs) map.delete(obs.options.id) // note calling .handleEvent() after this on the obs will result in an error obs.handleEvent = null } export const addMultipleObs = (getObserversMap: TGetObserversMap) => (evHandlersOrFuncs: (Func | IObserverEventHandlers)[]) => { return evHandlersOrFuncs.map((obsConfig) => __addObsDisp({}, getObserversMap)(obsConfig)) }