diff --git a/examples/react/develop/functional-component-ts/src/App.tsx b/examples/react/develop/functional-component-ts/src/App.tsx index 0574cf89..4c4d8f35 100644 --- a/examples/react/develop/functional-component-ts/src/App.tsx +++ b/examples/react/develop/functional-component-ts/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import './App.css'; -import { useAgile, useWatcher, useProxy } from '@agile-ts/react'; +import { useAgile, useWatcher, useProxy, useSelector } from '@agile-ts/react'; import { useEvent } from '@agile-ts/event'; import { COUNTUP, @@ -43,6 +43,10 @@ const App = (props: any) => { ]); const [myGroup] = useAgile([MY_COLLECTION.getGroupWithReference('myGroup')]); + const selectedObjectItem = useSelector(STATE_OBJECT, (value) => { + return value.age; + }); + const [stateObject, item2, collection2] = useProxy( [STATE_OBJECT, MY_COLLECTION.getItem('id2'), MY_COLLECTION], { key: 'useProxy' } @@ -106,7 +110,8 @@ const App = (props: any) => {

My State Object

- Deep Name: {stateObject.friends.hans.name} {stateObject.location} + Deep Name: {stateObject?.friends?.hans?.name}{' '} + {stateObject?.location}

diff --git a/examples/vue/develop/my-project/src/components/HelloWorld.vue b/examples/vue/develop/my-project/src/components/HelloWorld.vue index 7d665190..35dd8265 100644 --- a/examples/vue/develop/my-project/src/components/HelloWorld.vue +++ b/examples/vue/develop/my-project/src/components/HelloWorld.vue @@ -8,7 +8,7 @@
{{todo.name}}
- +
{{sharedState.todoValues}}
@@ -23,6 +23,12 @@ export default { }, data: function () { return { + // ...this.bindAgileOutputs({ + // todos: TODOS + // }), + ...this.bindAgileValues({ + todoValues: TODOS + }), ...this.bindAgileInstances({ todos: TODOS }), diff --git a/examples/vue/develop/my-project/src/core.js b/examples/vue/develop/my-project/src/core.js index 6eef563d..a8999d9f 100644 --- a/examples/vue/develop/my-project/src/core.js +++ b/examples/vue/develop/my-project/src/core.js @@ -11,11 +11,9 @@ export const App = new Agile({ // Create State export const MY_STATE = App.createState('World', { key: 'my-state', -}) - .computeValue((v) => { - return `Hello ${v}`; - }) - .persist(); +}).computeValue((v) => { + return `Hello ${v}`; +}); export const MY_COMPUTED = App.createComputed( async () => { diff --git a/packages/core/src/agile.ts b/packages/core/src/agile.ts index ed4a0a81..adf0419c 100644 --- a/packages/core/src/agile.ts +++ b/packages/core/src/agile.ts @@ -19,8 +19,7 @@ import { StateConfigInterface, flatMerge, LogCodeManager, - ComputedConfigInterface, - SubscribableAgileInstancesType, + DependableAgileInstancesType, CreateComputedConfigInterface, ComputeFunctionType, } from './internal'; @@ -220,13 +219,13 @@ export class Agile { */ public createComputed( computeFunction: ComputeFunctionType, - deps?: Array + deps?: Array ): Computed; public createComputed( computeFunction: ComputeFunctionType, configOrDeps?: | CreateComputedConfigInterface - | Array + | Array ): Computed { let _config: CreateComputedConfigInterface = {}; diff --git a/packages/core/src/collection/group/group.observer.ts b/packages/core/src/collection/group/group.observer.ts new file mode 100644 index 00000000..d298f653 --- /dev/null +++ b/packages/core/src/collection/group/group.observer.ts @@ -0,0 +1,139 @@ +import { + Observer, + Group, + CreateObserverConfigInterface, + copy, + defineConfig, + equal, + generateId, + RuntimeJob, + Item, + IngestConfigInterface, + CreateRuntimeJobConfigInterface, +} from '../../internal'; + +export class GroupObserver extends Observer { + // Group the Observer belongs to + public group: () => Group; + + // Next output applied to the Group + public nextGroupOutput: DataType[]; + + /** + * A Group Observer manages the subscriptions to Subscription Containers (UI-Components) + * and dependencies to other Observers (Agile Classes) + * for a Group Class. + * + * @internal + * @param group - Instance of Group the Observer belongs to. + * @param config - Configuration object + */ + constructor( + group: Group, + config: CreateObserverConfigInterface = {} + ) { + super(group.agileInstance(), { ...config, ...{ value: group._output } }); + this.group = () => group; + this.nextGroupOutput = copy(group._output); + } + + /** + * Rebuilds the Group and passes the Group Observer + * into the runtime wrapped into a Runtime-Job + * where it is executed accordingly. + * + * During the execution the runtime applies the rebuilt `nextGroupOutput` to the Group, + * updates its dependents and re-renders the UI-Components it is subscribed to. + * + * @internal + * @param config - Configuration object + */ + public ingest(config: GroupIngestConfigInterface = {}): void { + this.group().rebuild(config); + } + + /** + * Passes the Group Observer into the runtime wrapped into a Runtime-Job + * where it is executed accordingly. + * + * During the execution the runtime applies the specified `nextGroupOutput` to the Group, + * updates its dependents and re-renders the UI-Components it is subscribed to. + * + * @internal + * @param newGroupItems - New Group Items to be applied to the Group. + * @param config - Configuration object. + */ + public ingestItems( + newGroupItems: Item[], + config: GroupIngestConfigInterface = {} + ): void { + const group = this.group(); + config = defineConfig(config, { + perform: true, + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + maxTriesToUpdate: 3, + }); + + // Force overwriting the Group value if it is a placeholder. + // After assigning a value to the Group, the Group is supposed to be no placeholder anymore. + if (group.isPlaceholder) { + config.force = true; + } + + // Assign next Group output to Observer + this.nextGroupOutput = copy( + newGroupItems.map((item) => { + return item._value; + }) + ); + + // Check if current Group output and to assign Group output are equal + if (equal(group._output, this.nextGroupOutput) && !config.force) return; + + // Create Runtime-Job + const job = new RuntimeJob(this, { + sideEffects: config.sideEffects, + force: config.force, + background: config.background, + key: + config.key ?? + `${this._key != null ? this._key + '_' : ''}${generateId()}_output`, + maxTriesToUpdate: config.maxTriesToUpdate, + }); + + // Pass created Job into the Runtime + this.agileInstance().runtime.ingest(job, { + perform: config.perform, + }); + } + + /** + * Method executed by the Runtime to perform the Runtime-Job, + * previously ingested via the `ingest()` or `ingestItems()` method. + * + * Thereby the previously defined `nextGroupOutput` is assigned to the Group. + * + * @internal + * @param job - Runtime-Job to be performed. + */ + public perform(job: RuntimeJob) { + const observer = job.observer as GroupObserver; + const group = observer.group(); + + // Assign new Group output + group._output = copy(observer.nextGroupOutput); + + // Assign new public output to the Observer (output used by the Integrations) + job.observer.previousValue = copy(job.observer.value); + job.observer.value = copy(group._output); + } +} + +export interface GroupIngestConfigInterface + extends CreateRuntimeJobConfigInterface, + IngestConfigInterface {} diff --git a/packages/core/src/collection/group.ts b/packages/core/src/collection/group/index.ts similarity index 85% rename from packages/core/src/collection/group.ts rename to packages/core/src/collection/group/index.ts index 52dd9b6e..af25f737 100644 --- a/packages/core/src/collection/group.ts +++ b/packages/core/src/collection/group/index.ts @@ -15,11 +15,15 @@ import { StateIngestConfigInterface, removeProperties, LogCodeManager, -} from '../internal'; - -export class Group extends State< - Array -> { + StateObserversInterface, + GroupObserver, + StateObserver, +} from '../../internal'; + +export class Group< + DataType extends Object = DefaultItem, + ValueType = Array // To extract the Group Type Value in Integration methods like 'useAgile()' +> extends State> { // Collection the Group belongs to collection: () => Collection; @@ -27,8 +31,10 @@ export class Group extends State< // Item values represented by the Group _output: Array = []; - // Items represented by the Group - _items: Array<() => Item> = []; + + // Manages dependencies to other States and subscriptions of UI-Components. + // It also serves as an interface to the runtime. + public observers: GroupObservers = {} as any; // Keeps track of all Item identifiers for Items that couldn't be found in the Collection notFoundItemKeys: Array = []; @@ -49,10 +55,19 @@ export class Group extends State< */ constructor( collection: Collection, - initialItems?: Array, + initialItems: Array = [], config: GroupConfigInterface = {} ) { - super(collection.agileInstance(), initialItems || [], config); + super(collection.agileInstance(), initialItems, config); + // Have to redefine the value Observer (observers['value']) again, + // although it was technically set in the State Parent + // https://github.com/microsoft/TypeScript/issues/1617 + this.observers['value'] = new StateObserver(this, { + key: config.key, + }); + this.observers['output'] = new GroupObserver(this, { + key: config.key, + }); this.collection = () => collection; // Add side effect to Group @@ -71,7 +86,7 @@ export class Group extends State< * @public */ public get output(): Array { - ComputedTracker.tracked(this.observer); + ComputedTracker.tracked(this.observers['output']); return copy(this._output); } @@ -79,22 +94,6 @@ export class Group extends State< LogCodeManager.log('1C:03:00', [this._key]); } - /** - * Returns the Items clustered by the Group. - * - * [Learn more..](https://agile-ts.org/docs/core/collection/group/properties#items) - * - * @public - */ - public get items(): Array> { - ComputedTracker.tracked(this.observer); - return this._items.map((item) => item()); - } - - public set items(value: Array>) { - LogCodeManager.log('1C:03:01', [this._key]); - } - /** * Returns a boolean indicating whether an Item with the specified `itemKey` * is clustered in the Group or not. @@ -248,6 +247,20 @@ export class Group extends State< return this; } + /** + * Retrieves all existing Items of the Group from the corresponding Collection and returns them. + * Items that aren't present in the Collection are skipped. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods#getitems) + * + * @public + */ + public getItems(): Array> { + return this.value + .map((itemKey) => this.collection().getItem(itemKey)) + .filter((item): item is Item => item !== undefined); + } + /** * Preserves the Group `value` in the corresponding external Storage. * @@ -317,7 +330,8 @@ export class Group extends State< } /** - * Rebuilds the entire `output` and `items` property of the Group. + * Rebuilds the output of the Group + * and ingests it into the runtime. * * In doing so, it traverses the Group `value` (Item identifiers) * and fetches the fitting Items accordingly. @@ -325,8 +339,9 @@ export class Group extends State< * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods#rebuild) * * @internal + * @param config - Configuration object */ - public rebuild(): this { + public rebuild(config: StateIngestConfigInterface = {}): this { const notFoundItemKeys: Array = []; // Item keys that couldn't be found in the Collection const groupItems: Array> = []; @@ -342,11 +357,6 @@ export class Group extends State< else notFoundItemKeys.push(itemKey); }); - // Extract Item values from the retrieved Items - const groupOutput = groupItems.map((item) => { - return item.getPublicValue(); - }); - // Logging if (notFoundItemKeys.length > 0) { LogCodeManager.log( @@ -356,16 +366,25 @@ export class Group extends State< ); } - this._items = groupItems.map((item) => () => item); - this._output = groupOutput; this.notFoundItemKeys = notFoundItemKeys; + // Ingest rebuilt Group output into the Runtime + this.observers['output'].ingestItems(groupItems, config); + return this; } } export type GroupKey = string | number; +export interface GroupObservers + extends StateObserversInterface { + /** + * Observer responsible for the output of the Group. + */ + output: GroupObserver; +} + export interface GroupAddConfigInterface extends StateIngestConfigInterface { /** * In which way the `itemKey` should be added to the Group. diff --git a/packages/core/src/collection/index.ts b/packages/core/src/collection/index.ts index e4aec4b3..32272973 100644 --- a/packages/core/src/collection/index.ts +++ b/packages/core/src/collection/index.ts @@ -23,7 +23,10 @@ import { PatchOptionConfigInterface, } from '../internal'; -export class Collection { +export class Collection< + DataType extends Object = DefaultItem, + GroupValueType = Array // To extract the Group Type Value in Integration methods like 'useAgile()' +> { // Agile Instance the Collection belongs to public agileInstance: () => Agile; @@ -143,7 +146,7 @@ export class Collection { // Update key in Persistent (only if oldKey is equal to persistentKey // because otherwise the persistentKey is detached from the Collection key // -> not managed by Collection anymore) - if (value && this.persistent?._key === oldKey) + if (value != null && this.persistent?._key === oldKey) this.persistent?.setKey(value); return this; @@ -503,7 +506,7 @@ export class Collection { if (group == null || (!config.notExisting && !group.exists)) return undefined; - ComputedTracker.tracked(group.observer); + ComputedTracker.tracked(group.observers['value']); return group; } @@ -547,7 +550,7 @@ export class Collection { this.groups[groupKey] = group; } - ComputedTracker.tracked(group.observer); + ComputedTracker.tracked(group.observers['value']); return group; } @@ -676,7 +679,7 @@ export class Collection { if (selector == null || (!config.notExisting && !selector.exists)) return undefined; - ComputedTracker.tracked(selector.observer); + ComputedTracker.tracked(selector.observers['value']); return selector; } @@ -706,7 +709,7 @@ export class Collection { this.selectors[selectorKey] = selector; } - ComputedTracker.tracked(selector.observer); + ComputedTracker.tracked(selector.observers['value']); return selector; } @@ -782,7 +785,7 @@ export class Collection { // Check if Item exists if (item == null || (!config.notExisting && !item.exists)) return undefined; - ComputedTracker.tracked(item.observer); + ComputedTracker.tracked(item.observers['value']); return item; } @@ -804,7 +807,7 @@ export class Collection { // Create dummy Item to hold reference if (item == null) item = this.createPlaceholderItem(itemKey, true); - ComputedTracker.tracked(item.observer); + ComputedTracker.tracked(item.observers['value']); return item; } @@ -837,7 +840,7 @@ export class Collection { ) this.data[itemKey] = item; - ComputedTracker.tracked(item.observer); + ComputedTracker.tracked(item.observers['value']); return item; } @@ -892,7 +895,7 @@ export class Collection { // Because the default Group keeps track of all existing Items. // It also does control the Collection output in binding methods like 'useAgile()' // and therefore should do it here too. - items = defaultGroup?.items || []; + items = defaultGroup?.getItems() || []; } return items; @@ -1470,11 +1473,10 @@ export class Collection { // into the runtime is to rebuilt itself // group.rebuild(); - group?.ingest({ + group?.rebuild({ background: config?.background, - force: true, // because Group value didn't change, only the output might change sideEffects: config?.sideEffects, - storage: false, // because Group only rebuilds (-> actual persisted value hasn't changed) + storage: false, }); } } diff --git a/packages/core/src/computed/index.ts b/packages/core/src/computed/index.ts index 66997da0..b166deb1 100644 --- a/packages/core/src/computed/index.ts +++ b/packages/core/src/computed/index.ts @@ -11,6 +11,7 @@ import { removeProperties, LogCodeManager, isAsyncFunction, + extractRelevantObservers, } from '../internal'; export class Computed extends State< @@ -62,14 +63,14 @@ export class Computed extends State< }; // Extract Observer of passed hardcoded dependency instances - this.hardCodedDeps = extractObservers(config.computedDeps).filter( - (dep): dep is Observer => dep !== undefined - ); + this.hardCodedDeps = extractRelevantObservers( + config.computedDeps as DependableAgileInstancesType[] + ).filter((dep): dep is Observer => dep !== undefined); this.deps = new Set(this.hardCodedDeps); // Make this Observer depend on the specified hard coded dep Observers this.deps.forEach((observer) => { - observer.addDependent(this.observer); + observer.addDependent(this.observers['value']); }); // Initial recompute to assign the computed initial value to the Computed @@ -90,7 +91,7 @@ export class Computed extends State< autodetect: false, }); this.compute({ autodetect: config.autodetect }).then((result) => { - this.observer.ingestValue( + this.observers['value'].ingestValue( result, removeProperties(config, ['autodetect']) ); @@ -116,7 +117,7 @@ export class Computed extends State< */ public updateComputeFunction( computeFunction: () => ComputedValueType, - deps: Array = [], + deps: Array = [], config: RecomputeConfigInterface = {} ): this { config = defineConfig(config, { @@ -125,18 +126,18 @@ export class Computed extends State< // Make this Observer no longer depend on the old dep Observers this.deps.forEach((observer) => { - observer.removeDependent(this.observer); + observer.removeDependent(this.observers['value']); }); // Update dependencies of Computed - this.hardCodedDeps = extractObservers(deps).filter( + this.hardCodedDeps = extractRelevantObservers(deps).filter( (dep): dep is Observer => dep !== undefined ); this.deps = new Set(this.hardCodedDeps); // Make this Observer depend on the new hard coded dep Observers this.deps.forEach((observer) => { - observer.addDependent(this.observer); + observer.addDependent(this.observers['value']); }); // Update computeFunction @@ -179,7 +180,7 @@ export class Computed extends State< !this.hardCodedDeps.includes(observer) ) { this.deps.delete(observer); - observer.removeDependent(this.observer); + observer.removeDependent(this.observers['value']); } }); @@ -187,7 +188,7 @@ export class Computed extends State< foundDeps.forEach((observer) => { if (!this.deps.has(observer)) { this.deps.add(observer); - observer.addDependent(this.observer); + observer.addDependent(this.observers['value']); } }); } @@ -213,7 +214,7 @@ export interface CreateComputedConfigInterface extends StateConfigInterface { * Hard-coded dependencies the Computed Class should depend on. * @default [] */ - computedDeps?: Array; + computedDeps?: Array; /** * Whether the Computed should automatically detect * used dependencies in the specified compute method. @@ -256,4 +257,7 @@ export interface RecomputeConfigInterface extends StateIngestConfigInterface, ComputeConfigInterface {} -export type SubscribableAgileInstancesType = State | Collection | Observer; +export type DependableAgileInstancesType = + | State + | Collection //https://stackoverflow.com/questions/66987727/type-classa-id-number-name-string-is-not-assignable-to-type-classar + | Observer; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index 377b7183..6e403e5a 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -42,6 +42,7 @@ export * from './computed/computed.tracker'; // Collection export * from './collection'; export * from './collection/group'; +export * from './collection/group/group.observer'; export * from './collection/item'; export * from './collection/selector'; export * from './collection/collection.persistent'; diff --git a/packages/core/src/logCodeManager.ts b/packages/core/src/logCodeManager.ts index 75a6a71e..36a1eba0 100644 --- a/packages/core/src/logCodeManager.ts +++ b/packages/core/src/logCodeManager.ts @@ -157,6 +157,7 @@ const logCodeMessages = { // Utils '20:03:00': 'Failed to get Agile Instance from', '20:03:01': "Failed to create global Instance at '${0}'", + '20:03:02': "Required module '${0}' couldn't be retrieved!", // General '00:03:00': diff --git a/packages/core/src/state/index.ts b/packages/core/src/state/index.ts index ac5cedaa..b70a3a70 100644 --- a/packages/core/src/state/index.ts +++ b/packages/core/src/state/index.ts @@ -43,7 +43,7 @@ export class State { // Manages dependencies to other States and subscriptions of UI-Components. // It also serves as an interface to the runtime. - public observer: StateObserver; + public observers: StateObserversInterface = {} as any; // Registered side effects of changing the State value public sideEffects: { [key: string]: SideEffectInterface>; @@ -90,7 +90,7 @@ export class State { }); this.agileInstance = () => agileInstance; this._key = config.key; - this.observer = new StateObserver(this, { + this.observers['value'] = new StateObserver(this, { key: config.key, dependents: config.dependents, }); @@ -128,7 +128,7 @@ export class State { * @public */ public get value(): ValueType { - ComputedTracker.tracked(this.observer); + ComputedTracker.tracked(this.observers['value']); return copy(this._value); } @@ -169,13 +169,14 @@ export class State { // Update State key this._key = value; - // Update key of Observer - this.observer._key = value; + // Update key of Observers + for (const observerKey in this.observers) + this.observers[observerKey]._key = value; // Update key in Persistent (only if oldKey is equal to persistentKey // because otherwise the persistentKey is detached from the State key // -> not managed by State anymore) - if (value && this.persistent?._key === oldKey) + if (value != null && this.persistent?._key === oldKey) this.persistent?.setKey(value); return this; @@ -212,7 +213,7 @@ export class State { } // Ingest the State with the new value into the runtime - this.observer.ingestValue(_value, config); + this.observers['value'].ingestValue(_value, config); return this; } @@ -230,7 +231,7 @@ export class State { * @param config - Configuration object */ public ingest(config: StateIngestConfigInterface = {}): this { - this.observer.ingest(config); + this.observers['value'].ingest(config); return this; } @@ -749,19 +750,6 @@ export class State { return type === this.valueType; } - /** - * Returns the public value of the State. - * - * @internal - */ - public getPublicValue(): ValueType { - // If State value is used internally - // and output represents the public State value (for instance in Group) - if (this['output'] !== undefined) return this['output']; - - return this._value; - } - /** * Returns the persistable value of the State. * @@ -774,6 +762,13 @@ export class State { export type StateKey = string | number; +export interface StateObserversInterface { + /** + * Observer responsible for the value of the State. + */ + value: StateObserver; +} + export interface StateConfigInterface { /** * Key/Name identifier of the State. diff --git a/packages/core/src/state/state.observer.ts b/packages/core/src/state/state.observer.ts index 75d0fa96..85598d79 100644 --- a/packages/core/src/state/state.observer.ts +++ b/packages/core/src/state/state.observer.ts @@ -4,17 +4,17 @@ import { Computed, copy, defineConfig, - ObserverKey, equal, notEqual, isFunction, - SubscriptionContainer, IngestConfigInterface, StateRuntimeJob, SideEffectInterface, createArrayFromObject, CreateStateRuntimeJobConfigInterface, generateId, + SubscriptionContainer, + ObserverKey, } from '../internal'; export class StateObserver extends Observer { @@ -91,10 +91,11 @@ export class StateObserver extends Observer { force: false, storage: true, overwrite: false, + maxTriesToUpdate: 3, }); // Force overwriting the State value if it is a placeholder. - // After assigning a value to the State it shouldn't be a placeholder anymore. + // After assigning a value to the State, the State is supposed to be no placeholder anymore. if (state.isPlaceholder) { config.force = true; config.overwrite = true; @@ -117,7 +118,8 @@ export class StateObserver extends Observer { overwrite: config.overwrite, key: config.key ?? - `${this._key != null ? this._key + '_' : ''}${generateId()}`, + `${this._key != null ? this._key + '_' : ''}${generateId()}_value`, + maxTriesToUpdate: config.maxTriesToUpdate, }); // Pass created Job into the Runtime @@ -137,12 +139,13 @@ export class StateObserver extends Observer { * @param job - Runtime-Job to be performed. */ public perform(job: StateRuntimeJob) { - const state = job.observer.state(); + const observer = job.observer; + const state = observer.state(); // Assign new State values state.previousStateValue = copy(state._value); - state._value = copy(job.observer.nextStateValue); - state.nextStateValue = copy(job.observer.nextStateValue); + state._value = copy(observer.nextStateValue); + state.nextStateValue = copy(observer.nextStateValue); // TODO think about freezing the State value.. // https://www.geeksforgeeks.org/object-freeze-javascript/#:~:text=Object.freeze()%20Method&text=freeze()%20which%20is%20used,the%20prototype%20of%20the%20object. @@ -158,14 +161,9 @@ export class StateObserver extends Observer { state.isSet = notEqual(state._value, state.initialStateValue); this.sideEffects(job); - // Assign public value to the Observer after sideEffects like 'rebuildGroup' were executed. - // Because sometimes (for instance in a Group State) the 'publicValue()' - // is not the '.value' ('nextStateValue') property. - // The Observer value is at some point the public value - // since Integrations like React are using it as the return value. - // (For example 'useAgile()' returns 'Observer.value' and not 'State.value'.) - job.observer.previousValue = copy(job.observer.value); - job.observer.value = copy(state.getPublicValue()); + // Assign new public value to the Observer (value used by the Integrations) + job.observer.previousValue = copy(observer.value); + job.observer.value = copy(state._value); } /** @@ -184,7 +182,7 @@ export class StateObserver extends Observer { // Call watcher functions for (const watcherKey in state.watchers) if (isFunction(state.watchers[watcherKey])) - state.watchers[watcherKey](state.getPublicValue(), watcherKey); + state.watchers[watcherKey](state._value, watcherKey); // Call side effect functions if (job.config?.sideEffects?.enabled) { @@ -207,17 +205,17 @@ export class StateObserver extends Observer { export interface CreateStateObserverConfigInterface { /** - * Initial Observers to depend on the State Observer. + * Initial Observers to depend on the Observer. * @default [] */ dependents?: Array; /** - * Initial Subscription Containers the State Observer is subscribed to. + * Initial Subscription Containers the Observer is subscribed to. * @default [] */ subs?: Array; /** - * Key/Name identifier of the State Observer. + * Key/Name identifier of the Observer. * @default undefined */ key?: ObserverKey; diff --git a/packages/core/src/state/state.runtime.job.ts b/packages/core/src/state/state.runtime.job.ts index f42af5c3..16e1628a 100644 --- a/packages/core/src/state/state.runtime.job.ts +++ b/packages/core/src/state/state.runtime.job.ts @@ -34,6 +34,7 @@ export class StateRuntimeJob extends RuntimeJob { force: false, storage: true, overwrite: false, + maxTriesToUpdate: 3, }); this.config = { @@ -42,6 +43,7 @@ export class StateRuntimeJob extends RuntimeJob { sideEffects: config.sideEffects, storage: config.storage, overwrite: config.overwrite, + maxTriesToUpdate: config.maxTriesToUpdate, }; } } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index a36692dd..6a56adc4 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -35,56 +35,189 @@ export function getAgileInstance(instance: any): Agile | undefined { } /** - * Extracts the Observers from the specified Instances. + * Extracts all Observers from the specified Instances + * and returns them in the given order. + * + * ``` + * const response = extractObservers([myState, myGroup, undefined]); + * console.log(response); // See below + * { + * {value: Observer}, + * {value: Observer, output: Observer}, + * {} + * } + * ``` * * @internal * @param instances - Instances to extract the Observers from. */ -export function extractObservers(instances: any): Array { - const instancesArray: Array = []; +export function extractObservers( + instances: Array +): Array<{ [key: string]: Observer | undefined }>; +/** + * Extracts all Observers from the specified Instance. + * + * ``` + * const response = extractObservers(myState); + * console.log(response); // See below + * { + * value: Observer + * } + * ``` + * + * @internal + * @param instances - Instance to extract the Observers from. + */ +export function extractObservers( + instances: any +): { [key: string]: Observer | undefined }; +export function extractObservers( + instances: any | Array +): + | Array<{ [key: string]: Observer | undefined }> + | { [key: string]: Observer | undefined } { + const observers: Array<{ [key: string]: Observer | undefined }> = []; const tempInstancesArray = normalizeArray(instances, { createUndefinedArray: true, }); - // Get Observers from Instances + // Extract Observers from specified Instances for (const instance of tempInstancesArray) { - // If the Instance equals to 'undefined' - // (We have to add 'undefined' to the return value - // in order to properly build the return value of, - // for example, the 'useAgile()' hook later) + // If the Instance equals to 'null' or 'undefined' if (instance == null) { - instancesArray.push(undefined); + observers.push({}); continue; } // If the Instance equals to a Collection if (instance instanceof Collection) { - instancesArray.push( - instance.getGroupWithReference(instance.config.defaultGroupKey).observer + observers.push( + instance.getGroupWithReference(instance.config.defaultGroupKey) + .observers as any ); continue; } // If the Instance contains a property that is an Observer if (instance['observer'] && instance['observer'] instanceof Observer) { - instancesArray.push(instance['observer']); + observers.push({ value: instance['observer'] }); + continue; + } + + // If the Instance contains a property that contains multiple Observers + if (instance['observers']) { + const extractedObservers = {}; + for (const key in instance['observers']) { + if (instance['observers'][key] instanceof Observer) { + extractedObservers[key] = instance['observers'][key]; + } + } + observers.push(extractedObservers); continue; } // If the Instance equals to an Observer if (instance instanceof Observer) { - instancesArray.push(instance); + observers.push({ value: instance }); continue; } - // Push 'undefined' if no valid Observer was found - // (We have to add 'undefined' to the return value - // in order to properly build the return value of, - // for example, the 'useAgile()' hook later) - instancesArray.push(undefined); + // Push empty object if no valid Observer was found + observers.push({}); + } + + return Array.isArray(instances) ? observers : observers[0]; +} + +/** + * Extracts the most relevant Observers + * from the specified Instance/s in array shape + * and returns the extracted Observers in the given order. + * + * What type of Observer is extracted from an Instance, + * depends on the specified `observerType`. + * If no `observerType` is specified, the Observers found in the dependency + * are selected in the following `observerType` order. + * 1. `output` + * 2. `value` + * + * @internal + * @param instances - Instances in array shape to extract the Observers from. + * @param observerType - Type of Observer to be extracted. + */ +export function extractRelevantObservers>( + instances: X, + observerType?: string +): Array; +/** + * Extracts the most relevant Observers + * from the specified Instance/s in object shape + * and returns the extracted Observers in the given order. + * + * What type of Observer is extracted from an Instance, + * depends on the specified `observerType`. + * If no `observerType` is specified, the Observers found in the dependency + * are selected in the following `observerType` order. + * 1. `output` + * 2. `value` + * + * @internal + * @param instances - Instances in object shape to extract the Observers from. + * @param observerType - Type of Observer to be extracted. + */ +export function extractRelevantObservers( + instances: X, + observerType?: string +): { [key: string]: Observer | undefined }; + +export function extractRelevantObservers< + X extends { [key: string]: any }, + Y extends Array +>( + instances: X | Y, + observerType?: string +): Array | { [key: string]: Observer | undefined } { + const depsWithIndicator: { [key: string]: Observer | undefined } = {}; + const depsWithNoIndicator: Array = []; + + // Extract Observers from deps + for (const depKey in instances) { + const extractedObservers = extractObservers(instances[depKey]); + let observer: Observer | undefined = undefined; + + // Extract Observer at specified type + if (observerType != null && extractedObservers[observerType] != null) + observer = extractedObservers[observerType]; + + // Extract most relevant Observer + if (observerType == null) + observer = extractedObservers['output'] ?? extractedObservers['value']; + + if (Array.isArray(instances)) depsWithNoIndicator.push(observer); + else depsWithIndicator[depKey] = observer; } - return instancesArray; + return Array.isArray(instances) ? depsWithNoIndicator : depsWithIndicator; +} + +/** + * Retrieves the module with the specified key/name identifier + * and returns `null` if the module couldn't be found. + * + * @param moduleName - Key/Name identifier of the module to be retrieved. + * @param error - Whether to print an error, when the module couldn't be retrieved. + */ +export function optionalRequire( + moduleName: string, + error = true +): PackageType | null { + let requiredPackage = null; + try { + requiredPackage = require(moduleName); + } catch (e) { + if (error) LogCodeManager.log('20:03:02', [moduleName]); + } + return requiredPackage; } /** @@ -92,7 +225,6 @@ export function extractObservers(instances: any): Array { * * Learn more about global bound instances: * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis - * https://blog.logrocket.com/what-is-globalthis-why-use-it/ * * @public * @param key - Key/Name identifier of the specified Instance. diff --git a/packages/core/tests/unit/collection/collection.test.ts b/packages/core/tests/unit/collection/collection.test.ts index 8d709e8d..47db19a4 100644 --- a/packages/core/tests/unit/collection/collection.test.ts +++ b/packages/core/tests/unit/collection/collection.test.ts @@ -1117,7 +1117,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummyGroup); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummyGroup.observer + dummyGroup.observers['value'] ); }); @@ -1146,7 +1146,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummyGroup); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummyGroup.observer + dummyGroup.observers['value'] ); }); }); @@ -1182,7 +1182,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummyGroup); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummyGroup.observer + dummyGroup.observers['value'] ); }); @@ -1193,7 +1193,9 @@ describe('Collection Tests', () => { expect(response.isPlaceholder).toBeTruthy(); expect(response._key).toBe('notExistingGroup'); expect(collection.groups['notExistingGroup']).toBe(response); - expect(ComputedTracker.tracked).toHaveBeenCalledWith(response.observer); + expect(ComputedTracker.tracked).toHaveBeenCalledWith( + response.observers['value'] + ); }); }); @@ -1344,7 +1346,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummySelector); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummySelector.observer + dummySelector.observers['value'] ); }); @@ -1373,7 +1375,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummySelector); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummySelector.observer + dummySelector.observers['value'] ); }); }); @@ -1403,7 +1405,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummySelector); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummySelector.observer + dummySelector.observers['value'] ); }); @@ -1419,7 +1421,9 @@ describe('Collection Tests', () => { expect(response._key).toBe('notExistingSelector'); expect(collection.selectors['notExistingSelector']).toBe(response); - expect(ComputedTracker.tracked).toHaveBeenCalledWith(response.observer); + expect(ComputedTracker.tracked).toHaveBeenCalledWith( + response.observers['value'] + ); }); }); @@ -1508,7 +1512,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummyItem); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummyItem.observer + dummyItem.observers['value'] ); }); @@ -1537,7 +1541,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummyItem); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummyItem.observer + dummyItem.observers['value'] ); }); }); @@ -1566,7 +1570,7 @@ describe('Collection Tests', () => { expect(response).toBe(dummyItem); expect(collection.createPlaceholderItem).not.toHaveBeenCalled(); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - dummyItem.observer + dummyItem.observers['value'] ); }); @@ -1579,7 +1583,7 @@ describe('Collection Tests', () => { true ); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - placeholderItem.observer + placeholderItem.observers['value'] ); }); }); @@ -1610,7 +1614,7 @@ describe('Collection Tests', () => { expect(ComputedTracker.tracked).toHaveBeenCalledTimes(1); expect(ComputedTracker.tracked).not.toHaveBeenCalledWith( - dummyItem.observer + dummyItem.observers['value'] ); }); @@ -1628,7 +1632,7 @@ describe('Collection Tests', () => { expect(ComputedTracker.tracked).toHaveBeenCalledTimes(1); expect(ComputedTracker.tracked).not.toHaveBeenCalledWith( - dummyItem.observer + dummyItem.observers['value'] ); }); @@ -1647,7 +1651,7 @@ describe('Collection Tests', () => { expect(ComputedTracker.tracked).toHaveBeenCalledTimes(1); expect(ComputedTracker.tracked).not.toHaveBeenCalledWith( - dummyItem.observer + dummyItem.observers['value'] ); }); }); @@ -1705,6 +1709,7 @@ describe('Collection Tests', () => { let dummyItem1: Item; let dummyItem2: Item; let dummyItem3: Item; + let defaultGroup: Group; beforeEach(() => { dummyItem1 = new Item(collection, { id: '1', name: 'Jeff' }); @@ -1719,12 +1724,16 @@ describe('Collection Tests', () => { ['3']: dummyItem3, }; - collection.getDefaultGroup()?.add(['1', '2', '3']); + defaultGroup = collection.getDefaultGroup() as any; + defaultGroup.add(['1', '2', '3']); + jest.spyOn(defaultGroup, 'getItems'); }); it('should return all existing Items (default config)', () => { const items = collection.getAllItems(); + expect(defaultGroup.getItems).toHaveBeenCalled(); + expect(items.includes(dummyItem1)).toBeTruthy(); expect(items.includes(dummyItem2)).toBeFalsy(); expect(items.includes(dummyItem3)).toBeTruthy(); @@ -1733,6 +1742,8 @@ describe('Collection Tests', () => { it('should return all Items (config.notExisting = true)', () => { const items = collection.getAllItems({ notExisting: true }); + expect(defaultGroup.getItems).not.toHaveBeenCalled(); + expect(items.includes(dummyItem1)).toBeTruthy(); expect(items.includes(dummyItem2)).toBeTruthy(); expect(items.includes(dummyItem3)).toBeTruthy(); @@ -3000,23 +3011,22 @@ describe('Collection Tests', () => { dummyGroup2: dummyGroup2, }; - dummyGroup1.ingest = jest.fn(); - dummyGroup2.ingest = jest.fn(); + dummyGroup1.rebuild = jest.fn(); + dummyGroup2.rebuild = jest.fn(); }); it('should call ingest on each Group that includes the passed ItemKey (default config)', () => { collection.rebuildGroupsThatIncludeItemKey('dummyItem1'); - expect(dummyGroup1.ingest).toHaveBeenCalledWith({ + expect(dummyGroup1.rebuild).toHaveBeenCalledWith({ background: false, - force: true, sideEffects: { enabled: true, exclude: [], }, storage: false, }); - expect(dummyGroup2.ingest).not.toHaveBeenCalled(); + expect(dummyGroup2.rebuild).not.toHaveBeenCalled(); }); it('should call ingest on each Group that includes the passed ItemKey (specific config)', () => { @@ -3027,17 +3037,15 @@ describe('Collection Tests', () => { }, }); - expect(dummyGroup1.ingest).toHaveBeenCalledWith({ + expect(dummyGroup1.rebuild).toHaveBeenCalledWith({ background: true, - force: true, sideEffects: { enabled: false, }, storage: false, }); - expect(dummyGroup2.ingest).toHaveBeenCalledWith({ + expect(dummyGroup2.rebuild).toHaveBeenCalledWith({ background: true, - force: true, sideEffects: { enabled: false, }, diff --git a/packages/core/tests/unit/collection/group/group.observer.test.ts b/packages/core/tests/unit/collection/group/group.observer.test.ts new file mode 100644 index 00000000..f24e14d6 --- /dev/null +++ b/packages/core/tests/unit/collection/group/group.observer.test.ts @@ -0,0 +1,330 @@ +import { + Agile, + Observer, + RuntimeJob, + Item, + SubscriptionContainer, + Group, + Collection, + GroupObserver, +} from '../../../../src'; +import * as Utils from '@agile-ts/utils'; +import { LogMock } from '../../../helper/logMock'; + +describe('GroupObserver Tests', () => { + interface ItemInterface { + id: string; + name: string; + } + + let dummyAgile: Agile; + let dummyCollection: Collection; + let dummyGroup: Group; + let dummyItem1: Item; + let dummyItem2: Item; + + beforeEach(() => { + jest.clearAllMocks(); + LogMock.mockLogs(); + + dummyAgile = new Agile({ localStorage: false }); + dummyCollection = new Collection(dummyAgile); + dummyGroup = new Group(dummyCollection, [], { + key: 'dummyGroup', + }); + dummyItem1 = new Item(dummyCollection, { + id: 'dummyItem1Key', + name: 'frank', + }); + dummyItem2 = new Item(dummyCollection, { + id: 'dummyItem2Key', + name: 'jeff', + }); + }); + + it('should create Group Observer (default config)', () => { + dummyGroup._output = [dummyItem1._value, dummyItem2._value]; + + const groupObserver = new GroupObserver(dummyGroup); + + expect(groupObserver).toBeInstanceOf(GroupObserver); + expect(groupObserver.nextGroupOutput).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(groupObserver.group()).toBe(dummyGroup); + + // Check if Observer was called with correct parameters + expect(groupObserver.value).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(groupObserver.previousValue).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(groupObserver._key).toBeUndefined(); + expect(Array.from(groupObserver.dependents)).toStrictEqual([]); + expect(Array.from(groupObserver.subscribedTo)).toStrictEqual([]); + }); + + it('should create Group Observer (specific config)', () => { + const dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); + const dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); + const dummySubscription1 = new SubscriptionContainer([]); + const dummySubscription2 = new SubscriptionContainer([]); + + const groupObserver = new GroupObserver(dummyGroup, { + key: 'testKey', + dependents: [dummyObserver1, dummyObserver2], + subs: [dummySubscription1, dummySubscription2], + }); + + expect(groupObserver).toBeInstanceOf(GroupObserver); + expect(groupObserver.nextGroupOutput).toStrictEqual([]); + expect(groupObserver.group()).toBe(dummyGroup); + + // Check if Observer was called with correct parameters + expect(groupObserver.value).toStrictEqual([]); + expect(groupObserver.previousValue).toStrictEqual([]); + expect(groupObserver._key).toBe('testKey'); + expect(Array.from(groupObserver.dependents)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(groupObserver.subscribedTo)).toStrictEqual([ + dummySubscription1, + dummySubscription2, + ]); + }); + + describe('Group Observer Function Tests', () => { + let groupObserver: GroupObserver; + + beforeEach(() => { + groupObserver = new GroupObserver(dummyGroup, { + key: 'groupObserverKey', + }); + }); + + describe('ingest function tests', () => { + beforeEach(() => { + dummyGroup.rebuild = jest.fn(); + }); + + it('should rebuild the Group and ingests it into the runtime (default config)', () => { + groupObserver.ingest(); + + expect(dummyGroup.rebuild).toHaveBeenCalledWith({}); + }); + + it('should rebuild the Group and ingests it into the runtime (specific config)', () => { + groupObserver.ingest({ + background: true, + force: true, + maxTriesToUpdate: 5, + }); + + expect(dummyGroup.rebuild).toHaveBeenCalledWith({ + background: true, + force: true, + maxTriesToUpdate: 5, + }); + }); + }); + + describe('ingestItems function tests', () => { + beforeEach(() => { + dummyAgile.runtime.ingest = jest.fn(); + }); + + it( + 'should ingest the Group into the Runtime ' + + "if the new value isn't equal to the current value (default config)", + () => { + jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); + dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { + expect(job._key).toBe(`${groupObserver._key}_randomKey_output`); + expect(job.observer).toBe(groupObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + maxTriesToUpdate: 3, + }); + }); + + groupObserver.ingestItems([dummyItem1, dummyItem2]); + + expect(groupObserver.nextGroupOutput).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(RuntimeJob), + { + perform: true, + } + ); + } + ); + + it( + 'should ingest the Group into the Runtime ' + + "if the new value isn't equal to the current value (specific config)", + () => { + dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { + expect(job._key).toBe('dummyJob'); + expect(job.observer).toBe(groupObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: false, + }, + force: true, + maxTriesToUpdate: 5, + }); + }); + + groupObserver.ingestItems([dummyItem1, dummyItem2], { + perform: false, + force: true, + sideEffects: { + enabled: false, + }, + key: 'dummyJob', + maxTriesToUpdate: 5, + }); + + expect(groupObserver.nextGroupOutput).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(RuntimeJob), + { + perform: false, + } + ); + } + ); + + it( + "shouldn't ingest the Group into the Runtime " + + 'if the new value is equal to the current value (default config)', + () => { + dummyGroup._output = [dummyItem1._value, dummyItem2._value]; + + groupObserver.ingestItems([dummyItem1, dummyItem2]); + + expect(groupObserver.nextGroupOutput).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(dummyAgile.runtime.ingest).not.toHaveBeenCalled(); + } + ); + + it( + 'should ingest the Group into the Runtime ' + + 'if the new value is equal to the current value (config.force = true)', + () => { + jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); + dummyGroup._output = [dummyItem1._value, dummyItem2._value]; + dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { + expect(job._key).toBe(`${groupObserver._key}_randomKey_output`); + expect(job.observer).toBe(groupObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: true, + maxTriesToUpdate: 3, + }); + }); + + groupObserver.ingestItems([dummyItem1, dummyItem2], { force: true }); + + expect(groupObserver.nextGroupOutput).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(RuntimeJob), + { + perform: true, + } + ); + } + ); + + it('should ingest placeholder Group into the Runtime (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); + dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { + expect(job._key).toBe(`${groupObserver._key}_randomKey_output`); + expect(job.observer).toBe(groupObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: true, + maxTriesToUpdate: 3, + }); + }); + dummyGroup.isPlaceholder = true; + + groupObserver.ingestItems([dummyItem1, dummyItem2]); + + expect(groupObserver.nextGroupOutput).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(RuntimeJob), + { + perform: true, + } + ); + }); + }); + + describe('perform function tests', () => { + let dummyJob: RuntimeJob; + + beforeEach(() => { + dummyJob = new RuntimeJob(groupObserver, { + key: 'dummyJob', + }); + }); + + it('should perform the specified Job', () => { + (dummyJob.observer as GroupObserver).nextGroupOutput = [ + dummyItem1._value, + dummyItem2._value, + ]; + dummyJob.observer.value = [dummyItem1._value]; + dummyGroup._output = [dummyItem1._value]; + + groupObserver.perform(dummyJob); + + expect(dummyGroup._output).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + + expect(groupObserver.value).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); + expect(groupObserver.previousValue).toStrictEqual([dummyItem1._value]); + }); + }); + }); +}); diff --git a/packages/core/tests/unit/collection/group.test.ts b/packages/core/tests/unit/collection/group/group.test.ts similarity index 83% rename from packages/core/tests/unit/collection/group.test.ts rename to packages/core/tests/unit/collection/group/group.test.ts index 72ea153b..7aaceb4f 100644 --- a/packages/core/tests/unit/collection/group.test.ts +++ b/packages/core/tests/unit/collection/group/group.test.ts @@ -7,8 +7,9 @@ import { Item, State, CollectionPersistent, -} from '../../../src'; -import { LogMock } from '../../helper/logMock'; + GroupObserver, +} from '../../../../src'; +import { LogMock } from '../../../helper/logMock'; describe('Group Tests', () => { interface ItemInterface { @@ -45,9 +46,9 @@ describe('Group Tests', () => { expect(group.collection()).toBe(dummyCollection); expect(group._output).toStrictEqual([]); - expect(group._items).toStrictEqual([]); expect(group.notFoundItemKeys).toStrictEqual([]); + // Check if State was called with correct parameters expect(group._key).toBeUndefined(); expect(group.valueType).toBeUndefined(); expect(group.isSet).toBeFalsy(); @@ -56,9 +57,12 @@ describe('Group Tests', () => { expect(group._value).toStrictEqual([]); expect(group.previousStateValue).toStrictEqual([]); expect(group.nextStateValue).toStrictEqual([]); - expect(group.observer).toBeInstanceOf(StateObserver); - expect(group.observer.dependents.size).toBe(0); - expect(group.observer._key).toBeUndefined(); + expect(group.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(group.observers['value'].dependents)).toStrictEqual([]); + expect(group.observers['value']._key).toBeUndefined(); + expect(group.observers['output']).toBeInstanceOf(GroupObserver); + expect(Array.from(group.observers['output'].dependents)).toStrictEqual([]); + expect(group.observers['output']._key).toBeUndefined(); expect(group.sideEffects).toStrictEqual({}); expect(group.computeValueMethod).toBeUndefined(); expect(group.computeExistsMethod).toBeInstanceOf(Function); @@ -83,9 +87,9 @@ describe('Group Tests', () => { expect(group.collection()).toBe(dummyCollection); expect(group._output).toStrictEqual([]); - expect(group._items).toStrictEqual([]); expect(group.notFoundItemKeys).toStrictEqual([]); + // Check if State was called with correct parameters expect(group._key).toBe('dummyKey'); expect(group.valueType).toBeUndefined(); expect(group.isSet).toBeFalsy(); @@ -94,9 +98,12 @@ describe('Group Tests', () => { expect(group._value).toStrictEqual([]); expect(group.previousStateValue).toStrictEqual([]); expect(group.nextStateValue).toStrictEqual([]); - expect(group.observer).toBeInstanceOf(StateObserver); - expect(group.observer.dependents.size).toBe(0); - expect(group.observer._key).toBe('dummyKey'); + expect(group.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(group.observers['value'].dependents)).toStrictEqual([]); + expect(group.observers['value']._key).toBe('dummyKey'); + expect(group.observers['output']).toBeInstanceOf(GroupObserver); + expect(Array.from(group.observers['output'].dependents)).toStrictEqual([]); + expect(group.observers['output']._key).toBe('dummyKey'); expect(group.sideEffects).toStrictEqual({}); expect(group.computeValueMethod).toBeUndefined(); expect(group.computeExistsMethod).toBeInstanceOf(Function); @@ -118,9 +125,9 @@ describe('Group Tests', () => { expect(group.collection()).toBe(dummyCollection); expect(group._output).toStrictEqual([]); - expect(group._items).toStrictEqual([]); expect(group.notFoundItemKeys).toStrictEqual([]); + // Check if State was called with correct parameters expect(group._key).toBeUndefined(); expect(group.valueType).toBeUndefined(); expect(group.isSet).toBeFalsy(); @@ -129,9 +136,10 @@ describe('Group Tests', () => { expect(group._value).toStrictEqual(['test1', 'test2', 'test3']); expect(group.previousStateValue).toStrictEqual(['test1', 'test2', 'test3']); expect(group.nextStateValue).toStrictEqual(['test1', 'test2', 'test3']); - expect(group.observer).toBeInstanceOf(StateObserver); - expect(group.observer.dependents.size).toBe(0); - expect(group.observer._key).toBeUndefined(); + expect(group.observers['value']).toBeInstanceOf(StateObserver); + expect(group.observers['value']._key).toBeUndefined(); + expect(group.observers['output']).toBeInstanceOf(GroupObserver); + expect(group.observers['output']._key).toBeUndefined(); expect(group.sideEffects).toStrictEqual({}); expect(group.computeValueMethod).toBeUndefined(); expect(group.computeExistsMethod).toBeInstanceOf(Function); @@ -177,7 +185,9 @@ describe('Group Tests', () => { { id: '1', name: 'Frank' }, { id: '2', name: 'Hans' }, ]); - expect(ComputedTracker.tracked).toHaveBeenCalledWith(group.observer); + expect(ComputedTracker.tracked).toHaveBeenCalledWith( + group.observers['output'] + ); }); }); @@ -195,32 +205,6 @@ describe('Group Tests', () => { }); }); - describe('item get function tests', () => { - beforeEach(() => { - jest.spyOn(ComputedTracker, 'tracked'); - }); - - it('should return items of Group and call ComputedTracker.tracked', () => { - group._items = [() => dummyItem1, () => dummyItem2]; - - const response = group.items; - - expect(response).toStrictEqual([dummyItem1, dummyItem2]); - expect(ComputedTracker.tracked).toHaveBeenCalledWith(group.observer); - }); - }); - - describe('item set function tests', () => { - it("shouldn't set items to passed value and print error", () => { - group._items = null as any; - - group.items = [dummyItem1, dummyItem2]; - - expect(group._items).toStrictEqual(null); - expect(LogMock.hasLoggedCode('1C:03:01', [group._key])); - }); - }); - describe('has function tests', () => { beforeEach(() => { group._value = ['test1', 'test2']; @@ -414,6 +398,18 @@ describe('Group Tests', () => { }); }); + describe('getItems function tests', () => { + beforeEach(() => { + group._value = ['dummyItem1Key', 'dummyItem3Key', 'dummyItem2Key']; + }); + + it('should return all existing Items of the Group', () => { + const items = group.getItems(); + + expect(items).toStrictEqual([dummyItem1, dummyItem2]); + }); + }); + describe('persist function tests', () => { beforeEach(() => { jest.spyOn(State.prototype, 'persist'); @@ -515,17 +511,36 @@ describe('Group Tests', () => { describe('rebuild function tests', () => { beforeEach(() => { group._value = ['dummyItem1Key', 'dummyItem3Key', 'dummyItem2Key']; + group.observers['output'].ingestItems = jest.fn(); }); - it('should build Group output and items and set notFoundItemKeys to not found Item Keys', () => { + it('should ingest the built Group output and set notFoundItemKeys to the not found Item Keys (default config)', () => { group.rebuild(); expect(group.notFoundItemKeys).toStrictEqual(['dummyItem3Key']); - expect(group.items).toStrictEqual([dummyItem1, dummyItem2]); - expect(group._output).toStrictEqual([ - dummyItem1._value, - dummyItem2._value, - ]); + expect(group._output).toStrictEqual([]); // because of mocking 'ingestValue' + expect(group.observers['output'].ingestItems).toHaveBeenCalledWith( + [dummyItem1, dummyItem2], + {} + ); + + LogMock.hasLoggedCode( + '1C:02:00', + [dummyCollection._key, group._key], + ['dummyItem3Key'] + ); + }); + + it('should ingest the built Group output and set notFoundItemKeys to the not found Item Keys (specific config)', () => { + group.rebuild({ storage: true, overwrite: true, background: false }); + + expect(group.notFoundItemKeys).toStrictEqual(['dummyItem3Key']); + expect(group._output).toStrictEqual([]); // because of mocking 'ingestValue' + expect(group.observers['output'].ingestItems).toHaveBeenCalledWith( + [dummyItem1, dummyItem2], + { storage: true, overwrite: true, background: false } + ); + LogMock.hasLoggedCode( '1C:02:00', [dummyCollection._key, group._key], @@ -533,14 +548,14 @@ describe('Group Tests', () => { ); }); - it("shouldn't build Group output and items if Collection is not properly instantiated", () => { + it("shouldn't intest the build Group output if the Collection was not properly instantiated", () => { dummyCollection.isInstantiated = false; group.rebuild(); expect(group.notFoundItemKeys).toStrictEqual([]); - expect(group.items).toStrictEqual([]); expect(group._output).toStrictEqual([]); + expect(group.observers['output'].ingestItems).not.toHaveBeenCalled(); LogMock.hasNotLogged('warn'); }); }); diff --git a/packages/core/tests/unit/collection/item.test.ts b/packages/core/tests/unit/collection/item.test.ts index 3e55cff0..b50b620a 100644 --- a/packages/core/tests/unit/collection/item.test.ts +++ b/packages/core/tests/unit/collection/item.test.ts @@ -49,9 +49,9 @@ describe('Item Tests', () => { expect(item._value).toStrictEqual(dummyData); expect(item.previousStateValue).toStrictEqual(dummyData); expect(item.nextStateValue).toStrictEqual(dummyData); - expect(item.observer).toBeInstanceOf(StateObserver); - expect(item.observer.dependents.size).toBe(0); - expect(item.observer._key).toBe( + expect(item.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(item.observers['value'].dependents)).toStrictEqual([]); + expect(item.observers['value']._key).toBe( dummyData[dummyCollection.config.primaryKey] ); expect(item.sideEffects).toStrictEqual({}); @@ -79,6 +79,7 @@ describe('Item Tests', () => { item.addRebuildGroupThatIncludeItemKeySideEffect ).toHaveBeenCalledWith('dummyId'); + // Check if State was called with correct parameters expect(item._key).toBe(dummyData[dummyCollection.config.primaryKey]); expect(item.valueType).toBeUndefined(); expect(item.isSet).toBeFalsy(); @@ -87,9 +88,9 @@ describe('Item Tests', () => { expect(item._value).toStrictEqual(dummyData); expect(item.previousStateValue).toStrictEqual(dummyData); expect(item.nextStateValue).toStrictEqual(dummyData); - expect(item.observer).toBeInstanceOf(StateObserver); - expect(item.observer.dependents.size).toBe(0); - expect(item.observer._key).toBe( + expect(item.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(item.observers['value'].dependents)).toStrictEqual([]); + expect(item.observers['value']._key).toBe( dummyData[dummyCollection.config.primaryKey] ); expect(item.sideEffects).toStrictEqual({}); @@ -115,6 +116,7 @@ describe('Item Tests', () => { item.addRebuildGroupThatIncludeItemKeySideEffect ).not.toHaveBeenCalled(); + // Check if State was called with correct parameters expect(item._key).toBeUndefined(); expect(item.valueType).toBeUndefined(); expect(item.isSet).toBeFalsy(); @@ -123,9 +125,9 @@ describe('Item Tests', () => { expect(item._value).toStrictEqual(dummyData); expect(item.previousStateValue).toStrictEqual(dummyData); expect(item.nextStateValue).toStrictEqual(dummyData); - expect(item.observer).toBeInstanceOf(StateObserver); - expect(item.observer.dependents.size).toBe(0); - expect(item.observer._key).toBe( + expect(item.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(item.observers['value'].dependents)).toStrictEqual([]); + expect(item.observers['value']._key).toBe( dummyData[dummyCollection.config.primaryKey] ); expect(item.sideEffects).toStrictEqual({}); diff --git a/packages/core/tests/unit/collection/selector.test.ts b/packages/core/tests/unit/collection/selector.test.ts index 96e6170b..092a0d4c 100644 --- a/packages/core/tests/unit/collection/selector.test.ts +++ b/packages/core/tests/unit/collection/selector.test.ts @@ -44,9 +44,11 @@ describe('Selector Tests', () => { expect(selector._value).toBeNull(); expect(selector.previousStateValue).toBeNull(); expect(selector.nextStateValue).toBeNull(); - expect(selector.observer).toBeInstanceOf(StateObserver); - expect(selector.observer.dependents.size).toBe(0); - expect(selector.observer._key).toBeUndefined(); + expect(selector.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(selector.observers['value'].dependents)).toStrictEqual( + [] + ); + expect(selector.observers['value']._key).toBeUndefined(); expect(selector.sideEffects).toStrictEqual({}); expect(selector.computeValueMethod).toBeUndefined(); expect(selector.computeExistsMethod).toBeInstanceOf(Function); @@ -81,9 +83,11 @@ describe('Selector Tests', () => { expect(selector._value).toBeNull(); expect(selector.previousStateValue).toBeNull(); expect(selector.nextStateValue).toBeNull(); - expect(selector.observer).toBeInstanceOf(StateObserver); - expect(selector.observer.dependents.size).toBe(0); - expect(selector.observer._key).toBe('dummyKey'); + expect(selector.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(selector.observers['value'].dependents)).toStrictEqual( + [] + ); + expect(selector.observers['value']._key).toBe('dummyKey'); expect(selector.sideEffects).toStrictEqual({}); expect(selector.computeValueMethod).toBeUndefined(); expect(selector.computeExistsMethod).toBeInstanceOf(Function); @@ -116,9 +120,11 @@ describe('Selector Tests', () => { expect(selector._value).toBeNull(); expect(selector.previousStateValue).toBeNull(); expect(selector.nextStateValue).toBeNull(); - expect(selector.observer).toBeInstanceOf(StateObserver); - expect(selector.observer.dependents.size).toBe(0); - expect(selector.observer._key).toBeUndefined(); + expect(selector.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(selector.observers['value'].dependents)).toStrictEqual( + [] + ); + expect(selector.observers['value']._key).toBeUndefined(); expect(selector.sideEffects).toStrictEqual({}); expect(selector.computeValueMethod).toBeUndefined(); expect(selector.computeExistsMethod).toBeInstanceOf(Function); @@ -149,9 +155,11 @@ describe('Selector Tests', () => { expect(selector._value).toBeNull(); expect(selector.previousStateValue).toBeNull(); expect(selector.nextStateValue).toBeNull(); - expect(selector.observer).toBeInstanceOf(StateObserver); - expect(selector.observer.dependents.size).toBe(0); - expect(selector.observer._key).toBeUndefined(); + expect(selector.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(selector.observers['value'].dependents)).toStrictEqual( + [] + ); + expect(selector.observers['value']._key).toBeUndefined(); expect(selector.sideEffects).toStrictEqual({}); expect(selector.computeValueMethod).toBeUndefined(); expect(selector.computeExistsMethod).toBeInstanceOf(Function); diff --git a/packages/core/tests/unit/computed/computed.test.ts b/packages/core/tests/unit/computed/computed.test.ts index b8e3889e..7f4c58fe 100644 --- a/packages/core/tests/unit/computed/computed.test.ts +++ b/packages/core/tests/unit/computed/computed.test.ts @@ -6,6 +6,7 @@ import { State, ComputedTracker, } from '../../../src'; +import * as Utils from '../../../src/utils'; import { LogMock } from '../../helper/logMock'; import waitForExpect from 'wait-for-expect'; @@ -19,6 +20,7 @@ describe('Computed Tests', () => { dummyAgile = new Agile({ localStorage: false }); jest.spyOn(Computed.prototype, 'recompute'); + jest.spyOn(Utils, 'extractRelevantObservers'); }); it('should create Computed with a not async compute method (default config)', () => { @@ -30,6 +32,7 @@ describe('Computed Tests', () => { expect(computed.config).toStrictEqual({ autodetect: true }); expect(Array.from(computed.deps)).toStrictEqual([]); expect(computed.hardCodedDeps).toStrictEqual([]); + expect(Utils.extractRelevantObservers).toHaveBeenCalledWith([]); expect(computed.recompute).toHaveBeenCalledWith({ autodetect: computed.config.autodetect, @@ -45,9 +48,11 @@ describe('Computed Tests', () => { expect(computed._value).toBe(null); expect(computed.previousStateValue).toBe(null); expect(computed.nextStateValue).toBe(null); - expect(computed.observer).toBeInstanceOf(StateObserver); - expect(computed.observer.dependents.size).toBe(0); - expect(computed.observer._key).toBeUndefined(); + expect(computed.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(computed.observers['value'].dependents)).toStrictEqual( + [] + ); + expect(computed.observers['value']._key).toBeUndefined(); expect(computed.sideEffects).toStrictEqual({}); expect(computed.computeValueMethod).toBeUndefined(); expect(computed.computeExistsMethod).toBeInstanceOf(Function); @@ -64,7 +69,7 @@ describe('Computed Tests', () => { const dummyState = new State(dummyAgile, undefined); const dummyStateObserver = new StateObserver(dummyState); dummyStateObserver.addDependent = jest.fn(); - dummyState.observer = dummyStateObserver; + dummyState.observers['value'] = dummyStateObserver; const computedFunction = () => 'computedValue'; const computed = new Computed(dummyAgile, computedFunction, { @@ -84,6 +89,11 @@ describe('Computed Tests', () => { dummyObserver2, dummyStateObserver, ]); + expect(Utils.extractRelevantObservers).toHaveBeenCalledWith([ + dummyObserver2, + undefined, + dummyState, + ]); expect(computed.recompute).toHaveBeenCalledWith({ autodetect: computed.config.autodetect, @@ -91,9 +101,11 @@ describe('Computed Tests', () => { }); expect(dummyObserver1.addDependent).not.toHaveBeenCalled(); // Because no Computed dependent - expect(dummyObserver2.addDependent).toHaveBeenCalledWith(computed.observer); + expect(dummyObserver2.addDependent).toHaveBeenCalledWith( + computed.observers['value'] + ); expect(dummyStateObserver.addDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); // Check if State was called with correct parameters @@ -105,10 +117,11 @@ describe('Computed Tests', () => { expect(computed._value).toBe(null); expect(computed.previousStateValue).toBe(null); expect(computed.nextStateValue).toBe(null); - expect(computed.observer).toBeInstanceOf(StateObserver); - expect(computed.observer.dependents.size).toBe(1); - expect(computed.observer.dependents.has(dummyObserver1)).toBeTruthy(); - expect(computed.observer._key).toBe('coolComputed'); + expect(computed.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(computed.observers['value'].dependents)).toStrictEqual([ + dummyObserver1, + ]); + expect(computed.observers['value']._key).toBe('coolComputed'); expect(computed.sideEffects).toStrictEqual({}); expect(computed.computeValueMethod).toBeUndefined(); expect(computed.computeExistsMethod).toBeInstanceOf(Function); @@ -126,6 +139,7 @@ describe('Computed Tests', () => { expect(computed.config).toStrictEqual({ autodetect: false }); expect(Array.from(computed.deps)).toStrictEqual([]); expect(computed.hardCodedDeps).toStrictEqual([]); + expect(Utils.extractRelevantObservers).toHaveBeenCalledWith([]); expect(computed.recompute).toHaveBeenCalledWith({ autodetect: computed.config.autodetect, @@ -141,9 +155,11 @@ describe('Computed Tests', () => { expect(computed._value).toBe(null); expect(computed.previousStateValue).toBe(null); expect(computed.nextStateValue).toBe(null); - expect(computed.observer).toBeInstanceOf(StateObserver); - expect(computed.observer.dependents.size).toBe(0); - expect(computed.observer._key).toBeUndefined(); + expect(computed.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(computed.observers['value'].dependents)).toStrictEqual( + [] + ); + expect(computed.observers['value']._key).toBeUndefined(); expect(computed.sideEffects).toStrictEqual({}); expect(computed.computeValueMethod).toBeUndefined(); expect(computed.computeExistsMethod).toBeInstanceOf(Function); @@ -162,7 +178,7 @@ describe('Computed Tests', () => { describe('recompute function tests', () => { beforeEach(() => { - computed.observer.ingestValue = jest.fn(); + computed.observers['value'].ingestValue = jest.fn(); }); it('should ingest Computed Class into the Runtime (default config)', async () => { @@ -172,7 +188,7 @@ describe('Computed Tests', () => { expect(computed.compute).toHaveBeenCalledWith({ autodetect: false }); await waitForExpect(() => { - expect(computed.observer.ingestValue).toHaveBeenCalledWith( + expect(computed.observers['value'].ingestValue).toHaveBeenCalledWith( 'jeff', {} ); @@ -194,14 +210,17 @@ describe('Computed Tests', () => { expect(computed.compute).toHaveBeenCalledWith({ autodetect: true }); await waitForExpect(() => { - expect(computed.observer.ingestValue).toHaveBeenCalledWith('jeff', { - background: true, - sideEffects: { - enabled: false, - }, - force: false, - key: 'jeff', - }); + expect(computed.observers['value'].ingestValue).toHaveBeenCalledWith( + 'jeff', + { + background: true, + sideEffects: { + enabled: false, + }, + force: false, + key: 'jeff', + } + ); }); }); }); @@ -226,7 +245,7 @@ describe('Computed Tests', () => { dummyStateObserver = new StateObserver(dummyState); dummyStateObserver.removeDependent = jest.fn(); dummyStateObserver.addDependent = jest.fn(); - dummyState.observer = dummyStateObserver; + dummyState.observers['value'] = dummyStateObserver; computed.hardCodedDeps = [oldDummyObserver]; computed.deps = new Set([oldDummyObserver]); @@ -253,9 +272,14 @@ describe('Computed Tests', () => { autodetect: computed.config.autodetect, }); + expect(Utils.extractRelevantObservers).toHaveBeenCalledWith([ + dummyState, + dummyObserver, + ]); + // Make this Observer no longer depend on the old dep Observers expect(oldDummyObserver.removeDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); expect(dummyStateObserver.removeDependent).not.toHaveBeenCalled(); expect(dummyObserver.removeDependent).not.toHaveBeenCalled(); @@ -263,10 +287,10 @@ describe('Computed Tests', () => { // Make this Observer depend on the new hard coded dep Observers expect(oldDummyObserver.addDependent).not.toHaveBeenCalled(); expect(dummyStateObserver.addDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); expect(dummyObserver.addDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); }); @@ -304,9 +328,14 @@ describe('Computed Tests', () => { autodetect: false, }); + expect(Utils.extractRelevantObservers).toHaveBeenCalledWith([ + dummyState, + dummyObserver, + ]); + // Make this Observer no longer depend on the old dep Observers expect(oldDummyObserver.removeDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); expect(dummyStateObserver.removeDependent).not.toHaveBeenCalled(); expect(dummyObserver.removeDependent).not.toHaveBeenCalled(); @@ -314,10 +343,10 @@ describe('Computed Tests', () => { // Make this Observer depend on the new hard coded dep Observers expect(oldDummyObserver.addDependent).not.toHaveBeenCalled(); expect(dummyStateObserver.addDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); expect(dummyObserver.addDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); }); }); @@ -382,15 +411,15 @@ describe('Computed Tests', () => { expect(dummyObserver2.removeDependent).not.toHaveBeenCalled(); expect(dummyObserver3.removeDependent).not.toHaveBeenCalled(); expect(dummyObserver4.removeDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); // Make this Observer depend on the newly found dep Observers expect(dummyObserver1.addDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); expect(dummyObserver2.addDependent).toHaveBeenCalledWith( - computed.observer + computed.observers['value'] ); expect(dummyObserver3.addDependent).not.toHaveBeenCalled(); // Because Computed already depends on the 'dummyObserver3' expect(dummyObserver4.addDependent).not.toHaveBeenCalled(); diff --git a/packages/core/tests/unit/state/state.observer.test.ts b/packages/core/tests/unit/state/state.observer.test.ts index ac95ca58..32a99109 100644 --- a/packages/core/tests/unit/state/state.observer.test.ts +++ b/packages/core/tests/unit/state/state.observer.test.ts @@ -30,6 +30,8 @@ describe('StateObserver Tests', () => { expect(stateObserver).toBeInstanceOf(StateObserver); expect(stateObserver.nextStateValue).toBe('dummyValue'); expect(stateObserver.state()).toBe(dummyState); + + // Check if Observer was called with correct parameters expect(stateObserver.value).toBe('dummyValue'); expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver._key).toBeUndefined(); @@ -52,6 +54,8 @@ describe('StateObserver Tests', () => { expect(stateObserver).toBeInstanceOf(StateObserver); expect(stateObserver.nextStateValue).toBe('dummyValue'); expect(stateObserver.state()).toBe(dummyState); + + // Check if Observer was called with correct parameters expect(stateObserver.value).toBe('dummyValue'); expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver._key).toBe('testKey'); @@ -116,6 +120,7 @@ describe('StateObserver Tests', () => { }, background: true, perform: false, + maxTriesToUpdate: 5, }); expect(stateObserver.ingestValue).toHaveBeenCalledWith('nextValue', { @@ -127,6 +132,7 @@ describe('StateObserver Tests', () => { }, background: true, perform: false, + maxTriesToUpdate: 5, }); }); @@ -161,9 +167,8 @@ describe('StateObserver Tests', () => { "if the new value isn't equal to the current value (default config)", () => { jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); - dummyAgile.runtime.ingest = jest.fn((job: StateRuntimeJob) => { - expect(job._key).toBe(`${stateObserver._key}_randomKey`); + expect(job._key).toBe(`${stateObserver._key}_randomKey_value`); expect(job.observer).toBe(stateObserver); expect(job.config).toStrictEqual({ background: false, @@ -174,6 +179,7 @@ describe('StateObserver Tests', () => { force: false, storage: true, overwrite: false, + maxTriesToUpdate: 3, }); }); @@ -204,6 +210,7 @@ describe('StateObserver Tests', () => { force: true, storage: true, overwrite: true, + maxTriesToUpdate: 5, }); }); @@ -215,6 +222,7 @@ describe('StateObserver Tests', () => { }, overwrite: true, key: 'dummyJob', + maxTriesToUpdate: 5, }); expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); @@ -247,7 +255,7 @@ describe('StateObserver Tests', () => { jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); dummyState._value = 'updatedDummyValue'; dummyAgile.runtime.ingest = jest.fn((job: StateRuntimeJob) => { - expect(job._key).toBe(`${stateObserver._key}_randomKey`); + expect(job._key).toBe(`${stateObserver._key}_randomKey_value`); expect(job.observer).toBe(stateObserver); expect(job.config).toStrictEqual({ background: false, @@ -258,6 +266,7 @@ describe('StateObserver Tests', () => { force: true, storage: true, overwrite: false, + maxTriesToUpdate: 3, }); }); @@ -276,7 +285,7 @@ describe('StateObserver Tests', () => { it('should ingest placeholder State into the Runtime (default config)', () => { jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); dummyAgile.runtime.ingest = jest.fn((job: StateRuntimeJob) => { - expect(job._key).toBe(`${stateObserver._key}_randomKey`); + expect(job._key).toBe(`${stateObserver._key}_randomKey_value`); expect(job.observer).toBe(stateObserver); expect(job.config).toStrictEqual({ background: false, @@ -287,6 +296,7 @@ describe('StateObserver Tests', () => { force: true, storage: true, overwrite: true, + maxTriesToUpdate: 3, }); }); dummyState.isPlaceholder = true; @@ -303,8 +313,8 @@ describe('StateObserver Tests', () => { }); it( - 'should ingest the State into the Runtime and compute the new value ' + - 'if the State compute function is set (default config)', + 'should ingest the State into the Runtime and compute its new value ' + + 'if the State has a set compute function (default config)', () => { dummyState.computeValueMethod = (value) => `cool value '${value}'`; @@ -341,9 +351,6 @@ describe('StateObserver Tests', () => { dummyJob.observer.value = 'dummyValue'; dummyState.initialStateValue = 'initialValue'; dummyState._value = 'dummyValue'; - dummyState.getPublicValue = jest - .fn() - .mockReturnValueOnce('newPublicValue'); stateObserver.perform(dummyJob); @@ -353,7 +360,7 @@ describe('StateObserver Tests', () => { expect(dummyState.nextStateValue).toBe('newValue'); expect(dummyState.isSet).toBeTruthy(); - expect(stateObserver.value).toBe('newPublicValue'); + expect(stateObserver.value).toBe('newValue'); expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver.sideEffects).toHaveBeenCalledWith(dummyJob); }); @@ -365,9 +372,6 @@ describe('StateObserver Tests', () => { dummyState.isPlaceholder = true; dummyState.initialStateValue = 'overwriteValue'; dummyState._value = 'dummyValue'; - dummyState.getPublicValue = jest - .fn() - .mockReturnValueOnce('newPublicValue'); stateObserver.perform(dummyJob); @@ -378,7 +382,7 @@ describe('StateObserver Tests', () => { expect(dummyState.isSet).toBeFalsy(); expect(dummyState.isPlaceholder).toBeFalsy(); - expect(stateObserver.value).toBe('newPublicValue'); + expect(stateObserver.value).toBe('newValue'); expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver.sideEffects).toHaveBeenCalledWith(dummyJob); }); @@ -391,9 +395,6 @@ describe('StateObserver Tests', () => { dummyJob.observer.value = 'dummyValue'; dummyState.initialStateValue = 'newValue'; dummyState._value = 'dummyValue'; - dummyState.getPublicValue = jest - .fn() - .mockReturnValueOnce('newPublicValue'); stateObserver.perform(dummyJob); @@ -403,7 +404,7 @@ describe('StateObserver Tests', () => { expect(dummyState.nextStateValue).toBe('newValue'); expect(dummyState.isSet).toBeFalsy(); - expect(stateObserver.value).toBe('newPublicValue'); + expect(stateObserver.value).toBe('newValue'); expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver.sideEffects).toHaveBeenCalledWith(dummyJob); } diff --git a/packages/core/tests/unit/state/state.runtime.job.test.ts b/packages/core/tests/unit/state/state.runtime.job.test.ts index c5465182..58906f56 100644 --- a/packages/core/tests/unit/state/state.runtime.job.test.ts +++ b/packages/core/tests/unit/state/state.runtime.job.test.ts @@ -46,6 +46,7 @@ describe('RuntimeJob Tests', () => { force: false, storage: true, overwrite: false, + maxTriesToUpdate: 3, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); @@ -67,6 +68,7 @@ describe('RuntimeJob Tests', () => { enabled: false, }, force: true, + maxTriesToUpdate: 5, }); expect(job._key).toBe('dummyJob'); @@ -79,6 +81,7 @@ describe('RuntimeJob Tests', () => { force: true, storage: true, overwrite: false, + maxTriesToUpdate: 5, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); @@ -105,6 +108,7 @@ describe('RuntimeJob Tests', () => { force: false, storage: true, overwrite: false, + maxTriesToUpdate: 3, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); @@ -133,6 +137,7 @@ describe('RuntimeJob Tests', () => { force: false, storage: true, overwrite: false, + maxTriesToUpdate: 3, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); diff --git a/packages/core/tests/unit/state/state.test.ts b/packages/core/tests/unit/state/state.test.ts index b3914e79..3bfe253f 100644 --- a/packages/core/tests/unit/state/state.test.ts +++ b/packages/core/tests/unit/state/state.test.ts @@ -38,9 +38,9 @@ describe('State Tests', () => { expect(state._value).toBe('coolValue'); expect(state.previousStateValue).toBe('coolValue'); expect(state.nextStateValue).toBe('coolValue'); - expect(state.observer).toBeInstanceOf(StateObserver); - expect(state.observer.dependents.size).toBe(0); - expect(state.observer._key).toBeUndefined(); + expect(state.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(state.observers['value'].dependents)).toStrictEqual([]); + expect(state.observers['value']._key).toBeUndefined(); expect(state.sideEffects).toStrictEqual({}); expect(state.computeValueMethod).toBeUndefined(); expect(state.computeExistsMethod).toBeInstanceOf(Function); @@ -69,10 +69,11 @@ describe('State Tests', () => { expect(state._value).toBe('coolValue'); expect(state.previousStateValue).toBe('coolValue'); expect(state.nextStateValue).toBe('coolValue'); - expect(state.observer).toBeInstanceOf(StateObserver); - expect(state.observer.dependents.size).toBe(1); - expect(state.observer.dependents.has(dummyObserver)).toBeTruthy(); - expect(state.observer._key).toBe('coolState'); + expect(state.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(state.observers['value'].dependents)).toStrictEqual([ + dummyObserver, + ]); + expect(state.observers['value']._key).toBe('coolState'); expect(state.sideEffects).toStrictEqual({}); expect(state.computeValueMethod).toBeUndefined(); expect(state.computeExistsMethod).toBeInstanceOf(Function); @@ -96,9 +97,9 @@ describe('State Tests', () => { expect(state._value).toBe('coolValue'); expect(state.previousStateValue).toBe('coolValue'); expect(state.nextStateValue).toBe('coolValue'); - expect(state.observer).toBeInstanceOf(StateObserver); - expect(state.observer.dependents.size).toBe(0); - expect(state.observer._key).toBeUndefined(); + expect(state.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(state.observers['value'].dependents)).toStrictEqual([]); + expect(state.observers['value']._key).toBeUndefined(); expect(state.sideEffects).toStrictEqual({}); expect(state.computeValueMethod).toBeUndefined(); expect(state.computeExistsMethod).toBeInstanceOf(Function); @@ -153,7 +154,7 @@ describe('State Tests', () => { expect(value).toBe(10); expect(ComputedTracker.tracked).toHaveBeenCalledWith( - numberState.observer + numberState.observers['value'] ); }); }); @@ -175,8 +176,12 @@ describe('State Tests', () => { }); describe('setKey function tests', () => { + let dummyOutputObserver: Observer; + beforeEach(() => { + dummyOutputObserver = new StateObserver(numberState, { key: 'oldKey' }); numberState.persistent = new StatePersistent(numberState); + numberState.observers['output'] = dummyOutputObserver; numberState.persistent.setKey = jest.fn(); }); @@ -188,7 +193,8 @@ describe('State Tests', () => { numberState.setKey('newKey'); expect(numberState._key).toBe('newKey'); - expect(numberState.observer._key).toBe('newKey'); + expect(numberState.observers['value']._key).toBe('newKey'); + expect(numberState.observers['output']._key).toBe('newKey'); expect(numberState.persistent?.setKey).toHaveBeenCalledWith('newKey'); }); @@ -198,7 +204,8 @@ describe('State Tests', () => { numberState.setKey('newKey'); expect(numberState._key).toBe('newKey'); - expect(numberState.observer._key).toBe('newKey'); + expect(numberState.observers['value']._key).toBe('newKey'); + expect(numberState.observers['output']._key).toBe('newKey'); expect(numberState.persistent?.setKey).not.toHaveBeenCalled(); }); @@ -209,14 +216,15 @@ describe('State Tests', () => { numberState.setKey(undefined); expect(numberState._key).toBeUndefined(); - expect(numberState.observer._key).toBeUndefined(); + expect(numberState.observers['value']._key).toBeUndefined(); + expect(numberState.observers['output']._key).toBeUndefined(); expect(numberState.persistent?.setKey).not.toHaveBeenCalled(); }); }); describe('set function tests', () => { beforeEach(() => { - jest.spyOn(numberState.observer, 'ingestValue'); + jest.spyOn(numberState.observers['value'], 'ingestValue'); }); it('should ingestValue if value has correct type (default config)', () => { @@ -224,9 +232,12 @@ describe('State Tests', () => { LogMock.hasNotLogged('warn'); LogMock.hasNotLogged('error'); - expect(numberState.observer.ingestValue).toHaveBeenCalledWith(20, { - force: false, - }); + expect(numberState.observers['value'].ingestValue).toHaveBeenCalledWith( + 20, + { + force: false, + } + ); }); it('should ingestValue if passed function returns value with correct type (default config)', () => { @@ -234,9 +245,12 @@ describe('State Tests', () => { LogMock.hasNotLogged('warn'); LogMock.hasNotLogged('error'); - expect(numberState.observer.ingestValue).toHaveBeenCalledWith(30, { - force: false, - }); + expect(numberState.observers['value'].ingestValue).toHaveBeenCalledWith( + 30, + { + force: false, + } + ); }); it('should ingestValue if value has correct type (specific config)', () => { @@ -250,14 +264,17 @@ describe('State Tests', () => { LogMock.hasNotLogged('warn'); LogMock.hasNotLogged('error'); - expect(numberState.observer.ingestValue).toHaveBeenCalledWith(20, { - sideEffects: { - enabled: false, - }, - background: true, - storage: false, - force: false, - }); + expect(numberState.observers['value'].ingestValue).toHaveBeenCalledWith( + 20, + { + sideEffects: { + enabled: false, + }, + background: true, + storage: false, + force: false, + } + ); }); it("shouldn't ingestValue if value hasn't correct type (default config)", () => { @@ -267,7 +284,9 @@ describe('State Tests', () => { LogMock.hasNotLogged('warn'); LogMock.hasLoggedCode('14:03:00', ['string', 'number']); - expect(numberState.observer.ingestValue).not.toHaveBeenCalled(); + expect( + numberState.observers['value'].ingestValue + ).not.toHaveBeenCalled(); }); it("should ingestValue if value hasn't correct type (config.force = true)", () => { @@ -278,7 +297,7 @@ describe('State Tests', () => { LogMock.hasNotLogged('error'); LogMock.hasLoggedCode('14:02:00', ['string', 'number']); expect( - numberState.observer.ingestValue + numberState.observers['value'].ingestValue ).toHaveBeenCalledWith('coolValue', { force: true }); }); @@ -288,20 +307,20 @@ describe('State Tests', () => { LogMock.hasNotLogged('warn'); LogMock.hasNotLogged('error'); expect( - numberState.observer.ingestValue + numberState.observers['value'].ingestValue ).toHaveBeenCalledWith('coolValue', { force: false }); }); }); describe('ingest function tests', () => { beforeEach(() => { - numberState.observer.ingest = jest.fn(); + numberState.observers['value'].ingest = jest.fn(); }); it('should call ingest function in Observer (default config)', () => { numberState.ingest(); - expect(numberState.observer.ingest).toHaveBeenCalledWith({}); + expect(numberState.observers['value'].ingest).toHaveBeenCalledWith({}); }); it('should call ingest function in Observer (specific config)', () => { @@ -310,7 +329,7 @@ describe('State Tests', () => { background: true, }); - expect(numberState.observer.ingest).toHaveBeenCalledWith({ + expect(numberState.observers['value'].ingest).toHaveBeenCalledWith({ background: true, force: true, }); @@ -1075,17 +1094,5 @@ describe('State Tests', () => { expect(numberState.hasCorrectType('stringValue')).toBeTruthy(); }); }); - - describe('getPublicValue function tests', () => { - it('should return value of State', () => { - expect(numberState.getPublicValue()).toBe(10); - }); - - it('should return output of State', () => { - numberState['output'] = 99; - - expect(numberState.getPublicValue()).toBe(99); - }); - }); }); }); diff --git a/packages/core/tests/unit/utils.test.ts b/packages/core/tests/unit/utils.test.ts index 06ec3141..db4bd7e3 100644 --- a/packages/core/tests/unit/utils.test.ts +++ b/packages/core/tests/unit/utils.test.ts @@ -1,13 +1,14 @@ import { globalBind, getAgileInstance, - extractObservers, Agile, State, Observer, Collection, StateObserver, + GroupObserver, } from '../../src'; +import * as Utils from '../../src/utils'; import { LogMock } from '../helper/logMock'; describe('Utils Tests', () => { @@ -62,48 +63,292 @@ describe('Utils Tests', () => { }); describe('extractObservers function tests', () => { + // Observer 1 let dummyObserver: Observer; + + // Observer 2 let dummyObserver2: Observer; + + // State with one Observer let dummyStateObserver: StateObserver; let dummyState: State; - let dummyDefaultGroupObserver: StateObserver; + + // State with multiple Observer + let dummyStateWithMultipleObserver: State; + let dummyStateValueObserver: StateObserver; + let dummyStateRandomObserver: StateObserver; + + // Collection let dummyCollection: Collection; + let dummyDefaultGroupValueObserver: StateObserver; + let dummyDefaultGroupOutputObserver: GroupObserver; beforeEach(() => { + // Observer 1 dummyObserver = new Observer(dummyAgile); + + // Observer 2 dummyObserver2 = new Observer(dummyAgile); - dummyState = new State(dummyAgile, undefined); + // State with one Observer + dummyState = new State(dummyAgile, null); dummyStateObserver = new StateObserver(dummyState); - dummyState.observer = dummyStateObserver; - + dummyState.observers['value'] = dummyStateObserver; + + // State with multiple Observer + dummyStateWithMultipleObserver = new State(dummyAgile, null); + dummyStateValueObserver = new StateObserver(dummyState); + dummyStateWithMultipleObserver.observers[ + 'value' + ] = dummyStateValueObserver; + dummyStateRandomObserver = new StateObserver(dummyState); + dummyStateWithMultipleObserver.observers[ + 'random' + ] = dummyStateRandomObserver; + + // Collection dummyCollection = new Collection(dummyAgile); const defaultGroup = dummyCollection.groups[dummyCollection.config.defaultGroupKey]; - dummyDefaultGroupObserver = new StateObserver(defaultGroup); - defaultGroup.observer = dummyDefaultGroupObserver; + dummyDefaultGroupValueObserver = new StateObserver(defaultGroup); + defaultGroup.observers['value'] = dummyDefaultGroupValueObserver; + dummyDefaultGroupOutputObserver = new GroupObserver(defaultGroup); + defaultGroup.observers['output'] = dummyDefaultGroupOutputObserver; }); - it('should extract Observers from passed Instances', () => { - const response = extractObservers([ + it('should extract Observer from specified Instance', () => { + const response = Utils.extractObservers(dummyState); + + expect(response).toStrictEqual({ value: dummyStateObserver }); + }); + + it('should extract Observers from specified Instances', () => { + const response = Utils.extractObservers([ + // Observer 1 dummyObserver, + + // State with one Observer dummyState, + undefined, {}, + + // State with multiple Observer + dummyStateWithMultipleObserver, + { observer: 'fake' }, + + // Collection dummyCollection, + + // Observer 2 { observer: dummyObserver2 }, ]); expect(response).toStrictEqual([ - dummyObserver, - dummyStateObserver, + // Observer 1 + { value: dummyObserver }, + + // State with one Observer + { value: dummyStateObserver }, + + {}, + {}, + + // State with multiple Observer + { value: dummyStateValueObserver, random: dummyStateRandomObserver }, + + {}, + + // Collection + { + value: dummyDefaultGroupValueObserver, + output: dummyDefaultGroupOutputObserver, + }, + + // Observer 2 + { value: dummyObserver2 }, + ]); + }); + }); + + describe('extractRelevantObservers function tests', () => { + // State with one Observer + let dummyStateObserver: StateObserver; + let dummyState: State; + + // State with multiple Observer + let dummyStateWithMultipleObserver: State; + let dummyStateValueObserver: StateObserver; + let dummyStateRandomObserver: StateObserver; + + // Collection + let dummyCollection: Collection; + let dummyDefaultGroupValueObserver: StateObserver; + let dummyDefaultGroupOutputObserver: GroupObserver; + + beforeEach(() => { + // State with one Observer + dummyState = new State(dummyAgile, null); + dummyStateObserver = new StateObserver(dummyState); + dummyState.observers['value'] = dummyStateObserver; + + // State with multiple Observer + dummyStateWithMultipleObserver = new State(dummyAgile, null); + dummyStateValueObserver = new StateObserver(dummyState); + dummyStateWithMultipleObserver.observers[ + 'value' + ] = dummyStateValueObserver; + dummyStateRandomObserver = new StateObserver(dummyState); + dummyStateWithMultipleObserver.observers[ + 'random' + ] = dummyStateRandomObserver; + + // Collection + dummyCollection = new Collection(dummyAgile); + const defaultGroup = + dummyCollection.groups[dummyCollection.config.defaultGroupKey]; + dummyDefaultGroupValueObserver = new StateObserver(defaultGroup); + defaultGroup.observers['value'] = dummyDefaultGroupValueObserver; + dummyDefaultGroupOutputObserver = new GroupObserver(defaultGroup); + defaultGroup.observers['output'] = dummyDefaultGroupOutputObserver; + + jest.spyOn(Utils, 'extractObservers'); + }); + + it('should extract Observers at the specified observerType from the Instances (array shape)', () => { + const response = Utils.extractRelevantObservers( + [ + dummyState, + dummyStateWithMultipleObserver, + undefined, + dummyCollection, + ], + 'output' + ); + + expect(response).toStrictEqual([ + undefined, undefined, undefined, + dummyDefaultGroupOutputObserver, + ]); + + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyState); + // expect(Utils.extractObservers).toHaveBeenCalledWith( + // dummyStateWithMultipleObserver + // ); + // expect(Utils.extractObservers).toHaveBeenCalledWith(undefined); + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyCollection); + }); + + it('should extract the most relevant Observer from the Instances (array shape)', () => { + const response = Utils.extractRelevantObservers([ + dummyState, + dummyStateWithMultipleObserver, + undefined, + dummyCollection, + ]); + + expect(response).toStrictEqual([ + dummyStateObserver, + dummyStateValueObserver, undefined, - dummyDefaultGroupObserver, - dummyObserver2, + dummyDefaultGroupOutputObserver, ]); + + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyState); + // expect(Utils.extractObservers).toHaveBeenCalledWith( + // dummyStateWithMultipleObserver + // ); + // expect(Utils.extractObservers).toHaveBeenCalledWith(undefined); + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyCollection); + }); + + it('should extract Observers at the specified observerType from the Instances (object shape)', () => { + const response = Utils.extractRelevantObservers( + { + dummyState, + dummyStateWithMultipleObserver, + undefinedObserver: undefined, + dummyCollection, + }, + 'output' + ); + + expect(response).toStrictEqual({ + dummyState: undefined, + dummyStateWithMultipleObserver: undefined, + undefinedObserver: undefined, + dummyCollection: dummyDefaultGroupOutputObserver, + }); + + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyState); + // expect(Utils.extractObservers).toHaveBeenCalledWith( + // dummyStateWithMultipleObserver + // ); + // expect(Utils.extractObservers).toHaveBeenCalledWith(undefined); + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyCollection); + }); + + it('should extract the most relevant Observer from the Instances (object shape)', () => { + const response = Utils.extractRelevantObservers({ + dummyState, + dummyStateWithMultipleObserver, + undefinedObserver: undefined, + dummyCollection, + }); + + expect(response).toStrictEqual({ + dummyState: dummyStateObserver, + dummyStateWithMultipleObserver: dummyStateValueObserver, + undefinedObserver: undefined, + dummyCollection: dummyDefaultGroupOutputObserver, + }); + + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyState); + // expect(Utils.extractObservers).toHaveBeenCalledWith( + // dummyStateWithMultipleObserver + // ); + // expect(Utils.extractObservers).toHaveBeenCalledWith(undefined); + // expect(Utils.extractObservers).toHaveBeenCalledWith(dummyCollection); + }); + }); + + describe('optionalRequire function tests', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it("should return null if to retrieve package doesn't exist (error = false)", () => { + const response = Utils.optionalRequire('@notExisting/package', false); + + expect(response).toBeNull(); + LogMock.hasNotLoggedCode('20:03:02', ['@notExisting/package']); + }); + + it("should return null and print error if to retrieve package doesn't exist (error = true)", () => { + const response = Utils.optionalRequire('@notExisting/package', true); + + expect(response).toBeNull(); + LogMock.hasLoggedCode('20:03:02', ['@notExisting/package']); + }); + + it('should return package if to retrieve package exists', () => { + // Create fake package + const notExistingPackage = 'hehe fake package'; + jest.mock( + '@notExisting/package', + () => { + return notExistingPackage; + }, + { virtual: true } + ); + + const response = Utils.optionalRequire('@notExisting/package'); + + expect(response).toBe(notExistingPackage); + LogMock.hasNotLoggedCode('20:03:02', ['@notExisting/package']); }); }); @@ -114,13 +359,13 @@ describe('Utils Tests', () => { globalThis[dummyKey] = undefined; }); - it('should bind instance at key globally (default config)', () => { + it('should bind Instance globally at the specified key (default config)', () => { globalBind(dummyKey, 'dummyInstance'); expect(globalThis[dummyKey]).toBe('dummyInstance'); }); - it("shouldn't overwrite already existing instance at key (default config)", () => { + it("shouldn't overwrite already globally bound Instance at the same key (default config)", () => { globalBind(dummyKey, 'I am first!'); globalBind(dummyKey, 'dummyInstance'); @@ -128,7 +373,7 @@ describe('Utils Tests', () => { expect(globalThis[dummyKey]).toBe('I am first!'); }); - it('should overwrite already existing instance at key (overwrite = true)', () => { + it('should overwrite already globally bound Instance at the same key (overwrite = true)', () => { globalBind(dummyKey, 'I am first!'); globalBind(dummyKey, 'dummyInstance', true); diff --git a/packages/multieditor/src/multieditor.ts b/packages/multieditor/src/multieditor.ts index 107a98b9..5a08d585 100644 --- a/packages/multieditor/src/multieditor.ts +++ b/packages/multieditor/src/multieditor.ts @@ -114,7 +114,7 @@ export class MultiEditor< const deps: Array = []; for (const key in this.data) { const item = this.data[key]; - deps.push(item.observer); + deps.push(item.observers['value']); deps.push(item.status.observer); } return deps; @@ -132,7 +132,7 @@ export class MultiEditor< const deps: Array = []; const item = this.getItemById(key); if (item) { - deps.push(item.observer); + deps.push(item.observers['value']); deps.push(item.status.observer); } return deps; diff --git a/packages/react/package.json b/packages/react/package.json index 30a39b2d..7b8cdd9e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -45,11 +45,20 @@ }, "peerDependencies": { "react": "^16.13.1", - "@agile-ts/core": "^0.0.17" - }, - "dependencies": { + "@agile-ts/core": "^0.0.17", "@agile-ts/proxytree": "^0.0.3" }, + "peerDependenciesMeta": { + "react": { + "optional": false + }, + "agile-ts/core": { + "optional": false + }, + "@agile-ts/proxytree": { + "optional": true + } + }, "publishConfig": { "access": "public" }, diff --git a/packages/react/src/hocs/AgileHOC.ts b/packages/react/src/hocs/AgileHOC.ts index 189271d9..0a4261f2 100644 --- a/packages/react/src/hocs/AgileHOC.ts +++ b/packages/react/src/hocs/AgileHOC.ts @@ -8,18 +8,22 @@ import { Collection, isValidObject, flatMerge, - extractObservers, + extractRelevantObservers, + normalizeArray, } from '@agile-ts/core'; -//========================================================================================================= -// AgileHOC -//========================================================================================================= /** + * A Higher order Component for binding the most relevant value of multiple Agile Instances + * (like the Collection's output or the State's value) + * to a React Class Component. + * + * This binding ensures that the Component re-renders + * whenever the most relevant value Observer of an Agile Instance mutates. + * * @public - * React HOC that binds Agile Instance like Collection, State, Computed, .. to a React Functional or Class Component - * @param reactComponent - React Component to which the deps get bound - * @param deps - Agile Instances that gets bind to the React Component - * @param agileInstance - Agile Instance + * @param reactComponent - React Component to which the specified deps should be bound. + * @param deps - Agile Instances to be bound to the Class Component. + * @param agileInstance - Instance of Agile the React Component belongs to. */ export function AgileHOC( reactComponent: ComponentClass, @@ -29,7 +33,7 @@ export function AgileHOC( let depsWithoutIndicator: Array = []; let depsWithIndicator: DepsWithIndicatorType; - // Format Deps + // Format deps if (isValidObject(deps)) { depsWithIndicator = formatDepsWithIndicator(deps as any); } else { @@ -38,16 +42,13 @@ export function AgileHOC( depsWithoutIndicator = response.depsWithoutIndicator; } - // Try to get Agile Instance + // Try to extract Agile Instance from the specified Instance/s if (!agileInstance) { - // From deps without Indicator if (depsWithoutIndicator.length > 0) { for (const dep of depsWithoutIndicator) { if (!agileInstance) agileInstance = getAgileInstance(dep); } } - - // From deps with Indicator if (!agileInstance) { for (const depKey in depsWithIndicator) { if (!agileInstance) @@ -55,13 +56,12 @@ export function AgileHOC( } } } - - // If no Agile Instance found drop Error if (!agileInstance || !agileInstance.subController) { Agile.logger.error('Failed to subscribe Component with deps', deps); return reactComponent; } + // Wrap a HOC around the specified Component return createHOC( reactComponent, agileInstance, @@ -70,16 +70,15 @@ export function AgileHOC( ); } -//========================================================================================================= -// Create HOC -//========================================================================================================= /** + * Wraps a Higher order Component around the specified React Component + * to bind the provided dependencies to the Component. + * * @internal - * Creates Higher Order Component based on passed React Component that binds the deps to it - * @param ReactComponent - React Component - * @param agileInstance - Instance of Agile - * @param depsWithoutIndicator - Deps that have no Indicator - * @param depsWithIndicator - Deps that have an Indicator and get merged into the props of the React Component + * @param ReactComponent - React Component to wrap the HOC around. + * @param agileInstance - Instance of Agile the React Component belongs to. + * @param depsWithoutIndicator - Dependencies that have no safe unique key/name indicator. + * @param depsWithIndicator - Dependencies that have a unique key/name indicator. */ const createHOC = ( ReactComponent: ComponentClass, @@ -93,8 +92,8 @@ const createHOC = ( public componentSubscriptionContainers: Array< ComponentSubscriptionContainer - > = []; // Will be set and used in sub.ts - public agileProps = {}; // Props from Agile (get merged into normal Props) + > = []; // Represents all Subscription Container subscribed to this Component (set by subController) + public agileProps = {}; // Props of subscribed Agile Instances (are merged into the normal props) constructor(props: any) { super(props); @@ -102,19 +101,24 @@ const createHOC = ( this.waitForMount = agileInstance.config.waitForMount; } - // We have to go the 'UNSAFE' way because the constructor of a React Component gets called twice - // And because of that the subscriptionContainers get created twice (not clean) - // We could generate a id for each component but this would also happen in the constructor so idk + // We have to go the 'UNSAFE' way because the 'constructor' of a React Component is called twice. + // Thus it would create the corresponding Subscription Container twice, + // what should be avoided. // https://github.com/facebook/react/issues/12906 UNSAFE_componentWillMount() { - // Create Subscription with Observer that have no Indicator and can't be merged into 'this.state' (Rerender will be caused via force Update) + // Create Subscription with extracted Observers + // that have no unique key/name indicator + // and thus can't be merged into the 'this.state' property. + // (Re-render will be enforced via a force update) if (depsWithoutIndicator.length > 0) { this.agileInstance.subController.subscribe(this, depsWithoutIndicator, { waitForMount: this.waitForMount, }); } - // Create Subscription with Observer that have an Indicator (Rerender will be cause via mutating 'this.state') + // Create Subscription with extracted Observers + // that have a unique key/name indicator. + // (Rerender will be enforced via mutating the 'this.state' property) if (depsWithIndicator) { const response = this.agileInstance.subController.subscribe( this, @@ -122,8 +126,6 @@ const createHOC = ( { waitForMount: this.waitForMount } ); this.agileProps = response.props; - - // Merge depsWith Indicator into this.state this.setState(flatMerge(this.state || {}, depsWithIndicator)); } } @@ -145,30 +147,44 @@ const createHOC = ( }; }; -//========================================================================================================= -// Format Deps With No Safe Indicator -//========================================================================================================= /** + * Extracts the Observers from the specified dependencies + * which probably have no safe unique indicator key/name. + * + * If a unique key/name indicator could be found + * the extracted Observer is added to the `depsWithIndicator` object + * and otherwise to the `depsWithoutIndicator` array. + * + * What type of Observer is extracted from a dependency, + * depends on the specified `observerType`. + * If no `observerType` is specified, the Observers found in the dependency + * are selected in the following `observerType` order. + * 1. `output` + * 2. `value` + * * @internal - * Extract Observers from dependencies which might not have an indicator. - * If a indicator could be found it will be added to 'depsWithIndicator' otherwise to 'depsWithoutIndicator'. - * @param deps - Dependencies to be formatted + * @param deps - Dependencies in array shape to extract the Observers from. + * @param observerType - Type of Observer to be extracted. */ const formatDepsWithNoSafeIndicator = ( - deps: Array | SubscribableAgileInstancesType + deps: Array | SubscribableAgileInstancesType, + observerType?: string ): { depsWithoutIndicator: Observer[]; depsWithIndicator: DepsWithIndicatorType; } => { - const depsArray = extractObservers(deps); + const depsArray = extractRelevantObservers( + normalizeArray(deps), + observerType + ); const depsWithIndicator: DepsWithIndicatorType = {}; let depsWithoutIndicator: Observer[] = depsArray.filter( (dep): dep is Observer => dep !== undefined ); - // Add deps with key to 'depsWithIndicator' and remove them from 'depsWithoutIndicator' + // Try to extract a unique key/name identifiers from the extracted Observers depsWithoutIndicator = depsWithoutIndicator.filter((dep) => { - if (dep && dep['key']) { + if (dep && dep['key'] != null) { depsWithIndicator[dep['key']] = dep; return false; } @@ -181,31 +197,41 @@ const formatDepsWithNoSafeIndicator = ( }; }; -//========================================================================================================= -// Format Deps With Indicator -//========================================================================================================= /** + * Extracts the Observers from the specified dependencies + * which have a unique key/name identifier + * through the object property key. + * + * What type of Observer is extracted from a dependency, + * depends on the specified `observerType`. + * If no `observerType` is specified, the Observers found in the dependency + * are selected in the following `observerType` order. + * 1. `output` + * 2. `value` + * * @internal - * Extract Observers from dependencies which have an indicator through the object property key. - * @param deps - Dependencies to be formatted + * @param deps - Dependencies in object shape to extract the Observers from. + * @param observerType - Type of Observer to be extracted. */ -const formatDepsWithIndicator = (deps: { - [key: string]: SubscribableAgileInstancesType; -}): DepsWithIndicatorType => { +const formatDepsWithIndicator = ( + deps: { + [key: string]: SubscribableAgileInstancesType; + }, + observerType?: string +): DepsWithIndicatorType => { + const depsObject = extractRelevantObservers(deps, observerType); const depsWithIndicator: DepsWithIndicatorType = {}; - - // Extract Observers from Deps - for (const depKey in deps) { - const observer = extractObservers(deps[depKey])[0]; - if (observer) depsWithIndicator[depKey] = observer; + for (const key in depsObject) { + const observer = depsObject[key]; + if (observer != null) depsWithIndicator[key] = observer; } - return depsWithIndicator; }; -// Copy of the HOC class to have an typesafe base in the react.integration +// Copy of the HOC Class to have a typesafe interface of the AgileHOC in the React Integration export class AgileReactComponent extends React.Component { - // public agileInstance: () => Agile; + // @ts-ignore + public agileInstance: Agile; public componentSubscriptionContainers: Array< ComponentSubscriptionContainer > = []; diff --git a/packages/react/src/hooks/useAgile.ts b/packages/react/src/hooks/useAgile.ts index bafcce1d..a70793ae 100644 --- a/packages/react/src/hooks/useAgile.ts +++ b/packages/react/src/hooks/useAgile.ts @@ -3,8 +3,6 @@ import { Agile, Collection, getAgileInstance, - Group, - extractObservers, Observer, State, SubscriptionContainerKeyType, @@ -13,76 +11,114 @@ import { generateId, ProxyWeakMapType, ComponentIdType, + extractRelevantObservers, + SelectorWeakMapType, + SelectorMethodType, + optionalRequire, } from '@agile-ts/core'; import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; -import { ProxyTree } from '@agile-ts/proxytree'; +import { normalizeArray } from '@agile-ts/utils'; +import { AgileOutputHookArrayType, AgileOutputHookType } from './useOutput'; +const proxyPackage = optionalRequire('@agile-ts/proxytree'); -//========================================================================================================= -// useAgile -//========================================================================================================= /** - * React Hook that binds Agile Instances like Collections, States, Computeds, .. to a React Functional Component - * @param deps - Agile Instances that will be subscribed to this Component - * @param config - Config + * A React Hook for binding the most relevant value of multiple Agile Instances + * (like the Collection's output or the State's value) + * to a React Functional Component. + * + * This binding ensures that the Component re-renders + * whenever the most relevant Observer of an Agile Instance mutates. + * + * @public + * @param deps - Agile Instances to be bound to the Functional Component. + * @param config - Configuration object */ export function useAgile>( deps: X | [], config?: AgileHookConfigInterface -): AgileHookArrayType; - +): AgileOutputHookArrayType; /** - * React Hook that binds Agile Instance like Collection, State, Computed, .. to a React Functional Component - * @param dep - Agile Instance that will be subscribed to this Component - * @param config - Config + * A React Hook for binding the most relevant Agile Instance value + * (like the Collection's output or the State's value) + * to a React Functional Component. + * + * This binding ensures that the Component re-renders + * whenever the most relevant Observer of the Agile Instance mutates. + * + * @public + * @param dep - Agile Instance to be bound to the Functional Component. + * @param config - Configuration object */ export function useAgile( dep: X, config?: AgileHookConfigInterface -): AgileHookType; - +): AgileOutputHookType; export function useAgile< X extends Array, Y extends SubscribableAgileInstancesType >( deps: X | Y, config: AgileHookConfigInterface = {} -): AgileHookArrayType | AgileHookType { - const depsArray = extractObservers(deps); - const proxyTreeWeakMap = new WeakMap(); +): AgileOutputHookArrayType | AgileOutputHookType { config = defineConfig(config, { - proxyBased: false, key: generateId(), + proxyBased: false, agileInstance: null, + componentId: undefined, + observerType: undefined, }); + const depsArray = extractRelevantObservers( + normalizeArray(deps), + config.observerType + ); + const proxyTreeWeakMap = new WeakMap(); - // Creates Return Value of Hook, depending whether deps are in Array shape or not + // Builds return value, + // depending on whether the deps were provided in array shape or not const getReturnValue = ( depsArray: (Observer | undefined)[] - ): AgileHookArrayType | AgileHookType => { - const handleReturn = (dep: Observer | undefined): AgileHookType => { + ): AgileOutputHookArrayType | AgileOutputHookType => { + const handleReturn = ( + dep: Observer | undefined + ): AgileOutputHookType => { if (dep == null) return undefined as any; const value = dep.value; - // If proxyBased and value is of type object. - // Wrap a Proxy around the object to track the used properties - if (config.proxyBased && isValidObject(value, true)) { - const proxyTree = new ProxyTree(value); - proxyTreeWeakMap.set(dep, proxyTree); - return proxyTree.proxy; + if (proxyPackage != null) { + // If proxyBased and the value is of the type object. + // Wrap a Proxy around the object to track the accessed properties. + if (config.proxyBased && isValidObject(value, true)) { + const proxyTree = new proxyPackage.ProxyTree(value); + proxyTreeWeakMap.set(dep, proxyTree); + return proxyTree.proxy; + } + } else { + console.error( + 'In order to use the Agile proxy functionality, ' + + `the installation of an additional package called '@agile-ts/proxytree' is required!` + ); + } + + // If specified selector function and the value is of type object. + // Return the selected value. + // (Destroys the type of the useAgile hook, + // however the type is adjusted in the useSelector hook) + if (config.selector && isValidObject(value, true)) { + return config.selector(value); } return value; }; - // Handle single dep + // Handle single dep return value if (depsArray.length === 1 && !Array.isArray(deps)) { return handleReturn(depsArray[0]); } - // Handle deps array + // Handle deps array return value return depsArray.map((dep) => { return handleReturn(dep); - }) as AgileHookArrayType; + }) as AgileOutputHookArrayType; }; // Trigger State, used to force Component to rerender @@ -91,8 +127,13 @@ export function useAgile< useIsomorphicLayoutEffect(() => { let agileInstance = config.agileInstance; - // Try to get Agile Instance - if (!agileInstance) agileInstance = getAgileInstance(depsArray[0]); + // https://github.com/microsoft/TypeScript/issues/20812 + const observers: Observer[] = depsArray.filter( + (dep): dep is Observer => dep !== undefined + ); + + // Try to extract Agile Instance from the specified Instance/s + if (!agileInstance) agileInstance = getAgileInstance(observers[0]); if (!agileInstance || !agileInstance.subController) { Agile.logger.error( 'Failed to subscribe Component with deps because of missing valid Agile Instance.', @@ -101,18 +142,19 @@ export function useAgile< return; } - // https://github.com/microsoft/TypeScript/issues/20812 - const observers: Observer[] = depsArray.filter( - (dep): dep is Observer => dep !== undefined - ); - - // Build Proxy Path WeakMap Map based on the Proxy Tree WeakMap - // by extracting the routes of the Tree + // TODO Proxy doesn't work as expected when 'selecting' a not yet existing property. + // For example you select the 'user.data.name' property, but the 'user' object is undefined. + // -> No correct Proxy Path could be created on the Component mount, since the to select property doesn't exist + // -> Selector was created based on the not complete Proxy Path + // -> Component re-renders to often + // + // Build Proxy Path WeakMap based on the Proxy Tree WeakMap + // by extracting the routes from the Proxy Tree. // Building the Path WeakMap in the 'useIsomorphicLayoutEffect' - // because the 'useIsomorphicLayoutEffect' is called after the rerender - // -> In the Component used paths got successfully tracked + // because the 'useIsomorphicLayoutEffect' is called after the rerender. + // -> All used paths in the UI-Component were successfully tracked. const proxyWeakMap: ProxyWeakMapType = new WeakMap(); - if (config.proxyBased) { + if (config.proxyBased && proxyPackage != null) { for (const observer of observers) { const proxyTree = proxyTreeWeakMap.get(observer); if (proxyTree != null) { @@ -123,6 +165,14 @@ export function useAgile< } } + // Build Selector WeakMap based on the specified selector method + const selectorWeakMap: SelectorWeakMapType = new WeakMap(); + if (config.selector != null) { + for (const observer of observers) { + selectorWeakMap.set(observer, { methods: [config.selector] }); + } + } + // Create Callback based Subscription const subscriptionContainer = agileInstance.subController.subscribe( () => { @@ -134,10 +184,11 @@ export function useAgile< proxyWeakMap, waitForMount: false, componentId: config.componentId, + selectorWeakMap: selectorWeakMap, } ); - // Unsubscribe Callback based Subscription on Unmount + // Unsubscribe Callback based Subscription on unmount return () => { agileInstance?.subController.unsubscribe(subscriptionContainer); }; @@ -146,53 +197,58 @@ export function useAgile< return getReturnValue(depsArray); } -// Array Type -// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html -export type AgileHookArrayType = { - [K in keyof T]: T[K] extends Collection | Group - ? U[] - : T[K] extends State | Observer - ? U - : T[K] extends undefined - ? undefined - : T[K] extends Collection | Group | undefined - ? U[] | undefined - : T[K] extends State | Observer | undefined - ? U | undefined - : never; -}; - -// No Array Type -export type AgileHookType = T extends Collection | Group - ? U[] - : T extends State | Observer - ? U - : T extends undefined - ? undefined - : T extends Collection | Group | undefined - ? U[] | undefined - : T extends State | Observer | undefined - ? U | undefined - : never; - export type SubscribableAgileInstancesType = | State | Collection //https://stackoverflow.com/questions/66987727/type-classa-id-number-name-string-is-not-assignable-to-type-classar | Observer | undefined; -/** - * @param key - Key/Name of SubscriptionContainer that is created - * @param agileInstance - Instance of Agile - * @param proxyBased - If useAgile() should only rerender the Component when a used property mutates - */ -interface AgileHookConfigInterface { +export interface AgileHookConfigInterface { + /** + * Key/Name identifier of the Subscription Container to be created. + * @default undefined + */ key?: SubscriptionContainerKeyType; + /** + * Instance of Agile the Subscription Container belongs to. + * @default `undefined` if no Agile Instance could be extracted from the provided Instances. + */ agileInstance?: Agile; + /** + * Whether to wrap a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + * around the bound Agile Instance value object, + * to automatically constrain the way the selected Agile Instance + * is compared to determine whether the Component needs to be re-rendered + * based on the object's used properties. + * + * Requires an additional package called `@agile-ts/proxytree`! + * + * @default false + */ proxyBased?: boolean; + /** + * Equality comparison function + * that allows you to customize the way the selected Agile Instance + * is compared to determine whether the Component needs to be re-rendered. + * @default undefined + */ + selector?: SelectorMethodType; + /** + * Key/Name identifier of the UI-Component the Subscription Container is bound to. + * + * Note that setting this property can destroy the useAgile type. + * -> should only be used internal! + * + * @default undefined + */ componentId?: ComponentIdType; -} - -interface ProxyTreeMapInterface { - [key: string]: ProxyTree; + /** + * What type of Observer to be bound to the UI-Component. + * + * Note that setting this property can destroy the useAgile type. + * -> should only be used internal! + * + * @default undefined + */ + observerType?: string; } diff --git a/packages/react/src/hooks/useOutput.ts b/packages/react/src/hooks/useOutput.ts new file mode 100644 index 00000000..85913b47 --- /dev/null +++ b/packages/react/src/hooks/useOutput.ts @@ -0,0 +1,57 @@ +import { Collection, Group, Observer, State } from '@agile-ts/core'; +import { + AgileHookConfigInterface, + SubscribableAgileInstancesType, + useAgile, +} from './useAgile'; + +export function useOutput>( + deps: X | [], + config?: AgileHookConfigInterface +): AgileOutputHookArrayType; + +export function useOutput( + dep: X, + config?: AgileHookConfigInterface +): AgileOutputHookType; + +export function useOutput< + X extends Array, + Y extends SubscribableAgileInstancesType +>( + deps: X | Y, + config: AgileHookConfigInterface = {} +): AgileOutputHookArrayType | AgileOutputHookType { + return useAgile(deps as any, { ...config, ...{ observerType: 'output' } }); +} + +// Array Type +// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html +export type AgileOutputHookArrayType = { + [K in keyof T]: T[K] extends Collection | Group + ? U[] + : T[K] extends State | Observer + ? U + : T[K] extends undefined + ? undefined + : T[K] extends Collection | Group | undefined + ? U[] | undefined + : T[K] extends State | Observer | undefined + ? U | undefined + : never; +}; + +// No Array Type +export type AgileOutputHookType = T extends + | Collection + | Group + ? U[] + : T extends State | Observer + ? U + : T extends undefined + ? undefined + : T extends Collection | Group | undefined + ? U[] | undefined + : T extends State | Observer | undefined + ? U | undefined + : never; diff --git a/packages/react/src/hooks/useProxy.ts b/packages/react/src/hooks/useProxy.ts index b997ccbd..265ffd6a 100644 --- a/packages/react/src/hooks/useProxy.ts +++ b/packages/react/src/hooks/useProxy.ts @@ -1,51 +1,26 @@ -import { Agile, SubscriptionContainerKeyType } from '@agile-ts/core'; import { - AgileHookArrayType, - AgileHookType, + AgileHookConfigInterface, SubscribableAgileInstancesType, useAgile, } from './useAgile'; +import { AgileOutputHookArrayType, AgileOutputHookType } from './useOutput'; -//========================================================================================================= -// useProxy -//========================================================================================================= -/** - * React Hook that binds Agile Instances like Collections, States, Computeds, .. to a React Functional Component - * and optimizes the rerender count by only rerendering the Component when an access property mutates. - * @param deps - Agile Instances that will be subscribed to this Component - * @param config - Config - */ export function useProxy>( deps: X | [], - config?: ProxyHookConfigInterface -): AgileHookArrayType; + config?: AgileHookConfigInterface +): AgileOutputHookArrayType; -/** - * React Hook that binds Agile Instance like Collection, State, Computed, .. to a React Functional Component - * and optimizes the rerender count by only rerendering the Component when an access property mutates. - * @param dep - Agile Instance that will be subscribed to this Component - * @param config - Config - */ export function useProxy( dep: X, - config?: ProxyHookConfigInterface -): AgileHookType; + config?: AgileHookConfigInterface +): AgileOutputHookType; export function useProxy< X extends Array, Y extends SubscribableAgileInstancesType >( deps: X | Y, - config: ProxyHookConfigInterface = {} -): AgileHookArrayType | AgileHookType { + config: AgileHookConfigInterface = {} +): AgileOutputHookArrayType | AgileOutputHookType { return useAgile(deps as any, { ...config, ...{ proxyBased: true } }); } - -/** - * @param key - Key/Name of SubscriptionContainer that is created - * @param agileInstance - Instance of Agile - */ -interface ProxyHookConfigInterface { - key?: SubscriptionContainerKeyType; - agileInstance?: Agile; -} diff --git a/packages/react/src/hooks/useSelector.ts b/packages/react/src/hooks/useSelector.ts new file mode 100644 index 00000000..d4697fff --- /dev/null +++ b/packages/react/src/hooks/useSelector.ts @@ -0,0 +1,35 @@ +import { + AgileHookConfigInterface, + SubscribableAgileInstancesType, + useAgile, +} from './useAgile'; +import { SelectorMethodType } from '@agile-ts/core'; +import { AgileValueHookType } from './useValue'; + +export function useSelector< + ReturnType, + X extends SubscribableAgileInstancesType, + ValueType extends AgileValueHookType +>( + deps: X, + selector: SelectorMethodType, + config?: AgileHookConfigInterface +): ReturnType; + +export function useSelector( + deps: SubscribableAgileInstancesType, + selector: SelectorMethodType, + config?: AgileHookConfigInterface +): ReturnType; + +export function useSelector< + X extends SubscribableAgileInstancesType, + ValueType extends AgileValueHookType, + ReturnType = any +>( + deps: X, + selector: SelectorMethodType, + config: AgileHookConfigInterface = {} +): ReturnType { + return useAgile(deps as any, { ...config, ...{ selector: selector } }) as any; +} diff --git a/packages/react/src/hooks/useValue.ts b/packages/react/src/hooks/useValue.ts new file mode 100644 index 00000000..21ba8228 --- /dev/null +++ b/packages/react/src/hooks/useValue.ts @@ -0,0 +1,65 @@ +import { Collection, Group, Observer, State } from '@agile-ts/core'; +import { + AgileHookConfigInterface, + SubscribableAgileInstancesType, + useAgile, +} from './useAgile'; + +export function useValue>( + deps: X | [], + config?: AgileHookConfigInterface +): AgileValueHookArrayType; + +export function useValue( + dep: X, + config?: AgileHookConfigInterface +): AgileValueHookType; + +export function useValue< + X extends Array, + Y extends SubscribableAgileInstancesType +>( + deps: X | Y, + config: AgileHookConfigInterface = {} +): AgileValueHookArrayType | AgileValueHookType { + return useAgile(deps as any, { + ...config, + ...{ observerType: 'value' }, + }) as any; +} + +// Array Type +// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html +export type AgileValueHookArrayType = { + [K in keyof T]: T[K] extends + | Collection + | Group + ? Z + : T[K] extends State | Observer + ? U + : T[K] extends undefined + ? undefined + : T[K] extends + | Collection + | Group + | undefined + ? Z | undefined + : T[K] extends State | Observer | undefined + ? U | undefined + : never; +}; + +// No Array Type +export type AgileValueHookType = T extends + | Collection + | Group + ? Z + : T extends State | Observer + ? U + : T extends undefined + ? undefined + : T extends Collection | Group | undefined + ? Z | undefined + : T extends State | Observer | undefined + ? U | undefined + : never; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 53a6132e..e4833ffe 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,5 +4,8 @@ export { useAgile } from './hooks/useAgile'; export { AgileHOC } from './hocs/AgileHOC'; export { useWatcher } from './hooks/useWatcher'; export { useProxy } from './hooks/useProxy'; +export { useOutput } from './hooks/useOutput'; +export { useValue } from './hooks/useValue'; +export { useSelector } from './hooks/useSelector'; export default reactIntegration; diff --git a/packages/react/tests/old/useAgile.spec.ts b/packages/react/tests/old/useAgile.spec.ts index 4c36b30b..1cd16fe7 100644 --- a/packages/react/tests/old/useAgile.spec.ts +++ b/packages/react/tests/old/useAgile.spec.ts @@ -46,4 +46,8 @@ const myCollection10 = useAgile(MY_COLLECTION); const myCollection11 = useAgile( new Collection<{ id: number; name: string }>(App) ); + +const myGroupValue = useValue(MY_COLLECTION.getGroup('test')); +const myGroupOutput = useOutput(MY_COLLECTION.getGroup('test')); +const myGroupAgile = useAgile(MY_COLLECTION.getGroup('test')); */ diff --git a/packages/vue/package.json b/packages/vue/package.json index 199434a4..4aca4445 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -37,6 +37,14 @@ "@agile-ts/core": "^0.0.17", "vue": "^2.6.12" }, + "peerDependenciesMeta": { + "vue": { + "optional": false + }, + "agile-ts/core": { + "optional": false + } + }, "publishConfig": { "access": "public" }, diff --git a/packages/vue/src/bindAgileInstances.ts b/packages/vue/src/bindAgileInstances.ts index 451c759d..73d5508e 100644 --- a/packages/vue/src/bindAgileInstances.ts +++ b/packages/vue/src/bindAgileInstances.ts @@ -2,37 +2,43 @@ import Vue from 'vue'; import { Agile, Collection, - extractObservers, + extractRelevantObservers, Observer, State, } from '@agile-ts/core'; -import { isValidObject } from '@agile-ts/utils'; +import { isValidObject, normalizeArray } from '@agile-ts/utils'; export function bindAgileInstances( deps: DepsType, agile: Agile, - vueComponent: Vue + vueComponent: Vue, + observerType?: string ): { [key: string]: any } { let depsWithoutIndicator: Array = []; let depsWithIndicator: DepsWithIndicatorType; - // Format Deps + // Format deps if (isValidObject(deps)) { - depsWithIndicator = formatDepsWithIndicator(deps as any); + depsWithIndicator = formatDepsWithIndicator(deps as any, observerType); } else { - const response = formatDepsWithNoSafeIndicator(deps as any); + const response = formatDepsWithNoSafeIndicator(deps as any, observerType); depsWithIndicator = response.depsWithIndicator; depsWithoutIndicator = response.depsWithoutIndicator; } - // Create Subscription with Observer that have no Indicator and can't be merged into the 'sharedState' (Rerender will be caused via force Update) + // Create Subscription with extracted Observers + // that have no unique key/name indicator + // and thus can't be merged into the 'sharedState' property. + // (Re-render will be enforced via a force update) if (depsWithoutIndicator.length > 0) { agile.subController.subscribe(vueComponent, depsWithoutIndicator, { waitForMount: false, }); } - // Create Subscription with Observer that have an Indicator (Rerender will be cause via mutating 'this.$data.sharedState') + // Create Subscription with extracted Observers + // that have a unique key/name indicator. + // (Rerender will be enforced via mutating the 'this.$data.sharedState' property) if (depsWithIndicator) { return agile.subController.subscribe(vueComponent, depsWithIndicator, { waitForMount: false, @@ -42,30 +48,44 @@ export function bindAgileInstances( return {}; } -//========================================================================================================= -// Format Deps With No Safe Indicator -//========================================================================================================= /** + * Extracts the Observers from the specified dependencies + * which probably have no safe unique indicator key/name. + * + * If a unique key/name indicator could be found + * the extracted Observer is added to the `depsWithIndicator` object + * and otherwise to the `depsWithoutIndicator` array. + * + * What type of Observer is extracted from a dependency, + * depends on the specified `observerType`. + * If no `observerType` is specified, the Observers found in the dependency + * are selected in the following `observerType` order. + * 1. `output` + * 2. `value` + * * @internal - * Extract Observers from dependencies which might not have an indicator. - * If a indicator could be found it will be added to 'depsWithIndicator' otherwise to 'depsWithoutIndicator'. - * @param deps - Dependencies to be formatted + * @param deps - Dependencies in array shape to extract the Observers from. + * @param observerType - Type of Observer to be extracted. */ const formatDepsWithNoSafeIndicator = ( - deps: Array | SubscribableAgileInstancesType + deps: Array | SubscribableAgileInstancesType, + observerType?: string ): { depsWithoutIndicator: Observer[]; depsWithIndicator: DepsWithIndicatorType; } => { - const depsArray = extractObservers(deps); + const depsArray = extractRelevantObservers( + normalizeArray(deps), + observerType + ); const depsWithIndicator: DepsWithIndicatorType = {}; let depsWithoutIndicator: Observer[] = depsArray.filter( (dep): dep is Observer => dep !== undefined ); - // Add deps with key to 'depsWithIndicator' and remove them from 'depsWithoutIndicator' + // Try to extract a unique key/name identifiers from the extracted Observers depsWithoutIndicator = depsWithoutIndicator.filter((dep) => { - if (dep && dep['key']) { + if (dep && dep['key'] != null) { depsWithIndicator[dep['key']] = dep; return false; } @@ -78,25 +98,34 @@ const formatDepsWithNoSafeIndicator = ( }; }; -//========================================================================================================= -// Format Deps With Indicator -//========================================================================================================= /** + * Extracts the Observers from the specified dependencies + * which have a unique key/name identifier + * through the object property key. + * + * What type of Observer is extracted from a dependency, + * depends on the specified `observerType`. + * If no `observerType` is specified, the Observers found in the dependency + * are selected in the following `observerType` order. + * 1. `output` + * 2. `value` + * * @internal - * Extract Observers from dependencies which have an indicator through the object property key. - * @param deps - Dependencies to be formatted + * @param deps - Dependencies in object shape to extract the Observers from. + * @param observerType - Type of Observer to be extracted. */ -const formatDepsWithIndicator = (deps: { - [key: string]: SubscribableAgileInstancesType; -}): DepsWithIndicatorType => { +const formatDepsWithIndicator = ( + deps: { + [key: string]: SubscribableAgileInstancesType; + }, + observerType?: string +): DepsWithIndicatorType => { + const depsObject = extractRelevantObservers(deps, observerType); const depsWithIndicator: DepsWithIndicatorType = {}; - - // Extract Observers from Deps - for (const depKey in deps) { - const observer = extractObservers(deps[depKey])[0]; - if (observer) depsWithIndicator[depKey] = observer; + for (const key in depsObject) { + const observer = depsObject[key]; + if (observer != null) depsWithIndicator[key] = observer; } - return depsWithIndicator; }; diff --git a/packages/vue/src/vue.integration.ts b/packages/vue/src/vue.integration.ts index c2d69ed6..cf2268b9 100644 --- a/packages/vue/src/vue.integration.ts +++ b/packages/vue/src/vue.integration.ts @@ -16,10 +16,10 @@ const vueIntegration = new Integration({ updateMethod: (componentInstance, updatedData) => { const componentData = componentInstance.$data; - // Update existing Data or if a new one got created set it via Vue - // Note: Not merging 'updateData' into 'componentData' - // because Vue tracks the local State changes via Proxy - // and by merging it, Vue can't detect the changes + // Update existing data or if a new one was added set it via Vue. + // Note: Not merging the 'updateData' object into the 'componentData' object + // because Vue tracks the local State changes via a Proxy + // and by merging the two object together, Vue can't detect the changes. for (const key of Object.keys(updatedData)) { if (Object.prototype.hasOwnProperty.call(componentData, key)) { componentData.sharedState[key] = updatedData[key]; @@ -51,6 +51,28 @@ const vueIntegration = new Integration({ }, }; }, + // TODO make 'bindAgileValues' ('sharedState') more typesafe + bindAgileValues: function ( + deps: DepsType + ): { sharedState: { [key: string]: any } } { + return { + sharedState: { + ...(this?.$data?.sharedState || {}), + ...bindAgileInstances(deps, agile, this, 'value'), + }, + }; + }, + // TODO make 'bindAgileOutputs' ('sharedState') more typesafe + bindAgileOutputs: function ( + deps: DepsType + ): { sharedState: { [key: string]: any } } { + return { + sharedState: { + ...(this?.$data?.sharedState || {}), + ...bindAgileInstances(deps, agile, this, 'output'), + }, + }; + }, }, }); },