UNPKG

spyne

Version:

Reactive Real-DOM Framework for Advanced Javascript applications

474 lines (419 loc) 17.2 kB
import { registeredStreamNames } from './channels-config.js' import { ChannelPayload } from './channel-payload-class.js' import { SpyneAppProperties } from '../utils/spyne-app-properties.js' import { RouteChannelUpdater } from '../utils/route-channel-updater.js' import { ReplaySubject, Subject, forkJoin, EMPTY } from 'rxjs' import { filter, map, take, combineLatestWith } from 'rxjs/operators' import { ifElse, identity, head, mergeAll, objOf, view, is, chain, lensIndex, always, fromPairs, path, isEmpty, equals, prop, propEq , map as rMap } from 'ramda' export class Channel { /** * @module Channel * @type extendable * * @desc * <p>The Channel component wraps methods and functionality around an RxJs Subject, to create a one-way data flow between itself and other Channels and ViewStreams.</p> * <p>Channels get data by importing json objects, subscribing to other Channels and by parsing ViewStream info.</p> * <h3>The Basic Channel Structure</h3> * <ul> * <li>Channels requires a unique name, for example, <b>CHANNEL_MYCHANNEL</b>, which components use to select and subscribe to any channel.</li> * <li>Channels are instantiated and 'registered' with the LINK['SpyneApp', 'spyne-app'].</li> * <li>Channels remain persistent and are not deleted.</li> * </ul> * <h3>Components that extend Channel</h3> * <ul> * <li>The four Spyne Channels, LINK['SpyneChannelUI', 'spyne-channel-u-i'], LINK['SpyneChannelWindow', 'spyne-channel-window'], LINK['SpyneChannelRoute', 'spyne-channel-route'] and LINK['SpyneChannelLifecycle', 'spyne-channel-lifecycle'], all extend Channel.</li> * <li>ChannelFetch also extends Channel, adds a ChannelFetchUtil component to immediately publish fetched responses.</li> * </ul> * <h3>Sending ChannelPayloads</h3> * <p>Channels send data using the <a class='linker' data-channel="ROUTE" data-event-prevent-default="true" data-menu-item="channel-send-channel-payload" href="/guide/reference/channel-send-channel-payload" >sendChannelPayload</a> method.</p> * * * <h3>Receiving Data from ViewStream Instances</h3> * <p>ViewStreams can send data to any custom channel using the <a class='linker' data-channel="ROUTE" data-event-prevent-default="true" data-menu-item="view-stream-send-into-to-channel" href="/guide/reference/view-stream-send-info-to-channel" >sendInfoToChannel</a> method. Channels cam listen to any ViewStream data directed to itself through the default method, <a class='linker' data-channel="ROUTE" data-event-prevent-default="true" data-menu-item="channel-on-view-stream-info" href="/guide/reference/channel-on-view-stream-info" >onViewStreamInfo</a>. </p> * * * @example * TITLE["<h4>Registering a New Channel Instance</h4>"] * SpyneApp.register(new Channel("CHANNEL_MYCHANNEL"); * * * @constructor * @param {string} CHANNEL_NAME * @param {Object} props This json object takes in parameters to initialize the channel * @property {String} CHANNEL_NAME - = undefined; This will be the registered name for this channel. * @property {Object} props - = {}; The props objects allows for custom properties for the channel. * @property {Object} props.sendCachedPayload - = false; Publishes its most current payload to late subscribers, when set to true. * */ constructor(CHANNEL_NAME, props = {}) { this.addRegisteredActions.bind(this) this.createChannelActionsObj(CHANNEL_NAME, props.extendedActionsArr) // Basic props setup props.name = CHANNEL_NAME props.defaultActions = props.data !== undefined ? [`${props.name}_EVENT`] : [] this.props = props this.props.isRegistered = false this.props.isProxy = this.props.isProxy === undefined ? false : this.props.isProxy // Determine a default from presence of props.data const defaultCachedPayloadBool = this.props.data !== undefined // If props.replay is defined, override sendCachedPayload with it. // Otherwise, use the existing sendCachedPayload property or default. if (typeof this.props.replay !== 'undefined') { this.props.sendCachedPayload = this.props.replay } else { this.props.sendCachedPayload = this.props.sendCachedPayload === undefined ? defaultCachedPayloadBool : this.props.sendCachedPayload } this.sendPayloadToRouteChannel = new RouteChannelUpdater(this) this.createChannelActionMethods() // Set up streams this.streamsController = SpyneAppProperties.channelsMap const observer$ = this.getMainObserver() this.checkForPersistentDataMode = Channel.checkForPersistentDataMode.bind(this) this.observer$ = this.props.observer = observer$ // Subscribe to DISPATCHER const dispatcherStream$ = this.streamsController.getStream('DISPATCHER') const payloadPredByChannelName = propEq(props.name, 'name') dispatcherStream$ .pipe(filter(payloadPredByChannelName)) .subscribe((val) => this.onReceivedObservable(val)) } getMainObserver() { if (this.streamsController === undefined) { console.warn(`Spyne Warning: The following channel, ${this.props.name}, appears to be registered before Spyne has been initialized.`) } const proxyExists = this.streamsController.testStream(this.props.name) if (proxyExists === true) { return this.streamsController.getProxySubject(this.props.name, this.props.sendCachedPayload) } else { return this.props.sendCachedPayload === true ? new ReplaySubject(1) : new Subject() } } // OVERRIDE INITIALIZATION METHOD /** * <p>(Deprecated. Use onRegistered). This method is empty and is called as soon as the Channel has been registered.</p> * <p>Tasks such as subscribing to other channels, and sending initial payloads can be added here.</p> */ onChannelInitialized() { } // OVERRIDE INITIALIZATION METHOD /** * <p>This method is empty and is called as soon as the Channel has been registered.</p> * <p>Tasks such as subscribing to other channels, and sending initial payloads can be added here.</p> */ onRegistered(props = this.props) { if (props.data !== undefined) { const action = Object.keys(this.channelActions)[0] // console.log("CHANNELS ACTIONS IS ",this.channelActions); // Object(this.channelActions).keys[0]; this.sendChannelPayload(action, props.data) } } get isProxy() { return this.props.isProxy } get channelName() { return this.props.name } /** * * @desc * returns the source observable for the channel */ get observer() { return this.observer$ } checkForTraits() { const addTraits = (traits) => { if (traits.constructor.name !== 'Array') { traits = [traits] } const addTrait = (TraitClass) => { return new TraitClass(this) } traits.forEach(addTrait) } if (this.props.traits !== undefined) { addTraits(this.props.traits) } } // DO NOT OVERRIDE THIS METHOD initializeStream() { this.checkForTraits() this.onChannelInitialized() this.checkForPersistentDataMode() this.onRegistered() this.props.isRegistered = true } static checkForPersistentDataMode(props = this.props, actionsObj = this.channelActions) { const actionsObjIsEmpty = isEmpty(actionsObj) const dataIsAdded = prop('data', props) !== undefined const autoSetToCachedPayload = actionsObjIsEmpty === true && dataIsAdded === true const setDefaultActionsObj = () => { const { name } = props const actionStr = `${name}_EVENT` return { [actionStr] : actionStr } } if (autoSetToCachedPayload) { props.sendCachedPayload = true actionsObj = setDefaultActionsObj() if (this.channelActions !== undefined) { this.channelActions = actionsObj } } // console.log("PROPS IS ",{actionsObjIsEmpty, dataIsAdded, props, actionsObj}, this.channelActions) return { props, actionsObj } } setTrace(bool) { } createChannelActionsObj(name, extendedActionsArr = []) { const getActionVal = ifElse(is(String), identity, head) const mainArr = extendedActionsArr.concat(this.addRegisteredActions(name)) const arr = rMap(getActionVal, mainArr) const converter = str => objOf(str, str) const obj = mergeAll(chain(converter, arr)) this.channelActions = obj } createChannelActionMethods() { const defaultFn = 'onViewStreamInfo' const getActionVal = ifElse(is(String), identity, head) const delayCheckIfTraitMethodHasBeenAdded = (methodStr, val) => { const delayer = () => { if (typeof this[methodStr] !== 'function') { console.warn(`"${this.props.name}", REQUIRES THE FOLLOWING METHOD ${methodStr} FOR ACTION, ${val[0]}`) } } window.setTimeout(delayer, 100) } const getCustomMethod = val => { const methodStr = view(lensIndex(1), val) delayCheckIfTraitMethodHasBeenAdded(methodStr, val) return methodStr } const getArrMethod = ifElse(is(String), always(defaultFn), getCustomMethod) const createObj = val => { const key = getActionVal(val) const method = getArrMethod(val) return [key, method] } this.channelActionMethods = fromPairs(rMap(createObj, this.addRegisteredActions())) // console.log('the channel action methods ',this.channelActionMethods); } /** * * @desc * <p>Channels send along Action names along with its payload.</p> * <p>Before any action can be used, that action needs to be registered using this method.</p> * <p>ViewStream instances can filter ChannelPayloads by binding specific LINK['actions to local methods', 'view-stream-add-action-listeners'].</p> * <p>Forcing registration of actions allows Spyne to validate Actions, and is also a useful way to keep track of all Actions that are intended to be used.</p> * * @returns * Array of Strings * * @example * TITLE["<h4>Registering Actions in the addRegisteredActions method</h4>"] * addRegisteredActions() { * return [ * 'CHANNEL_MY_CHANNEL_EVENT', * 'CHANNEL_MY_CHANNEL_UPDATE_EVENT' * ]; * } * */ addRegisteredActions() { let arr = [] if (path(['props', 'data'], this)) { arr = [`${this.props.name}_EVENT`] } return arr } onReceivedObservable(obj) { this.onIncomingObservable(obj) } getActionMethodForObservable(obj) { obj.unpacked = true const defaultFn = this.onViewStreamInfo.bind(this) const methodStr = path(['data', 'action'], obj) const methodVal = prop(methodStr, this.channelActionMethods) let fn = defaultFn if (methodVal !== undefined && methodVal !== 'onViewStreamInfo') { const methodExists = typeof (this[methodVal]) === 'function' if (methodExists === true) { fn = this[methodVal].bind(this) } } return fn } onIncomingObservable(obj) { const eqsName = equals(obj.name, this.props.name) const { action, payload, srcElement } = obj.data // console.log("INCOMING ",{action, payload, srcElement}, {obj}); const mergeProps = (d) => mergeAll([d, { action: prop('action', d) }, prop('payload', d), prop('srcElement', d)]) const dataObj = obsVal => ({ clone: () => mergeProps(obj.data), action, payload, srcElement, event: obsVal }) const onSuccess = (obj) => obj.observable.pipe(map(dataObj)) .subscribe(this.getActionMethodForObservable(obj)) const onError = () => {} return eqsName === true ? onSuccess(obj) : onError() } /** * * <p>ViewSteam instances can send info to channels through its LINK['sendInfoToChannel, 'view-stream-send-info-to-channel'] method.</p> * <p>Data received from ViewStreams are directed to this method.</p> * <p>ViewStreams send data using the LINK['ViewStreamPayload', 'view-stream-payload'] format.</p> * * @param {ViewStreamPayload} obj * * @example * TITLE["<h4>Parsing Data Returned from a ViewStream Instance</h4>"] * onViewStreamInfo(obj){ * let data = obj.viewStreamInfo; * let action = data.action; * let newPayload = this.parseViewStreamData(data); * this.sendChannelPayload(action, newPayload); * } * * */ onViewStreamInfo(obs) { } /** * * @desc * * <p>This method takes an action, a data object and other properties to create and publish a LINK['ChannelPayload', 'channel-payload'] object.</p> * <p>Once the action and payload is validated, this method will publish the data by using the channel's source Subject next() method. * <p>This consistent format allows subscribers to understand how to parse any incoming channel data.</p> * * @param {String} action * @param {Object} payload * @param {HTMLElement} srcElement * @param {HTMLElement} event * @param {Observable} obs$ * @property {String} action - = undefined; Required. An action is a string, typically in the format of "CHANNEL_NAME_ACTION_NAME_EVENT", and that has been added in the addRegisteredActions method. * @property {Object} payload - = undefined; Required. This can be any javascript object and is used to send any custom data. * @property {HTMLElement} srcElement - = Not Required. undefined; This can be either the element returned from the UI Channel, or the srcElement from a ViewStream instance. * @property {UIEvent} event - = undefined; Not Required. This will be defined if the event is from the UI Channel. * @property {Observable} obs$ - = this.observer; This default is the source observable for this channel. * * @example * TITLE['<h4>Publishing a ChannelPayload</h4>'] * let action = "CHANNEL_MY_CHANNEL_REGISTERED_ACTION_EVENT"; * let data = {foo:"bar"}; * this.sendChannelPayload(action, data); * */ sendChannelPayload(action, payload, srcElement = {}, event = {}, obs$ = this.observer$) { // MAKES ALL CHANNEL BASE AND DATA STREAMS CONSISTENT const channelPayloadItem = new ChannelPayload(this.props.name, action, payload, srcElement, event) // console.log("CHANNEL STREEM ITEM ",channelPayloadItem); // const onNextFrame = ()=>obs$.next(channelPayloadItem); // requestAnimationFrame(onNextFrame) obs$.next(channelPayloadItem) } /** * * <p>Allows channels to subscribe to other channels.</p> * <p>This method returns the source rxjs Subject for the requested Channel, which can be listened to by calling its subscribe method.</p> * <p>Knowledge of rxjs is not required to subscribe to and parse Channel data.</p> * <p>But accessing the rxjs Subject gives developers the ability to use all of the available rxjs mapping and observable tools.</p> * * @param {String} channelName The registered name of the requested channel. * @returns * The source rxjs Subject of the requested channel. * @example * TITLE["<h4>Subscribing to a Channel Using the getChannel method</h4>"] * let route$ = this.getChannel("CHANNEL_ROUTE") * route$.subscribe(localMethod); * * */ getChannel(channelName, payloadFilter) { const isValidChannel = c => registeredStreamNames().includes(c) const error = c => console.warn( `channel name ${c} is not within ${registeredStreamNames}`) const startSubscribe = (c) => { const obs$ = this.streamsController.getStream(c).observer if (payloadFilter !== undefined) { return obs$.pipe(filter(payloadFilter)) } return obs$ } const fn = ifElse(isValidChannel, startSubscribe, error) return fn(channelName) } /** * Merge Channels is a convenience method to forkJoin Channel.observer$ * * */ mergeChannels(channelsArr, emitOnce = true) { // 1) Normalize inputs to Observables const channelObservables = channelsArr.map(item => { if (typeof item === 'string') { const chan$ = this.getChannel(item) return emitOnce ? chan$.pipe(take(1)) : chan$ } return emitOnce ? item.pipe(take(1)) : item }) // 2) Combine them let combined$ if (emitOnce) { // one‑shot: snapshot then complete combined$ = forkJoin(channelObservables) } else { // live updates: combineLatestWith if (channelObservables.length === 0) { combined$ = EMPTY } else if (channelObservables.length === 1) { combined$ = channelObservables[0] } else { const [first$, ...rest$] = channelObservables combined$ = first$.pipe(combineLatestWith(...rest$)) } } // 3) Map array → keyed object return combined$.pipe( map(resultsArr => { const obj = {} channelsArr.forEach((item, idx) => { const key = typeof item === 'string' ? item : `channel_${idx}` obj[key] = resultsArr[idx] }) return obj }) ) } static checkForNotTrackFlag(props = {}) { if (props.doNotTrack === true) { SpyneAppProperties.doNotTrackChannel(props.channelName) } } }