diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ba0242fc..a46c1a84 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -31,4 +31,4 @@ jobs: title: Next Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} \ No newline at end of file + NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/examples/react/develop/functional-component-ts/package.json b/examples/react/develop/functional-component-ts/package.json index be742bb8..08b5324c 100644 --- a/examples/react/develop/functional-component-ts/package.json +++ b/examples/react/develop/functional-component-ts/package.json @@ -30,7 +30,7 @@ "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "install:agile": "yalc add @agile-ts/core @agile-ts/react @agile-ts/api @agile-ts/multieditor @agile-ts/event & yarn install" + "install:agile": "yalc add @agile-ts/core @agile-ts/react @agile-ts/api @agile-ts/multieditor @agile-ts/event @agile-ts/proxytree & yarn install" }, "eslintConfig": { "extends": "react-app" diff --git a/examples/react/develop/functional-component-ts/src/App.tsx b/examples/react/develop/functional-component-ts/src/App.tsx index ad7478e5..0574cf89 100644 --- a/examples/react/develop/functional-component-ts/src/App.tsx +++ b/examples/react/develop/functional-component-ts/src/App.tsx @@ -4,6 +4,7 @@ import { useAgile, useWatcher, useProxy } from '@agile-ts/react'; import { useEvent } from '@agile-ts/event'; import { COUNTUP, + externalCreatedItem, MY_COLLECTION, MY_COMPUTED, MY_EVENT, @@ -12,7 +13,7 @@ import { MY_STATE_3, STATE_OBJECT, } from './core'; -import { generateId, globalBind } from '@agile-ts/core'; +import { generateId, globalBind, Item } from '@agile-ts/core'; let rerenderCount = 0; let rerenderCountInCountupView = 0; @@ -42,11 +43,10 @@ const App = (props: any) => { ]); const [myGroup] = useAgile([MY_COLLECTION.getGroupWithReference('myGroup')]); - const [stateObject, item2, collection2] = useProxy([ - STATE_OBJECT, - MY_COLLECTION.getItem('id2'), - MY_COLLECTION, - ]); + const [stateObject, item2, collection2] = useProxy( + [STATE_OBJECT, MY_COLLECTION.getItem('id2'), MY_COLLECTION], + { key: 'useProxy' } + ); console.log('Item1: ', item2?.name); console.log('Collection: ', collection2.slice(0, 2)); @@ -142,6 +142,12 @@ const App = (props: any) => { }> Collect + diff --git a/examples/react/develop/functional-component-ts/src/core/index.ts b/examples/react/develop/functional-component-ts/src/core/index.ts index c55426d9..cf448f46 100644 --- a/examples/react/develop/functional-component-ts/src/core/index.ts +++ b/examples/react/develop/functional-component-ts/src/core/index.ts @@ -1,4 +1,4 @@ -import { Agile, clone, Collection, Logger } from '@agile-ts/core'; +import { Agile, clone, Item, Logger } from '@agile-ts/core'; import Event from '@agile-ts/event'; export const myStorage: any = {}; @@ -86,7 +86,7 @@ export const MY_COLLECTION = App.createCollection( ).persist(); MY_COLLECTION.collect({ key: 'id1', name: 'test' }); MY_COLLECTION.collect({ key: 'id2', name: 'test2' }, 'myGroup'); -MY_COLLECTION.update('id1', { id: 'id1Updated', name: 'testUpdated' }); +MY_COLLECTION.update('id1', { key: 'id1Updated', name: 'testUpdated' }); MY_COLLECTION.getGroup('myGroup')?.persist({ followCollectionPersistKeyPattern: true, }); @@ -94,6 +94,11 @@ MY_COLLECTION.onLoad(() => { console.log('On Load MY_COLLECTION'); }); +export const externalCreatedItem = new Item(MY_COLLECTION, { + key: 'id10', + name: 'test', +}).persist({ followCollectionPersistKeyPattern: true }); + console.log('Initial: myCollection ', clone(MY_COLLECTION)); export const MY_EVENT = new Event<{ name: string }>(App, { diff --git a/examples/react/develop/functional-component-ts/yarn.lock b/examples/react/develop/functional-component-ts/yarn.lock index 54955758..21b151e8 100644 --- a/examples/react/develop/functional-component-ts/yarn.lock +++ b/examples/react/develop/functional-component-ts/yarn.lock @@ -3,46 +3,46 @@ "@agile-ts/api@file:.yalc/@agile-ts/api": - version "0.0.16" + version "0.0.18" dependencies: - "@agile-ts/utils" "^0.0.2" + "@agile-ts/utils" "^0.0.4" "@agile-ts/core@file:.yalc/@agile-ts/core": - version "0.0.15" + version "0.0.17" dependencies: - "@agile-ts/logger" "^0.0.2" - "@agile-ts/utils" "^0.0.2" + "@agile-ts/logger" "^0.0.4" + "@agile-ts/utils" "^0.0.4" "@agile-ts/event@file:.yalc/@agile-ts/event": - version "0.0.5" + version "0.0.7" -"@agile-ts/logger@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.2.tgz#80a726531dd63ca7d1c9a123383e57b5501efbb0" - integrity sha512-rJJ5pqXtOriYxjuZPhHs2J9N1FnIaAZqItCw0MXW9/5od/uhJ28aiG7w9RUBZts9SjDcICYEfjFMcTJ/kYJsMg== +"@agile-ts/logger@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.4.tgz#7f4d82ef8f03b13089af0878c360575c43f0962d" + integrity sha512-qm0obAKqJMaPKM+c76gktRXyw3OL1v39AnhMZ0FBGwJqHWU+fLRkCzlQwjaROCr3F1XP01Lc/Ls3efF0WzyEPw== dependencies: - "@agile-ts/utils" "^0.0.2" + "@agile-ts/utils" "^0.0.4" "@agile-ts/multieditor@file:.yalc/@agile-ts/multieditor": - version "0.0.15" + version "0.0.17" -"@agile-ts/proxytree@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@agile-ts/proxytree/-/proxytree-0.0.2.tgz#516ed19ee8d58aeecb291788a1e47be3dc23df8c" - integrity sha512-PbSiChF0GcUoWnrbnHauzBxZ5r/+4pZSZWpYjkBcIFa48DgTtFzg5DfQzsW3Rc1Y0QSFGYqcZOvCK1xAjLIQ2g== +"@agile-ts/proxytree@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@agile-ts/proxytree/-/proxytree-0.0.3.tgz#e3dacab123a311f2f0d4a0369793fe90fdab7569" + integrity sha512-auO6trCo7ivLJYuLjxrnK4xuUTangVPTq8UuOMTlGbJFjmb8PLEkaXuRoVGSzv9jsT2FeS7KsP7Fs+yvv0WPdg== "@agile-ts/proxytree@file:.yalc/@agile-ts/proxytree": - version "0.0.1" + version "0.0.3" "@agile-ts/react@file:.yalc/@agile-ts/react": - version "0.0.16" + version "0.0.18" dependencies: - "@agile-ts/proxytree" "^0.0.2" + "@agile-ts/proxytree" "^0.0.3" -"@agile-ts/utils@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.2.tgz#5f03761ace569b6c9ddd28c22f7b0fbec8b006b1" - integrity sha512-LqgQyMdK+zDuTCmOX6FOxTH4JNXhEvGFqIyNqRDoP99BK6MHGrK+n7nOW+1b4x6ZCYe0+VmwtG5CeOPOm3Siow== +"@agile-ts/utils@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.4.tgz#66e9536e561796489a37155da6b74ce2dc482697" + integrity sha512-GiZyTYmCm4j2N57oDjeMuPpfQdgn9clb0Cxpfuwi2Bq5T/KPXlaROLsVGwHLjwwT+NX7xxr5qNJH8pZTnHnYRQ== "@babel/code-frame@7.8.3": version "7.8.3" diff --git a/examples/vue/develop/my-project/src/core.js b/examples/vue/develop/my-project/src/core.js index fe4efb2f..2e3f7902 100644 --- a/examples/vue/develop/my-project/src/core.js +++ b/examples/vue/develop/my-project/src/core.js @@ -1,4 +1,4 @@ -import { Agile, Logger } from '@agile-ts/core'; +import { Agile, Logger, globalBind } from '@agile-ts/core'; import vueIntegration from '@agile-ts/vue'; // Create Agile Instance @@ -7,9 +7,18 @@ export const App = new Agile({ }).integrate(vueIntegration); // Create State -export const MY_STATE = App.createState('Hello World'); +export const MY_STATE = App.createState('World', { + key: 'my-state', +}).computeValue((v) => { + return `Hello ${v}`; +}); // Create Collection export const TODOS = App.createCollection({ initialData: [{ id: 1, name: 'Clean Bathroom' }], + selectors: [1], }).persist('todos'); + +// TODOS.collect({ id: 2, name: 'jeff' }); + +globalBind('__core__', { App, MY_STATE, TODOS }); diff --git a/examples/vue/develop/my-project/yarn.lock b/examples/vue/develop/my-project/yarn.lock index 9bd6b58a..04c336c0 100644 --- a/examples/vue/develop/my-project/yarn.lock +++ b/examples/vue/develop/my-project/yarn.lock @@ -3,25 +3,25 @@ "@agile-ts/core@file:.yalc/@agile-ts/core": - version "0.0.16" + version "0.0.17" dependencies: - "@agile-ts/logger" "^0.0.3" - "@agile-ts/utils" "^0.0.3" + "@agile-ts/logger" "^0.0.4" + "@agile-ts/utils" "^0.0.4" -"@agile-ts/logger@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.3.tgz#21f460bab99b5a1f50fbe6be95e1e9ed471ef456" - integrity sha512-8yejNCB7LXJ334smxovGaBWoqyXIUTHHO0/l2jPJt7WiMag0337KWbo1jyx6D8IkDioI9lunsN2U4CIBsRRhYA== +"@agile-ts/logger@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.4.tgz#7f4d82ef8f03b13089af0878c360575c43f0962d" + integrity sha512-qm0obAKqJMaPKM+c76gktRXyw3OL1v39AnhMZ0FBGwJqHWU+fLRkCzlQwjaROCr3F1XP01Lc/Ls3efF0WzyEPw== dependencies: - "@agile-ts/utils" "^0.0.3" + "@agile-ts/utils" "^0.0.4" -"@agile-ts/utils@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.3.tgz#f0e99d9ed9b21744f31effd99f7f7f32d26e3aec" - integrity sha512-h/gbPRRnFYxpIH4D0F/+6gVcZoZ2YPreT+cl8TCysjkjR6XnZ4YgC7patHIopX7ZvR97IMiu+BtpmS1UDbOftg== +"@agile-ts/utils@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.4.tgz#66e9536e561796489a37155da6b74ce2dc482697" + integrity sha512-GiZyTYmCm4j2N57oDjeMuPpfQdgn9clb0Cxpfuwi2Bq5T/KPXlaROLsVGwHLjwwT+NX7xxr5qNJH8pZTnHnYRQ== "@agile-ts/vue@file:.yalc/@agile-ts/vue": - version "0.0.4" + version "0.0.5" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13": version "7.12.13" diff --git a/package.json b/package.json index 5ace251e..472b9722 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@changesets/cli": "^2.12.0", + "@size-limit/file": "^4.12.0", "@types/jest": "^26.0.15", "@types/node": "^14.14.7", "@typescript-eslint/eslint-plugin": "^4.12.0", @@ -62,6 +63,7 @@ "lerna-changelog": "^1.0.1", "nodemon": "^2.0.6", "prettier": "2.1.2", + "size-limit": "^4.12.0", "ts-jest": "^26.4.4", "ts-node": "^8.10.2", "tsc-watch": "^4.1.0", diff --git a/packages/core/.size-limit.js b/packages/core/.size-limit.js new file mode 100644 index 00000000..1b8247f6 --- /dev/null +++ b/packages/core/.size-limit.js @@ -0,0 +1,6 @@ +module.exports = [ + { + path: 'dist/*', + limit: '35 kB', + }, +]; diff --git a/packages/core/package.json b/packages/core/package.json index 334f739d..c1b896fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,7 +36,8 @@ "preview": "npm pack", "test": "jest", "test:coverage": "jest --coverage", - "lint": "eslint src/**/*" + "lint": "eslint src/**/*", + "size": "yarn run build && size-limit" }, "devDependencies": { "@agile-ts/logger": "file:../logger", diff --git a/packages/core/src/agile.ts b/packages/core/src/agile.ts index f4c765f2..d47775ae 100644 --- a/packages/core/src/agile.ts +++ b/packages/core/src/agile.ts @@ -8,7 +8,6 @@ import { DefaultItem, Computed, Integrations, - Observer, SubController, globalBind, Storages, @@ -19,35 +18,62 @@ import { CreateLoggerConfigInterface, StateConfigInterface, flatMerge, - Group, LogCodeManager, + ComputedConfigInterface, + SubscribableAgileInstancesType, } from './internal'; export class Agile { public config: AgileConfigInterface; - public runtime: Runtime; // Handles assigning Values to Agile Instances - public subController: SubController; // Handles subscriptions to Components - public storages: Storages; // Handles permanent saving + // Queues and executes incoming Observer-based Jobs + public runtime: Runtime; + // Manages and simplifies the subscription to UI-Components + public subController: SubController; + // Handles the permanent persistence of Agile Classes + public storages: Storages; - // Integrations - public integrations: Integrations; // Integrated frameworks - static initialIntegrations: Integration[] = []; // External added initial Integrations + // Integrations (UI-Frameworks) that are integrated into AgileTs + public integrations: Integrations; + // External added Integrations that are to integrate into AgileTs when it is instantiated + static initialIntegrations: Integration[] = []; - // Static Logger with default config -> will be overwritten by config of last created Agile Instance + // Static AgileTs Logger with the default config + // (-> is overwritten by the last created Agile Instance) static logger = new Logger({ prefix: 'Agile', active: true, level: Logger.level.WARN, }); - // Key used to bind AgileTs globally + // Identifier used to bind an Agile Instance globally static globalKey = '__agile__'; /** + * The Agile Class is the main Instance of AgileTs + * and should be unique to your application. + * + * Simply put, the Agile Instance is the brain of AgileTs + * and manages all [Agile Sub Instance](https://agile-ts.org/docs/introduction/#agile-sub-instance) + * such as States. + * + * It should be noted that it doesn't store the States; + * It only manages them. Each State has an Instance of the Agile Class, + * for example, to ingest its changes into the Runtime. + * In summary, the main tasks of the Agile Class are to: + * - queue [Agile Sub Instance](https://agile-ts.org/docs/introduction/#agile-sub-instance) + * changes in the Runtime to prevent race conditions + * - update/rerender subscribed UI-Components through the provided Integrations + * such as the [React Integration](https://agile-ts.org/docs/react) + * - integrate with the persistent [Storage](https://agile-ts.org/docs/core/storage) + * - provide configuration object + * + * Each Agile Sub Instance requires an Agile Instance to be instantiated and function properly. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/) + * * @public - * Agile - Global state and logic framework for reactive Typescript & Javascript applications - * @param config - Config + * @param config - Configuration object */ constructor(config: CreateAgileConfigInterface = {}) { config = defineConfig(config, { @@ -73,39 +99,51 @@ export class Agile { localStorage: config.localStorage, }); - // Assign customized config to Logger + // Assign customized Logger config to the static Logger Agile.logger = new Logger(config.logConfig); - // Logging LogCodeManager.log('10:00:00', [], this, Agile.logger); - // Create global instance of Agile - // Why? getAgileInstance() returns the global AgileInstance if it couldn't find the Agile Instance in the passed Instance - if (config.bindGlobal) { + // Create a global instance of the Agile Instance. + // Why? 'getAgileInstance()' returns the global Agile Instance + // if it couldn't find any Agile Instance in the specified Instance. + if (config.bindGlobal) if (!globalBind(Agile.globalKey, this)) LogCodeManager.log('10:02:00'); - } } - //========================================================================================================= - // Storage - //========================================================================================================= /** + * Returns a newly created Storage. + * + * A Storage Class serves as an interface to external storages, + * such as the [Async Storage](https://github.com/react-native-async-storage/async-storage) or + * [Local Storage](https://www.w3schools.com/html/html5_webstorage.asp). + * + * It creates the foundation to easily [`persist()`](https://agile-ts.org/docs/core/state/methods#persist) [Agile Sub Instances](https://agile-ts.org/docs/introduction/#agile-sub-instance) + * (like States or Collections) in nearly any external storage. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#createstorage) + * * @public - * Storage - Handy Interface for storing Items permanently - * @param config - Config + * @param config - Configuration object */ public createStorage(config: CreateStorageConfigInterface): Storage { return new Storage(config); } - //========================================================================================================= - // State - //========================================================================================================= /** + * Returns a newly created State. + * + * A State manages a piece of Information + * that we need to remember globally at a later point in time. + * While providing a toolkit to use and mutate this piece of Information. + * + * You can create as many global States as you need. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#createstate) + * * @public - * State - Class that holds one Value and causes rerender on subscribed Components - * @param initialValue - Initial Value of the State - * @param config - Config + * @param initialValue - Initial value of the State. + * @param config - Configuration object */ public createState( initialValue: ValueType, @@ -114,13 +152,23 @@ export class Agile { return new State(this, initialValue, config); } - //========================================================================================================= - // Collection - //========================================================================================================= /** + * Returns a newly created Collection. + * + * A Collection manages a reactive set of Information + * that we need to remember globally at a later point in time. + * While providing a toolkit to use and mutate this set of Information. + * + * It is designed for arrays of data objects following the same pattern. + * + * Each of these data object must have a unique `primaryKey` to be correctly identified later. + * + * You can create as many global Collections as you need. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#createcollection) + * * @public - * Collection - Class that holds a List of Objects with key and causes rerender on subscribed Components - * @param config - Config + * @param config - Configuration object */ public createCollection( config?: CollectionConfig @@ -128,77 +176,98 @@ export class Agile { return new Collection(this, config); } - //========================================================================================================= - // Computed - //========================================================================================================= /** + * Returns a newly created Computed. + * + * A Computed is an extension of the State Class + * that computes its value based on a specified compute function. + * + * The computed value will be cached to avoid unnecessary recomputes + * and is only recomputed when one of its direct dependencies changes. + * + * Direct dependencies can be States and Collections. + * So when, for example, a dependent State value changes, the computed value is recomputed. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#createstate) + * * @public - * Computed - Function that recomputes its value if a dependency changes - * @param computeFunction - Function for computing value - * @param config - Config - * @param deps - Hard coded dependencies of Computed Function + * @param computeFunction - Function to compute the computed value. + * @param config - Configuration object */ public createComputed( computeFunction: () => ComputedValueType, - config?: StateConfigInterface, - deps?: Array + config?: ComputedConfigInterface ): Computed; /** + * Returns a newly created Computed. + * + * A Computed is an extension of the State Class + * that computes its value based on a specified compute function. + * + * The computed value will be cached to avoid unnecessary recomputes + * and is only recomputed when one of its direct dependencies changes. + * + * Direct dependencies can be States and Collections. + * So when, for example, a dependent State value changes, the computed value is recomputed. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#createcomputed) + * * @public - * Computed - Function that recomputes its value if a dependency changes - * @param computeFunction - Function for computing value - * @param deps - Hard coded dependencies of Computed Function + * @param computeFunction - Function to compute the computed value. + * @param deps - Hard-coded dependencies on which the Computed Class should depend. */ public createComputed( computeFunction: () => ComputedValueType, - deps?: Array + deps?: Array ): Computed; public createComputed( computeFunction: () => ComputedValueType, - configOrDeps?: StateConfigInterface | Array, - deps?: Array + configOrDeps?: + | ComputedConfigInterface + | Array ): Computed { - let _deps: Array; - let _config: StateConfigInterface; + let _config: ComputedConfigInterface = {}; if (Array.isArray(configOrDeps)) { - _deps = configOrDeps; - _config = {}; + _config = flatMerge(_config, { + computedDeps: configOrDeps, + }); } else { - _config = configOrDeps || {}; - _deps = deps || []; + if (configOrDeps) _config = configOrDeps; } - return new Computed( - this, - computeFunction, - flatMerge(_config, { - computedDeps: _deps, - }) - ); + return new Computed(this, computeFunction, _config); } - //========================================================================================================= - // Integrate - //========================================================================================================= /** + * Registers the specified Integration with AgileTs. + * + * After a successful registration, + * [Agile Sub Instances](https://agile-ts.org/docs/introduction/#agile-sub-instance) such as States + * can be bound to the Integration's UI-Components for reactivity. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#integrate) + * * @public - * Integrates framework into Agile - * @param integration - Integration that gets registered/integrated + * @param integration - Integration to be integrated/registered. */ public integrate(integration: Integration) { this.integrations.integrate(integration); return this; } - //========================================================================================================= - // Register Storage - //========================================================================================================= /** + * Registers the specified Storage with AgileTs. + * + * After a successful registration, + * [Agile Sub Instances](https://agile-ts.org/docs/introduction/#agile-sub-instance) such as States + * can be persisted in the external Storage. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#registerstorage) + * * @public - * Registers new Storage as Agile Storage - * @param storage - new Storage - * @param config - Config + * @param storage - Storage to be registered. + * @param config - Configuration object */ public registerStorage( storage: Storage, @@ -208,45 +277,67 @@ export class Agile { return this; } - //========================================================================================================= - // Has Integration - //========================================================================================================= /** + * Returns a boolean indicating whether any Integration + * has been registered with AgileTs or not. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#hasintegration) + * * @public - * Checks if Agile has any registered Integration */ public hasIntegration(): boolean { return this.integrations.hasIntegration(); } - //========================================================================================================= - // Has Storage - //========================================================================================================= /** + * Returns a boolean indicating whether any Storage + * has been registered with AgileTs or not. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#hasstorage) + * * @public - * Checks if Agile has any registered Storage */ public hasStorage(): boolean { return this.storages.hasStorage(); } } -/** - * @param logJobs - Allow Agile Logs - * @param waitForMount - If Agile should wait until the component mounts - * @param storageConfig - To configure Agile Storage - * @param bindGlobal - Binds Agile Instance Global - */ export interface CreateAgileConfigInterface { + /** + * Configures the logging behaviour of AgileTs. + * @default { + prefix: 'Agile', + active: true, + level: Logger.level.WARN, + canUseCustomStyles: true, + allowedTags: ['runtime', 'storage', 'subscription', 'multieditor'], + } + */ logConfig?: CreateLoggerConfigInterface; + /** + * Whether the Subscription Container shouldn't be ready + * until the UI-Component it represents has been mounted. + * @default true + */ waitForMount?: boolean; + /** + * Whether the Local Storage should be registered as a Agile Storage by default. + * @default true + */ localStorage?: boolean; + /** + * Whether the Agile Instance should be globally bound (globalThis) + * and thus be globally available. + * @default false + */ bindGlobal?: boolean; } -/** - * @param waitForMount - If Agile should wait until the component mounts - */ export interface AgileConfigInterface { + /** + * Whether the Subscription Container shouldn't be ready + * until the UI-Component it represents has been mounted. + * @default true + */ waitForMount: boolean; } diff --git a/packages/core/src/collection/collection.persistent.ts b/packages/core/src/collection/collection.persistent.ts index 04bbe301..f7425ff1 100644 --- a/packages/core/src/collection/collection.persistent.ts +++ b/packages/core/src/collection/collection.persistent.ts @@ -1,5 +1,4 @@ import { - Agile, Collection, CollectionKey, CreatePersistentConfigInterface, @@ -17,6 +16,7 @@ import { export class CollectionPersistent< DataType extends Object = DefaultItem > extends Persistent { + // Collection the Persistent belongs to public collection: () => Collection; static defaultGroupSideEffectKey = 'rebuildGroupStorageValue'; @@ -24,10 +24,11 @@ export class CollectionPersistent< static storageGroupKeyPattern = '_${collectionKey}_group_${groupKey}'; /** + * Internal Class for managing the permanent persistence of a Collection. + * * @internal - * Collection Persist Manager - Handles permanent storing of Collection Value - * @param collection - Collection that gets stored - * @param config - Config + * @param collection - Collection to be persisted. + * @param config - Configuration object */ constructor( collection: Collection, @@ -48,47 +49,16 @@ export class CollectionPersistent< defaultStorageKey: config.defaultStorageKey, }); - // Load/Store persisted Value/s for the first Time + // Load/Store persisted value/s for the first time if (this.ready && config.instantiate) this.initialLoading(); } - //========================================================================================================= - // Set Key - //========================================================================================================= /** + * Loads the persisted value into the Collection + * or persists the Collection value in the corresponding Storage. + * This behaviour depends on whether the Collection has been persisted before. + * * @internal - * Updates Key/Name of Persistent - * @param value - New Key/Name of Persistent - */ - public async setKey(value?: StorageKey): Promise { - const oldKey = this._key; - const wasReady = this.ready; - - // Assign Key - if (value === this._key) return; - this._key = value || Persistent.placeHolderKey; - - const isValid = this.validatePersistent(); - - // Try to Initial Load Value if persistent wasn't ready - if (!wasReady) { - if (isValid) await this.initialLoading(); - return; - } - - // Remove value at old Key - await this.removePersistedValue(oldKey); - - // Assign Value to new Key - if (isValid) await this.persistValue(value); - } - - //========================================================================================================= - // Initial Loading - //========================================================================================================= - /** - * @internal - * Loads/Saves Storage Value for the first Time */ public async initialLoading() { super.initialLoading().then(() => { @@ -96,195 +66,267 @@ export class CollectionPersistent< }); } - //========================================================================================================= - // Load Persisted Value - //========================================================================================================= /** + * Loads Collection Instances (like Items or Groups) from the corresponding Storage + * and sets up side effects that dynamically update + * the Storage value when the Collection (Instances) changes. + * * @internal - * Loads Collection from Storage - * @param storageItemKey - Prefix Key of Persisted Instances (default PersistentKey) - * @return Success? + * @param storageItemKey - Prefix Storage key of the to load Collection Instances. + * | default = Persistent.key | + * @return Whether the loading of the persisted Collection Instances and setting up of the corresponding side effects was successful. */ public async loadPersistedValue( storageItemKey?: PersistentKey ): Promise { if (!this.ready) return false; - const _storageItemKey = storageItemKey || this._key; + const _storageItemKey = storageItemKey ?? this._key; - // Check if Collection is Persisted + // Check if Collection is already persisted + // (indicated by the persistence of 'true' at '_storageItemKey') const isPersisted = await this.agileInstance().storages.get( _storageItemKey, this.config.defaultStorageKey as any ); + + // Return 'false' if Collection isn't persisted yet if (!isPersisted) return false; - // Loads Values into Collection + // Helper function to load persisted values into the Collection const loadValuesIntoCollection = async () => { - const defaultGroup = this.collection().getGroup( - this.collection().config.defaultGroupKey + const defaultGroup = this.collection().getDefaultGroup(); + if (defaultGroup == null) return false; + const defaultGroupStorageKey = CollectionPersistent.getGroupStorageKey( + defaultGroup._key, + _storageItemKey ); - if (!defaultGroup) return false; - // Persist Default Group and load its Value manually to be 100% sure it got loaded - defaultGroup.persist({ + // Persist default Group and load its value manually to be 100% sure + // that it was loaded completely + defaultGroup.persist(defaultGroupStorageKey, { loadValue: false, - followCollectionPersistKeyPattern: true, + defaultStorageKey: this.config.defaultStorageKey || undefined, + storageKeys: this.storageKeys, + followCollectionPersistKeyPattern: false, // Because of the dynamic 'storageItemKey', the key is already formatted above }); - if (defaultGroup.persistent?.ready) { - await defaultGroup.persistent?.initialLoading(); - defaultGroup.isPersisted = true; - } - - // Load Items into Collection + if (defaultGroup.persistent?.ready) + await defaultGroup.persistent.initialLoading(); + + // TODO rebuild the default Group once at the end when all Items were loaded into the Collection + // because otherwise it rebuilds the Group for each loaded Item + // (-> warnings are printed for all not yet loaded Items when rebuilding the Group) + // or rethink the whole Group rebuild process by adding a 'addItem()', 'removeItem()' and 'updateItem()' function + // so that there is no need for rebuilding the whole Group when for example only Item B changed or Item C was added + // + // See Issue by starting the vue develop example app and adding some todos to the _todo_ list + + // Persist Items found in the default Group's value for (const itemKey of defaultGroup._value) { + const item = this.collection().getItem(itemKey); const itemStorageKey = CollectionPersistent.getItemStorageKey( itemKey, _storageItemKey ); - // Get Storage Value - const storageValue = await this.agileInstance().storages.get( - itemStorageKey, - this.config.defaultStorageKey as any - ); - if (!storageValue) continue; - - // Collect found Storage Value - this.collection().collect(storageValue); + // Persist and therefore load already present Item + if (item != null) { + item.persist(itemStorageKey, { + defaultStorageKey: this.config.defaultStorageKey || undefined, + storageKeys: this.storageKeys, + followCollectionPersistKeyPattern: false, // Because of the dynamic 'storageItemKey', the key is already formatted above + }); + } + // Persist and therefore load not present Item + else { + // Create temporary placeholder Item in which the Item value will be loaded + const dummyItem = this.collection().createPlaceholderItem(itemKey); + + // Persist dummy Item and load its value manually to be 100% sure + // that it was loaded completely and exists at all + dummyItem?.persist(itemStorageKey, { + loadValue: false, + defaultStorageKey: this.config.defaultStorageKey || undefined, + storageKeys: this.storageKeys, + followCollectionPersistKeyPattern: false, // Because of the dynamic 'storageItemKey', the key is already formatted above + }); + if (dummyItem?.persistent?.ready) { + const loadedPersistedValueIntoItem = await dummyItem.persistent.loadPersistedValue( + itemStorageKey + ); // TODO FIRST GROUP REBUILD (by assigning loaded value to Item) + + // If successfully loaded Item value, assign Item to Collection + if (loadedPersistedValueIntoItem) + this.collection().assignItem(dummyItem); // TODO SECOND GROUP REBUILD (by calling rebuildGroupThatInclude() in the assignItem() method) + } + } } + return true; }; const success = await loadValuesIntoCollection(); - // Persist Collection, so that the Storage Value updates dynamically if the Collection updates - if (success) await this.persistValue(_storageItemKey); + // Setup side effects to keep the Storage value in sync + // with the Collection (Instances) value + if (success) this.setupSideEffects(_storageItemKey); return success; } - //========================================================================================================= - // Persist Value - //========================================================================================================= /** + * Persists Collection Instances (like Items or Groups) in the corresponding Storage + * and sets up side effects that dynamically update + * the Storage value when the Collection (Instances) changes. + * * @internal - * Sets everything up so that the Collection gets saved in the Storage - * @param storageItemKey - Prefix Key of Persisted Instances (default PersistentKey) - * @return Success? + * @param storageItemKey - Prefix Storage key of the to persist Collection Instances. + * | default = Persistent.key | + * @return Whether the persisting of the Collection Instances and the setting up of the corresponding side effects was successful. */ public async persistValue(storageItemKey?: PersistentKey): Promise { if (!this.ready) return false; - const _storageItemKey = storageItemKey || this._key; - const defaultGroup = this.collection().getGroup( - this.collection().config.defaultGroupKey + const _storageItemKey = storageItemKey ?? this._key; + const defaultGroup = this.collection().getDefaultGroup(); + if (defaultGroup == null) return false; + const defaultGroupStorageKey = CollectionPersistent.getGroupStorageKey( + defaultGroup._key, + _storageItemKey ); - if (!defaultGroup) return false; - // Set Collection to Persisted (in Storage) + // Set flag in Storage to indicate that the Collection is persisted this.agileInstance().storages.set(_storageItemKey, true, this.storageKeys); // Persist default Group - if (!defaultGroup.isPersisted) - defaultGroup.persist({ followCollectionPersistKeyPattern: true }); - - // Add sideEffect to default Group which adds and removes Items from the Storage depending on the Group Value - defaultGroup.addSideEffect( - CollectionPersistent.defaultGroupSideEffectKey, - () => this.rebuildStorageSideEffect(defaultGroup, _storageItemKey), - { weight: 0 } - ); + defaultGroup.persist(defaultGroupStorageKey, { + defaultStorageKey: this.config.defaultStorageKey || undefined, + storageKeys: this.storageKeys, + followCollectionPersistKeyPattern: false, // Because of the dynamic 'storageItemKey', the key is already formatted above + }); - // Persist Collection Items + // Persist Items found in the default Group's value for (const itemKey of defaultGroup._value) { const item = this.collection().getItem(itemKey); const itemStorageKey = CollectionPersistent.getItemStorageKey( itemKey, _storageItemKey ); - item?.persist(itemStorageKey); + item?.persist(itemStorageKey, { + defaultStorageKey: this.config.defaultStorageKey || undefined, + storageKeys: this.storageKeys, + followCollectionPersistKeyPattern: false, // Because of the dynamic 'storageItemKey', the key is already formatted above + }); } + // Setup side effects to keep the Storage value in sync + // with the Collection (Instances) value + this.setupSideEffects(_storageItemKey); + this.isPersisted = true; return true; } - //========================================================================================================= - // Remove Persisted Value - //========================================================================================================= /** + * Sets up side effects to keep the Storage value in sync + * with the Collection (Instances) value. + * + * @internal + * @param storageItemKey - Prefix Storage key of the to remove Collection Instances. + * | default = Persistent.key | + */ + public setupSideEffects(storageItemKey?: PersistentKey): void { + const _storageItemKey = storageItemKey ?? this._key; + const defaultGroup = this.collection().getDefaultGroup(); + if (defaultGroup == null) return; + + // Add side effect to the default Group + // that adds and removes Items from the Storage based on the Group value + defaultGroup.addSideEffect( + CollectionPersistent.defaultGroupSideEffectKey, + (instance) => this.rebuildStorageSideEffect(instance, _storageItemKey), + { weight: 0 } + ); + } + + /** + * Removes the Collection from the corresponding Storage. + * -> Collection is no longer persisted + * * @internal - * Removes Collection from the Storage - * @param storageItemKey - Prefix Key of Persisted Instances (default PersistentKey) - * @return Success? + * @param storageItemKey - Prefix Storage key of the persisted Collection Instances. + * | default = Persistent.key | + * @return Whether the removal of the Collection Instances was successful. */ public async removePersistedValue( storageItemKey?: PersistentKey ): Promise { if (!this.ready) return false; - const _storageItemKey = storageItemKey || this._key; - const defaultGroup = this.collection().getGroup( - this.collection().config.defaultGroupKey - ); + const _storageItemKey = storageItemKey ?? this._key; + const defaultGroup = this.collection().getDefaultGroup(); if (!defaultGroup) return false; + const defaultGroupStorageKey = CollectionPersistent.getGroupStorageKey( + defaultGroup._key, + _storageItemKey + ); - // Set Collection to not Persisted + // Remove Collection is persisted indicator flag from Storage this.agileInstance().storages.remove(_storageItemKey, this.storageKeys); - // Remove default Group from Storage - defaultGroup.persistent?.removePersistedValue(); - - // Remove Rebuild Storage sideEffect from default Group + // Remove default Group from the Storage + defaultGroup.persistent?.removePersistedValue(defaultGroupStorageKey); defaultGroup.removeSideEffect( CollectionPersistent.defaultGroupSideEffectKey ); - // Remove Collection Items from Storage + // Remove Items found in the default Group's value from the Storage for (const itemKey of defaultGroup._value) { const item = this.collection().getItem(itemKey); - item?.persistent?.removePersistedValue(); + const itemStorageKey = CollectionPersistent.getItemStorageKey( + itemKey, + _storageItemKey + ); + item?.persistent?.removePersistedValue(itemStorageKey); } this.isPersisted = false; return true; } - //========================================================================================================= - // Format Key - //========================================================================================================= /** + * Formats the specified key so that it can be used as a valid Storage key + * and returns the formatted variant of it. + * + * If no formatable key (`undefined`/`null`) was provided, + * an attempt is made to use the Collection identifier key as Storage key. + * * @internal - * Formats Storage Key - * @param key - Key that gets formatted + * @param key - Storage key to be formatted. */ - public formatKey(key?: StorageKey): StorageKey | undefined { - const collection = this.collection(); - - // Get key from Collection - if (key == null && collection._key) return collection._key; - + public formatKey(key: StorageKey | undefined | null): StorageKey | undefined { + if (key == null && this.collection()._key) return this.collection()._key; if (key == null) return; - - // Set Storage Key to Collection Key if Collection has no key - if (collection._key == null) collection._key = key; - + if (this.collection()._key == null) this.collection()._key = key; return key; } - //========================================================================================================= - // Rebuild Storage SideEffect - //========================================================================================================= /** + * Adds and removes Items from the Storage based on the Group value. + * * @internal - * Rebuilds Storage depending on Group - * @param group - Group - * @param key - Prefix Key of Persisted Instances (default PersistentKey) + * @param group - Group whose Items are to be dynamically added or removed from the Storage. + * @param storageItemKey - Prefix Storage key of the persisted Collection Instances. + * | default = Persistent.key | */ - public rebuildStorageSideEffect(group: Group, key?: PersistentKey) { + public rebuildStorageSideEffect( + group: Group, + storageItemKey?: PersistentKey + ) { const collection = group.collection(); - const _key = key || collection.persistent?._key; + const _storageItemKey = storageItemKey || collection.persistent?._key; - // Return if only a ItemKey got updated + // Return if no Item got added or removed + // because then the changed Item performs the Storage update itself if (group.previousStateValue.length === group._value.length) return; + // Extract Item keys that got added or removed from the Group const addedKeys = group._value.filter( (key) => !group.previousStateValue.includes(key) ); @@ -292,36 +334,43 @@ export class CollectionPersistent< (key) => !group._value.includes(key) ); - // Persist Added Keys + // Persist newly added Items addedKeys.forEach((itemKey) => { const item = collection.getItem(itemKey); - const _itemKey = CollectionPersistent.getItemStorageKey(itemKey, _key); - if (!item) return; - if (!item.isPersisted) item.persist(_itemKey); - else item.persistent?.persistValue(_itemKey); + const itemStorageKey = CollectionPersistent.getItemStorageKey( + itemKey, + _storageItemKey + ); + if (item != null && !item.isPersisted) + item.persist(itemStorageKey, { + defaultStorageKey: this.config.defaultStorageKey || undefined, + storageKeys: this.storageKeys, + followCollectionPersistKeyPattern: false, // Because of the dynamic 'storageItemKey', the key is already formatted above + }); }); - // Unpersist removed Keys + // Remove removed Items from the Storage removedKeys.forEach((itemKey) => { const item = collection.getItem(itemKey); - const _itemKey = CollectionPersistent.getItemStorageKey(itemKey, _key); - if (!item) return; - if (item.isPersisted) item.persistent?.removePersistedValue(_itemKey); + const itemStorageKey = CollectionPersistent.getItemStorageKey( + itemKey, + _storageItemKey + ); + if (item != null && item.isPersisted) + item.persistent?.removePersistedValue(itemStorageKey); }); } - //========================================================================================================= - // Get Item Storage Key - //========================================================================================================= /** + * Builds valid Item Storage key based on the 'Collection Item Persist Pattern'. + * * @internal - * Build Item StorageKey with Collection Persist Pattern - * @param itemKey - Key of Item - * @param collectionKey - Key of Collection + * @param itemKey - Key identifier of Item + * @param collectionKey - Key identifier of Collection */ public static getItemStorageKey( - itemKey?: ItemKey, - collectionKey?: CollectionKey + itemKey: ItemKey | undefined | null, + collectionKey: CollectionKey | undefined | null ): string { if (itemKey == null || collectionKey == null) LogCodeManager.log('1A:02:00'); @@ -332,24 +381,21 @@ export class CollectionPersistent< .replace('${itemKey}', itemKey.toString()); } - //========================================================================================================= - // Get Group Storage Key - //========================================================================================================= /** + * Builds valid Item Storage key based on the 'Collection Group Persist Pattern'. + * * @internal - * Build Group StorageKey with Collection Persist Pattern - * @param groupKey - Key of Group - * @param collectionKey - Key of Collection + * @param groupKey - Key identifier of Group + * @param collectionKey - Key identifier of Collection */ public static getGroupStorageKey( - groupKey?: GroupKey, - collectionKey?: CollectionKey + groupKey: GroupKey | undefined | null, + collectionKey: CollectionKey | undefined | null ): string { if (groupKey == null || collectionKey == null) LogCodeManager.log('1A:02:01'); if (groupKey == null) groupKey = 'unknown'; if (collectionKey == null) collectionKey = 'unknown'; - return this.storageGroupKeyPattern .replace('${collectionKey}', collectionKey.toString()) .replace('${groupKey}', groupKey.toString()); diff --git a/packages/core/src/collection/group.ts b/packages/core/src/collection/group.ts index 98fa4ccc..52dd9b6e 100644 --- a/packages/core/src/collection/group.ts +++ b/packages/core/src/collection/group.ts @@ -12,7 +12,6 @@ import { isValidObject, PersistentKey, ComputedTracker, - StateRuntimeJobConfigInterface, StateIngestConfigInterface, removeProperties, LogCodeManager, @@ -21,19 +20,32 @@ import { export class Group extends State< Array > { + // Collection the Group belongs to + collection: () => Collection; + static rebuildGroupSideEffectKey = 'rebuildGroup'; - collection: () => Collection; // Collection the Group belongs to - _output: Array = []; // Output of Group - _items: Array<() => Item> = []; // Items of Group - notFoundItemKeys: Array = []; // Contains all keys of Group that can't be found in Collection + // Item values represented by the Group + _output: Array = []; + // Items represented by the Group + _items: Array<() => Item> = []; + + // Keeps track of all Item identifiers for Items that couldn't be found in the Collection + notFoundItemKeys: Array = []; /** + * An extension of the State Class that categorizes and preserves the ordering of structured data. + * It allows us to cluster together data from a Collection as an array of Item keys. + * + * Note that a Group doesn't store the actual Items. It only keeps track of the Item keys + * and retrieves the fitting Items when needed. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/) + * * @public - * Group - Holds Items of Collection - * @param collection - Collection to that the Group belongs - * @param initialItems - Initial Key of Items in this Group - * @param config - Config + * @param collection - Collection to which the Group belongs. + * @param initialItems - Key/Name identifiers of the Items to be clustered by the Group. + * @param config - Configuration object */ constructor( collection: Collection, @@ -43,78 +55,79 @@ export class Group extends State< super(collection.agileInstance(), initialItems || [], config); this.collection = () => collection; - // Add rebuild to sideEffects to rebuild Group on Value Change + // Add side effect to Group + // that rebuilds the Group whenever the Group value changes this.addSideEffect(Group.rebuildGroupSideEffectKey, () => this.rebuild()); - // Initial Rebuild + // Initial rebuild this.rebuild(); } /** + * Returns the values of the Items clustered by the Group. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/properties#output) + * * @public - * Get Item Values of Group */ public get output(): Array { ComputedTracker.tracked(this.observer); - return this._output; + return copy(this._output); } - /** - * @public - * Set Item Values of Group - */ public set output(value: DataType[]) { - this._output = value; + 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 - * Get Items of Group */ public get items(): Array> { ComputedTracker.tracked(this.observer); return this._items.map((item) => item()); } - /** - * @public - * Set Items of Group - */ public set items(value: Array>) { - this._items = value.map((item) => () => item); + LogCodeManager.log('1C:03:01', [this._key]); } - //========================================================================================================= - // Has - //========================================================================================================= /** + * Returns a boolean indicating whether an Item with the specified `itemKey` + * is clustered in the Group or not. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods/#has) + * * @public - * Checks if Group contains ItemKey - * @param itemKey - ItemKey that gets checked + * @param itemKey - Key/Name identifier of the Item. */ public has(itemKey: ItemKey) { return this.value.findIndex((key) => key === itemKey) !== -1; } - //========================================================================================================= - // Size - //========================================================================================================= /** + * Returns the count of Items clustered by the Group. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/properties#size) + * * @public - * Get size of Group (-> How many Items it contains) */ public get size(): number { return this.value.length; } - //========================================================================================================= - // Remove - //========================================================================================================= /** + * Removes an Item with the specified key/name identifier from the Group, + * if it exists in the Group. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods#remove) + * * @public - * Removes ItemKey/s from Group - * @param itemKeys - ItemKey/s that get removed from Group - * @param config - Config + * @param itemKeys - Key/Name identifier/s of the Item/s to be removed. + * @param config - Configuration object */ public remove( itemKeys: ItemKey | ItemKey[], @@ -125,7 +138,7 @@ export class Group extends State< const notExistingItemKeys: Array = []; let newGroupValue = copy(this.nextStateValue); - // Remove ItemKeys from Group + // Remove itemKeys from Group _itemKeys.forEach((itemKey) => { // Check if itemKey exists in Group if (!newGroupValue.includes(itemKey)) { @@ -134,18 +147,19 @@ export class Group extends State< return; } - // Check if ItemKey exists in Collection + // Check if itemKey exists in Collection if (!this.collection().getItem(itemKey)) notExistingItemKeysInCollection.push(itemKey); - // Remove ItemKey from Group + // Remove itemKey from Group newGroupValue = newGroupValue.filter((key) => key !== itemKey); }); - // Return if passed ItemKeys doesn't exist + // Return if none of the specified itemKeys exists if (notExistingItemKeys.length >= _itemKeys.length) return this; - // If all removed ItemKeys doesn't exist in Collection -> no rerender necessary since output doesn't change + // If all removed itemKeys don't exist in the Collection + // -> no rerender necessary since the output won't change if (notExistingItemKeysInCollection.length >= _itemKeys.length) config.background = true; @@ -154,14 +168,14 @@ export class Group extends State< return this; } - //========================================================================================================= - // Add - //========================================================================================================= /** + * Appends new Item/s to the end of the Group. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods#add) + * * @public - * Adds ItemKey/s to Group - * @param itemKeys - ItemKey/s that get added to the Group - * @param config - Config + * @param itemKeys - Key/Name identifier/s of Item/s to be added. + * @param config - Configuration object */ public add( itemKeys: ItemKey | ItemKey[], @@ -176,16 +190,15 @@ export class Group extends State< overwrite: false, }); - // Add ItemKeys to Group + // Add itemKeys to Group _itemKeys.forEach((itemKey) => { - const existsInGroup = newGroupValue.includes(itemKey); - - // Check if ItemKey exists in Collection + // Check if itemKey exists in Collection if (!this.collection().getItem(itemKey)) notExistingItemKeysInCollection.push(itemKey); - // Remove ItemKey from Group if it should get overwritten and already exists - if (existsInGroup) { + // Remove itemKey temporary from newGroupValue + // if it should be overwritten and already exists in the newGroupValue + if (newGroupValue.includes(itemKey)) { if (config.overwrite) { newGroupValue = newGroupValue.filter((key) => key !== itemKey); } else { @@ -194,14 +207,15 @@ export class Group extends State< } } - // Add new ItemKey to Group + // Add new itemKey to Group newGroupValue[config.method || 'push'](itemKey); }); - // Return if passed ItemKeys already exist + // Return if all specified itemKeys already exist if (existingItemKeys.length >= _itemKeys.length) return this; - // If all added ItemKeys doesn't exist in Collection or already exist -> no rerender necessary since output doesn't change + // If all added itemKeys don't exist in the Collection + // -> no rerender necessary since the output won't change if ( notExistingItemKeysInCollection.concat(existingItemKeys).length >= _itemKeys.length @@ -213,20 +227,20 @@ export class Group extends State< return this; } - //========================================================================================================= - // Replace - //========================================================================================================= /** + * Replaces the old `itemKey` with a new specified `itemKey`. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods#replace) + * * @public - * Replaces oldItemKey with newItemKey - * @param oldItemKey - Old ItemKey - * @param newItemKey - New ItemKey - * @param config - Config + * @param oldItemKey - Old `itemKey` to be replaced. + * @param newItemKey - New `itemKey` to replace the before specified old `itemKey`. + * @param config - Configuration object */ public replace( oldItemKey: ItemKey, newItemKey: ItemKey, - config: StateRuntimeJobConfigInterface = {} + config: StateIngestConfigInterface = {} ): this { const newGroupValue = copy(this._value); newGroupValue.splice(newGroupValue.indexOf(oldItemKey), 1, newItemKey); @@ -234,20 +248,29 @@ export class Group extends State< return this; } - //========================================================================================================= - // Persist - //========================================================================================================= /** + * Preserves the Group `value` in the corresponding external Storage. + * + * The Group key/name is used as the unique identifier for the Persistent. + * If that is not desired or the Group has no unique identifier, + * please specify a separate unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#persist) + * * @public - * Stores Group Value into Agile Storage permanently - * @param config - Config + * @param config - Configuration object */ public persist(config?: GroupPersistConfigInterface): this; /** + * Preserves the Group `value` in the corresponding external Storage. + * + * The specified key is used as the unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#persist) + * * @public - * Stores Group Value into Agile Storage permanently - * @param key - Key/Name of created Persistent (Note: Key required if Group has no set Key!) - * @param config - Config + * @param key - Key/Name identifier of Persistent. + * @param config - Configuration object */ public persist( key?: PersistentKey, @@ -270,12 +293,12 @@ export class Group extends State< _config = defineConfig(_config, { loadValue: true, - followCollectionPattern: false, + followCollectionPersistKeyPattern: true, storageKeys: [], defaultStorageKey: null, }); - // Create storageItemKey based on Collection Name + // Create storageItemKey based on Collection key/name identifier if (_config.followCollectionPersistKeyPattern) { key = CollectionPersistent.getGroupStorageKey( key || this._key, @@ -283,6 +306,7 @@ export class Group extends State< ); } + // Persist Group super.persist(key, { loadValue: _config.loadValue, storageKeys: _config.storageKeys, @@ -292,30 +316,33 @@ export class Group extends State< return this; } - //========================================================================================================= - // Rebuild - //========================================================================================================= /** + * Rebuilds the entire `output` and `items` property of the Group. + * + * In doing so, it traverses the Group `value` (Item identifiers) + * and fetches the fitting Items accordingly. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods#rebuild) + * * @internal - * Rebuilds Output and Items of Group */ public rebuild(): this { - const notFoundItemKeys: Array = []; // Item Keys that couldn't be found in Collection + const notFoundItemKeys: Array = []; // Item keys that couldn't be found in the Collection const groupItems: Array> = []; - // Don't rebuild Group if Collection is not properly instantiated + // Don't rebuild Group if Collection isn't correctly instantiated yet // (because only after a successful instantiation the Collection - // contains Items which are essential for a proper rebuild) + // contains the Items which are essential for a proper rebuild) if (!this.collection().isInstantiated) return this; - // Create groupItems by finding Item at ItemKey in Collection + // Fetch Items from Collection this._value.forEach((itemKey) => { const item = this.collection().getItem(itemKey); if (item != null) groupItems.push(item); else notFoundItemKeys.push(itemKey); }); - // Create groupOutput out of groupItems + // Extract Item values from the retrieved Items const groupOutput = groupItems.map((item) => { return item.getPublicValue(); }); @@ -329,7 +356,7 @@ export class Group extends State< ); } - this.items = groupItems; + this._items = groupItems.map((item) => () => item); this._output = groupOutput; this.notFoundItemKeys = notFoundItemKeys; @@ -339,36 +366,43 @@ export class Group extends State< export type GroupKey = string | number; -/** - * @param method - Way of adding ItemKey to Group (push, unshift) - * @param overwrite - If adding ItemKey overwrites old ItemKey (-> otherwise it gets added to the end of the Group) - * @param background - If adding ItemKey happens in the background (-> not causing any rerender) - */ export interface GroupAddConfigInterface extends StateIngestConfigInterface { + /** + * In which way the `itemKey` should be added to the Group. + * - 'push' = at the end + * - 'unshift' = at the beginning + * https://www.tutorialspoint.com/what-are-the-differences-between-unshift-and-push-methods-in-javascript + * @default 'push' + */ method?: 'unshift' | 'push'; + /** + * If the to add `itemKey` already exists, + * whether its position should be overwritten with the position of the new `itemKey`. + * @default false + */ overwrite?: boolean; } -/** - * @param background - If removing ItemKey happens in the background (-> not causing any rerender) - */ -export interface GroupRemoveConfigInterface { - background?: boolean; -} - -/** - * @param key - Key/Name of Group - * @param isPlaceholder - If Group is initially a Placeholder - */ export interface GroupConfigInterface { + /** + * Key/Name identifier of the Group. + * @default undefined + */ key?: GroupKey; + /** + * Whether the Group should be a placeholder + * and therefore should only exist in the background. + * @default false + */ isPlaceholder?: boolean; } -/** - * @param useCollectionPattern - If Group storageKey follows the Collection Group StorageKey Pattern - */ export interface GroupPersistConfigInterface extends StatePersistentConfigInterface { + /** + * Whether to format the specified Storage key following the Collection Group Storage key pattern. + * `_${collectionKey}_group_${groupKey}` + * @default true + */ followCollectionPersistKeyPattern?: boolean; } diff --git a/packages/core/src/collection/index.ts b/packages/core/src/collection/index.ts index d88e53db..edf12d81 100644 --- a/packages/core/src/collection/index.ts +++ b/packages/core/src/collection/index.ts @@ -20,30 +20,51 @@ import { removeProperties, isFunction, LogCodeManager, + PatchOptionConfigInterface, } from '../internal'; export class Collection { + // Agile Instance the Collection belongs to public agileInstance: () => Agile; public config: CollectionConfigInterface; private initialConfig: CreateCollectionConfigInterface; - public size = 0; // Amount of Items stored in Collection - public data: { [key: string]: Item } = {}; // Collection Data + // Key/Name identifier of the Collection public _key?: CollectionKey; - public isPersisted = false; // If Collection can be stored in Agile Storage (-> successfully integrated persistent) - public persistent: CollectionPersistent | undefined; // Manages storing Collection Value into Storage - + // Amount of the Items stored in the Collection + public size = 0; + // Items stored in the Collection + public data: { [key: string]: Item } = {}; + // Whether the Collection is persisted in an external Storage + public isPersisted = false; + // Manages the permanent persistent in external Storages + public persistent: CollectionPersistent | undefined; + + // Registered Groups of Collection public groups: { [key: string]: Group } = {}; + // Registered Selectors of Collection public selectors: { [key: string]: Selector } = {}; + // Whether the Collection was instantiated correctly public isInstantiated = false; /** + * A Collection manages a reactive set of Information + * that we need to remember globally at a later point in time. + * While providing a toolkit to use and mutate this set of Information. + * + * It is designed for arrays of data objects following the same pattern. + * + * Each of these data object must have a unique `primaryKey` to be correctly identified later. + * + * You can create as many global Collections as you need. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/) + * * @public - * Collection - Class that holds a List of Objects with key and causes rerender on subscribed Components - * @param agileInstance - An instance of Agile - * @param config - Config + * @param agileInstance - Instance of Agile the Collection belongs to. + * @param config - Configuration object */ constructor(agileInstance: Agile, config: CollectionConfig = {}) { this.agileInstance = () => agileInstance; @@ -64,66 +85,84 @@ export class Collection { this.initGroups(_config.groups as any); this.initSelectors(_config.selectors as any); - if (_config.initialData) this.collect(_config.initialData); - this.isInstantiated = true; + // Add 'initialData' to Collection + // (after 'isInstantiated' to add them properly to the Collection) + if (_config.initialData) this.collect(_config.initialData); + // Reselect Selector Items // Necessary because the selection of an Item - // hasn't worked with a not 'instantiated' Collection + // hasn't worked with a not correctly 'instantiated' Collection before for (const key in this.selectors) this.selectors[key].reselect(); // Rebuild of Groups // Not necessary because if Items are added to the Collection, - // the Groups which contain these added Items get rebuilt. + // (after 'isInstantiated = true') + // the Groups which contain these added Items are rebuilt. // for (const key in this.groups) this.groups[key].rebuild(); } /** + * Updates the key/name identifier of the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/properties#key) + * * @public - * Set Key/Name of Collection + * @param value - New key/name identifier. */ public set key(value: CollectionKey | undefined) { this.setKey(value); } /** + * Returns the key/name identifier of the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/properties#key) + * * @public - * Get Key/Name of Collection */ public get key(): CollectionKey | undefined { return this._key; } - //========================================================================================================= - // Set Key - //========================================================================================================= /** + * Updates the key/name identifier of the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#setkey) + * * @public - * Set Key/Name of Collection - * @param value - New Key/Name of Collection + * @param value - New key/name identifier. */ public setKey(value: CollectionKey | undefined) { const oldKey = this._key; - // Update State Key + // Update Collection key this._key = value; - // Update Key in Persistent (only if oldKey equal to persistentKey -> otherwise the PersistentKey got formatted and will be set where other) + // 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) this.persistent?.setKey(value); return this; } - //========================================================================================================= - // Group - //========================================================================================================= /** + * Creates a new Group without associating it to the Collection. + * + * This way of creating a Group is intended for use in the Collection configuration object, + * where the `constructor()` takes care of the binding. + * + * After a successful initiation of the Collection we recommend using `createGroup()`, + * because it automatically connects the Group to the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#group) + * * @public - * Group - Holds Items of this Collection - * @param initialItems - Initial ItemKeys of Group - * @param config - Config + * @param initialItems - Key/Name identifiers of the Items to be clustered by the Group. + * @param config - Configuration object */ public Group( initialItems?: Array, @@ -138,14 +177,20 @@ export class Collection { return new Group(this, initialItems, config); } - //========================================================================================================= - // Selector - //========================================================================================================= /** + * Creates a new Selector without associating it to the Collection. + * + * This way of creating a Selector is intended for use in the Collection configuration object, + * where the `constructor()` takes care of the binding. + * + * After a successful initiation of the Collection we recommend using `createSelector()`, + * because it automatically connects the Group to the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#selector) + * * @public - * Selector - Represents an Item of this Collection - * @param initialKey - Key of Item that the Selector represents - * @param config - Config + * @param initialKey - Key/Name identifier of the Item to be represented by the Selector. + * @param config - Configuration object */ public Selector( initialKey: ItemKey, @@ -160,18 +205,21 @@ export class Collection { return new Selector(this, initialKey, config); } - //========================================================================================================= - // Init Groups - //========================================================================================================= /** + * Sets up the specified Groups or Group keys + * and assigns them to the Collection if they are valid. + * + * It also instantiates and assigns the default Group to the Collection. + * The default Group reflects the default pattern of the Collection. + * * @internal - * Instantiates Groups + * @param groups - Entire Groups or Group keys to be set up. */ - public initGroups(groups: { [key: string]: Group } | string[]) { + public initGroups(groups: { [key: string]: Group } | string[]): void { if (!groups) return; let groupsObject: { [key: string]: Group } = {}; - // If groups is Array of GroupNames transform it to Group Object + // If groups is Array of Group keys/names, create the Groups based on these keys if (Array.isArray(groups)) { groups.forEach((groupKey) => { groupsObject[groupKey] = new Group(this, [], { @@ -185,25 +233,25 @@ export class Collection { key: this.config.defaultGroupKey, }); - // Set Key/Name of Group to property Name + // Assign missing key/name to Group based on the property key for (const key in groupsObject) if (groupsObject[key]._key == null) groupsObject[key].setKey(key); this.groups = groupsObject; } - //========================================================================================================= - // Init Selectors - //========================================================================================================= /** + * Sets up the specified Selectors or Selector keys + * and assigns them to the Collection if they are valid. + * * @internal - * Instantiates Selectors + * @param selectors - Entire Selectors or Selector keys to be set up. */ public initSelectors(selectors: { [key: string]: Selector } | string[]) { if (!selectors) return; let selectorsObject: { [key: string]: Selector } = {}; - // If selectors is Array of SelectorNames transform it to Selector Object + // If selectors is Array of Selector keys/names, create the Selectors based on these keys if (Array.isArray(selectors)) { selectors.forEach((selectorKey) => { selectorsObject[selectorKey] = new Selector( @@ -216,29 +264,40 @@ export class Collection { }); } else selectorsObject = selectors; - // Set Key/Name of Selector to property Name + // Assign missing key/name to Selector based on the property key for (const key in selectorsObject) if (selectorsObject[key]._key == null) selectorsObject[key].setKey(key); this.selectors = selectorsObject; } - //========================================================================================================= - // Collect - //========================================================================================================= /** + * Appends new data objects following the same pattern to the end of the Collection. + * + * Each collected `data object` requires a unique identifier at the primaryKey property (by default 'id') + * to be correctly identified later. + * + * For example, if we collect some kind of user object, + * it must contain such unique identifier at 'id' + * to be added to the Collection. + * ``` + * MY_COLLECTION.collect({id: '1', name: 'jeff'}); // valid + * MY_COLLECTION.collect({name: 'frank'}); // invalid + * ``` + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#collect) + * * @public - * Collect Item/s - * @param data - Data that gets added to Collection - * @param groupKeys - Add collected Item/s to certain Groups - * @param config - Config + * @param data - Data objects or entire Items to be added. + * @param groupKeys - Group/s to which the specified data objects or Items are to be added. + * @param config - Configuration object */ public collect( - data: DataType | Array, + data: DataType | Item | Array>, groupKeys?: GroupKey | Array, config: CollectConfigInterface = {} ): this { - const _data = normalizeArray(data); + const _data = normalizeArray>(data); const _groupKeys = normalizeArray(groupKeys); const defaultGroupKey = this.config.defaultGroupKey; const primaryKey = this.config.primaryKey; @@ -249,7 +308,7 @@ export class Collection { select: false, }); - // Add default GroupKey, because Items get always added to default Group + // Add default groupKey, since all Items are added to the default Group if (!_groupKeys.includes(defaultGroupKey)) _groupKeys.push(defaultGroupKey); // Create not existing Groups @@ -258,39 +317,51 @@ export class Collection { ); _data.forEach((data, index) => { - const itemKey = data[primaryKey]; - - // Add Item to Collection - const success = this.setData(data, { - patch: config.patch, - background: config.background, - }); - if (!success) return this; + let itemKey; + let success = false; - // Add ItemKey to provided Groups - _groupKeys.forEach((groupKey) => { - this.getGroup(groupKey)?.add(itemKey, { - method: config.method, + // Assign Data or Item to Collection + if (data instanceof Item) { + success = this.assignItem(data, { background: config.background, }); - }); + itemKey = data._key; + } else { + success = this.assignData(data, { + patch: config.patch, + background: config.background, + }); + itemKey = data[primaryKey]; + } + + // Add itemKey to provided Groups and create corresponding Selector + if (success) { + _groupKeys.forEach((groupKey) => { + this.getGroup(groupKey)?.add(itemKey, { + method: config.method, + background: config.background, + }); + }); - if (config.select) this.createSelector(itemKey, itemKey); - if (config.forEachItem) config.forEachItem(data, itemKey, index); + if (config.select) this.createSelector(itemKey, itemKey); + } + + if (config.forEachItem) config.forEachItem(data, itemKey, success, index); }); return this; } - //========================================================================================================= - // Update - //========================================================================================================= /** + * Updates the Item `data object` with the specified `object with changes`, if the Item exists. + * By default the `object with changes` is merged into the Item `data object` at top level. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#update) + * * @public - * Updates Item at provided Key - * @param itemKey - ItemKey of Item that gets updated - * @param changes - Changes that will be merged into the Item (flatMerge) - * @param config - Config + * @param itemKey - Key/Name identifier of the Item to be updated. + * @param changes - Object with changes to be merged into the Item data object. + * @param config - Configuration object */ public update( itemKey: ItemKey, @@ -304,6 +375,7 @@ export class Collection { background: false, }); + // Check if the given conditions are suitable for a update action if (item == null) { LogCodeManager.log('1B:03:00', [itemKey, this._key]); return undefined; @@ -315,17 +387,17 @@ export class Collection { const oldItemKey = item._value[primaryKey]; const newItemKey = changes[primaryKey] || oldItemKey; - const updateItemKey = oldItemKey !== newItemKey; - // Update ItemKey - if (updateItemKey) + // Update itemKey if the new itemKey differs from the old one + if (oldItemKey !== newItemKey) this.updateItemKey(oldItemKey, newItemKey, { background: config.background, }); - // Patch changes into Item + // Patch changes into Item data object if (config.patch) { - // Delete primaryKey from 'changes' because if it has changed, it gets properly updated in 'updateItemKey' (see above) + // Delete primaryKey property from 'changes object' because if it has changed, + // it is correctly updated in the above called 'updateItemKey()' method if (changes[primaryKey]) delete changes[primaryKey]; let patchConfig: { addNewProperties?: boolean } = @@ -334,22 +406,19 @@ export class Collection { addNewProperties: true, }); - // Apply changes to Item item.patch(changes as any, { background: config.background, addNewProperties: patchConfig.addNewProperties, }); } - - // Set changes into Item - if (!config.patch) { - // To make sure that the primaryKey doesn't differ from the changes object primaryKey + // Apply changes to Item data object + else { + // Ensure that the current Item identifier isn't different from the 'changes object' itemKey if (changes[this.config.primaryKey] !== itemKey) { changes[this.config.primaryKey] = itemKey; LogCodeManager.log('1B:02:02', [], changes); } - // Apply changes to Item item.set(changes as any, { background: config.background, }); @@ -358,21 +427,20 @@ export class Collection { return item; } - //========================================================================================================= - // Create Group - //========================================================================================================= /** + * Creates a new Group and associates it to the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#createGroup) + * * @public - * Creates new Group that can hold Items of Collection - * @param groupKey - Name/Key of Group - * @param initialItems - Initial ItemKeys of Group + * @param groupKey - Unique identifier of the Group to be created. + * @param initialItems - Key/Name identifiers of the Items to be clustered by the Group. */ public createGroup( groupKey: GroupKey, initialItems: Array = [] ): Group { let group = this.getGroup(groupKey, { notExisting: true }); - if (!this.isInstantiated) LogCodeManager.log('1B:02:03'); // Check if Group already exists @@ -385,21 +453,22 @@ export class Collection { return group; } - // Create Group + // Create new Group group = new Group(this, initialItems, { key: groupKey }); this.groups[groupKey] = group; return group; } - //========================================================================================================= - // Has Group - //========================================================================================================= /** + * Returns a boolean indicating whether a Group with the specified `groupKey` + * exists in the Collection or not. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#hasgroup) + * * @public - * Check if Group exists in Collection - * @param groupKey - Key/Name of Group - * @param config - Config + * @param groupKey - Key/Name identifier of the Group to be checked for existence. + * @param config - Configuration object */ public hasGroup( groupKey: GroupKey | undefined, @@ -408,14 +477,16 @@ export class Collection { return !!this.getGroup(groupKey, config); } - //========================================================================================================= - // Get Group - //========================================================================================================= /** + * Retrieves a single Group with the specified key/name identifier from the Collection. + * + * If the to retrieve Group doesn't exist, `undefined` is returned. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getgroup) + * * @public - * Get Group by Key/Name - * @param groupKey - Key/Name of Group - * @param config - Config + * @param groupKey - Key/Name identifier of the Group. + * @param config - Configuration object */ public getGroup( groupKey: GroupKey | undefined, @@ -425,35 +496,44 @@ export class Collection { notExisting: false, }); - // Get Group + // Retrieve Group const group = groupKey ? this.groups[groupKey] : undefined; - // Check if Group exists - if (group == null || (!config.notExisting && group.isPlaceholder)) + // Check if retrieved Group exists + if (group == null || (!config.notExisting && !group.exists)) return undefined; ComputedTracker.tracked(group.observer); return group; } - //========================================================================================================= - // Get Default Group - //========================================================================================================= /** + * Retrieves the default Group from the Collection. + * + * Every Collection should have a default Group, + * which represents the default pattern of the Collection. + * + * If the default Group, for what ever reason, doesn't exist, `undefined` is returned. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getdefaultgroup) + * * @public - * Get default Group of Collection */ public getDefaultGroup(): Group | undefined { return this.getGroup(this.config.defaultGroupKey); } - //========================================================================================================= - // Get Group With Reference - //========================================================================================================= /** + * Retrieves a single Group with the specified key/name identifier from the Collection. + * + * If the to retrieve Group doesn't exist, a reference Group is returned. + * This has the advantage that Components that have the reference Group bound to themselves + * are rerenderd when the original Group is created. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getgroupwithreference) + * * @public - * Get Group by Key/Name or a Reference to it if it doesn't exist yet - * @param groupKey - Name/Key of Group + * @param groupKey - Key/Name identifier of the Group. */ public getGroupWithReference(groupKey: GroupKey): Group { let group = this.getGroup(groupKey, { notExisting: true }); @@ -471,35 +551,47 @@ export class Collection { return group; } - //========================================================================================================= - // Remove Group - //========================================================================================================= /** + * Removes a Group with the specified key/name identifier from the Collection, + * if it exists in the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#removegroup) + * * @public - * Removes Group by Key/Name - * @param groupKey - Name/Key of Group + * @param groupKey - Key/Name identifier of the Group to be removed. */ public removeGroup(groupKey: GroupKey): this { - if (this.groups[groupKey] == null) return this; - delete this.groups[groupKey]; + if (this.groups[groupKey] != null) delete this.groups[groupKey]; return this; } - //========================================================================================================= - // Create Selector - //========================================================================================================= /** + * Returns the count of registered Groups in the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getgroupcount) + * * @public - * Creates new Selector that represents an Item of the Collection - * @param selectorKey - Name/Key of Selector - * @param itemKey - Key of Item which the Selector represents + */ + public getGroupCount(): number { + let size = 0; + Object.keys(this.groups).map(() => size++); + return size; + } + + /** + * Creates a new Selector and associates it to the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#createSelector) + * + * @public + * @param selectorKey - Unique identifier of the Selector to be created. + * @param itemKey - Key/Name identifier of the Item to be represented by the Selector. */ public createSelector( selectorKey: SelectorKey, itemKey: ItemKey ): Selector { let selector = this.getSelector(selectorKey, { notExisting: true }); - if (!this.isInstantiated) LogCodeManager.log('1B:02:04'); // Check if Selector already exists @@ -512,7 +604,7 @@ export class Collection { return selector; } - // Create Selector + // Create new Selector selector = new Selector(this, itemKey, { key: selectorKey, }); @@ -521,26 +613,35 @@ export class Collection { return selector; } - //========================================================================================================= - // Select - //========================================================================================================= /** + * Creates a new Selector and associates it to the Collection. + * + * The specified `itemKey` is used as the unique identifier key of the new Selector. + * ``` + * MY_COLLECTION.select('1'); + * // is equivalent to + * MY_COLLECTION.createSelector('1', '1'); + * ``` + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#select) + * * @public - * Creates new Selector that represents an Item of the Collection - * @param itemKey - Key of Item which the Selector represents + * @param itemKey - Key/Name identifier of the Item to be represented by the Selector + * and used as unique identifier of the Selector. */ public select(itemKey: ItemKey): Selector { return this.createSelector(itemKey, itemKey); } - //========================================================================================================= - // Has Selector - //========================================================================================================= /** + * Returns a boolean indicating whether a Selector with the specified `selectorKey` + * exists in the Collection or not. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#hasselector) + * * @public - * Check if Selector exists in Collection - * @param selectorKey - Key/Name of Selector - * @param config - Config + * @param selectorKey - Key/Name identifier of the Selector to be checked for existence. + * @param config - Configuration object */ public hasSelector( selectorKey: SelectorKey | undefined, @@ -549,14 +650,16 @@ export class Collection { return !!this.getSelector(selectorKey, config); } - //========================================================================================================= - // Get Selector - //========================================================================================================= /** + * Retrieves a single Selector with the specified key/name identifier from the Collection. + * + * If the to retrieve Selector doesn't exist, `undefined` is returned. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getselector) + * * @public - * Get Selector by Key/Name - * @param selectorKey - Key/Name of Selector - * @param config - Config + * @param selectorKey - Key/Name identifier of the Selector. + * @param config - Configuration object */ public getSelector( selectorKey: SelectorKey | undefined, @@ -570,20 +673,24 @@ export class Collection { const selector = selectorKey ? this.selectors[selectorKey] : undefined; // Check if Selector exists - if (selector == null || (!config.notExisting && selector.isPlaceholder)) + if (selector == null || (!config.notExisting && !selector.exists)) return undefined; ComputedTracker.tracked(selector.observer); return selector; } - //========================================================================================================= - // Get Selector With Reference - //========================================================================================================= /** + * Retrieves a single Selector with the specified key/name identifier from the Collection. + * + * If the to retrieve Selector doesn't exist, a reference Selector is returned. + * This has the advantage that Components that have the reference Selector bound to themselves + * are rerenderd when the original Selector is created. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getselectorwithreference) + * * @public - * Get Selector by Key/Name or a Reference to it if it doesn't exist yet - * @param selectorKey - Name/Key of Selector + * @param selectorKey - Key/Name identifier of the Selector. */ public getSelectorWithReference( selectorKey: SelectorKey @@ -607,29 +714,45 @@ export class Collection { return selector; } - //========================================================================================================= - // Remove Selector - //========================================================================================================= /** + * Removes a Selector with the specified key/name identifier from the Collection, + * if it exists in the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#removeselector) + * * @public - * Removes Selector by Key/Name - * @param selectorKey - Name/Key of Selector + * @param selectorKey - Key/Name identifier of the Selector to be removed. */ public removeSelector(selectorKey: SelectorKey): this { - if (this.selectors[selectorKey] == null) return this; - this.selectors[selectorKey].unselect(); // Unselects current selected Item - delete this.selectors[selectorKey]; + if (this.selectors[selectorKey] != null) { + this.selectors[selectorKey].unselect(); + delete this.selectors[selectorKey]; + } return this; } - //========================================================================================================= - // Has Item - //========================================================================================================= /** + * Returns the count of registered Selectors in the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getselectorcount) + * + * @public + */ + public getSelectorCount(): number { + let size = 0; + Object.keys(this.selectors).map(() => size++); + return size; + } + + /** + * Returns a boolean indicating whether a Item with the specified `itemKey` + * exists in the Collection or not. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#hasitem) + * * @public - * Check if Item exists in Collection - * @param itemKey - Key/Name of Item - * @param config - Config + * @param itemKey - Key/Name identifier of the Item. + * @param config - Configuration object */ public hasItem( itemKey: ItemKey | undefined, @@ -638,14 +761,16 @@ export class Collection { return !!this.getItem(itemKey, config); } - //========================================================================================================= - // Get Item by Id - //========================================================================================================= /** + * Retrieves a single Item with the specified key/name identifier from the Collection. + * + * If the to retrieve Item doesn't exist, `undefined` is returned. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getitem) + * * @public - * Get Item by Key/Name - * @param itemKey - ItemKey of Item - * @param config - Config + * @param itemKey - Key/Name identifier of the Item. + * @param config - Configuration object */ public getItem( itemKey: ItemKey | undefined, @@ -666,40 +791,71 @@ export class Collection { } /** + * Retrieves a single Item with the specified key/name identifier from the Collection. + * + * If the to retrieve Item doesn't exist, a reference Item is returned. + * This has the advantage that Components that have the reference Item bound to themselves + * are rerenderd when the original Item is created. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getitemwithreference) + * * @public - * Get Item by Key/Name or a Reference to it if it doesn't exist yet - * @param itemKey - Key/Name of Item + * @param itemKey - Key/Name identifier of the Item. */ public getItemWithReference(itemKey: ItemKey): Item { let item = this.getItem(itemKey, { notExisting: true }); // Create dummy Item to hold reference - if (item == null) { - item = new Item( - this, - { - [this.config.primaryKey]: itemKey, // Setting PrimaryKey of Item to passed itemKey - dummy: 'item', - } as any, - { - isPlaceholder: true, - } - ); + if (item == null) item = this.createPlaceholderItem(itemKey, true); + + ComputedTracker.tracked(item.observer); + return item; + } + + /** + * Creates a placeholder Item + * that can be used to hold a reference to a not existing Item. + * + * @internal + * @param itemKey - Unique identifier of the to create placeholder Item. + * @param addToCollection - Whether to add the Item to be created to the Collection. + */ + public createPlaceholderItem( + itemKey: ItemKey, + addToCollection = false + ): Item { + // Create placeholder Item + const item = new Item( + this, + { + [this.config.primaryKey]: itemKey, // Setting primaryKey of the Item to passed itemKey + dummy: 'item', + } as any, + { isPlaceholder: true } + ); + + // Add placeholder Item to Collection + if ( + addToCollection && + !Object.prototype.hasOwnProperty.call(this.data, itemKey) + ) this.data[itemKey] = item; - } ComputedTracker.tracked(item.observer); return item; } - //========================================================================================================= - // Get Value by Id - //========================================================================================================= /** + * Retrieves the value (data object) of a single Item + * with the specified key/name identifier from the Collection. + * + * If the to retrieve Item containing the value doesn't exist, `undefined` is returned. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getitemvalue) + * * @public - * Get Value of Item by Key/Name - * @param itemKey - ItemKey of Item that holds the Value - * @param config - Config + * @param itemKey - Key/Name identifier of the Item. + * @param config - Configuration object */ public getItemValue( itemKey: ItemKey | undefined, @@ -710,13 +866,18 @@ export class Collection { return item.value; } - //========================================================================================================= - // Get All Items - //========================================================================================================= /** + * Retrieves all Items from the Collection. + * ``` + * MY_COLLECTION.getAllItems(); + * // is equivalent to + * MY_COLLECTION.getDefaultGroup().items; + * ``` + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getallitems) + * * @public - * Get all Items of Collection - * @param config - Config + * @param config - Configuration object */ public getAllItems(config: HasConfigInterface = {}): Array> { config = defineConfig(config, { @@ -726,46 +887,62 @@ export class Collection { const defaultGroup = this.getDefaultGroup(); let items: Array> = []; - // If config.notExisting transform this.data into array, otherwise return the default Group items + // If config.notExisting transform the data object into array since it contains all Items, + // otherwise return the default Group Items if (config.notExisting) { for (const key in this.data) items.push(this.data[key]); } else { - // Why defaultGroup Items and not all .exists === true Items? - // Because the default Group keeps track of all existing Items - // It also does control the Collection output in useAgile() and should do it here too + // Why default Group Items and not all '.exists === true' Items? + // 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 || []; } return items; } - //========================================================================================================= - // Get All Item Values - //========================================================================================================= /** + * Retrieves the values (data objects) of all Items from the Collection. + * ``` + * MY_COLLECTION.getAllItemValues(); + * // is equivalent to + * MY_COLLECTION.getDefaultGroup().output; + * ``` + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getallitemvalues) + * * @public - * Get all Values of Items in a Collection - * @param config - Config + * @param config - Configuration object */ public getAllItemValues(config: HasConfigInterface = {}): Array { const items = this.getAllItems(config); return items.map((item) => item.value); } - //========================================================================================================= - // Persist - //========================================================================================================= /** + * Preserves the Collection `value` in the corresponding external Storage. + * + * The Collection key/name is used as the unique identifier for the Persistent. + * If that is not desired or the Collection has no unique identifier, + * please specify a separate unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#persist) + * * @public - * Stores Collection Value into Agile Storage permanently - * @param config - Config + * @param config - Configuration object */ public persist(config?: CollectionPersistentConfigInterface): this; /** + * Preserves the Collection `value` in the corresponding external Storage. + * + * The specified key is used as the unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#persist) + * * @public - * Stores Collection Value into Agile Storage permanently - * @param key - Key/Name of created Persistent (Note: Key required if Collection has no set Key!) - * @param config - Config + * @param key - Key/Name identifier of Persistent. + * @param config - Configuration object */ public persist( key?: StorageKey, @@ -792,7 +969,10 @@ export class Collection { defaultStorageKey: null, }); - // Create persistent -> Persist Value + // Check if Collection is already persisted + if (this.persistent != null && this.isPersisted) return this; + + // Create Persistent (-> persist value) this.persistent = new CollectionPersistent(this, { instantiate: _config.loadValue, storageKeys: _config.storageKeys, @@ -803,67 +983,44 @@ export class Collection { return this; } - //========================================================================================================= - // On Load - //========================================================================================================= /** + * Fires immediately after the persisted `value` + * is loaded into the Collection from a corresponding external Storage. + * + * Registering such callback function makes only sense + * when the Collection is [persisted](https://agile-ts.org/docs/core/collection/methods/#persist) in an external Storage. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#onload) + * * @public - * Callback Function that gets called if the persisted Value gets loaded into the Collection for the first Time - * Note: Only useful for persisted Collections! - * @param callback - Callback Function + * @param callback - A function to be executed after the externally persisted `value` was loaded into the Collection. */ public onLoad(callback: (success: boolean) => void): this { if (!this.persistent) return this; - - // Check if Callback is valid Function if (!isFunction(callback)) { LogCodeManager.log('00:03:01', ['OnLoad Callback', 'function']); return this; } + // Register specified callback this.persistent.onLoad = callback; - // If Collection is already 'isPersisted' the loading was successful -> callback can be called + // If Collection is already persisted ('isPersisted') fire specified callback immediately if (this.isPersisted) callback(true); return this; } - //========================================================================================================= - // Get Group Count - //========================================================================================================= - /** - * @public - * Get count of registered Groups in Collection - */ - public getGroupCount(): number { - let size = 0; - for (const group in this.groups) size++; - return size; - } - - //========================================================================================================= - // Get Selector Count - //========================================================================================================= - /** - * @public - * Get count of registered Selectors in Collection - */ - public getSelectorCount(): number { - let size = 0; - for (const selector in this.selectors) size++; - return size; - } - - //========================================================================================================= - // Reset - //========================================================================================================= /** + * Removes all Items from the Collection + * and resets all Groups and Selectors of the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#reset) + * * @public - * Resets this Collection */ public reset(): this { - // Reset Data + // Reset data this.data = {}; this.size = 0; @@ -876,15 +1033,15 @@ export class Collection { return this; } - //========================================================================================================= - // Put - //========================================================================================================= /** + * Puts `itemKeys/s` into Group/s. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#put) + * * @public - * Puts ItemKey/s into Group/s (GroupKey/s) - * @param itemKeys - ItemKey/s that get added to provided Group/s - * @param groupKeys - Group/s to which the ItemKey/s get added - * @param config - Config + * @param itemKeys - `itemKey/s` to be put into the specified Group/s. + * @param groupKeys - Key/Name Identifier/s of the Group/s the specified `itemKey/s` are to put in. + * @param config - Configuration object */ public put( itemKeys: ItemKey | Array, @@ -894,7 +1051,7 @@ export class Collection { const _itemKeys = normalizeArray(itemKeys); const _groupKeys = normalizeArray(groupKeys); - // Add ItemKeys to Groups + // Assign itemKeys to Groups _groupKeys.forEach((groupKey) => { this.getGroup(groupKey)?.add(_itemKeys, config); }); @@ -902,16 +1059,16 @@ export class Collection { return this; } - //========================================================================================================= - // Move - //========================================================================================================= /** + * Moves specified `itemKey/s` from one Group to another Group. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#move) + * * @public - * Move ItemKey/s from one Group to another - * @param itemKeys - ItemKey/s that are moved - * @param oldGroupKey - GroupKey of the Group that currently keeps the Items at itemKey/s - * @param newGroupKey - GroupKey of the Group into which the Items at itemKey/s are moved - * @param config - Config + * @param itemKeys - `itemKey/s` to be moved. + * @param oldGroupKey - Key/Name Identifier of the Group the `itemKey/s` are moved from. + * @param newGroupKey - Key/Name Identifier of the Group the `itemKey/s` are moved in. + * @param config - Configuration object */ public move( itemKeys: ItemKey | Array, @@ -927,21 +1084,21 @@ export class Collection { removeProperties(config, ['method', 'overwrite']) ); - // Add itemKeys to new Group + // Assign itemKeys to new Group this.getGroup(newGroupKey)?.add(_itemKeys, config); return this; } - //========================================================================================================= - // Update Item Key - //========================================================================================================= /** + * Updates the key/name identifier of the Item + * and returns a boolean indicating + * whether the Item identifier was updated successfully. + * * @internal - * Updates Key/Name of Item in all Instances (Group, Selector, ..) - * @param oldItemKey - Old ItemKey - * @param newItemKey - New ItemKey - * @param config - Config + * @param oldItemKey - Old key/name Item identifier. + * @param newItemKey - New key/name Item identifier. + * @param config - Configuration object */ public updateItemKey( oldItemKey: ItemKey, @@ -961,39 +1118,47 @@ export class Collection { return false; } - // Remove Item from old ItemKey and add Item to new ItemKey + // Update itemKey in data object delete this.data[oldItemKey]; this.data[newItemKey] = item; - // Update Key/Name of Item + // Update key/name of the Item item.setKey(newItemKey, { background: config.background, }); - // Update persist Key of Item (Doesn't get updated by updating key of Item because PersistKey is special formatted) - item.persistent?.setKey( - CollectionPersistent.getItemStorageKey(newItemKey, this._key) - ); + // Update Persistent key of the Item if it follows the Item Storage Key pattern + // and therefore differs from the actual Item key + // (-> isn't automatically updated when the Item key is updated) + if ( + item.persistent != null && + item.persistent._key === + CollectionPersistent.getItemStorageKey(oldItemKey, this._key) + ) + item.persistent?.setKey( + CollectionPersistent.getItemStorageKey(newItemKey, this._key) + ); - // Update ItemKey in Groups + // Update itemKey in Groups for (const groupKey in this.groups) { const group = this.getGroup(groupKey, { notExisting: true }); - if (!group?.has(oldItemKey)) continue; - group?.replace(oldItemKey, newItemKey, { background: config.background }); + if (group == null || !group.has(oldItemKey)) continue; + group.replace(oldItemKey, newItemKey, { background: config.background }); } - // Update ItemKey in Selectors + // Update itemKey in Selectors for (const selectorKey in this.selectors) { const selector = this.getSelector(selectorKey, { notExisting: true }); if (selector == null) continue; - // Reselect Item in Selector that has selected the newItemKey - // Necessary because the reference placeholder Item got removed - // and replaced with the new Item (Item of which the primaryKey was renamed) - // -> needs to find new Item with the same itemKey + // Reselect Item in Selector that has selected the newItemKey. + // Necessary because potential reference placeholder Item got overwritten + // with the new (renamed) Item + // -> has to find the new Item at selected itemKey + // since the placeholder Item got overwritten if (selector.hasSelected(newItemKey, false)) { selector.reselect({ - force: true, // Because ItemKeys are the same + force: true, // Because itemKeys are the same (but not the Items at this itemKey anymore) background: config.background, }); } @@ -1008,30 +1173,45 @@ export class Collection { return true; } - //========================================================================================================= - // Get GroupKeys That Have ItemKey - //========================================================================================================= /** + * Returns all key/name identifiers of the Group/s containing the specified `itemKey`. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getgroupkeysthathaveitemkey) + * * @public - * Gets GroupKeys that contain the passed ItemKey - * @param itemKey - ItemKey + * @param itemKey - `itemKey` to be contained in Group/s. */ public getGroupKeysThatHaveItemKey(itemKey: ItemKey): Array { const groupKeys: Array = []; for (const groupKey in this.groups) { - const group = this.getGroup(groupKey, { notExisting: true }); + const group = this.groups[groupKey]; if (group?.has(itemKey)) groupKeys.push(groupKey); } return groupKeys; } - //========================================================================================================= - // Remove - //========================================================================================================= /** + * Removes Item/s from: + * + * - `.everywhere()`: + * Removes Item/s from the entire Collection and all its Groups and Selectors (i.e. from everywhere) + * ``` + * MY_COLLECTION.remove('1').everywhere(); + * // is equivalent to + * MY_COLLECTION.removeItems('1'); + * ``` + * - `.fromGroups()`: + * Removes Item/s only from specified Groups. + * ``` + * MY_COLLECTION.remove('1').fromGroups(['1', '2']); + * // is equivalent to + * MY_COLLECTION.removeFromGroups('1', ['1', '2']); + * ``` + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#remove) + * * @public - * Remove Items from Collection - * @param itemKeys - ItemKey/s that get removed + * @param itemKeys - Item/s with identifier/s to be removed. */ public remove( itemKeys: ItemKey | Array @@ -1046,14 +1226,14 @@ export class Collection { }; } - //========================================================================================================= - // Remove From Groups - //========================================================================================================= /** + * Remove Item/s from specified Group/s. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#removefromgroups) + * * @public - * Removes Item/s from Group/s - * @param itemKeys - ItemKey/s that get removed from Group/s - * @param groupKeys - GroupKey/s of Group/s form which the ItemKey/s will be removed + * @param itemKeys - Key/Name Identifier/s of the Item/s to be removed from the Group/s. + * @param groupKeys - Key/Name Identifier/s of the Group/s the Item/s are to remove from. */ public removeFromGroups( itemKeys: ItemKey | Array, @@ -1065,7 +1245,7 @@ export class Collection { _itemKeys.forEach((itemKey) => { let removedFromGroupsCount = 0; - // Remove ItemKey from Groups + // Remove itemKey from the Groups _groupKeys.forEach((groupKey) => { const group = this.getGroup(groupKey, { notExisting: true }); if (!group?.has(itemKey)) return; @@ -1073,7 +1253,8 @@ export class Collection { removedFromGroupsCount++; }); - // If Item got removed from every Groups the Item was in, remove it completely + // If the Item was removed from each Group representing the Item, + // remove it completely if ( removedFromGroupsCount >= this.getGroupKeysThatHaveItemKey(itemKey).length @@ -1084,14 +1265,14 @@ export class Collection { return this; } - //========================================================================================================= - // Remove Items - //========================================================================================================= /** + * Removes Item/s from the entire Collection and all the Collection's Groups and Selectors. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#removeitems) + * * @public - * Removes Item completely from Collection - * @param itemKeys - ItemKey/s of Item/s - * @param config - Config + * @param itemKeys - Key/Name identifier/s of the Item/s to be removed from the entire Collection. + * @param config - Configuration object */ public removeItems( itemKeys: ItemKey | Array, @@ -1108,7 +1289,7 @@ export class Collection { if (item == null) return; const wasPlaceholder = item.isPlaceholder; - // Remove Item from Groups + // Remove Item from the Groups for (const groupKey in this.groups) { const group = this.getGroup(groupKey, { notExisting: true }); if (group?.has(itemKey)) group?.remove(itemKey); @@ -1120,16 +1301,18 @@ export class Collection { // Remove Item from Collection delete this.data[itemKey]; - // Reselect or remove Selectors representing the removed Item + // Reselect or remove Selectors which have represented the removed Item for (const selectorKey in this.selectors) { const selector = this.getSelector(selectorKey, { notExisting: true }); - if (selector?.hasSelected(itemKey, false)) { + if (selector != null && selector.hasSelected(itemKey, false)) { if (config.removeSelector) { // Remove Selector - this.removeSelector(selector?._key ?? 'unknown'); + this.removeSelector(selector._key ?? 'unknown'); } else { - // Reselect Item in Selector (to create new dummyItem to hold a reference to this removed Item) - selector?.reselect({ force: true }); + // Reselect Item in Selector + // in order to create a new dummyItem + // to hold a reference to the now not existing Item + selector.reselect({ force: true }); } } } @@ -1140,68 +1323,134 @@ export class Collection { return this; } - //========================================================================================================= - // Set Data - //========================================================================================================= /** + * Assigns the provided `data` object to an already existing Item + * with specified key/name identifier found in the `data` object. + * If the Item doesn't exist yet, a new Item with the `data` object as value + * is created and assigned to the Collection. + * + * Returns a boolean indicating + * whether the `data` object was assigned/updated successfully. + * * @internal - * Updates existing or creates Item from provided Data - * @param data - Data - * @param config - Config + * @param data - Data object + * @param config - Configuration object */ - public setData(data: DataType, config: SetDataConfigInterface = {}): boolean { - const _data = copy(data as any); // Transformed Data to any because of unknown Object (DataType) - const primaryKey = this.config.primaryKey; + public assignData( + data: DataType, + config: AssignDataConfigInterface = {} + ): boolean { config = defineConfig(config, { patch: false, background: false, }); + const _data = copy(data); // Copy data object to get rid of reference + const primaryKey = this.config.primaryKey; if (!isValidObject(_data)) { LogCodeManager.log('1B:03:05', [this._key]); return false; } + // Check if data object contains valid itemKey, + // otherwise add random itemKey to Item if (!Object.prototype.hasOwnProperty.call(_data, primaryKey)) { - LogCodeManager.log('1B:02:05', [this._key, this.config.primaryKey]); - _data[this.config.primaryKey] = generateId(); + LogCodeManager.log('1B:02:05', [this._key, primaryKey]); + _data[primaryKey] = generateId(); } const itemKey = _data[primaryKey]; - let item = this.getItem(itemKey, { notExisting: true }); + const item = this.getItem(itemKey, { notExisting: true }); const wasPlaceholder = item?.isPlaceholder || false; - const createItem = item == null; - - // Create or update Item - if (!createItem && config.patch) - item?.patch(_data, { background: config.background }); - if (!createItem && !config.patch) - item?.set(_data, { background: config.background }); - if (createItem) { - // Create and assign Item to Collection - item = new Item(this, _data); - this.data[itemKey] = item; - // Rebuild Groups That include ItemKey after assigning Item to Collection (otherwise it can't find Item) - this.rebuildGroupsThatIncludeItemKey(itemKey, { + // Create new Item or update existing Item + if (item != null) { + if (config.patch) { + item.patch(_data, { background: config.background }); + } else { + item.set(_data, { background: config.background }); + } + } else { + this.assignItem(new Item(this, _data), { background: config.background, }); } - // Increase size of Collection - if (createItem || wasPlaceholder) this.size++; + // Increase size of Collection if Item was previously a placeholder + // (-> hasn't officially existed in Collection before) + if (wasPlaceholder) this.size++; return true; } - //========================================================================================================= - // Rebuild Groups That Includes Item Key - //========================================================================================================= /** + * Assigns the specified Item to the Collection + * at the key/name identifier of the Item. + * + * And returns a boolean indicating + * whether the Item was assigned successfully. + * * @internal - * Rebuilds Groups that include the provided ItemKey - * @itemKey - Item Key - * @config - Config + * @param item - Item to be added. + * @param config - Configuration object + */ + public assignItem( + item: Item, + config: AssignItemConfigInterface = {} + ): boolean { + config = defineConfig(config, { + overwrite: false, + background: false, + }); + const primaryKey = this.config.primaryKey; + let itemKey = item._value[primaryKey]; + let increaseCollectionSize = true; + + // Check if Item has valid itemKey, + // otherwise add random itemKey to Item + if (!Object.prototype.hasOwnProperty.call(item._value, primaryKey)) { + LogCodeManager.log('1B:02:05', [this._key, primaryKey]); + itemKey = generateId(); + item.patch( + { [this.config.primaryKey]: itemKey }, + { background: config.background } + ); + item._key = itemKey; + } + + // Check if Item belongs to this Collection + if (item.collection() !== this) { + LogCodeManager.log('1B:03:06', [this._key, item.collection()._key]); + return false; + } + + // Check if Item already exists + if (this.getItem(itemKey) != null) { + if (!config.overwrite) return true; + else increaseCollectionSize = false; + } + + // Assign/add Item to Collection + this.data[itemKey] = item; + + // Rebuild Groups that include itemKey + // after adding Item with itemKey to the Collection + // (because otherwise it can't find the Item as it isn't added yet) + this.rebuildGroupsThatIncludeItemKey(itemKey, { + background: config.background, + }); + + if (increaseCollectionSize) this.size++; + + return true; + } + + /** + * Rebuilds all Groups that contain the specified `itemKey`. + * + * @internal + * @itemKey - `itemKey` Groups must contain to be rebuilt. + * @config - Configuration object */ public rebuildGroupsThatIncludeItemKey( itemKey: ItemKey, @@ -1215,16 +1464,19 @@ export class Collection { }, }); - // Rebuild Groups that include ItemKey + // Rebuild Groups that include itemKey for (const groupKey in this.groups) { const group = this.getGroup(groupKey); if (group?.has(itemKey)) { - // group.rebuild(); Not necessary because a sideEffect of the Group is to rebuild it self + // Not necessary because a sideEffect of ingesting the Group + // into the runtime is to rebuilt itself + // group.rebuild(); + group?.ingest({ background: config?.background, - force: true, // because Group value doesn't change only the output changes + force: true, // because Group value didn't change, only the output might change sideEffects: config?.sideEffects, - storage: false, // because Group only rebuilds and doesn't change its value + storage: false, // because Group only rebuilds (-> actual persisted value hasn't changed) }); } } @@ -1235,115 +1487,206 @@ export type DefaultItem = Record; // same as { [key: string]: any } export type CollectionKey = string | number; export type ItemKey = string | number; -/** - * @param key - Key/Name of Collection - * @param groups - Groups of Collection - * @param selectors - Selectors of Collection - * @param primaryKey - Name of Property that holds the PrimaryKey (default = id) - * @param defaultGroupKey - Key/Name of Default Group that holds all collected Items - * @param initialData - Initial Data of Collection - */ export interface CreateCollectionConfigInterface { + /** + * Initial Groups of the Collection. + * @default [] + */ groups?: { [key: string]: Group } | string[]; + /** + * Initial Selectors of the Collection + * @default [] + */ selectors?: { [key: string]: Selector } | string[]; + /** + * Key/Name identifier of the Collection. + * @default undefined + */ key?: CollectionKey; + /** + * Key/Name of the property + * which represents the unique Item identifier + * in collected data objects. + * @default 'id' + */ primaryKey?: string; + /** + * Key/Name identifier of the default Group that is created shortly after instantiation. + * The default Group represents the default pattern of the Collection. + * @default 'default' + */ defaultGroupKey?: GroupKey; + /** + * Initial data objects of the Collection. + * @default [] + */ initialData?: Array; } -/** - * @param primaryKey - Name of Property that holds the PrimaryKey (default = id) - * @param defaultGroupKey - Key/Name of Default Group that holds all collected Items - */ +export type CollectionConfig = + | CreateCollectionConfigInterface + | (( + collection: Collection + ) => CreateCollectionConfigInterface); + export interface CollectionConfigInterface { + /** + * Key/Name of the property + * which represents the unique Item identifier + * in collected data objects. + * @default 'id' + */ primaryKey: string; + /** + * Key/Name identifier of the default Group that is created shortly after instantiation. + * The default Group represents the default pattern of the Collection. + * @default 'default' + */ defaultGroupKey: ItemKey; } -/** - * @param patch - If Item gets patched into existing Item with the same Id - * @param method - Way of adding Item to Collection (push, unshift) - * @param forEachItem - Gets called for each Item that got collected - * @param background - If collecting an Item happens in the background (-> not causing any rerender) - * @param select - If collected Items get selected with a Selector - */ -export interface CollectConfigInterface { - patch?: boolean; +export interface CollectConfigInterface + extends AssignDataConfigInterface { + /** + * In which way the collected data should be added to the Collection. + * - 'push' = at the end + * - 'unshift' = at the beginning + * https://www.tutorialspoint.com/what-are-the-differences-between-unshift-and-push-methods-in-javascript + * @default 'push' + */ method?: 'push' | 'unshift'; - forEachItem?: (data: DataType, key: ItemKey, index: number) => void; - background?: boolean; + /** + * Performs the specified action for each collected data object. + * @default undefined + */ + forEachItem?: ( + data: DataType | Item, + key: ItemKey, + success: boolean, + index: number + ) => void; + /** + * Whether to create a Selector for each collected data object. + * @default false + */ select?: boolean; } -/** - * @param patch - If Data gets merged into the current Data - * @param background - If updating an Item happens in the background (-> not causing any rerender) - */ export interface UpdateConfigInterface { - patch?: boolean | { addNewProperties?: boolean }; + /** + * Whether to merge the data object with changes into the existing Item data object + * or overwrite the existing Item data object entirely. + * @default true + */ + patch?: boolean | PatchOptionConfigInterface; + /** + * Whether to update the data object in background. + * So that the UI isn't notified of these changes and thus doesn't rerender. + * @default false + */ background?: boolean; } -/** - * @param background - If updating the primaryKey of an Item happens in the background (-> not causing any rerender) - */ export interface UpdateItemKeyConfigInterface { + /** + * Whether to update the Item key/name identifier in background + * So that the UI isn't notified of these changes and thus doesn't rerender. + * @default false + */ background?: boolean; } -/** - * @param background - If assigning a new value happens in the background (-> not causing any rerender) - * @param force - Force creating and performing Job - * @param sideEffects - If Side Effects of Group gets executed - */ export interface RebuildGroupsThatIncludeItemKeyConfigInterface { + /** + * Whether to rebuilt the Group in background. + * So that the UI isn't notified of these changes and thus doesn't rerender. + * @default false + */ background?: boolean; - force?: boolean; + /** + * Whether to execute the defined side effects. + * @default true + */ sideEffects?: SideEffectConfigInterface; } -/** - * @param notExisting - If placeholder can be found - */ export interface HasConfigInterface { + /** + * Whether Items that do not officially exist, + * such as placeholder Items, can be found + * @default true + */ notExisting?: boolean; } -/** - * @param loadValue - If Persistent loads the persisted value into the Collection - * @param storageKeys - Key/Name of Storages which gets used to persist the Collection Value (NOTE: If not passed the default Storage will be used) - * @param defaultStorageKey - Default Storage Key (if not provided it takes the first index of storageKeys or the AgileTs default Storage) - */ export interface CollectionPersistentConfigInterface { + /** + * Whether the Persistent should automatically load + * the persisted value into the Collection after its instantiation. + * @default true + */ loadValue?: boolean; + /** + * Key/Name identifier of Storages + * in which the Collection value should be or is persisted. + * @default [`defaultStorageKey`] + */ storageKeys?: StorageKey[]; + /** + * Key/Name identifier of the default Storage of the specified Storage keys. + * + * The Collection value is loaded from the default Storage by default + * and is only loaded from the remaining Storages (`storageKeys`) + * if the loading from the default Storage failed. + * + * @default first index of the specified Storage keys or the AgileTs default Storage key + */ defaultStorageKey?: StorageKey; } -/* - * @param notExisting - If not existing Items like placeholder Items can be removed. - * Keep in mind that sometimes it won't remove the Item entirely - * because another Instance (like a Selector) needs to keep reference to it. - * https://github.com/agile-ts/agile/pull/152 - * @param - If Selectors that have selected an Item to be removed, should be removed too - */ export interface RemoveItemsConfigInterface { + /** + * Whether to remove not officially existing Items (such as placeholder Items). + * Keep in mind that sometimes it won't remove an Item entirely + * as another Instance (like a Selector) might need to keep reference to it. + * https://github.com/agile-ts/agile/pull/152 + * @default false + */ notExisting?: boolean; + /** + * Whether to remove Selectors that have selected an Item to be removed. + * @default false + */ removeSelector?: boolean; } -/** - * @param patch - If Data gets patched into existing Item - * @param background - If assigning Data happens in background - */ -export interface SetDataConfigInterface { +export interface AssignDataConfigInterface { + /** + * When the Item identifier of the to assign data object already exists in the Collection, + * whether to merge the newly assigned data into the existing one + * or overwrite the existing one entirely. + * @default true + */ patch?: boolean; + /** + * Whether to assign the data object to the Collection in background. + * So that the UI isn't notified of these changes and thus doesn't rerender. + * @default false + */ background?: boolean; } -export type CollectionConfig = - | CreateCollectionConfigInterface - | (( - collection: Collection - ) => CreateCollectionConfigInterface); +export interface AssignItemConfigInterface { + /** + * If an Item with the Item identifier already exists, + * whether to overwrite it entirely with the new one. + * @default false + */ + overwrite?: boolean; + /** + * Whether to assign the Item to the Collection in background. + * So that the UI isn't notified of these changes and thus doesn't rerender. + * @default false + */ + background?: boolean; +} diff --git a/packages/core/src/collection/item.ts b/packages/core/src/collection/item.ts index 35843c30..5375b143 100644 --- a/packages/core/src/collection/item.ts +++ b/packages/core/src/collection/item.ts @@ -1,26 +1,37 @@ import { State, Collection, - DefaultItem, StateKey, StateRuntimeJobConfigInterface, defineConfig, SelectorKey, + PersistentKey, + isValidObject, + CollectionPersistent, + StatePersistentConfigInterface, + DefaultItem, } from '../internal'; export class Item extends State< DataType > { - static updateGroupSideEffectKey = 'rebuildGroup'; - public selectedBy: Set = new Set(); // Keys of Selectors that have selected this Item + // Collection the Group belongs to public collection: () => Collection; + static updateGroupSideEffectKey = 'rebuildGroup'; + + // Key/Name identifiers of Selectors which have selected the Item + public selectedBy: Set = new Set(); + /** + * An extension of the State Class that represents a single data object of a Collection. + * + * It can be used independently, but is always synchronized with the Collection. + * * @public - * Item of Collection - * @param collection - Collection to which the Item belongs - * @param data - Data that the Item holds - * @param config - Config + * @param collection - Collection to which the Item belongs. + * @param data - Data object to be represented by the Item. + * @param config - Configuration object */ constructor( collection: Collection, @@ -29,24 +40,23 @@ export class Item extends State< ) { super(collection.agileInstance(), data, { isPlaceholder: config.isPlaceholder, - key: data[collection.config.primaryKey], // Set Key/Name of Item to primaryKey of Data + key: data[collection.config.primaryKey], // Set key/name of Item to identifier at primaryKey property }); this.collection = () => collection; - // Add rebuildGroupsThatIncludeItemKey to sideEffects to rebuild Groups that include this Item if it mutates - this.addRebuildGroupThatIncludeItemKeySideEffect( - this._key != null ? this._key : 'unknown' - ); + // Add side effect to Item + // that rebuilds all Groups containing the Item whenever it changes + if (this._key != null) { + this.addRebuildGroupThatIncludeItemKeySideEffect(this._key); + } } - //========================================================================================================= - // Set Key - //========================================================================================================= /** + * Updates the key/name identifier of Item. + * * @internal - * Updates Key/Name of State - * @param value - New Key/Name of State - * @param config - Config + * @param value - New key/name identifier. + * @param config - Configuration object */ public setKey( value: StateKey | undefined, @@ -65,47 +75,122 @@ export class Item extends State< }); if (value == null) return this; - // Remove old rebuildGroupsThatIncludeItemKey sideEffect + // Update 'rebuildGroupsThatIncludeItemKey' side effect to the new itemKey this.removeSideEffect(Item.updateGroupSideEffectKey); - - // Add rebuildGroupsThatIncludeItemKey to sideEffects to rebuild Groups that include this Item if it mutates this.addRebuildGroupThatIncludeItemKeySideEffect(value); - // Update ItemKey in ItemValue (After updating the sideEffect because otherwise it calls the old sideEffect) - this.patch( - { [this.collection().config.primaryKey]: value }, - { - sideEffects: config.sideEffects, - background: config.background, - force: config.force, - storage: config.storage, - overwrite: config.overwrite, - } - ); + // Update itemKey in Item value + // (After updating the side effect, because otherwise it would call the old side effect) + this.patch({ [this.collection().config.primaryKey]: value }, config); + + return this; + } + + /** + * Preserves the Item `value` in the corresponding external Storage. + * + * The Item key/name is used as the unique identifier for the Persistent. + * If that is not desired or the Item has no unique identifier, + * please specify a separate unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#persist) + * + * @public + * @param config - Configuration object + */ + public persist(config?: ItemPersistConfigInterface): this; + /** + * Preserves the Item `value` in the corresponding external Storage. + * + * The specified key is used as the unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#persist) + * + * @public + * @param key - Key/Name identifier of Persistent. + * @param config - Configuration object + */ + public persist( + key?: PersistentKey, + config?: ItemPersistConfigInterface + ): this; + public persist( + keyOrConfig: PersistentKey | ItemPersistConfigInterface = {}, + config: ItemPersistConfigInterface = {} + ): this { + let _config: ItemPersistConfigInterface; + let key: PersistentKey | undefined; + + if (isValidObject(keyOrConfig)) { + _config = keyOrConfig as ItemPersistConfigInterface; + key = this._key; + } else { + _config = config || {}; + key = keyOrConfig as PersistentKey; + } + + _config = defineConfig(_config, { + loadValue: true, + followCollectionPersistKeyPattern: true, + storageKeys: [], + defaultStorageKey: null, + }); + + // Create storageItemKey based on Collection key/name identifier + if (_config.followCollectionPersistKeyPattern) { + key = CollectionPersistent.getItemStorageKey( + key || this._key, + this.collection()._key + ); + } + + // Persist Item + super.persist(key, { + loadValue: _config.loadValue, + storageKeys: _config.storageKeys, + defaultStorageKey: _config.defaultStorageKey, + }); + return this; } - //========================================================================================================= - // Add Rebuild Group That Include ItemKey SideEffect - //========================================================================================================= /** + * Adds side effect to Item + * that rebuilds all Groups containing the specified Item identifier + * whenever the Item changes. + * * @internal - * Adds rebuildGroupThatIncludeItemKey to the Item sideEffects - * @param itemKey - ItemKey at which the groups has to rebuild + * @param itemKey - Item identifier that has to be contained in Groups. */ public addRebuildGroupThatIncludeItemKeySideEffect(itemKey: StateKey) { this.addSideEffect>( Item.updateGroupSideEffectKey, - (instance, config) => - instance.collection().rebuildGroupsThatIncludeItemKey(itemKey, config), + (instance, config) => { + // TODO optimise this because currently the whole Group rebuilds + // although only one Item value has changed which definitely needs no complete rebuild + // https://github.com/agile-ts/agile/issues/113 + instance.collection().rebuildGroupsThatIncludeItemKey(itemKey, config); + }, { weight: 100 } ); } } -/** - * @param isPlaceholder - If Item is initially a Placeholder - */ export interface ItemConfigInterface { + /** + * Whether the Item should be a placeholder + * and therefore should only exist in the background. + * @default false + */ isPlaceholder?: boolean; } + +export interface ItemPersistConfigInterface + extends StatePersistentConfigInterface { + /** + * Whether to format the specified Storage key following the Collection Item Storage key pattern. + * `_${collectionKey}_item_${itemKey}` + * @default true + */ + followCollectionPersistKeyPattern?: boolean; +} diff --git a/packages/core/src/collection/selector.ts b/packages/core/src/collection/selector.ts index deaf17b7..1366f24d 100644 --- a/packages/core/src/collection/selector.ts +++ b/packages/core/src/collection/selector.ts @@ -11,19 +11,31 @@ import { export class Selector extends State< DataType | undefined > { + // Collection the Selector belongs to + public collection: () => Collection; + static unknownItemPlaceholderKey = '__UNKNOWN__ITEM__KEY__'; static rebuildSelectorSideEffectKey = 'rebuildSelector'; static rebuildItemSideEffectKey = 'rebuildItem'; - public collection: () => Collection; - public item: Item | undefined; - public _itemKey: ItemKey; // Key of Item the Selector represents + + // Item the Selector represents + public _item: Item | undefined; + // Key/Name identifier of the Item the Selector represents + public _itemKey: ItemKey; /** + * A Selector represents an Item from a Collection in the long term. + * It can be mutated dynamically and remains in sync with the Collection. + * + * Components that need one piece of data from a Collection such as the "current user" + * would benefit from using Selectors. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector) + * * @public - * Represents Item of Collection - * @param collection - Collection that contains the Item - * @param itemKey - ItemKey of Item that the Selector represents - * @param config - Config + * @param collection - Collection to which the Selector belongs. + * @param itemKey - Key/Name identifier of the Item to be represented by the Selector. + * @param config - Configuration object */ constructor( collection: Collection, @@ -35,41 +47,73 @@ export class Selector extends State< }); super(collection.agileInstance(), undefined, config); this.collection = () => collection; - this.item = undefined; + this._item = undefined; this._itemKey = !config.isPlaceholder ? itemKey : Selector.unknownItemPlaceholderKey; this._key = config?.key; this.isPlaceholder = true; // Because hasn't selected any Item yet - // Initial Select + // Initial select of the Item if (!config.isPlaceholder) this.select(itemKey, { overwrite: true }); } /** + * Returns the `itemKey` currently selected by the Selector. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/properties#itemkey) + * * @public - * Set ItemKey that the Selector represents + */ + public get itemKey(): ItemKey { + return this._itemKey; + } + + /** + * Updates the currently selected Item of the Selector + * based on the specified `itemKey`. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/properties#itemkey) + * + * @public + * @param value - New key/name identifier of the Item to be represented by the Selector. */ public set itemKey(value: ItemKey) { this.select(value); } /** + * Retrieves the Item currently selected by the Selector. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/properties#item) + * * @public - * Get ItemKey that the Selector represents */ - public get itemKey() { - return this._itemKey; + public get item(): Item | undefined { + return this._item; } - //========================================================================================================= - // Select - //========================================================================================================= /** + * Updates the currently selected Item of the Selector + * based on the specified Item. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/properties#item) + * * @public - * Select new ItemKey - * @param itemKey - New ItemKey - * @param config - Config + * @param value - New Item to be represented by the Selector. + */ + public set item(value: Item | undefined) { + if (value?._key) this.select(value._key); + } + + /** + * Updates the currently selected Item of the Selector. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/methods#select) + * + * @public + * @param itemKey - New key/name identifier of the Item to be represented by the Selector. + * @param config - Configuration object */ public select( itemKey: ItemKey, @@ -82,11 +126,11 @@ export class Selector extends State< exclude: [], }, force: false, - overwrite: this.item?.isPlaceholder ?? false, + overwrite: this._item?.isPlaceholder ?? false, storage: true, }); - // Don't select Item if Collection is not properly instantiated + // Don't select Item if Collection is not correctly instantiated yet // (because only after a successful instantiation the Collection // contains the Items which are essential for a proper selection) if ( @@ -98,27 +142,29 @@ export class Selector extends State< // Unselect old Item this.unselect({ background: true }); - // Get new Item + // Retrieve new Item from Collection const newItem = this.collection().getItemWithReference(itemKey); // Select new Item this._itemKey = itemKey; - this.item = newItem; + this._item = newItem; newItem.selectedBy.add(this._key as any); - // Add SideEffect to newItem, that rebuild this Selector depending on the current Item Value + // Add side effect to the newly selected Item + // that rebuilds the Selector value depending on the current Item value newItem.addSideEffect( Selector.rebuildSelectorSideEffectKey, (instance, config) => this.rebuildSelector(config), { weight: 100 } ); - // Add sideEffect to Selector, that updates the Item Value if this Value got updated + // Add side effect to Selector + // that updates the Item value depending on the current Selector value this.addSideEffect>( Selector.rebuildItemSideEffectKey, (instance, config) => { - if (!instance.item?.isPlaceholder) - instance.item?.set(instance._value as any, { + if (!instance._item?.isPlaceholder) + instance._item?.set(instance._value as any, { ...config, ...{ sideEffects: { @@ -131,21 +177,25 @@ export class Selector extends State< { weight: 90 } ); - // Rebuild Selector for instantiating new 'selected' ItemKey properly + // Rebuild the Selector to properly 'instantiate' the newly selected Item this.rebuildSelector(config); return this; } - //========================================================================================================= - // Reselect - //========================================================================================================= /** + * Reselects the currently selected Item. + * + * This might be helpful if the Selector failed to select the Item correctly before + * and therefore should try to select it again. + * + * You can use the 'hasSelected()' method to check + * whether the 'selected' Item is selected correctly. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/methods#reselect) + * * @public - * Reselects current Item - * Might help if the Selector failed to select an Item correctly. - * You can check with 'hasSelected()' if an Item got correctly selected. - * @param config - Config + * @param config - Configuration object */ public reselect(config: StateRuntimeJobConfigInterface = {}): this { if ( @@ -156,18 +206,19 @@ export class Selector extends State< return this; } - //========================================================================================================= - // Unselect - //========================================================================================================= /** + * Unselects the currently selected Item. + * + * Therefore, it sets the `itemKey` and `item` property to `undefined`, + * since the Selector no longer represents any Item. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/methods#unselect) + * * @public - * Unselects current selected Item. - * Often not necessary because by selecting a new Item, - * the old Item is automatically unselected. - * @param config - Config + * @param config - Configuration object */ public unselect(config: StateRuntimeJobConfigInterface = {}): this { - // Because this.item might be outdated + // Retrieve Item from the Collection because 'this._item' might be outdated const item = this.collection().getItem(this._itemKey, { notExisting: true, }); @@ -180,53 +231,54 @@ export class Selector extends State< if (item.isPlaceholder) delete this.collection().data[this._itemKey]; } - // Reset and rebuild Selector - this.item = undefined; + // Reset Selector + this._item = undefined; this._itemKey = Selector.unknownItemPlaceholderKey; this.rebuildSelector(config); - this.isPlaceholder = true; return this; } - //========================================================================================================= - // Has Selected - //========================================================================================================= /** - * Checks if Selector has correctly selected the Item at the passed itemKey - * @param itemKey - ItemKey - * @param correctlySelected - If it should consider only correctly selected Items + * Returns a boolean indicating whether an Item with the specified `itemKey` + * is selected by the Selector or not. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/methods#hasselected) + * + * @public + * @param itemKey - Key/Name identifier of the Item. + * @param correctlySelected - Whether the Item has to be selected correctly. */ public hasSelected(itemKey: ItemKey, correctlySelected = true): boolean { if (correctlySelected) { return ( this._itemKey === itemKey && - this.item != null && - this.item.selectedBy.has(this._key as any) + this._item != null && + this._item.selectedBy.has(this._key as any) ); } return this._itemKey === itemKey; } - //========================================================================================================= - // Rebuild Selector - //========================================================================================================= /** + * Rebuilds the Selector. + * During this process, it updates the Selector `value` based on the Item `value`. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/selector/methods#rebuild) + * * @public - * Rebuilds Selector, - * which updates the Selector value based on the Item value - * @param config - Config + * @param config - Configuration object */ public rebuildSelector(config: StateRuntimeJobConfigInterface = {}): this { - // Set Selector Value to undefined if Item doesn't exist - if (this.item == null || this.item.isPlaceholder) { + // Assign 'undefined' to the Selector value if no Item is set + if (this._item == null || this._item.isPlaceholder) { this.set(undefined, config); return this; } - // Set Selector Value to updated Item Value - this.set(this.item._value, config); + // Assign the current Item value to the Selector value + this.set(this._item._value, config); return this; } @@ -234,11 +286,16 @@ export class Selector extends State< export type SelectorKey = string | number; -/** - * @param key - Key/Name of Selector - * @param isPlaceholder - If Selector is initially a Placeholder - */ export interface SelectorConfigInterface { + /** + * Key/Name identifier of the Selector. + * @default undefined + */ key?: SelectorKey; + /** + * Whether the Selector should be a placeholder + * and therefore should only exist in the background. + * @default false + */ isPlaceholder?: boolean; } diff --git a/packages/core/src/computed/computed.tracker.ts b/packages/core/src/computed/computed.tracker.ts index 48d8b87e..e3254f90 100644 --- a/packages/core/src/computed/computed.tracker.ts +++ b/packages/core/src/computed/computed.tracker.ts @@ -4,40 +4,45 @@ export class ComputedTracker { static isTracking = false; static trackedObservers: Set = new Set(); - //========================================================================================================= - // Track - //========================================================================================================= /** + * Helper Class for automatic tracking used Observers (dependencies) in a compute function. + * + * @internal + */ + constructor() { + // empty + } + + /** + * Activates Computed Tracker to globally track used Observers. + * * @internal - * Starts tracking Observers */ static track(): void { this.isTracking = true; } - //========================================================================================================= - // Tracked - //========================================================================================================= /** + * Tracks the passed Observer and caches it + * when the Computed Tracker is actively tracking. + * * @internal - * Adds passed Observer to tracked Observers, if ComputedTracker is currently tracking * @param observer - Observer */ static tracked(observer: Observer) { if (this.isTracking) this.trackedObservers.add(observer); } - //========================================================================================================= - // Get Tracked Observers - //========================================================================================================= /** + * Returns the latest tracked Observers + * and stops the Computed Tracker from tracking any more Observers. + * * @internal - * Returns tracked Observers and stops tracking anymore Observers */ static getTrackedObservers(): Array { const trackedObservers = Array.from(this.trackedObservers); - // Reset tracking + // Reset Computed Tracker this.isTracking = false; this.trackedObservers = new Set(); diff --git a/packages/core/src/computed/index.ts b/packages/core/src/computed/index.ts index e4470c22..f0d95f6b 100644 --- a/packages/core/src/computed/index.ts +++ b/packages/core/src/computed/index.ts @@ -15,18 +15,32 @@ import { export class Computed extends State< ComputedValueType > { + // Agile Instance the Computed belongs to public agileInstance: () => Agile; + // Function to compute the Computed Class value public computeFunction: () => ComputedValueType; - public deps: Array = []; // All Dependencies of Computed (hardCoded and autoDetected) - public hardCodedDeps: Array = []; // HardCoded Dependencies of Computed + // All dependencies the Computed Class depends on (including hardCoded and automatically detected dependencies) + public deps: Array = []; + // Only hardCoded dependencies the Computed Class depends on + public hardCodedDeps: Array = []; /** + * A Computed is an extension of the State Class + * that computes its value based on a specified compute function. + * + * The computed value will be cached to avoid unnecessary recomputes + * and is only recomputed when one of its direct dependencies changes. + * + * Direct dependencies can be States and Collections. + * So when, for example, a dependent State value changes, the computed value is recomputed. + * + * [Learn more..](https://agile-ts.org/docs/core/computed/) + * * @public - * Computed - Function that recomputes its value if a dependency changes - * @param agileInstance - An instance of Agile - * @param computeFunction - Function for computing value - * @param config - Config + * @param agileInstance - Instance of Agile the Computed belongs to. + * @param computeFunction - Function to compute the computed value. + * @param config - Configuration object */ constructor( agileInstance: Agile, @@ -43,23 +57,23 @@ export class Computed extends State< this.agileInstance = () => agileInstance; this.computeFunction = computeFunction; - // Format hardCodedDeps + // Extract Observer of passed hardcoded dependency instances this.hardCodedDeps = extractObservers(config.computedDeps).filter( (dep): dep is Observer => dep !== undefined ); this.deps = this.hardCodedDeps; - // Recompute for setting initial value and adding missing dependencies + // Initial recompute to assign initial value and autodetect missing dependencies this.recompute({ autodetect: true }); } - //========================================================================================================= - // Recompute - //========================================================================================================= /** + * Forces a recomputation of the cached value with the compute function. + * + * [Learn more..](https://agile-ts.org/docs/core/computed/methods/#recompute) + * * @public - * Recomputes Value of Computed - * @param config - Config + * @param config - Configuration object */ public recompute(config: RecomputeConfigInterface = {}): this { config = defineConfig(config, { @@ -72,15 +86,21 @@ export class Computed extends State< return this; } - //========================================================================================================= - // Updates Compute Function - //========================================================================================================= /** + * Assigns a new function to the Computed Class for computing its value. + * + * The dependencies of the new compute function are automatically detected + * and accordingly updated. + * + * An initial computation is performed with the new function + * to change the obsolete cached value. + * + * [Learn more..](https://agile-ts.org/docs/core/computed/methods/#updatecomputefunction) + * * @public - * Applies new compute Function to Computed - * @param computeFunction - New Function for computing value - * @param deps - Hard coded dependencies of Computed Function - * @param config - Config + * @param computeFunction - New function to compute the value of the Computed Class. + * @param deps - Hard coded dependencies on which the Computed Class depends. + * @param config - Configuration object */ public updateComputeFunction( computeFunction: () => ComputedValueType, @@ -92,7 +112,7 @@ export class Computed extends State< autodetect: true, }); - // Update deps + // Update dependencies of Computed const newDeps = extractObservers(deps).filter( (dep): dep is Observer => dep !== undefined ); @@ -103,25 +123,25 @@ export class Computed extends State< // Update computeFunction this.computeFunction = computeFunction; - // Recompute for setting initial Computed Function Value and adding missing Dependencies + // Recompute to assign new computed value and autodetect missing dependencies this.recompute(removeProperties(config, ['overwriteDeps'])); return this; } - //========================================================================================================= - // Compute - //========================================================================================================= /** + * Computes the new value of the Computed Class + * and autodetects used dependencies in the compute function. + * * @internal - * Recomputes value and adds missing dependencies to Computed + * @param config - Configuration object */ public compute(config: ComputeConfigInterface = {}): ComputedValueType { config = defineConfig(config, { autodetect: true, }); - // Start auto tracking Observers the computeFunction might depend on + // Start auto tracking of Observers on which the computeFunction might depend if (config.autodetect) ComputedTracker.track(); const computedValue = this.computeFunction(); @@ -129,14 +149,12 @@ export class Computed extends State< // Handle auto tracked Observers if (config.autodetect) { const foundDeps = ComputedTracker.getTrackedObservers(); - - // Handle foundDeps and hardCodedDeps const newDeps: Array = []; this.hardCodedDeps.concat(foundDeps).forEach((observer) => { newDeps.push(observer); - // Make this Observer depend on foundDep Observer - observer.depend(this.observer); + // Make this Observer depend on the found dep Observers + observer.addDependent(this.observer); }); this.deps = newDeps; @@ -145,45 +163,54 @@ export class Computed extends State< return computedValue; } - //========================================================================================================= - // Overwriting some functions which aren't allowed to use in Computed - //========================================================================================================= - + /** + * Not usable in Computed Class. + */ public patch() { LogCodeManager.log('19:03:00'); return this; } + /** + * Not usable in Computed Class. + */ public persist(): this { LogCodeManager.log('19:03:01'); return this; } + /** + * Not usable in Computed Class. + */ public invert(): this { LogCodeManager.log('19:03:02'); return this; } } -/** - * @param computedDeps - Hard coded dependencies of Computed Function - */ export interface ComputedConfigInterface extends StateConfigInterface { + /** + * Hard-coded dependencies on which the Computed Class should depend. + * @default [] + */ computedDeps?: Array; } -/** - * @param autodetect - If dependencies get autodetected - */ export interface ComputeConfigInterface { + /** + * Whether to automatically detect used dependencies in the compute method. + * @default true + */ autodetect?: boolean; } -/** - * @param overwriteDeps - If old hardCoded deps get overwritten - */ export interface UpdateComputeFunctionConfigInterface extends RecomputeConfigInterface { + /** + * Whether to overwrite the old hard-coded dependencies with the new ones + * or merge them into the new ones. + * @default false + */ overwriteDeps?: boolean; } @@ -191,4 +218,4 @@ export interface RecomputeConfigInterface extends StateIngestConfigInterface, ComputeConfigInterface {} -type SubscribableAgileInstancesType = State | Collection | Observer; +export type SubscribableAgileInstancesType = State | Collection | Observer; diff --git a/packages/core/src/integrations/index.ts b/packages/core/src/integrations/index.ts index 09cfa12f..038166db 100644 --- a/packages/core/src/integrations/index.ts +++ b/packages/core/src/integrations/index.ts @@ -1,31 +1,35 @@ import { Agile, Integration, LogCodeManager } from '../internal'; export class Integrations { + // Agile Instance the Integrations belongs to public agileInstance: () => Agile; - public integrations: Set = new Set(); // All registered Integrations + // Registered Integrations + public integrations: Set = new Set(); /** + * The Integrations Class manages all Integrations for an Agile Instance + * and provides an interface to easily update + * and invoke functions in all registered Integrations. + * * @internal - * Integrations - Manages Integrations of Agile - * @param agileInstance - An Instance of Agile + * @param agileInstance - Instance of Agile the Integrations belongs to. */ constructor(agileInstance: Agile) { this.agileInstance = () => agileInstance; - // Integrate initial Integrations which are static and got set external + // Integrate initial Integrations which were statically set externally Agile.initialIntegrations.forEach((integration) => this.integrate(integration) ); } - //========================================================================================================= - // Integrate - //========================================================================================================= /** - * @internal - * Integrates Framework(Integration) into Agile - * @param integration - Integration/Framework that gets integrated + * Integrates the specified Integration into AgileTs + * and sets it to ready when the binding was successful. + * + * @public + * @param integration - Integration to be integrated into AgileTs. */ public async integrate(integration: Integration): Promise { // Check if Integration is valid @@ -34,12 +38,12 @@ export class Integrations { return false; } - // Bind Framework to Agile + // Bind to integrate Integration to AgileTs if (integration.methods.bind) integration.ready = await integration.methods.bind(this.agileInstance()); else integration.ready = true; - // Integrate Framework + // Integrate Integration this.integrations.add(integration); integration.integrated = true; @@ -48,15 +52,16 @@ export class Integrations { return true; } - //========================================================================================================= - // Update - //========================================================================================================= /** - * @internal - * Updates registered and ready Integrations - * -> calls 'updateMethod' in all registered and ready Integrations - * @param componentInstance - Component that gets updated - * @param updatedData - Properties that differ from the last Value + * Updates the specified UI-Component Instance + * with the updated data object in all registered Integrations that are ready. + * + * In doing so, it calls the `updateMethod()` method + * in all registered Integrations with the specified parameters. + * + * @public + * @param componentInstance - Component Instance to be updated. + * @param updatedData - Data object with updated data. */ public update(componentInstance: any, updatedData: Object): void { this.integrations.forEach((integration) => { @@ -69,12 +74,11 @@ export class Integrations { }); } - //========================================================================================================= - // Has Integration - //========================================================================================================= /** - * @internal - * Check if at least one Integration got registered + * Returns a boolean indicating whether any Integration + * has been registered with the Agile Instance or not. + * + * @public */ public hasIntegration(): boolean { return this.integrations.size > 0; diff --git a/packages/core/src/integrations/integration.ts b/packages/core/src/integrations/integration.ts index 94c011db..f2c409ac 100644 --- a/packages/core/src/integrations/integration.ts +++ b/packages/core/src/integrations/integration.ts @@ -1,16 +1,26 @@ import { Agile } from '../internal'; export class Integration { + // Key/Name identifier of the Integration public _key: IntegrationKey; + // Instance of the Framework the Integration represents public frameworkInstance?: F; + // Whether the Integration is ready and the binding to AgileTs was successful public ready = false; + // Whether the Integration was integrated into AgileTs public integrated = false; + // Methods to interact with the Framework represented by the Integration public methods: IntegrationMethods; /** + * An Integration is an interface to a UI-Framework, + * and allows the easy interaction with that Framework. + * + * Due to the Integration, AgileTs can be integrated into almost any UI-Framework + * without a huge overhead. + * * @public - * Integration - Represents a Framework/Integration of Agile - * @param config - Config + * @param config - Configuration object */ constructor(config: CreateIntegrationConfig) { this._key = config.key; @@ -22,38 +32,63 @@ export class Integration { } /** + * Updates the key/name identifier of the Integration. + * * @public - * Set Value of Integration + * @param value - New key/name identifier. */ - public set key(key: IntegrationKey) { - this._key = key; + public set key(value: IntegrationKey) { + this._key = value; } /** + * Returns the key/name identifier of the Integration. + * * @public - * Get Value of Integration */ public get key(): IntegrationKey { return this._key; } } -/** - * @param key - Key/Name of Integration - * @param frameworkInstance - An Instance of the Framework that this Integration represents (for instance React) - */ export interface CreateIntegrationConfig extends IntegrationMethods { + /** + * Key/Name identifier of the Integration. + * @default undefined + */ key: string; + /** + * An Instance of the UI-Framework to be represented by the Integration. + * For example, in the case of React, the React Instance. + * @default undefined + */ frameworkInstance?: F; } -/** - * @param bind - Binds the Framework/Integration to Agile | Will be called after a successful integration - * @param updateMethod - Will be called if a Observer updates his subs (Only in Component based Subscriptions!) - */ export interface IntegrationMethods { + /** + * Binds the Integration to an Agile Instance. + * + * This method is called shortly after the Integration was registered with an Agile Instance. + * It is intended to set up things that are important + * for an seamless integration into AgileTs on the UI-Framework side. + * + * @param agileInstance - Agile Instance into which the Integration is to be integrated. + * @return Indicating whether the to integrate Integration is ready on the Framework side. + */ bind?: (agileInstance: Agile) => Promise; + /** + * Method to apply the updated data to the provided UI-Component + * in order to trigger a re-render on it. + * + * This method is called when the value of an [Agile Sub Instance](https://agile-ts.org/docs/introduction/#agile-sub-instance) + * bound to the specified UI-Component changes ([Component based Subscription](https://agile-ts.org/docs/core/integration/#component-based)). + * The updated Agile Sub Instance values were mapped in the provided `updatedData` object. + * + * @param componentInstance - Component Instance of the to update UI-Component. + * @param updatedData - Data object containing the updated data. + */ updateMethod?: (componentInstance: C, updatedData: Object) => void; } diff --git a/packages/core/src/logCodeManager.ts b/packages/core/src/logCodeManager.ts index 1f098fbd..9df75ad7 100644 --- a/packages/core/src/logCodeManager.ts +++ b/packages/core/src/logCodeManager.ts @@ -1,11 +1,19 @@ import { Agile } from './agile'; +// The Log Code Manager keeps track +// and manages all important Logs of AgileTs. +// +// How does the identification of Log Messages work? +// Let's take a look at this example: // 00:00:00 +// // |00|:00:00 first digits are based on the Agile Class // 00 = General // 10 = Agile // 11 = Storage // .. +// +// --- // 00:|00|:00 second digits are based on the Log Type const logCodeTypes = { '00': 'success', @@ -13,6 +21,8 @@ const logCodeTypes = { '02': 'warn', '03': 'error', }; +// +// --- // 00:00:|00| third digits are based on the Log Message (ascending counted) const logCodeMessages = { @@ -21,7 +31,7 @@ const logCodeMessages = { '10:02:00': 'Be careful when binding multiple Agile Instances globally in one application!', - // Storage + // Storages '11:02:00': "The 'Local Storage' is not available in your current environment." + "To use the '.persist()' functionality, please provide a custom Storage!", @@ -49,6 +59,9 @@ const logCodeMessages = { "The Storage with the key/name '${1}' doesn't exists!`", // Storage + '13:01:00': "GET value at key '${1}' from Storage '${0}'.", + '13:01:01': "SET value at key '${1}' in Storage '${0}'.", + '13:01:02': "REMOVE value at key '${1}' from Storage '${0}'.", '13:02:00': 'Using normalGet() in a async-based Storage might result in an unexpected return value. ' + 'Instead of a resolved value a Promise is returned!', @@ -60,9 +73,8 @@ const logCodeMessages = { '14:03:01': "'${1}' is a not supported type! Supported types are: String, Boolean, Array, Object, Number.", '14:03:02': "The 'patch()' method works only in object based States!", - '14:03:03': "Watcher Callback with the key/name '${0}' already exists!", - '14:03:04': 'Only one Interval can be active at once!', - '14:03:05': "The 'invert()' method works only in boolean based States!", + '14:03:03': 'Only one Interval can be active at once!', + '14:03:04': "Failed to invert value of the type '${0}'!", // SubController '15:01:00': "Unregistered 'Callback' based Subscription.", @@ -134,11 +146,19 @@ const logCodeMessages = { "Couldn't update ItemKey from '${0}' to '${1}' " + "because an Item with the key/name '${1}' already exists in the Collection '${2}'!", '1B:03:05': "Item Data of Collection '${0}' has to be a valid object!", + '1B:03:06': + "Item tried to add to the Collection '${0}' belongs to another Collection '${1}'!", // Group '1C:02:00': "Couldn't find some Items in the Collection '${0}' " + "during the rebuild of the Group '${1}' output.", + '1C:03:00': + "The 'output' property of the Group '${0}' is a automatically generated readonly property " + + 'that can only be mutated by the Group itself!', + '1C:03:01': + "The 'item' property of the Group '${0}' is a automatically generated readonly property " + + 'that can only be mutated by the Group itself!', // Utils '20:03:00': 'Failed to get Agile Instance from', @@ -151,15 +171,13 @@ const logCodeMessages = { '00:03:01': "'${0}' has to be of the type ${1}!", }; -//========================================================================================================= -// Get Log -//========================================================================================================= /** + * Returns the log message according to the specified log code. + * * @internal - * Returns the log message according to the passed logCode - * @param logCode - Log Code of Message + * @param logCode - Log code of the message to be returned. * @param replacers - Instances that replace these '${x}' placeholders based on the index - * For example: replacers[0] replaces '${0}', replacers[1] replaces '${1}', ... + * For example: 'replacers[0]' replaces '${0}', 'replacers[1]' replaces '${1}', .. */ function getLog>( logCode: T, @@ -175,16 +193,15 @@ function getLog>( return result; } -//========================================================================================================= -// Log -//========================================================================================================= /** + * Logs the log message according to the specified log code + * with the Agile Logger. + * * @internal - * Logs message at the provided logCode with the Agile.logger - * @param logCode - Log Code of Message + * @param logCode - Log code of the message to be returned. * @param replacers - Instances that replace these '${x}' placeholders based on the index - * For example: replacers[0] replaces '${0}', replacers[1] replaces '${1}', .. - * @param data - Data attached to the end of the log message + * For example: 'replacers[0]' replaces '${0}', 'replacers[1]' replaces '${1}', .. + * @param data - Data to be attached to the end of the log message. */ function log>( logCode: T, @@ -197,8 +214,10 @@ function log>( } /** + * The Log Code Manager keeps track + * and manages all important Logs of AgileTs. + * * @internal - * Manages logCode based logging of AgileTs */ export const LogCodeManager = { getLog, diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index e3b1c409..9a93ce62 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -6,75 +6,109 @@ import { ComponentSubscriptionContainer, defineConfig, notEqual, - isValidObject, LogCodeManager, } from '../internal'; export class Runtime { + // Agile Instance the Runtime belongs to public agileInstance: () => Agile; - // Queue system + // Job that is currently being performed public currentJob: RuntimeJob | null = null; + // Jobs to be performed public jobQueue: Array = []; - public notReadyJobsToRerender: Set = new Set(); // Jobs that got performed but aren't ready to get rerendered (wait for mount) - public jobsToRerender: Array = []; // Jobs that are performed and will be rendered + + // Jobs that were performed and are ready to re-render + public jobsToRerender: Array = []; + // Jobs that were performed and couldn't be re-rendered yet. + // That is the case when at least one Subscription Container (UI-Component) in the Job + // wasn't ready to update (re-render). + public notReadyJobsToRerender: Set = new Set(); + + // Whether the `jobQueue` is currently being actively processed + public isPerformingJobs = false; /** + * The Runtime queues and executes incoming Observer-based Jobs + * to prevent [race conditions](https://en.wikipedia.org/wiki/Race_condition#:~:text=A%20race%20condition%20or%20race,the%20possible%20behaviors%20is%20undesirable.) + * and optimized the re-rendering of the Observer's subscribed UI-Components. + * + * Each queued Job is executed when it is its turn + * by calling the Job Observer's `perform()` method. + * + * After successful execution, the Job is added to a re-render queue, + * which is first put into the browser's 'Bucket' and started to work off + * when resources are left. + * + * The re-render queue is designed for optimizing the render count + * by batching multiple re-render Jobs of the same UI-Component + * and ignoring re-render requests for unmounted UI-Components. + * * @internal - * Runtime - Performs ingested Observers - * @param agileInstance - An instance of Agile + * @param agileInstance - Instance of Agile the Runtime belongs to. */ constructor(agileInstance: Agile) { this.agileInstance = () => agileInstance; } - //========================================================================================================= - // Ingest - //========================================================================================================= /** - * @internal - * Ingests Job into Runtime that gets performed - * @param job - Job - * @param config - Config + * Adds the specified Observer-based Job to the internal Job queue, + * where it is executed when it is its turn. + * + * After successful execution, the Job is assigned to the re-render queue, + * where all the Observer's subscribed Subscription Containers (UI-Components) + * are updated (re-rendered). + * + * @public + * @param job - Job to be added to the Job queue. + * @param config - Configuration object */ public ingest(job: RuntimeJob, config: IngestConfigInterface = {}): void { config = defineConfig(config, { - perform: true, + perform: !this.isPerformingJobs, }); + // Add specified Job to the queue this.jobQueue.push(job); Agile.logger.if .tag(['runtime']) .info(LogCodeManager.getLog('16:01:00', [job._key]), job); - // Perform Job + // Run first Job from the queue if (config.perform) { const performJob = this.jobQueue.shift(); if (performJob) this.perform(performJob); } } - //========================================================================================================= - // Perform - //========================================================================================================= /** + * Performs the specified Job + * and assigns it to the re-render queue if necessary. + * + * After the execution of the provided Job, it is checked whether + * there are still Jobs left in the Job queue. + * - If so, the next Job in the `jobQueue` is performed. + * - If not, the `jobsToRerender` queue is started to work off. + * * @internal - * Performs Job and adds it to the rerender queue if necessary - * @param job - Job that gets performed + * @param job - Job to be performed. */ public perform(job: RuntimeJob): void { + this.isPerformingJobs = true; this.currentJob = job; // Perform Job job.observer.perform(job); job.performed = true; - // Ingest Dependents of Observer into Runtime + // Ingest dependents of the Observer into runtime, + // since they depend on the Observer and therefore have properly changed too job.observer.dependents.forEach((observer) => observer.ingest({ perform: false }) ); + // Add Job to rerender queue and reset current Job property if (job.rerender) this.jobsToRerender.push(job); this.currentJob = null; @@ -82,11 +116,14 @@ export class Runtime { .tag(['runtime']) .info(LogCodeManager.getLog('16:01:01', [job._key]), job); - // Perform Jobs as long as Jobs are left in queue, if no job left update/rerender Subscribers of jobsToRerender + // Perform Jobs as long as Jobs are left in the queue. + // If no Job is left start updating (re-rendering) Subscription Container (UI-Components) + // of the Job based on the 'jobsToRerender' queue. if (this.jobQueue.length > 0) { const performJob = this.jobQueue.shift(); if (performJob) this.perform(performJob); } else { + this.isPerformingJobs = false; if (this.jobsToRerender.length > 0) { // https://stackoverflow.com/questions/9083594/call-settimeout-without-delay setTimeout(() => { @@ -96,50 +133,63 @@ export class Runtime { } } - //========================================================================================================= - // Update Subscribers - //========================================================================================================= /** + * Processes the `jobsToRerender` queue by updating (causing a re-render on) + * the subscribed Subscription Containers (UI-Components) of each Job Observer. + * + * It returns a boolean indicating whether + * any Subscription Container (UI-Component) was updated (re-rendered) or not. + * * @internal - * Updates/Rerenders all Subscribed Components (SubscriptionContainer) of the Job (Observer) - * @return If any subscriptionContainer got updated (-> triggered a rerender on the Component it represents) */ public updateSubscribers(): boolean { - if (!this.agileInstance().hasIntegration()) { - this.jobsToRerender = []; - this.notReadyJobsToRerender = new Set(); - return false; - } - if ( - this.jobsToRerender.length <= 0 && - this.notReadyJobsToRerender.size <= 0 - ) - return false; - - // Subscriptions that has to be updated/rerendered - // A Set() to combine several equal SubscriptionContainers into one (optimizes rerender) - // (Even better would be to combine SubscriptionContainer based on the Component, - // since a Component can have multiple SubscriptionContainers) - const subscriptionsToUpdate = new Set(); - - // Build final jobsToRerender array based on new jobsToRerender and not ready jobsToRerender + // Build final 'jobsToRerender' array + // based on the new 'jobsToRerender' array and the 'notReadyJobsToRerender' array const jobsToRerender = this.jobsToRerender.concat( Array.from(this.notReadyJobsToRerender) ); this.notReadyJobsToRerender = new Set(); this.jobsToRerender = []; - // Check if Job SubscriptionContainers should be updated and if so add them to the subscriptionsToUpdate array - jobsToRerender.forEach((job) => { + if (!this.agileInstance().hasIntegration() || jobsToRerender.length <= 0) + return false; + + // Extract the Subscription Container to be re-rendered from the Jobs + const subscriptionContainerToUpdate = this.extractToUpdateSubscriptionContainer( + jobsToRerender + ); + if (subscriptionContainerToUpdate.length <= 0) return false; + + // Update Subscription Container (trigger re-render on the UI-Component they represent) + this.updateSubscriptionContainer(subscriptionContainerToUpdate); + + return true; + } + + /** + * Extracts the Subscription Containers (UI-Components) + * to be updated (re-rendered) from the specified Runtime Jobs. + * + * @internal + * @param jobs - Jobs from which to extract the Subscription Containers to be updated. + */ + public extractToUpdateSubscriptionContainer( + jobs: Array + ): Array { + const subscriptionsToUpdate = new Set(); + + jobs.forEach((job) => { job.subscriptionContainersToUpdate.forEach((subscriptionContainer) => { + let updateSubscriptionContainer = true; + + // Handle not ready Subscription Container if (!subscriptionContainer.ready) { if ( - !job.config.numberOfTriesToUpdate || - job.triesToUpdate < job.config.numberOfTriesToUpdate + !job.config.maxTriesToUpdate || + job.timesTriedToUpdateCount < job.config.maxTriesToUpdate ) { - job.triesToUpdate++; + job.timesTriedToUpdateCount++; this.notReadyJobsToRerender.add(job); - LogCodeManager.log( '16:02:00', [subscriptionContainer.key], @@ -148,165 +198,145 @@ export class Runtime { } else { LogCodeManager.log( '16:02:01', - [job.config.numberOfTriesToUpdate], + [job.config.maxTriesToUpdate], subscriptionContainer ); } return; } - // Handle Object based Subscription - if (subscriptionContainer.isObjectBased) - this.handleObjectBasedSubscription(subscriptionContainer, job); - - // Check if subscriptionContainer should be updated - const updateSubscriptionContainer = subscriptionContainer.proxyBased - ? this.handleProxyBasedSubscription(subscriptionContainer, job) - : true; - - if (updateSubscriptionContainer) + // TODO has to be overthought because when it is a Component based Subscription + // the rerender is triggered via merging the changed properties into the Component. + // Although the 'componentId' might be equal, it doesn't mean + // that the changed properties are equal! (-> changed properties might get missing) + // Check if Subscription Container with same 'componentId' + // is already in the 'subscriptionToUpdate' queue (rerender optimisation) + // updateSubscriptionContainer = + // updateSubscriptionContainer && + // Array.from(subscriptionsToUpdate).findIndex( + // (sc) => sc.componentId === subscriptionContainer.componentId + // ) === -1; + + // Check whether a selected part of the Observer value has changed + updateSubscriptionContainer = + updateSubscriptionContainer && + this.handleSelectors(subscriptionContainer, job); + + // Add Subscription Container to the 'subscriptionsToUpdate' queue + if (updateSubscriptionContainer) { + subscriptionContainer.updatedSubscribers.add(job.observer); subscriptionsToUpdate.add(subscriptionContainer); + } job.subscriptionContainersToUpdate.delete(subscriptionContainer); }); }); - if (subscriptionsToUpdate.size <= 0) return false; + return Array.from(subscriptionsToUpdate); + } - // Update Subscription Containers (trigger rerender on subscribed Component) + /** + * Updates the specified Subscription Containers. + * + * Updating a Subscription Container triggers a re-render + * on the Component it represents, based on the type of the Subscription Containers. + * + * @internal + * @param subscriptionsToUpdate - Subscription Containers to be updated. + */ + public updateSubscriptionContainer( + subscriptionsToUpdate: Array + ): void { subscriptionsToUpdate.forEach((subscriptionContainer) => { // Call 'callback function' if Callback based Subscription if (subscriptionContainer instanceof CallbackSubscriptionContainer) subscriptionContainer.callback(); - // Call 'update method' if Component based Subscription + // Call 'update method' in Integrations if Component based Subscription if (subscriptionContainer instanceof ComponentSubscriptionContainer) this.agileInstance().integrations.update( subscriptionContainer.component, - this.getObjectBasedProps(subscriptionContainer) + this.getUpdatedObserverValues(subscriptionContainer) ); + + subscriptionContainer.updatedSubscribers.clear(); }); Agile.logger.if .tag(['runtime']) .info(LogCodeManager.getLog('16:01:02'), subscriptionsToUpdate); - - return true; } - //========================================================================================================= - // Handle Object Based Subscription - //========================================================================================================= /** + * Maps the values of the updated Observers (`updatedSubscribers`) + * of the specified Subscription Container into a key map object. + * + * The key containing the Observer value is extracted from the Observer itself + * or from the Subscription Container's `subscriberKeysWeakMap`. + * * @internal - * Finds key of Observer (Job) in subsObject and adds it to 'changedObjectKeys' - * @param subscriptionContainer - Object based SubscriptionContainer - * @param job - Job that holds the searched Observer + * @param subscriptionContainer - Subscription Container from which the `updatedSubscribers` are to be mapped into a key map. */ - public handleObjectBasedSubscription( - subscriptionContainer: SubscriptionContainer, - job: RuntimeJob - ): void { - let foundKey: string | null = null; - - // Check if SubscriptionContainer is Object based - if (!subscriptionContainer.isObjectBased) return; - - // Find Key of Job Observer in SubscriptionContainer - for (const key in subscriptionContainer.subsObject) - if (subscriptionContainer.subsObject[key] === job.observer) - foundKey = key; - - if (foundKey) subscriptionContainer.observerKeysToUpdate.push(foundKey); - } - - //========================================================================================================= - // Get Object Based Props - //========================================================================================================= - /** - * @internal - * Builds Object out of changedObjectKeys with Observer Value - * @param subscriptionContainer - Object based SubscriptionContainer - */ - public getObjectBasedProps( + public getUpdatedObserverValues( subscriptionContainer: SubscriptionContainer ): { [key: string]: any } { const props: { [key: string]: any } = {}; - - // Map trough observerKeysToUpdate and build object out of Observer value - if (subscriptionContainer.subsObject) - for (const updatedKey of subscriptionContainer.observerKeysToUpdate) - props[updatedKey] = subscriptionContainer.subsObject[updatedKey]?.value; - - subscriptionContainer.observerKeysToUpdate = []; + for (const observer of subscriptionContainer.updatedSubscribers) { + const key = + subscriptionContainer.subscriberKeysWeakMap.get(observer) ?? + observer.key; + if (key != null) props[key] = observer.value; + } return props; } - //========================================================================================================= - // Handle Proxy Based Subscription - //========================================================================================================= /** + * Returns a boolean indicating whether the specified Subscription Container can be updated or not, + * based on its selector functions (`selectorsWeakMap`). + * + * This is done by checking the '.value' and the '.previousValue' property of the Observer represented by the Job. + * If a selected property differs, the Subscription Container (UI-Component) is allowed to update (re-render) + * and `true` is returned. + * + * If the Subscription Container has no selector function at all, `true` is returned. + * * @internal - * Checks if the subscriptionContainer should be updated. - * Therefore it reviews the '.value' and the '.previousValue' property of the Observer the Job represents. - * If a property at the proxy detected path differs, the subscriptionContainer is allowed to update. - * @param subscriptionContainer - SubscriptionContainer - * @param job - Job - * @return {boolean} If the subscriptionContainer should be updated - * -> If a from the Proxy Tree detected property differs from the same property in the previous value - * or the passed subscriptionContainer isn't properly proxy based + * @param subscriptionContainer - Subscription Container to be checked if it can be updated. + * @param job - Job containing the Observer that is subscribed to the Subscription Container. */ - public handleProxyBasedSubscription( + public handleSelectors( subscriptionContainer: SubscriptionContainer, job: RuntimeJob ): boolean { - // Return true because in this cases the subscriptionContainer isn't properly proxyBased - if ( - !subscriptionContainer.proxyBased || - !job.observer._key || - !subscriptionContainer.proxyKeyMap[job.observer._key] - ) - return true; - - const paths = subscriptionContainer.proxyKeyMap[job.observer._key].paths; - - if (paths) { - for (const path of paths) { - // Get property in new Value located at path - let newValue = job.observer.value; - let newValueDeepness = 0; - for (const branch of path) { - if (!isValidObject(newValue, true)) break; - newValue = newValue[branch]; - newValueDeepness++; - } - - // Get property in previous Value located at path - let previousValue = job.observer.previousValue; - let previousValueDeepness = 0; - for (const branch of path) { - if (!isValidObject(previousValue, true)) break; - previousValue = previousValue[branch]; - previousValueDeepness++; - } - - // Check if found values differ - if ( - notEqual(newValue, previousValue) || - newValueDeepness !== previousValueDeepness - ) { - return true; - } - } + const selectorMethods = subscriptionContainer.selectorsWeakMap.get( + job.observer + )?.methods; + + // If no selector functions found, return true. + // Because no specific part of the Observer was selected. + // -> The Subscription Container should be updated + // no matter what has updated in the Observer. + if (selectorMethods == null) return true; + + // Check if a selected part of the Observer value has changed + const previousValue = job.observer.previousValue; + const newValue = job.observer.value; + for (const selectorMethod of selectorMethods) { + if ( + notEqual(selectorMethod(newValue), selectorMethod(previousValue)) + // || newValueDeepness !== previousValueDeepness // Not possible to check the object deepness + ) + return true; } return false; } } -/** - * @param perform - If Job gets performed immediately - */ export interface IngestConfigInterface { + /** + * Whether the ingested Job should be performed immediately + * or added to the queue first and then executed when it is his turn. + */ perform?: boolean; } diff --git a/packages/core/src/runtime/observer.ts b/packages/core/src/runtime/observer.ts index 5710bd18..ebffc0fb 100644 --- a/packages/core/src/runtime/observer.ts +++ b/packages/core/src/runtime/observer.ts @@ -7,25 +7,54 @@ import { IngestConfigInterface, CreateRuntimeJobConfigInterface, LogCodeManager, + generateId, } from '../internal'; export type ObserverKey = string | number; export class Observer { + // Agile Instance the Observer belongs to public agileInstance: () => Agile; + // Key/Name identifier of the Observer public _key?: ObserverKey; - public dependents: Set = new Set(); // Observers that depend on this Observer - public subscribedTo: Set = new Set(); // SubscriptionContainers (Components) that this Observer is subscribed to - public value?: ValueType; // Value of Observer - public previousValue?: ValueType; // Previous Value of Observer + // Observers that depend on this Observer + public dependents: Set = new Set(); + // Subscription Containers (UI-Components) the Observer is subscribed to + public subscribedTo: Set = new Set(); + + // Current value of the Observer + public value?: ValueType; + // Previous value of the Observer + public previousValue?: ValueType; /** + * An Observer manages the subscriptions to Subscription Containers (UI-Components) + * and dependencies to other Observers (Agile Classes) + * for an Agile Class such as the `State Class`. + * + * Agile Classes often use an Observer as an interface to the Runtime. + * In doing so, they ingest their own Observer into the Runtime + * when the Agile Class has changed in such a way + * that these changes need to be applied to UI-Components + * or dependent other Observers. + * + * After the Observer has been ingested into the Runtime + * wrapped into a Runtime-Job, it is first added to the Jobs queue + * to prevent race conditions. + * When it is executed, the Observer's `perform()` method is called, + * where the accordingly changes are applied to the Agile Class. + * + * Now that the Job was performed, it is added to the rerender queue, + * where the subscribed Subscription Container (UI-Components) + * of the Observer are updated (re-rendered). + * + * Note that the Observer itself is no standalone class + * and should be adapted to the Agile Class needs it belongs to. + * * @internal - * Observer - Handles subscriptions and dependencies of an Agile Class and is like an instance to the Runtime - * Note: No stand alone class!! - * @param agileInstance - An instance of Agile - * @param config - Config + * @param agileInstance - Instance of Agile the Observer belongs to. + * @param config - Configuration object */ constructor( agileInstance: Agile, @@ -39,35 +68,40 @@ export class Observer { this._key = config.key; this.value = config.value; this.previousValue = config.value; - config.dependents?.forEach((observer) => this.depend(observer)); + config.dependents?.forEach((observer) => this.addDependent(observer)); config.subs?.forEach((subscriptionContainer) => - this.subscribe(subscriptionContainer) + subscriptionContainer.addSubscription(this) ); } /** - * @internal - * Set Key/Name of Observer + * Updates the key/name identifier of the Observer. + * + * @public + * @param value - New key/name identifier. */ public set key(value: StateKey | undefined) { this._key = value; } /** - * @internal - * Get Key/Name of Observer + * Returns the key/name identifier of the Observer. + * + * @public */ public get key(): StateKey | undefined { return this._key; } - //========================================================================================================= - // Ingest - //========================================================================================================= /** - * @internal - * Ingests Observer into Runtime - * @param config - Configuration + * Passes the Observer into the runtime wrapped into a Runtime-Job + * where it is executed accordingly. + * + * During the execution the runtime performs the Observer's `perform()` method, + * updates its dependents and re-renders the UI-Components it is subscribed to. + * + * @public + * @param config - Configuration object */ public ingest(config: ObserverIngestConfigInterface = {}): void { config = defineConfig(config, { @@ -80,86 +114,80 @@ export class Observer { force: false, }); - // Create Job + // Create Runtime-Job const job = new RuntimeJob(this, { force: config.force, sideEffects: config.sideEffects, background: config.background, - key: config.key || this._key, + key: + config.key ?? + `${this._key != null ? this._key + '_' : ''}${generateId()}`, }); + // Pass created Job into the Runtime this.agileInstance().runtime.ingest(job, { perform: config.perform, }); } - //========================================================================================================= - // Perform - //========================================================================================================= /** - * @internal - * Performs Job of Runtime - * @param job - Job that gets performed + * Method executed by the Runtime to perform the Runtime-Job, + * previously ingested via the `ingest()` method. + * + * Note that this method should be overwritten + * to correctly apply the changes to the Agile Class + * the Observer belongs to. + * + * @public + * @param job - Runtime-Job to be performed. */ public perform(job: RuntimeJob): void { LogCodeManager.log('17:03:00'); } - //========================================================================================================= - // Depend - //========================================================================================================= /** - * @internal - * Adds Dependent to Observer which gets ingested into the Runtime whenever this Observer mutates - * @param observer - Observer that will depend on this Observer + * Makes the specified Observer depend on the Observer. + * + * A dependent Observer is always ingested into the Runtime, + * when the Observer it depends on has also been ingested. + * + * @public + * @param observer - Observer to depend on the Observer. */ - public depend(observer: Observer): void { + public addDependent(observer: Observer): void { if (!this.dependents.has(observer)) this.dependents.add(observer); } +} - //========================================================================================================= - // Subscribe - //========================================================================================================= +export interface CreateObserverConfigInterface { /** - * @internal - * Adds Subscription to Observer - * @param subscriptionContainer - SubscriptionContainer(Component) that gets subscribed by this Observer + * Initial Observers to depend on the Observer. + * @default [] */ - public subscribe(subscriptionContainer: SubscriptionContainer): void { - if (!this.subscribedTo.has(subscriptionContainer)) { - this.subscribedTo.add(subscriptionContainer); - - // Add this to subscriptionContainer to keep track of the Observers the subscriptionContainer hold - subscriptionContainer.subscribers.add(this); - } - } - - //========================================================================================================= - // Unsubscribe - //========================================================================================================= + dependents?: Array; /** - * @internal - * Removes Subscription from Observer - * @param subscriptionContainer - SubscriptionContainer(Component) that gets unsubscribed by this Observer + * Initial Subscription Containers the Observer is subscribed to. + * @default [] */ - public unsubscribe(subscriptionContainer: SubscriptionContainer): void { - if (this.subscribedTo.has(subscriptionContainer)) { - this.subscribedTo.delete(subscriptionContainer); - subscriptionContainer.subscribers.delete(this); - } - } -} - -/** - * @param deps - Initial Dependents of Observer - * @param subs - Initial Subscriptions of Observer - * @param key - Key/Name of Observer - * @param value - Initial Value of Observer - */ -export interface CreateObserverConfigInterface { - dependents?: Array; subs?: Array; + /** + * Key/Name identifier of the Observer. + * @default undefined + */ key?: ObserverKey; + /** + * Initial value of the Observer. + * + * The value of an Observer is given to the Integration's `updateMethod()` method + * (Component Subscription Container) where it can be, + * for example, merged in a local State Management property of the UI-Component + * it is subscribed to. + * + * Also the selection of specific properties of an Agile Class value + * is based on the Observer `value` and `previousValue`. + * + * @default undefined + */ value?: ValueType; } diff --git a/packages/core/src/runtime/runtime.job.ts b/packages/core/src/runtime/runtime.job.ts index a3e559ea..d3eea3f2 100644 --- a/packages/core/src/runtime/runtime.job.ts +++ b/packages/core/src/runtime/runtime.job.ts @@ -1,19 +1,32 @@ import { Observer, defineConfig, SubscriptionContainer } from '../internal'; export class RuntimeJob { - public _key?: RuntimeJobKey; public config: RuntimeJobConfigInterface; - public observer: ObserverType; // Observer the Job represents - public rerender: boolean; // If Job will cause rerender on subscriptionContainer in Observer - public performed = false; // If Job has been performed by Runtime - public subscriptionContainersToUpdate = new Set(); // SubscriptionContainer (from Observer) that have to be updated/rerendered - public triesToUpdate = 0; // How often not ready subscriptionContainers of this Job have been tried to update + + // Key/Name identifier of the Runtime Job + public _key?: RuntimeJobKey; + // Observer the Job represents + public observer: ObserverType; + // Whether the Subscription Containers (UI-Components) of the Observer should be updated (re-rendered) + public rerender: boolean; + // Subscription Containers (UI-Components) of the Observer that have to be updated (re-rendered) + public subscriptionContainersToUpdate = new Set(); + // How often not ready Subscription Containers of the Observer have been tried to update + public timesTriedToUpdateCount = 0; + + // Whether the Job has been performed by the runtime + public performed = false; /** + * A Runtime Job is sent to the Runtime on behalf of the Observer it represents. + * + * In the Runtime, the Observer is performed via its `perform()` method + * and the Subscription Containers (UI-Components) + * to which it is subscribed are updated (re-rendered) accordingly. + * * @internal - * Job - Represents Observer that gets performed by the Runtime - * @param observer - Observer - * @param config - Config + * @param observer - Observer to be represented by the Runtime Job. + * @param config - Configuration object */ constructor( observer: ObserverType, @@ -26,13 +39,13 @@ export class RuntimeJob { exclude: [], }, force: false, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); this.config = { background: config.background, force: config.force, sideEffects: config.sideEffects, - numberOfTriesToUpdate: config.numberOfTriesToUpdate, + maxTriesToUpdate: config.maxTriesToUpdate, }; this.observer = observer; this.rerender = @@ -42,45 +55,76 @@ export class RuntimeJob { this.subscriptionContainersToUpdate = new Set(observer.subscribedTo); } - public get key(): RuntimeJobKey | undefined { - return this._key; - } - + /** + * Updates the key/name identifier of the Runtime Job. + * + * @public + * @param value - New key/name identifier. + */ public set key(value: RuntimeJobKey | undefined) { this._key = value; } + + /** + * Returns the key/name identifier of the Runtime Job. + * + * @public + */ + public get key(): RuntimeJobKey | undefined { + return this._key; + } } export type RuntimeJobKey = string | number; -/** - * @param key - Key/Name of RuntimeJob - */ export interface CreateRuntimeJobConfigInterface extends RuntimeJobConfigInterface { + /** + * Key/Name identifier of the Runtime Job. + * @default undefined + */ key?: RuntimeJobKey; } -/** - * @param background - If Job gets executed in the background -> not causing any rerender - * @param sideEffects - If SideEffects get executed - * @param force - Force performing Job - * @param numberOfTriesToUpdate - How often the runtime should try to update not ready SubscriptionContainers of this Job - * If 'null' the runtime tries to update the not ready SubscriptionContainer until they are ready (infinite). - * But be aware that this can lead to an overflow of 'old' Jobs after some time. (affects performance) - */ export interface RuntimeJobConfigInterface { + /** + * Whether to perform the Runtime Job in background. + * So that the Subscription Containers (UI-Components) aren't notified + * of these changes and thus doesn't update (re-render). + * @default false + */ background?: boolean; + /** + * Configuration of the execution of defined side effects. + * @default {enabled: true, exclude: []} + */ sideEffects?: SideEffectConfigInterface; + /** + * Whether the Runtime Job should be forced through the runtime + * although it might be useless from the current viewpoint of the runtime. + * @default false + */ force?: boolean; - numberOfTriesToUpdate?: number | null; + /** + * How often the Runtime should try to update not ready Subscription Containers + * subscribed by the Observer which the Job represents. + * + * When `null` the Runtime tries to update the not ready Subscription Containers + * until they are ready (infinite). + * @default 3 + */ + maxTriesToUpdate?: number | null; } -/** - * @param enabled - If SideEffects get executed - * @param exclude - SideEffect at Keys that doesn't get executed - */ export interface SideEffectConfigInterface { + /** + * Whether to execute the defined side effects. + * @default true + */ enabled?: boolean; + /** + * Side effect key identifier that won't be executed. + * @default [] + */ exclude?: string[]; } diff --git a/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts b/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts index e9771a0b..bf974bea 100644 --- a/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts +++ b/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts @@ -5,18 +5,30 @@ import { } from '../../../internal'; export class CallbackSubscriptionContainer extends SubscriptionContainer { + /** + * Callback function to trigger a re-render + * on the UI-Component which is represented by the Subscription Container. + */ public callback: Function; /** + * A Callback Subscription Container represents a UI-Component in AgileTs + * and triggers re-renders on the UI-Component via the specified callback function. + * + * The Callback Subscription Container doesn't keep track of the Component itself. + * It only knows how to trigger re-renders on it by calling the callback function. + * + * [Learn more..](https://agile-ts.org/docs/core/integration#callback-based) + * * @internal - * CallbackSubscriptionContainer - Subscription Container for Callback based Subscriptions - * @param callback - Callback Function that causes rerender on Component that is subscribed by Agile - * @param subs - Initial Subscriptions - * @param config - Config + * @param callback - Callback function to cause a rerender on the Component + * to be represented by the Subscription Container. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ constructor( callback: Function, - subs: Array = [], + subs: Array | { [key: string]: Observer }, config: SubscriptionContainerConfigInterface = {} ) { super(subs, config); diff --git a/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts b/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts index d71c3e2f..ff80eded 100644 --- a/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts +++ b/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts @@ -4,19 +4,63 @@ import { SubscriptionContainerConfigInterface, } from '../../../internal'; -export class ComponentSubscriptionContainer extends SubscriptionContainer { - public component: any; +export class ComponentSubscriptionContainer< + C = any +> extends SubscriptionContainer { + /** + * UI-Component which is represented by the Subscription Container + * and mutated via the Integration's `updateMethod()` method + * to cause re-renders on it. + */ + public component: C; /** + * A Component Subscription Container represents a UI-Component in AgileTs + * and triggers re-renders on the UI-Component by muting the specified Component Instance + * via the Integration's `updateMethod()` method. + * For example by updating a local State Management property of the Component + * (like in React Class Components the `this.state` property). + * + * The Component Subscription Container keeps track of the Component itself, + * to mutate it appropriately so that re-renders can be triggered on it. + * + * For this to work well, a Component Subscription Container is often object based. + * Meaning that each Observer was provided in an object keymap + * with a unique key identifier. + * ``` + * // Object based (guaranteed unique key identifier) + * { + * state1: Observer, + * state2: Observer + * } + * + * // Array based (no guaranteed unique key identifier) + * [Observer, Observer] + * ``` + * Thus the Integration's 'updateMethod()' method can be called + * with a complete object of updated Observer values. + * ``` + * updateMethod: (componentInstance, updatedData) => { + * console.log(componentInstance); // Returns 'this.component' + * console.log(updatedData); // Returns updated Observer values keymap (see below) + * // { + * // state1: Observer.value, + * // state2: Observer.value, + * // } + * } + * ``` + * + * [Learn more..](https://agile-ts.org/docs/core/integration#component-based) + * * @internal - * ComponentSubscriptionContainer - SubscriptionContainer for Component based Subscription - * @param component - Component that is subscribed by Agile - * @param subs - Initial Subscriptions - * @param config - Config + * @param component - UI-Component to be represented by the Subscription Container + * and mutated via the Integration's 'updateMethod()' method to trigger re-renders on it. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ constructor( - component: any, - subs: Array = [], + component: C, + subs: Array | { [key: string]: Observer }, config: SubscriptionContainerConfigInterface = {} ) { super(subs, config); diff --git a/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts b/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts index ef7c7bb5..4c0dcb17 100644 --- a/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts +++ b/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts @@ -1,73 +1,283 @@ import { defineConfig, generateId, - notEqual, + isValidObject, Observer, } from '../../../internal'; export class SubscriptionContainer { + /** + * Key/Name identifier of the Subscription Container. + */ public key?: SubscriptionContainerKeyType; + /** + * Whether the Subscription Container + * and the UI-Component it represents are ready. + * + * When both instances are ready, + * the Subscription Container is allowed + * to trigger re-renders on the UI-Component. + */ public ready = false; + /** + * Unique identifier of the UI-Component + * the Subscription Container represents. + */ + public componentId?: ComponentIdType; - public subscribers: Set; // Observers that are Subscribed to this SubscriptionContainer (Component) - - // Represents the paths to the accessed properties of the State/s this SubscriptionContainer represents - public proxyKeyMap: ProxyKeyMapInterface; - public proxyBased = false; + /** + * Observers that are subscribed to the Subscription Container. + * + * The subscribed Observers use the Subscription Container + * as an interface to the UI-Component it represents. + * + * Through the Subscription Container, the Observers can easily trigger re-renders + * on the UI-Component, for example, when their value updates. + * + * [Learn more..](https://agile-ts.org/docs/core/integration#-subscriptions) + */ + public subscribers: Set; + /** + * Temporary stores the subscribed Observers, + * that were updated by the runtime + * and are currently running through + * the update (rerender) Subscription Container (UI-Component) process. + */ + public updatedSubscribers: Set = new Set(); - // For Object based Subscription + /** + * Whether the Subscription Container is object based. + * + * A Subscription Container is object based when the subscribed Observers + * have been provided in an Observer keymap object + * ``` + * { + * state1: Observer, + * state2: Observer + * } + * ``` + * Thus each Observer has its 'external' unique key stored in the `subscribersWeakMap`. + * + * Often Component based Subscriptions are object based, + * because each Observer requires in such Subscription a unique identifier. + * Mainly to be properly represented in the `updatedData` object + * sent to the Integration's `updateMethod()` method + * when the Subscription Container updates (re-renders the UI-Component). + */ public isObjectBased = false; - public observerKeysToUpdate: Array = []; // Holds temporary keys of Observers that got updated (Note: keys based on 'subsObject') - public subsObject?: { [key: string]: Observer }; // Same as subs but in Object shape + /** + * Weak map for storing 'external' key identifiers for subscribed Observers. + * + * Why is the key not applied directly to the Observer? + * + * Because the key defined here should be only valid + * for the scope of the Subscription Container. + * + * https://stackoverflow.com/questions/29413222/what-are-the-actual-uses-of-es6-weakmap + */ + public subscriberKeysWeakMap: WeakMap; + + /** + * Weak Map for storing selector functions for subscribed Observers. + * + * A selector function allows the partial subscription to an Observer value. + * Only when the selected Observer value part changes, + * the Subscription Container is updated (-> re-renders the UI-Component). + * + * Why are the selector functions not applied directly to the Observer? + * + * Because the selector function defined here should be only valid + * for the scope of the Subscription Container. + * + * https://stackoverflow.com/questions/29413222/what-are-the-actual-uses-of-es6-weakmap + */ + public selectorsWeakMap: SelectorWeakMapType; /** + * A Subscription Container represents a UI-Component in AgileTs + * that can be subscribed by multiple Observer Instances. + * + * The subscribed Observers can use the Subscription Container as an interface + * to the UI-Component it represents. + * For example, to trigger re-renders on the UI-Component, + * when their value has changed. + * * @internal - * SubscriptionContainer - Represents Component/(Way to rerender Component) that is subscribed by Observer/s (Agile) - * -> Used to cause rerender on Component - * @param subs - Initial Subscriptions - * @param config - Config + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ constructor( - subs: Array = [], + subs: Array | { [key: string]: Observer }, config: SubscriptionContainerConfigInterface = {} ) { config = defineConfig(config, { - proxyKeyMap: {}, + proxyWeakMap: new WeakMap(), + selectorWeakMap: new WeakMap(), key: generateId(), }); - this.subscribers = new Set(subs); + this.subscribers = new Set(); this.key = config.key; - this.proxyKeyMap = config.proxyKeyMap as any; - this.proxyBased = notEqual(this.proxyKeyMap, {}); + this.componentId = config?.componentId; + this.subscriberKeysWeakMap = new WeakMap(); + this.selectorsWeakMap = new WeakMap(); + this.isObjectBased = !Array.isArray(subs); + + // Assign initial Observers to the Subscription Container + for (const key in subs) { + this.addSubscription(subs[key], { + proxyPaths: config.proxyWeakMap?.get(subs[key])?.paths, + selectorMethods: config.selectorWeakMap?.get(subs[key])?.methods, + key: this.isObjectBased ? key : undefined, + }); + } + } + + /** + * Subscribes the specified Observer to the Subscription Container. + * + * @internal + * @param sub - Observer to be subscribed to the Subscription Container + * @param config - Configuration object + */ + public addSubscription( + sub: Observer, + config: AddSubscriptionMethodConfigInterface = {} + ): void { + const toAddSelectorMethods: SelectorMethodType[] = + config.selectorMethods ?? []; + const proxyPaths = config.proxyPaths ?? []; + + // Create additional selector methods based on the specified proxy paths + for (const path of proxyPaths) { + toAddSelectorMethods.push((value) => { + let _value = value; + for (const branch of path) { + if (!isValidObject(_value, true)) break; + _value = _value[branch]; + } + return _value; + }); + } + + // Assign defined/created selector methods to the 'selectorsWeakMap' + // (Not to the Observer itself, since the selector methods specified here + // only count for the scope of the Subscription Container) + const existingSelectorMethods = + this.selectorsWeakMap.get(sub)?.methods ?? []; + const newSelectorMethods = existingSelectorMethods.concat( + toAddSelectorMethods + ); + if (newSelectorMethods.length > 0) + this.selectorsWeakMap.set(sub, { methods: newSelectorMethods }); + + // Assign specified key to the 'subscriberKeysWeakMap' + // (Not to the Observer itself, since the key specified here + // only counts for the scope of the Subscription Container) + if (config.key != null) this.subscriberKeysWeakMap.set(sub, config.key); + + // Add Observer to subscribers + this.subscribers.add(sub); + + // Add Subscription Container to Observer + // so that the Observer can cause updates on it + // (trigger re-render on the UI-Component it represents). + sub.subscribedTo.add(this); + } + + /** + * Unsubscribes the specified Observer from the Subscription Container. + * + * @internal + * @param sub - Observer to be unsubscribed from the Subscription Container. + */ + public removeSubscription(sub: Observer) { + if (this.subscribers.has(sub)) { + this.selectorsWeakMap.delete(sub); + this.subscriberKeysWeakMap.delete(sub); + this.subscribers.delete(sub); + sub.subscribedTo.delete(this); + } } } export type SubscriptionContainerKeyType = string | number; -/** - * @param proxyKeyMap - A keymap with a 2 dimensional arrays with paths/routes to particular properties in the State at key. - * The subscriptionContainer will then only rerender the Component, when a property at a given path changes. - * Not anymore if anything in the State object mutates, although it might not even be displayed in the Component. - * For example: - * { - * myState1: {paths: [['data', 'name']]}, - * myState2: {paths: [['car', 'speed']]} - * } - * Now the subscriptionContain will only trigger a rerender on the Component - * if 'data.name' in myState1 or 'car.speed' in myState2 changes. - * If, for instance, 'data.age' in myState1 mutates it won't trigger a rerender, - * since 'data.age' isn't represented in the proxyKeyMap. - * - * These particular paths can be tracked with the ProxyTree. - * https://github.com/agile-ts/agile/tree/master/packages/proxytree - * @param key - Key/Name of Subscription Container - */ export interface SubscriptionContainerConfigInterface { - proxyKeyMap?: ProxyKeyMapInterface; + /** + * Key/Name identifier of the Subscription Container + * @default undefined + */ key?: SubscriptionContainerKeyType; + /** + * Key/Name identifier of the UI-Component to be represented by the Subscription Container. + * @default undefined + */ + componentId?: ComponentIdType; + /** + * A Weak Map with a set of proxy paths to certain properties + * in an Observer value for subscribed Observers. + * + * These paths are then selected via selector functions + * which allow the partly subscription to an Observer value. + * Only if the selected Observer value part changes, + * the Subscription Container re-renders the UI-Component it represents. + * + * For example: + * ``` + * WeakMap: { + * Observer1: {paths: [['data', 'name']]}, + * Observer2: {paths: [['car', 'speed']]} + * } + * ``` + * Now the Subscription Container will only trigger a re-render on the UI-Component + * if 'data.name' in Observer1 or 'car.speed' in Observer2 updates. + * If, for instance, 'data.age' in Observer1 mutates it won't trigger a re-render, + * since 'data.age' isn't represented in the specified Proxy Weak Map. + * + * These particular paths can, for example, be tracked via the ProxyTree. + * https://github.com/agile-ts/agile/tree/master/packages/proxytree + * + * @default new WeakMap() + */ + proxyWeakMap?: ProxyWeakMapType; + /** + * A Weak Map with a set of selector functions for Observers. + * + * Selector functions allow the partly subscription to Observer values. + * Only if the selected Observer value part changes, + * the Subscription Container re-renders the UI-Component it represents. + * + * For example: + * ``` + * WeakMap: { + * Observer1: {methods: [(value) => value.data.name]}, + * Observer2: {methods: [(value) => value.car.speed]} + * } + * ``` + * Now the Subscription Container will only trigger a re-render on the UI-Component + * if 'data.name' in Observer1 or 'car.speed' in Observer2 updates. + * If, for instance, 'data.age' in Observer1 mutates it won't trigger a re-render, + * since 'data.age' isn't selected by any selector method in the specified Selector Weak Map. + * + * @default new WeakMap() + */ + selectorWeakMap?: SelectorWeakMapType; } -export interface ProxyKeyMapInterface { - [key: string]: { paths: string[][] }; +export interface AddSubscriptionMethodConfigInterface { + proxyPaths?: ProxyPathType[]; + selectorMethods?: SelectorMethodType[]; + key?: string; } + +export type ProxyPathType = string[]; +export type ProxyWeakMapType = WeakMap; + +export type SelectorMethodType = (value: T) => any; +export type SelectorWeakMapType = WeakMap< + Observer, + { methods: SelectorMethodType[] } +>; + +export type ComponentIdType = string | number; diff --git a/packages/core/src/runtime/subscription/sub.controller.ts b/packages/core/src/runtime/subscription/sub.controller.ts index fbd9b37c..d738d5b5 100644 --- a/packages/core/src/runtime/subscription/sub.controller.ts +++ b/packages/core/src/runtime/subscription/sub.controller.ts @@ -12,164 +12,185 @@ import { } from '../../internal'; export class SubController { + // Agile Instance the SubController belongs to public agileInstance: () => Agile; - public componentSubs: Set = new Set(); // Holds all registered Component based Subscriptions - public callbackSubs: Set = new Set(); // Holds all registered Callback based Subscriptions + // Keeps track of all registered Component based Subscriptions + public componentSubs: Set = new Set(); + // Keeps track of all registered Callback based Subscriptions + public callbackSubs: Set = new Set(); - public mountedComponents: Set = new Set(); // Holds all mounted Components (only if agileInstance.config.mount = true) + // Keeps track of all mounted Components (only if agileInstance.config.mount = true) + public mountedComponents: Set = new Set(); /** + * The Subscription Controller manages and simplifies the subscription to UI-Components. + * + * Thus it creates Subscription Containers (Interfaces to UI-Components) + * and assigns them to the specified Observers. + * These Observers can then easily trigger re-renders on UI-Components + * via the created Subscription Containers. + * * @internal - * SubController - Handles subscriptions to Components - * @param agileInstance - An instance of Agile + * @param agileInstance - Instance of Agile the Subscription Controller belongs to. */ public constructor(agileInstance: Agile) { this.agileInstance = () => agileInstance; } - //========================================================================================================= - // Subscribe with Subs Object - //========================================================================================================= /** - * @internal - * Subscribe with Object shaped Subscriptions - * @param integrationInstance - Callback Function or Component - * @param subs - Initial Subscription Object - * @param config - Config + * Creates a so called Subscription Container that represents an UI-Component in AgileTs. + * Such Subscription Container know how to trigger a re-render on the UI-Component it represents + * through the provided `integrationInstance`. + * + * Currently, there are two different ways the Subscription Container can trigger a re-render on the UI-Component. + * - 1. Via a callback function that directly triggers a rerender on the UI-Component. + * (= Callback based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#callback-based) + * - 2. Via the Component instance itself. + * For example by mutating a local State Management property. + * (= Component based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#component-based) + * + * The in an array specified Observers are then automatically subscribed + * to the created Subscription Container and thus to the UI-Component it represents. + * + * @public + * @param integrationInstance - Callback function or Component Instance to trigger a rerender on a UI-Component. + * @param subs - Observers to be subscribed to the to create Subscription Container. + * @param config - Configuration object */ - public subscribeWithSubsObject( + public subscribe( integrationInstance: any, - subs: { [key: string]: Observer } = {}, - config: RegisterSubscriptionConfigInterface = {} + subs: Array, + config?: RegisterSubscriptionConfigInterface + ): SubscriptionContainer; + /** + * Creates a so called Subscription Container that represents an UI-Component in AgileTs. + * Such Subscription Container know how to trigger a re-render on the UI-Component it represents + * through the provided `integrationInstance`. + * + * Currently, there are two different ways the Subscription Container can trigger a re-render on the UI-Component. + * - 1. Via a callback function that directly triggers a rerender on the UI-Component. + * (= Callback based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#callback-based) + * - 2. Via the Component instance itself. + * For example by mutating a local State Management property. + * (= Component based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#component-based) + * + * The in an object keymap specified Observers are then automatically subscribed + * to the created Subscription Container and thus to the UI-Component it represents. + * + * The advantage of subscribing the Observer via a object keymap, + * is that each Observer has its own unique 'external' key identifier. + * Such key identifier is, for example, required when merging the Observer value into + * a local UI-Component State Management property. + * ``` + * this.state = {...this.state, {state1: Observer1.value, state2: Observer2.value}} + * ``` + * + * @public + * @param integrationInstance - Callback function or Component Instance to trigger a rerender on a UI-Component. + * @param subs - Observers to be subscribed to the to create Subscription Container. + * @param config - Configuration object + */ + public subscribe( + integrationInstance: any, + subs: { [key: string]: Observer }, + config?: RegisterSubscriptionConfigInterface ): { subscriptionContainer: SubscriptionContainer; props: { [key: string]: Observer['value'] }; - } { - const props: { [key: string]: Observer['value'] } = {}; - - // Create subsArray - const subsArray: Observer[] = []; - for (const key in subs) subsArray.push(subs[key]); - - // Register Subscription -> decide weather subscriptionInstance is callback or component based - const subscriptionContainer = this.registerSubscription( - integrationInstance, - subsArray, - config - ); - - // Set SubscriptionContainer to Object based - subscriptionContainer.isObjectBased = true; - subscriptionContainer.subsObject = subs; - - // Register subs and build props object - for (const key in subs) { - const observer = subs[key]; - observer.subscribe(subscriptionContainer); - if (observer.value) props[key] = observer.value; - } - - return { - subscriptionContainer: subscriptionContainer, - props: props, - }; - } - - //========================================================================================================= - // Subscribe with Subs Array - //========================================================================================================= - /** - * @internal - * Subscribe with Array shaped Subscriptions - * @param integrationInstance - Callback Function or Component - * @param subs - Initial Subscription Array - * @param config - Config - */ - public subscribeWithSubsArray( + }; + public subscribe( integrationInstance: any, - subs: Array = [], + subs: { [key: string]: Observer } | Array, config: RegisterSubscriptionConfigInterface = {} - ): SubscriptionContainer { - // Register Subscription -> decide weather subscriptionInstance is callback or component based - const subscriptionContainer = this.registerSubscription( - integrationInstance, - subs, - config - ); + ): + | SubscriptionContainer + | { + subscriptionContainer: SubscriptionContainer; + props: { [key: string]: Observer['value'] }; + } { + config = defineConfig(config, { + waitForMount: this.agileInstance().config.waitForMount, + }); - // Register subs - subs.forEach((observer) => observer.subscribe(subscriptionContainer)); + // Create Subscription Container based on specified 'integrationInstance' + const subscriptionContainer = isFunction(integrationInstance) + ? this.createCallbackSubscriptionContainer( + integrationInstance, + subs, + config + ) + : this.createComponentSubscriptionContainer( + integrationInstance, + subs, + config + ); + + // Return object based Subscription Container and an Observer value keymap + if (subscriptionContainer.isObjectBased && !Array.isArray(subs)) { + const props: { [key: string]: Observer['value'] } = {}; + for (const key in subs) if (subs[key].value) props[key] = subs[key].value; + return { subscriptionContainer, props }; + } + // Return array based Subscription Container return subscriptionContainer; } - //========================================================================================================= - // Unsubscribe - //========================================================================================================= /** - * @internal - * Unsubscribes SubscriptionContainer(Component) - * @param subscriptionInstance - SubscriptionContainer or Component that holds an SubscriptionContainer + * Removes the Subscription Container extracted from the specified 'subscriptionInstance' + * from all Observers that were subscribed to it. + * + * We should always unregister a Subscription Container when it is no longer in use. + * For example, when the UI-Component it represents has been unmounted. + * + * @public + * @param subscriptionInstance - UI-Component that contains an instance of a Subscription Container + * or a Subscription Container to be unsubscribed/unregistered. */ public unsubscribe(subscriptionInstance: any) { - // Helper function to remove SubscriptionContainer from Observer + // Helper function to remove Subscription Container from Observer const unsub = (subscriptionContainer: SubscriptionContainer) => { subscriptionContainer.ready = false; - - // Remove SubscriptionContainers from Observer subscriptionContainer.subscribers.forEach((observer) => { - observer.unsubscribe(subscriptionContainer); + subscriptionContainer.removeSubscription(observer); }); }; - // Unsubscribe callback based Subscription + // Unsubscribe Callback based Subscription Container if (subscriptionInstance instanceof CallbackSubscriptionContainer) { unsub(subscriptionInstance); this.callbackSubs.delete(subscriptionInstance); - Agile.logger.if .tag(['runtime', 'subscription']) .info(LogCodeManager.getLog('15:01:00'), subscriptionInstance); return; } - // Unsubscribe component based Subscription + // Unsubscribe Component based Subscription Container if (subscriptionInstance instanceof ComponentSubscriptionContainer) { unsub(subscriptionInstance); this.componentSubs.delete(subscriptionInstance); - Agile.logger.if .tag(['runtime', 'subscription']) .info(LogCodeManager.getLog('15:01:01'), subscriptionInstance); return; } - // Unsubscribe component based Subscription with subscriptionInstance that holds a componentSubscriptionContainer - if (subscriptionInstance.componentSubscriptionContainer) { - unsub( - subscriptionInstance.componentSubscriptionContainer as ComponentSubscriptionContainer - ); - this.componentSubs.delete( - subscriptionInstance.componentSubscriptionContainer - ); - - Agile.logger.if - .tag(['runtime', 'subscription']) - .info(LogCodeManager.getLog('15:01:01'), subscriptionInstance); - return; - } - - // Unsubscribe component based Subscription with subscriptionInstance that holds componentSubscriptionContainers + // Unsubscribe Component based Subscription Container + // extracted from the 'componentSubscriptionContainers' property if ( - subscriptionInstance.componentSubscriptionContainers && + subscriptionInstance['componentSubscriptionContainers'] !== null && Array.isArray(subscriptionInstance.componentSubscriptionContainers) ) { subscriptionInstance.componentSubscriptionContainers.forEach( (subContainer) => { unsub(subContainer as ComponentSubscriptionContainer); this.componentSubs.delete(subContainer); - Agile.logger.if .tag(['runtime', 'subscription']) .info(LogCodeManager.getLog('15:01:01'), subscriptionInstance); @@ -179,52 +200,18 @@ export class SubController { } } - //========================================================================================================= - // Register Subscription - //========================================================================================================= /** + * Returns a newly created Component based Subscription Container. + * * @internal - * Registers SubscriptionContainer and decides weather integrationInstance is a callback or component based Subscription - * @param integrationInstance - Callback Function or Component - * @param subs - Initial Subscriptions - * @param config - Config + * @param componentInstance - UI-Component to be represented by the Subscription Container + * and mutated via the Integration's 'updateMethod()' method to trigger re-renders on it. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object. */ - public registerSubscription( - integrationInstance: any, - subs: Array = [], - config: RegisterSubscriptionConfigInterface = {} - ): SubscriptionContainer { - config = defineConfig(config, { - waitForMount: this.agileInstance().config.waitForMount, - }); - if (isFunction(integrationInstance)) - return this.registerCallbackSubscription( - integrationInstance, - subs, - config - ); - return this.registerComponentSubscription( - integrationInstance, - subs, - config - ); - } - - //========================================================================================================= - // Register Component Subscription - //========================================================================================================= - /** - * @internal - * Registers Component based Subscription and applies SubscriptionContainer to Component. - * If an instance called 'subscriptionContainers' exists in Component it will push the new SubscriptionContainer to this Array, - * otherwise it creates a new Instance called 'subscriptionContainer' which holds the new SubscriptionContainer - * @param componentInstance - Component that got subscribed by Observer/s - * @param subs - Initial Subscriptions - * @param config - Config - */ - public registerComponentSubscription( + public createComponentSubscriptionContainer( componentInstance: any, - subs: Array = [], + subs: { [key: string]: Observer } | Array, config: RegisterSubscriptionConfigInterface = {} ): ComponentSubscriptionContainer { const componentSubscriptionContainer = new ComponentSubscriptionContainer( @@ -234,22 +221,25 @@ export class SubController { ); this.componentSubs.add(componentSubscriptionContainer); - // Set to ready if not waiting for component to mount + // Define ready state of Subscription Container if (config.waitForMount) { if (this.mountedComponents.has(componentInstance)) componentSubscriptionContainer.ready = true; } else componentSubscriptionContainer.ready = true; - // Add subscriptionContainer to Component, to have an instance of it there (necessary to unsubscribe SubscriptionContainer later) + // Add Subscription Container to the UI-Component it represents. + // (For example, useful to unsubscribe the Subscription Container via the Component Instance) if ( - componentInstance.componentSubscriptionContainers && + componentInstance['componentSubscriptionContainers'] != null && Array.isArray(componentInstance.componentSubscriptionContainers) ) componentInstance.componentSubscriptionContainers.push( componentSubscriptionContainer ); else - componentInstance.componentSubscriptionContainer = componentSubscriptionContainer; + componentInstance['componentSubscriptionContainers'] = [ + componentSubscriptionContainer, + ]; Agile.logger.if .tag(['runtime', 'subscription']) @@ -258,19 +248,18 @@ export class SubController { return componentSubscriptionContainer; } - //========================================================================================================= - // Register Callback Subscription - //========================================================================================================= /** + * Returns a newly created Callback based Subscription Container. + * * @internal - * Registers Callback based Subscription - * @param callbackFunction - Callback Function that causes rerender on Component which got subscribed by Observer/s - * @param subs - Initial Subscriptions - * @param config - Config + * @param callbackFunction - Callback function to cause a rerender on the Component + * to be represented by the Subscription Container. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ - public registerCallbackSubscription( + public createCallbackSubscriptionContainer( callbackFunction: () => void, - subs: Array = [], + subs: { [key: string]: Observer } | Array, config: RegisterSubscriptionConfigInterface = {} ): CallbackSubscriptionContainer { const callbackSubscriptionContainer = new CallbackSubscriptionContainer( @@ -288,42 +277,51 @@ export class SubController { return callbackSubscriptionContainer; } - //========================================================================================================= - // Mount - //========================================================================================================= /** - * @internal - * Mounts Component based SubscriptionContainer - * @param componentInstance - SubscriptionContainer(Component) that gets mounted + * Notifies the Subscription Containers representing the specified UI-Component (`componentInstance`) + * that the UI-Component they represent has been mounted. + * + * @public + * @param componentInstance - Component Instance containing Subscription Containers to be mounted. */ public mount(componentInstance: any) { - if (componentInstance.componentSubscriptionContainer) - componentInstance.componentSubscriptionContainer.ready = true; + if ( + componentInstance['componentSubscriptionContainers'] != null && + Array.isArray(componentInstance.componentSubscriptionContainers) + ) + componentInstance.componentSubscriptionContainers.map( + (c) => (c.ready = true) + ); this.mountedComponents.add(componentInstance); } - //========================================================================================================= - // Unmount - //========================================================================================================= /** - * @internal - * Unmounts Component based SubscriptionContainer - * @param componentInstance - SubscriptionContainer(Component) that gets unmounted + * Notifies the Subscription Containers representing the specified UI-Component (`componentInstance`) + * that the UI-Component they represent has been unmounted. + * + * @public + * @param componentInstance - Component Instance containing Subscription Containers to be unmounted */ public unmount(componentInstance: any) { - if (componentInstance.componentSubscriptionContainer) - componentInstance.componentSubscriptionContainer.ready = false; + if ( + componentInstance['componentSubscriptionContainers'] != null && + Array.isArray(componentInstance.componentSubscriptionContainers) + ) + componentInstance.componentSubscriptionContainers.map( + (c) => (c.ready = false) + ); this.mountedComponents.delete(componentInstance); } } -/** - * @param waitForMount - Whether the subscriptionContainer should only become ready - * when the Component has been mounted. (default = agileInstance.config.waitForMount) - */ interface RegisterSubscriptionConfigInterface extends SubscriptionContainerConfigInterface { + /** + * Whether the Subscription Container shouldn't be ready + * until the UI-Component it represents has been mounted. + * @default agileInstance.config.waitForMount + */ waitForMount?: boolean; } diff --git a/packages/core/src/state/index.ts b/packages/core/src/state/index.ts index 3c778f96..ac5cedaa 100644 --- a/packages/core/src/state/index.ts +++ b/packages/core/src/state/index.ts @@ -20,37 +20,64 @@ import { } from '../internal'; export class State { + // Agile Instance the State belongs to public agileInstance: () => Agile; + // Key/Name identifier of the State public _key?: StateKey; - public valueType?: string; // primitive Type of State Value (for JS users) - public isSet = false; // If value is not the same as initialValue + // Primitive type which constrains the State value (for basic typesafety in Javascript) + public valueType?: string; + // Whether the current value differs from the initial value + public isSet = false; + // Whether the State is a placeholder and only exist in the background public isPlaceholder = false; + + // First value assigned to the State public initialStateValue: ValueType; - public _value: ValueType; // Current Value of State + // Current value of the State + public _value: ValueType; + // Previous value of the State public previousStateValue: ValueType; - public nextStateValue: ValueType; // Represents the next Value of the State (mostly used internal) + // Next value of the State (which can be used for dynamic State updates) + public nextStateValue: ValueType; - public observer: StateObserver; // Handles deps and subs of State and is like an interface to the Runtime + // Manages dependencies to other States and subscriptions of UI-Components. + // It also serves as an interface to the runtime. + public observer: StateObserver; + // Registered side effects of changing the State value public sideEffects: { [key: string]: SideEffectInterface>; - } = {}; // SideEffects of State (will be executed in Runtime) + } = {}; + + // Method for dynamically computing the State value public computeValueMethod?: ComputeValueMethod; + // Method for dynamically computing the existence of the State public computeExistsMethod: ComputeExistsMethod; - public isPersisted = false; // If State can be stored in Agile Storage (-> successfully integrated persistent) - public persistent: StatePersistent | undefined; // Manages storing State Value into Storage + // Whether the State is persisted in an external Storage + public isPersisted = false; + // Manages the permanent persistent in external Storages + public persistent: StatePersistent | undefined; + // Registered callbacks that are fired on each State value change public watchers: { [key: string]: StateWatcherCallback } = {}; + // When an interval is active, the 'intervalId' to clear the interval is temporary stored here public currentInterval?: NodeJS.Timer | number; /** + * A State manages a piece of Information + * that we need to remember globally at a later point in time. + * While providing a toolkit to use and mutate this piece of Information. + * + * You can create as many global States as you need. + * + * [Learn more..](https://agile-ts.org/docs/core/state/) + * * @public - * State - Class that holds one Value and causes rerender on subscribed Components - * @param agileInstance - An instance of Agile - * @param initialValue - Initial Value of State - * @param config - Config + * @param agileInstance - Instance of Agile the State belongs to. + * @param initialValue - Initial value of the State. + * @param config - Configuration object */ constructor( agileInstance: Agile, @@ -76,75 +103,93 @@ export class State { return v != null; }; - // Initial Set + // Set State value to specified initial value if (!config.isPlaceholder) this.set(initialValue, { overwrite: true }); } /** + * Assigns a new value to the State + * and rerenders all subscribed Components. + * + * [Learn more..](https://agile-ts.org/docs/core/state/properties#value) + * * @public - * Set Value of State + * @param value - New State value. */ public set value(value: ValueType) { this.set(value); } /** + * Returns a reference-free version of the current State value. + * + * [Learn more..](https://agile-ts.org/docs/core/state/properties#value) + * * @public - * Get Value of State */ public get value(): ValueType { ComputedTracker.tracked(this.observer); - return this._value; + return copy(this._value); } /** + * Updates the key/name identifier of the State. + * + * [Learn more..](https://agile-ts.org/docs/core/state/properties#key) + * * @public - * Set Key/Name of State + * @param value - New key/name identifier. */ public set key(value: StateKey | undefined) { this.setKey(value); } /** + * Returns the key/name identifier of the State. + * + * [Learn more..](https://agile-ts.org/docs/core/state/properties#key) + * * @public - * Get Key/Name of State */ public get key(): StateKey | undefined { return this._key; } - //========================================================================================================= - // Set Key - //========================================================================================================= /** - * @internal - * Updates Key/Name of State - * @param value - New Key/Name of State + * Updates the key/name identifier of the State. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#setkey) + * + * @public + * @param value - New key/name identifier. */ public setKey(value: StateKey | undefined): this { const oldKey = this._key; - // Update State Key + // Update State key this._key = value; - // Update Key in Observer + // Update key of Observer this.observer._key = value; - // Update Key in Persistent (only if oldKey equal to persistentKey -> otherwise the PersistentKey got formatted and will be set where other) + // 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) this.persistent?.setKey(value); return this; } - //========================================================================================================= - // Set - //========================================================================================================= /** + * Assigns a new value to the State + * and re-renders all subscribed UI-Components. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#set) + * * @public - * Updates Value of State - * @param value - new State Value - * @param config - Config + * @param value - New State value + * @param config - Configuration object */ public set( value: ValueType | ((value: ValueType) => ValueType), @@ -157,7 +202,7 @@ export class State { ? (value as any)(copy(this._value)) : value; - // Check value has correct Type (js) + // Check if value has correct type (Javascript) if (!this.hasCorrectType(_value)) { LogCodeManager.log(config.force ? '14:02:00' : '14:03:00', [ typeof _value, @@ -166,82 +211,88 @@ export class State { if (!config.force) return this; } - // Ingest new value into Runtime + // Ingest the State with the new value into the runtime this.observer.ingestValue(_value, config); return this; } - //========================================================================================================= - // Ingest - //========================================================================================================= /** + * Ingests the State without any specified new value into the runtime. + * + * Since no new value was defined either the new State value is computed + * based on a compute method (Computed Class) + * or the `nextStateValue` is taken as the next State value. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#ingest) + * * @internal - * Ingests nextStateValue, computedValue into Runtime - * @param config - Config + * @param config - Configuration object */ public ingest(config: StateIngestConfigInterface = {}): this { this.observer.ingest(config); return this; } - //========================================================================================================= - // Type - //========================================================================================================= /** + * Assigns a primitive type to the State + * which constrains the State value on the specified type + * to ensure basic typesafety in Javascript. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#type) + * * @public - * Assign primitive type to State Value - * Note: This function is mainly thought for JS users - * @param type - wished Type ('String', 'Boolean', 'Array', 'Object', 'Number') + * @param type - Primitive type the State value must follow (`String`, `Boolean`, `Array`, `Object`, `Number`). */ public type(type: any): this { const supportedTypes = ['String', 'Boolean', 'Array', 'Object', 'Number']; - - // Check if type is a supported Type if (!supportedTypes.includes(type.name)) { LogCodeManager.log('14:03:01', [type]); return this; } - this.valueType = type.name.toLowerCase(); return this; } - //========================================================================================================= - // Undo - //========================================================================================================= /** + * Undoes the latest State value change. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#undo) + * * @public - * Undoes latest State Value change - * @param config - Config + * @param config - Configuration object */ public undo(config: StateIngestConfigInterface = {}): this { this.set(this.previousStateValue, config); return this; } - //========================================================================================================= - // Reset - //========================================================================================================= /** + * Resets the State value to its initial value. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#reset) + * * @public - * Resets State to its initial Value - * @param config - Config + * @param config - Configuration object */ public reset(config: StateIngestConfigInterface = {}): this { this.set(this.initialStateValue, config); return this; } - //========================================================================================================= - // Patch - //========================================================================================================= /** + * Merges the specified `targetWithChanges` object into the current State value. + * This merge can differ for different value combinations: + * - If the current State value is an `object`, it does a partial update for the object. + * - If the current State value is an `array` and the specified argument is an array too, + * it concatenates the current State value with the value of the argument. + * - If the current State value is neither an `object` nor an `array`, the patch can't be performed. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#patch) + * * @public - * Patches Object with changes into State Value - * Note: Only useful if State is an Object - * @param targetWithChanges - Object that holds changes which get patched into State Value - * @param config - Config + * @param targetWithChanges - Object to be merged into the current State value. + * @param config - Configuration object */ public patch( targetWithChanges: Object, @@ -251,44 +302,58 @@ export class State { addNewProperties: true, }); + // Check if the given conditions are suitable for a patch action if (!isValidObject(this.nextStateValue, true)) { LogCodeManager.log('14:03:02'); return this; } - if (!isValidObject(targetWithChanges, true)) { LogCodeManager.log('00:03:01', ['TargetWithChanges', 'object']); return this; } - // Merge targetWithChanges into nextStateValue - this.nextStateValue = flatMerge( - copy(this.nextStateValue), - targetWithChanges, - { addNewProperties: config.addNewProperties } - ); + // Merge targetWithChanges object into the nextStateValue + if ( + Array.isArray(targetWithChanges) && + Array.isArray(this.nextStateValue) + ) { + this.nextStateValue = [ + ...this.nextStateValue, + ...targetWithChanges, + ] as any; + } else { + this.nextStateValue = flatMerge( + this.nextStateValue, + targetWithChanges, + { addNewProperties: config.addNewProperties } + ); + } - // Ingest updated nextStateValue into Runtime + // Ingest updated 'nextStateValue' into runtime this.ingest(removeProperties(config, ['addNewProperties'])); return this; } - //========================================================================================================= - // Watch - //========================================================================================================= /** + * Fires on each State value change. + * + * Returns the key/name identifier of the created watcher callback. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#watch) + * * @public - * Watches State and detects State changes - * @param callback - Callback Function that gets called if the State Value changes - * @return Key of Watcher + * @param callback - A function to be executed on each State value change. */ public watch(callback: StateWatcherCallback): string; /** + * Fires on each State value change. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#watch) + * * @public - * Watches State and detects State changes - * @param key - Key/Name of Watcher Function - * @param callback - Callback Function that gets called if the State Value changes + * @param key - Key/Name identifier of the watcher callback. + * @param callback - A function to be executed on each State value change. */ public watch(key: string, callback: StateWatcherCallback): this; public watch( @@ -307,29 +372,21 @@ export class State { _callback = callback as StateWatcherCallback; } - // Check if Callback is valid Function if (!isFunction(_callback)) { LogCodeManager.log('00:03:01', ['Watcher Callback', 'function']); return this; } - - // Check if watcherKey is already occupied - if (this.watchers[key]) { - LogCodeManager.log('14:03:03', [key]); - return this; - } - this.watchers[key] = _callback; return generateKey ? key : this; } - //========================================================================================================= - // Remove Watcher - //========================================================================================================= /** + * Removes a watcher callback with the specified key/name identifier from the State. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#removewatcher) + * * @public - * Removes Watcher at given Key - * @param key - Key of Watcher that gets removed + * @param key - Key/Name identifier of the watcher callback to be removed. */ public removeWatcher(key: string): this { delete this.watchers[key]; @@ -337,9 +394,25 @@ export class State { } /** + * Returns a boolean indicating whether a watcher callback with the specified `key` + * exists in the State or not. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#haswatcher) + * * @public - * Creates a Watcher that gets once called when the State Value changes for the first time and than destroys itself - * @param callback - Callback Function that gets called if the State Value changes + * @param key - Key/Name identifier of the watcher callback to be checked for existence. + */ + public hasWatcher(key: string): boolean { + return !!this.watchers[key]; + } + + /** + * Fires on the initial State value assignment and then destroys itself. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#oninaugurated) + * + * @public + * @param callback - A function to be executed after the first State value assignment. */ public onInaugurated(callback: StateWatcherCallback): this { const watcherKey = 'InauguratedWatcherKey'; @@ -350,32 +423,29 @@ export class State { return this; } - //========================================================================================================= - // Has Watcher - //========================================================================================================= /** + * Preserves the State `value` in the corresponding external Storage. + * + * The State key/name is used as the unique identifier for the Persistent. + * If that is not desired or the State has no unique identifier, + * please specify a separate unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#persist) + * * @public - * Checks if watcher at given Key exists - * @param key - Key/Name of Watcher - */ - public hasWatcher(key: string): boolean { - return !!this.watchers[key]; - } - - //========================================================================================================= - // Persist - //========================================================================================================= - /** - * @public - * Stores State Value into Agile Storage permanently - * @param config - Config + * @param config - Configuration object */ public persist(config?: StatePersistentConfigInterface): this; /** + * Preserves the State `value` in the corresponding external Storage. + * + * The specified key is used as the unique identifier for the Persistent. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#persist) + * * @public - * Stores State Value into Agile Storage permanently - * @param key - Key/Name of created Persistent (Note: Key required if State has no set Key!) - * @param config - Config + * @param key - Key/Name identifier of Persistent. + * @param config - Configuration object */ public persist( key?: PersistentKey, @@ -402,7 +472,10 @@ export class State { defaultStorageKey: null, }); - // Create persistent -> Persist Value + // Check if State is already persisted + if (this.persistent != null && this.isPersisted) return this; + + // Create Persistent (-> persist value) this.persistent = new StatePersistent(this, { instantiate: _config.loadValue, storageKeys: _config.storageKeys, @@ -413,67 +486,70 @@ export class State { return this; } - //========================================================================================================= - // On Load - //========================================================================================================= /** + * Fires immediately after the persisted `value` + * is loaded into the State from a corresponding external Storage. + * + * Registering such callback function makes only sense + * when the State is [persisted](https://agile-ts.org/docs/core/state/methods/#persist). + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#onload) + * * @public - * Callback Function that gets called if the persisted Value gets loaded into the State for the first Time - * Note: Only useful for persisted States! - * @param callback - Callback Function + * @param callback - A function to be executed after the externally persisted `value` was loaded into the State. */ public onLoad(callback: (success: boolean) => void): this { if (!this.persistent) return this; - - // Check if Callback is valid Function if (!isFunction(callback)) { LogCodeManager.log('00:03:01', ['OnLoad Callback', 'function']); return this; } + // Register specified callback this.persistent.onLoad = callback; - // If State is already 'isPersisted' the loading was successful -> callback can be called + // If State is already persisted ('isPersisted') fire specified callback immediately if (this.isPersisted) callback(true); return this; } - //========================================================================================================= - // Interval - //========================================================================================================= /** + * Repeatedly calls the specified callback function, + * with a fixed time delay between each call. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#interval) + * * @public - * Calls callback at certain intervals in milliseconds and assigns the callback return value to the State - * @param callback- Callback that is called on each interval and should return the new State value - * @param ms - The intervals in milliseconds + * @param handler - A function to be executed every delay milliseconds. + * @param delay - The time, in milliseconds (thousandths of a second), + * the timer should delay in between executions of the specified function. */ public interval( - callback: (value: ValueType) => ValueType, - ms?: number + handler: (value: ValueType) => ValueType, + delay?: number ): this { - if (!isFunction(callback)) { + if (!isFunction(handler)) { LogCodeManager.log('00:03:01', ['Interval Callback', 'function']); return this; } if (this.currentInterval) { - LogCodeManager.log('14:03:04', [], this.currentInterval); + LogCodeManager.log('14:03:03', [], this.currentInterval); return this; } - this.currentInterval = setInterval(() => { - this.set(callback(this._value)); - }, ms ?? 1000); - + this.set(handler(this._value)); + }, delay ?? 1000); return this; } - //========================================================================================================= - // Clear Interval - //========================================================================================================= /** + * Cancels a active timed, repeating action + * which was previously established by a call to `interval()`. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#clearinterval) + * * @public - * Clears the current Interval */ public clearInterval(): void { if (this.currentInterval) { @@ -482,35 +558,30 @@ export class State { } } - //========================================================================================================= - // Copy - //========================================================================================================= - /** - * @public - * Creates fresh copy of State Value (-> No reference to State Value) - */ - public copy(): ValueType { - return copy(this.value); - } - - //========================================================================================================= - // Exists - //========================================================================================================= /** + * Returns a boolean indicating whether the State exists. + * + * It calculates the value based on the `computeExistsMethod()` + * and whether the State is a placeholder. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#exists) + * * @public - * Checks if State exists */ public get exists(): boolean { return !this.isPlaceholder && this.computeExistsMethod(this.value); } - //========================================================================================================= - // Compute Exists - //========================================================================================================= /** + * Defines the method used to compute the existence of the State. + * + * It is retrieved on each `exists()` method call + * to determine whether the State exists or not. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#computeexists) + * * @public - * Function that computes the exists status of the State - * @param method - Computed Function + * @param method - Method to compute the existence of the State. */ public computeExists(method: ComputeExistsMethod): this { if (!isFunction(method)) { @@ -518,81 +589,107 @@ export class State { return this; } this.computeExistsMethod = method; + return this; + } + + /** + * Defines the method used to compute the value of the State. + * + * It is retrieved on each State value change, + * in order to compute the new State value + * based on the specified compute method. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#computevalue) + * + * @public + * @param method - Method to compute the value of the State. + */ + public computeValue(method: ComputeValueMethod): this { + if (!isFunction(method)) { + LogCodeManager.log('00:03:01', ['Compute Value Method', 'function']); + return this; + } + this.computeValueMethod = method; + + // Initial compute + // (not directly computing it here since it is computed once in the runtime!) + this.set(this.nextStateValue); return this; } - //========================================================================================================= - // Is - //========================================================================================================= /** + * Returns a boolean indicating whether the specified value is equal to the current State value. + * + * Equivalent to `===` with the difference that it looks at the value + * and not on the reference in the case of objects. + * * @public - * Equivalent to === - * @param value - Value that gets checked if its equals to the State Value + * @param value - Value to be compared with the current State value. */ public is(value: ValueType): boolean { return equal(value, this.value); } - //========================================================================================================= - // Is Not - //========================================================================================================= /** + * Returns a boolean indicating whether the specified value is not equal to the current State value. + * + * Equivalent to `!==` with the difference that it looks at the value + * and not on the reference in the case of objects. + * * @public - * Equivalent to !== - * @param value - Value that gets checked if its not equals to the State Value + * @param value - Value to be compared with the current State value. */ public isNot(value: ValueType): boolean { return notEqual(value, this.value); } - //========================================================================================================= - // Invert - //========================================================================================================= /** + * Inverts the current State value. + * + * Some examples are: + * - `'jeff'` -> `'ffej'` + * - `true` -> `false` + * - `[1, 2, 3]` -> `[3, 2, 1]` + * - `10` -> `-10` + * * @public - * Inverts State Value - * Note: Only useful with boolean based States */ public invert(): this { - if (typeof this._value === 'boolean') { - this.set(!this._value as any); - } else { - LogCodeManager.log('14:03:05'); - } - return this; - } - - //========================================================================================================= - // Compute Value - //========================================================================================================= - /** - * @public - * Function that recomputes State Value if it changes - * @param method - Computed Function - */ - public computeValue(method: ComputeValueMethod): this { - if (!isFunction(method)) { - LogCodeManager.log('00:03:01', ['Compute Value Method', 'function']); - return this; + switch (typeof this.nextStateValue) { + case 'boolean': + this.set(!this.nextStateValue as any); + break; + case 'object': + if (Array.isArray(this.nextStateValue)) + this.set(this.nextStateValue.reverse() as any); + break; + case 'string': + this.set(this.nextStateValue.split('').reverse().join('') as any); + break; + case 'number': + this.set((this.nextStateValue * -1) as any); + break; + default: + LogCodeManager.log('14:03:04', [typeof this.nextStateValue]); } - this.computeValueMethod = method; - - // Initial compute - this.set(method(this.nextStateValue)); - return this; } - //========================================================================================================= - // Add SideEffect - //========================================================================================================= /** + * + * Registers a `callback` function that is executed in the `runtime` + * as a side effect of State changes. + * + * For example, it is called when the State value changes from 'jeff' to 'hans'. + * + * A typical side effect of a State change + * could be the updating of the external Storage value. + * * @internal - * Adds SideEffect to State - * @param key - Key/Name of SideEffect - * @param callback - Callback Function that gets called on every State Value change - * @param config - Config + * @param key - Key/Name identifier of the to register side effect. + * @param callback - Callback function to be fired on each State value change. + * @param config - Configuration object */ public addSideEffect>( key: string, @@ -613,39 +710,38 @@ export class State { return this; } - //========================================================================================================= - // Remove SideEffect - //========================================================================================================= /** + * Removes a side effect callback with the specified key/name identifier from the State. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#removesideeffect) + * * @internal - * Removes SideEffect at given Key - * @param key - Key of the SideEffect that gets removed + * @param key - Key/Name identifier of the side effect callback to be removed. */ public removeSideEffect(key: string): this { delete this.sideEffects[key]; return this; } - //========================================================================================================= - // Has SideEffect - //========================================================================================================= /** + * Returns a boolean indicating whether a side effect callback with the specified `key` + * exists in the State or not. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#hassideeffect) + * * @internal - * Checks if sideEffect at given Key exists - * @param key - Key of SideEffect + * @param key - Key/Name identifier of the side effect callback to be checked for existence. */ public hasSideEffect(key: string): boolean { return !!this.sideEffects[key]; } - //========================================================================================================= - // Is Correct Type - //========================================================================================================= /** + * Returns a boolean indicating whether the passed value + * is of the before defined State `valueType` or not. + * * @internal - * Checks if Value has correct valueType (js) - * Note: If no valueType set, it returns true - * @param value - Value that gets checked for its correct Type + * @param value - Value to be checked for the correct type. */ public hasCorrectType(value: any): boolean { if (!this.valueType) return true; @@ -653,26 +749,23 @@ export class State { return type === this.valueType; } - //========================================================================================================= - // Get Public Value - //========================================================================================================= /** + * Returns the public value of the State. + * * @internal - * Returns public Value of State */ public getPublicValue(): ValueType { - // If State Value is used internally and output represents the real state value (for instance in Group) + // 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; } - //========================================================================================================= - // Get Persistable Value - //========================================================================================================= /** + * Returns the persistable value of the State. + * * @internal - * Returns Value that gets written into the Agile Storage */ public getPersistableValue(): any { return this._value; @@ -681,32 +774,59 @@ export class State { export type StateKey = string | number; -/** - * @param key - Key/Name of State - * @param deps - Initial deps of State - * @param isPlaceholder - If State is initially a Placeholder - */ export interface StateConfigInterface { + /** + * Key/Name identifier of the State. + * @default undefined + */ key?: StateKey; + /** + * Observers that depend on the State. + * @default [] + */ dependents?: Array; + /** + * Whether the State should be a placeholder + * and therefore should only exist in the background. + * @default false + */ isPlaceholder?: boolean; } -/** - * @param addNewProperties - If new Properties gets added to the State Value - */ -export interface PatchConfigInterface extends StateIngestConfigInterface { +export interface PatchConfigInterface + extends StateIngestConfigInterface, + PatchOptionConfigInterface {} + +export interface PatchOptionConfigInterface { + /** + * Whether to add new properties to the object during the merge. + * @default true + */ addNewProperties?: boolean; } -/** - * @param loadValue - If Persistent loads the persisted value into the State - * @param storageKeys - Key/Name of Storages which gets used to persist the State Value (NOTE: If not passed the default Storage will be used) - * @param defaultStorageKey - Default Storage Key (if not provided it takes the first index of storageKeys or the AgileTs default Storage) - */ export interface StatePersistentConfigInterface { + /** + * Whether the Persistent should automatically load + * the persisted value into the State after its instantiation. + * @default true + */ loadValue?: boolean; + /** + * Key/Name identifier of Storages + * in which the State value should be or is persisted. + * @default [`defaultStorageKey`] + */ storageKeys?: StorageKey[]; + /** + * Key/Name identifier of the default Storage of the specified Storage keys. + * + * The State value is loaded from the default Storage by default + * and is only loaded from the remaining Storages (`storageKeys`) + * if the loading from the default Storage failed. + * + * @default first index of the specified Storage keys or the AgileTs default Storage key + */ defaultStorageKey?: StorageKey; } @@ -714,25 +834,32 @@ export type StateWatcherCallback = (value: T, key: string) => void; export type ComputeValueMethod = (value: T) => T; export type ComputeExistsMethod = (value: T) => boolean; -export type SideEffectFunctionType> = ( +export type SideEffectFunctionType = ( instance: Instance, properties?: { [key: string]: any; } ) => void; -/** - * @param callback - Callback Function that gets called on every State Value change - * @param weight - When the sideEffect gets executed. The higher, the earlier it gets executed. - */ -export interface SideEffectInterface> { +export interface SideEffectInterface { + /** + * Callback function to be called on every State value change. + * @return () => {} + */ callback: SideEffectFunctionType; + /** + * Weight of the side effect. + * The weight determines the order of execution of the registered side effects. + * The higher the weight, the earlier it is executed. + */ weight: number; } -/** - * @param weight - When the sideEffect gets executed. The higher, the earlier it gets executed. - */ export interface AddSideEffectConfigInterface { + /** + * Weight of the side effect. + * The weight determines the order of execution of the registered side effects. + * The higher the weight, the earlier it is executed. + */ weight?: number; } diff --git a/packages/core/src/state/state.observer.ts b/packages/core/src/state/state.observer.ts index 9c540b6b..b5a4ee2e 100644 --- a/packages/core/src/state/state.observer.ts +++ b/packages/core/src/state/state.observer.ts @@ -14,17 +14,24 @@ import { SideEffectInterface, createArrayFromObject, CreateStateRuntimeJobConfigInterface, + generateId, } from '../internal'; export class StateObserver extends Observer { + // State the Observer belongs to public state: () => State; - public nextStateValue: ValueType; // Next State value + + // Next value applied to the State + public nextStateValue: ValueType; /** + * A State Observer manages the subscriptions to Subscription Containers (UI-Components) + * and dependencies to other Observers (Agile Classes) + * for a State Class. + * * @internal - * State Observer - Handles State changes, dependencies (-> Interface to Runtime) - * @param state - State - * @param config - Config + * @param state - Instance of State the Observer belongs to. + * @param config - Configuration object */ constructor( state: State, @@ -35,13 +42,16 @@ export class StateObserver extends Observer { this.nextStateValue = copy(state._value); } - //========================================================================================================= - // Ingest - //========================================================================================================= /** + * Passes the State Observer into the runtime wrapped into a Runtime-Job + * where it is executed accordingly. + * + * During the execution the runtime applies the `nextStateValue` + * or the `computedValue` (Computed Class) to the State, + * updates its dependents and re-renders the UI-Components it is subscribed to. + * * @internal - * Ingests nextStateValue or computedValue into Runtime and applies it to the State - * @param config - Config + * @param config - Configuration object */ public ingest(config: StateIngestConfigInterface = {}): void { const state = this.state(); @@ -53,14 +63,16 @@ export class StateObserver extends Observer { this.ingestValue(newStateValue, config); } - //========================================================================================================= - // Ingest Value - //========================================================================================================= /** + * Passes the State Observer into the runtime wrapped into a Runtime-Job + * where it is executed accordingly. + * + * During the execution the runtime applies the specified `newStateValue` to the State, + * updates its dependents and re-renders the UI-Components it is subscribed to. + * * @internal - * Ingests new State Value into Runtime and applies it to the State - * @param newStateValue - New Value of the State - * @param config - Config + * @param newStateValue - New value to be applied to the State. + * @param config - Configuration object. */ public ingestValue( newStateValue: ValueType, @@ -79,53 +91,62 @@ export class StateObserver extends Observer { overwrite: false, }); - // Force overwriting State because if assigning Value to State, the State shouldn't be a placeholder anymore + // Force overwriting the State value if it is a placeholder. + // After assigning a value to the State it shouldn't be a placeholder anymore. if (state.isPlaceholder) { config.force = true; config.overwrite = true; } - // Assign next State Value and compute it if necessary + // Assign next State value to Observer and compute it if necessary this.nextStateValue = state.computeValueMethod ? copy(state.computeValueMethod(newStateValue)) : copy(newStateValue); - // Check if State Value and new/next Value are equals + // Check if current State value and to assign State value are equal if (equal(state._value, this.nextStateValue) && !config.force) return; - // Create Job + // Create Runtime-Job const job = new StateRuntimeJob(this, { storage: config.storage, sideEffects: config.sideEffects, force: config.force, background: config.background, overwrite: config.overwrite, - key: config.key || this._key, + key: + config.key ?? + `${this._key != null ? this._key + '_' : ''}${generateId()}`, }); + // Pass created Job into the Runtime this.agileInstance().runtime.ingest(job, { perform: config.perform, }); } - //========================================================================================================= - // Perform - //========================================================================================================= /** + * Method executed by the Runtime to perform the Runtime-Job, + * previously ingested via the `ingest()` or `ingestValue()` method. + * + * Thereby the previously defined `nextStateValue` is assigned to the State. + * Also side effects (like calling watcher callbacks) of a State change are executed. + * * @internal - * Performs Job that holds this Observer - * @param job - Job + * @param job - Runtime-Job to be performed. */ public perform(job: StateRuntimeJob) { const state = job.observer.state(); - const previousValue = copy(state.getPublicValue()); - // Assign new State Values + // Assign new State values state.previousStateValue = copy(state._value); state._value = copy(job.observer.nextStateValue); state.nextStateValue = copy(job.observer.nextStateValue); - // Overwrite old State Values + // 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. + // if (typeof state._value === 'object') Object.freeze(state._value); + + // Overwrite entire State with the newly assigned value if (job.config.overwrite) { state.initialStateValue = copy(state._value); state.previousStateValue = copy(state._value); @@ -133,35 +154,37 @@ export class StateObserver extends Observer { } state.isSet = notEqual(state._value, state.initialStateValue); - - // Execute sideEffects like 'rebuildGroup' or 'rebuildStateStorageValue' this.sideEffects(job); - // Assign Public Value to Observer after sideEffects like 'rebuildGroup', - // 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 because Integrations like React are using it as return value. - // For example 'useAgile()' returns the Observer.value and not the State.value. + // 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()); - job.observer.previousValue = previousValue; } - //========================================================================================================= - // Side Effect - //========================================================================================================= /** + * Performs the side effects of applying the next State value to the State. + * + * Side effects are, for example, calling the watcher callbacks + * or executing the side effects defined in the State Class + * like 'rebuildGroup' or 'rebuildStateStorageValue'. + * * @internal - * SideEffects of Job - * @param job - Job + * @param job - Job that is currently performed. */ public sideEffects(job: StateRuntimeJob) { const state = job.observer.state(); - // Call Watchers Functions + // Call watcher functions for (const watcherKey in state.watchers) if (isFunction(state.watchers[watcherKey])) state.watchers[watcherKey](state.getPublicValue(), watcherKey); - // Call SideEffect Functions + // Call side effect functions if (job.config?.sideEffects?.enabled) { const sideEffectArray = createArrayFromObject< SideEffectInterface> @@ -169,6 +192,7 @@ export class StateObserver extends Observer { sideEffectArray.sort(function (a, b) { return b.instance.weight - a.instance.weight; }); + for (const sideEffect of sideEffectArray) { if (isFunction(sideEffect.instance.callback)) { if (!job.config.sideEffects.exclude?.includes(sideEffect.key)) @@ -179,14 +203,21 @@ export class StateObserver extends Observer { } } -/** - * @param dependents - Initial Dependents of State Observer - * @param subs - Initial Subscriptions of State Observer - * @param key - Key/Name of State Observer - */ export interface CreateStateObserverConfigInterface { + /** + * Initial Observers to depend on the State Observer. + * @default [] + */ dependents?: Array; + /** + * Initial Subscription Containers the State Observer is subscribed to. + * @default [] + */ subs?: Array; + /** + * Key/Name identifier of the State Observer. + * @default undefined + */ key?: ObserverKey; } diff --git a/packages/core/src/state/state.persistent.ts b/packages/core/src/state/state.persistent.ts index 23d2f961..03029ff0 100644 --- a/packages/core/src/state/state.persistent.ts +++ b/packages/core/src/state/state.persistent.ts @@ -4,18 +4,20 @@ import { Persistent, PersistentKey, State, - StorageKey, } from '../internal'; export class StatePersistent extends Persistent { - static storeValueSideEffectKey = 'rebuildStateStorageValue'; + // State the Persistent belongs to public state: () => State; + static storeValueSideEffectKey = 'rebuildStateStorageValue'; + /** + * Internal Class for managing the permanent persistence of a State. + * * @internal - * State Persist Manager - Handles permanent storing of State Value - * @param state - State that gets stored - * @param config - Config + * @param state - State to be persisted. + * @param config - Configuration object */ constructor( state: State, @@ -36,47 +38,16 @@ export class StatePersistent extends Persistent { defaultStorageKey: config.defaultStorageKey, }); - // Load/Store persisted Value for the first Time + // Load/Store persisted value/s for the first time if (this.ready && config.instantiate) this.initialLoading(); } - //========================================================================================================= - // Set Key - //========================================================================================================= /** + * Loads the persisted value into the State + * or persists the State value in the corresponding Storage. + * This behaviour depends on whether the State has been persisted before. + * * @internal - * Updates Key/Name of Persistent - * @param value - New Key/Name of Persistent - */ - public async setKey(value?: StorageKey): Promise { - const oldKey = this._key; - const wasReady = this.ready; - - // Assign Key - if (value === this._key) return; - this._key = value || Persistent.placeHolderKey; - - const isValid = this.validatePersistent(); - - // Try to Initial Load Value if persistent wasn't ready and return - if (!wasReady) { - if (isValid) await this.initialLoading(); - return; - } - - // Remove value at old Key - await this.removePersistedValue(oldKey); - - // Assign Value to new Key - if (isValid) await this.persistValue(value); - } - - //========================================================================================================= - // Initial Loading - //========================================================================================================= - /** - * @internal - * Loads/Saves Storage Value for the first Time */ public async initialLoading() { super.initialLoading().then(() => { @@ -84,14 +55,15 @@ export class StatePersistent extends Persistent { }); } - //========================================================================================================= - // Load Persisted Value - //========================================================================================================= /** + * Loads the State from the corresponding Storage + * and sets up side effects that dynamically update + * the Storage value when the State changes. + * * @internal - * Loads State Value from the Storage - * @param storageItemKey - Prefix Key of Persisted Instances (default PersistentKey) - * @return Success? + * @param storageItemKey - Storage key of the to load State Instance. + * | default = Persistent.key | + * @return Whether the loading of the persisted State Instance and the setting up of the corresponding side effects was successful. */ public async loadPersistedValue( storageItemKey?: PersistentKey @@ -99,36 +71,61 @@ export class StatePersistent extends Persistent { if (!this.ready) return false; const _storageItemKey = storageItemKey ?? this._key; - // Load Value from default Storage + // Load State value from the default Storage const loadedValue = await this.agileInstance().storages.get( _storageItemKey, this.config.defaultStorageKey as any ); - if (!loadedValue) return false; + if (loadedValue == null) return false; - // Assign loaded Value to State - this.state().set(loadedValue, { storage: false }); + // Assign loaded value to the State + this.state().set(loadedValue, { + storage: false, + overwrite: true, + }); - // Persist State, so that the Storage Value updates dynamically if the State updates - await this.persistValue(_storageItemKey); + // Setup side effects to keep the Storage value in sync + // with the current State value + this.setupSideEffects(_storageItemKey); return true; } - //========================================================================================================= - // Persist Value - //========================================================================================================= /** + * Persists the State in the corresponding Storage + * and sets up side effects that dynamically update + * the Storage value when the State changes. + * * @internal - * Sets everything up so that the State is saved in the Storage on every Value change - * @param storageItemKey - Prefix Key of Persisted Instances (default PersistentKey) - * @return Success? + * @param storageItemKey - Storage key of the to persist State Instance. + * | default = Persistent.key | + * @return Whether the persisting of the State Instance and setting up of the corresponding side effects was successful. */ public async persistValue(storageItemKey?: PersistentKey): Promise { if (!this.ready) return false; const _storageItemKey = storageItemKey ?? this._key; - // Add SideEffect to State, that updates the saved State Value depending on the current State Value + // Setup side effects to keep the Storage value in sync + // with the State value + this.setupSideEffects(_storageItemKey); + + // Initial rebuild Storage for persisting State value in the corresponding Storage + this.rebuildStorageSideEffect(this.state(), _storageItemKey); + + this.isPersisted = true; + return true; + } + + /** + * Sets up side effects to keep the Storage value in sync + * with the current State value. + * + * @internal + * @param storageItemKey - Storage key of the persisted State Instance. + * | default = Persistent.key | + */ + public setupSideEffects(storageItemKey?: PersistentKey) { + const _storageItemKey = storageItemKey ?? this._key; this.state().addSideEffect( StatePersistent.storeValueSideEffectKey, (instance, config) => { @@ -136,82 +133,67 @@ export class StatePersistent extends Persistent { }, { weight: 0 } ); - - // Initial rebuild Storage for saving State Value in the Storage - this.rebuildStorageSideEffect(this.state(), _storageItemKey); - - this.isPersisted = true; - return true; } - //========================================================================================================= - // Remove Persisted Value - //========================================================================================================= /** + * Removes the State from the corresponding Storage. + * -> State is no longer persisted + * * @internal - * Removes State Value form the Storage - * @param storageItemKey - Prefix Key of Persisted Instances (default PersistentKey) - * @return Success? + * @param storageItemKey - Storage key of the to remove State Instance. + * | default = Persistent.key | + * @return Whether the removal of the persisted State Instance was successful. */ public async removePersistedValue( storageItemKey?: PersistentKey ): Promise { if (!this.ready) return false; const _storageItemKey = storageItemKey || this._key; - - // Remove SideEffect this.state().removeSideEffect(StatePersistent.storeValueSideEffectKey); - - // Remove Value from Storage this.agileInstance().storages.remove(_storageItemKey, this.storageKeys); - this.isPersisted = false; return true; } - //========================================================================================================= - // Format Key - //========================================================================================================= /** + * Formats the specified key so that it can be used as a valid Storage key + * and returns the formatted variant of it. + * + * If no formatable key (`undefined`/`null`) was provided, + * an attempt is made to use the State identifier key as Storage key. + * * @internal - * Formats Storage Key - * @param key - Key that gets formatted + * @param key - Storage key to be formatted. */ - public formatKey(key?: PersistentKey): PersistentKey | undefined { - const state = this.state(); - - // Get key from State - if (!key && state._key) return state._key; - - if (!key) return; - - // Set State Key to Storage Key if State has no key - if (!state._key) state._key = key; - + public formatKey( + key: PersistentKey | undefined | null + ): PersistentKey | undefined { + if (key == null && this.state()._key) return this.state()._key; + if (key == null) return; + if (this.state()._key == null) this.state()._key = key; return key; } - //========================================================================================================= - // Rebuild Storage SideEffect - //========================================================================================================= /** + * Rebuilds Storage value based on the current State value. + * * @internal - * Rebuilds Storage depending on the State Value (Saves current State Value into the Storage) - * @param state - State that holds the new Value - * @param storageKey - StorageKey where value should be persisted - * @param config - Config + * @param state - State whose current value to be applied to the Storage value. + * @param storageItemKey - Storage key of the persisted State Instance. + * | default = Persistent.key | + * @param config - Configuration object */ public rebuildStorageSideEffect( state: State, - storageKey: PersistentKey, + storageItemKey: PersistentKey, config: { [key: string]: any } = {} ) { - if (config.storage !== undefined && !config.storage) return; - - this.agileInstance().storages.set( - storageKey, - this.state().getPersistableValue(), - this.storageKeys - ); + if (config['storage'] == null || config.storage) { + this.agileInstance().storages.set( + storageItemKey, + this.state().getPersistableValue(), + this.storageKeys + ); + } } } diff --git a/packages/core/src/state/state.runtime.job.ts b/packages/core/src/state/state.runtime.job.ts index 5bd298f6..f42af5c3 100644 --- a/packages/core/src/state/state.runtime.job.ts +++ b/packages/core/src/state/state.runtime.job.ts @@ -9,6 +9,17 @@ import { export class StateRuntimeJob extends RuntimeJob { public config: StateRuntimeJobConfigInterface; + /** + * A State Runtime Job is sent to the Runtime on behalf of the State Observer it represents. + * + * In the Runtime, the State Observer is performed via its `perform()` method + * and the Subscription Containers (UI-Components) + * to which it is subscribed are updated (re-rendered) accordingly. + * + * @internal + * @param observer - State Observer to be represented by the State Runtime Job. + * @param config - Configuration object + */ constructor( observer: StateObserver, config: CreateStateRuntimeJobConfigInterface = {} @@ -35,20 +46,26 @@ export class StateRuntimeJob extends RuntimeJob { } } -/** - * @param key - Key/Name of Job - */ export interface CreateStateRuntimeJobConfigInterface extends StateRuntimeJobConfigInterface { + /** + * Key/Name identifier of the State Runtime Job. + * @default undefined + */ key?: RuntimeJobKey; } -/** - * @param overwrite - If whole State Value gets overwritten with Job Value - * @param storage - If Job Value can be saved in Storage - */ export interface StateRuntimeJobConfigInterface extends RuntimeJobConfigInterface { + /** + * Whether to overwrite the whole State with the new State value. + * @default false + */ overwrite?: boolean; + /** + * If the State is persisted, + * whether to apply the new State value to the external Storages. + * @default true + */ storage?: boolean; } diff --git a/packages/core/src/storages/index.ts b/packages/core/src/storages/index.ts index 7d1df906..552a96b7 100644 --- a/packages/core/src/storages/index.ts +++ b/packages/core/src/storages/index.ts @@ -10,17 +10,24 @@ import { } from '../internal'; export class Storages { + // Agile Instance the Storages belongs to public agileInstance: () => Agile; public config: StoragesConfigInterface; - public storages: { [key: string]: Storage } = {}; // All registered Storages + + // Registered Storages + public storages: { [key: string]: Storage } = {}; + // Persistent from Instances (for example States) that were persisted public persistentInstances: Set = new Set(); /** + * The Storages Class manages all external Storages for an Agile Instance + * and provides an interface to easily store, + * load and remove values from multiple Storages at once. + * * @internal - * Storages - Manages Storages of Agile - * @param agileInstance - An Instance of Agile - * @param config - Config + * @param agileInstance - Instance of Agile the Storages belongs to. + * @param config - Configuration object */ constructor( agileInstance: Agile, @@ -35,21 +42,19 @@ export class Storages { if (config.localStorage) this.instantiateLocalStorage(); } - //========================================================================================================= - // Instantiate Local Storage - //========================================================================================================= /** + * Instantiates and registers the + * [Local Storage](https://developer.mozilla.org/de/docs/Web/API/Window/localStorage). + * + * Note that the Local Storage is only available in a web environment. + * * @internal - * Instantiates Local Storage */ public instantiateLocalStorage(): boolean { - // Check if Local Storage is Available if (!Storages.localStorageAvailable()) { LogCodeManager.log('11:02:00'); return false; } - - // Create and register Local Storage const _localStorage = new Storage({ key: 'localStorage', async: false, @@ -62,14 +67,14 @@ export class Storages { return this.register(_localStorage, { default: true }); } - //========================================================================================================= - // Register - //========================================================================================================= /** - * @internal - * Register new Storage as Agile Storage - * @param storage - new Storage - * @param config - Config + * Registers the specified Storage with AgileTs + * and updates the Persistent Instances that have already attempted + * to use the previously unregistered Storage. + * + * @public + * @param storage - Storage to be registered with AgileTs. + * @param config - Configuration object */ public register( storage: Storage, @@ -83,7 +88,7 @@ export class Storages { return false; } - // Set first added Storage as default Storage + // Assign Storage as default Storage if it is the first one added if (!hasRegisteredAnyStorage && config.default === false) LogCodeManager.log('11:02:01'); if (!hasRegisteredAnyStorage) config.default = true; @@ -93,15 +98,17 @@ export class Storages { if (config.default) this.config.defaultStorageKey = storage.key; this.persistentInstances.forEach((persistent) => { - // Revalidate Persistent that includes the newly registered StorageKey + // Revalidate Persistent, which contains key/name identifier of the newly registered Storage if (persistent.storageKeys.includes(storage.key)) { const isValid = persistent.validatePersistent(); if (isValid) persistent.initialLoading(); return; } - // If persistent has no default StorageKey (reassign StorageKeys since this registered Storage might be tagged as default Storage) - if (!persistent.config.defaultStorageKey) { + // If Persistent has no default Storage key, + // reassign Storage keys since the now registered Storage + // might be tagged as default Storage of AgileTs + if (persistent.config.defaultStorageKey == null) { persistent.assignStorageKeys(); const isValid = persistent.validatePersistent(); if (isValid) persistent.initialLoading(); @@ -111,43 +118,42 @@ export class Storages { return true; } - //========================================================================================================= - // Get Storage - //========================================================================================================= /** - * @internal - * Get Storage at Key/Name - * @param storageKey - Key/Name of Storage + * Retrieves a single Storage with the specified key/name identifier + * from the Storages Class. + * + * If the to retrieve Storage doesn't exist, `undefined` is returned. + * + * @public + * @param storageKey - Key/Name identifier of the Storage. */ public getStorage( storageKey: StorageKey | undefined | null ): Storage | undefined { if (!storageKey) return undefined; const storage = this.storages[storageKey]; - - // Check if Storage exists if (!storage) { LogCodeManager.log('11:03:01', [storageKey]); return undefined; } - - // Check if Storage is ready if (!storage.ready) { LogCodeManager.log('11:03:02', [storageKey]); return undefined; } - return storage; } - //========================================================================================================= - // Get - //========================================================================================================= /** - * @internal - * Gets value at provided Key - * @param storageItemKey - Key of Storage property - * @param storageKey - Key/Name of Storage from which the Item is fetched (if not provided default Storage will be used) + * Retrieves the stored value at the specified Storage Item key + * from the defined external Storage (`storageKey`). + * + * When no Storage has been specified, + * the value is retrieved from the default Storage. + * + * @public + * @param storageItemKey - Key/Name identifier of the value to be retrieved. + * @param storageKey - Key/Name identifier of the external Storage + * from which the value is to be retrieved. */ public get( storageItemKey: StorageItemKey, @@ -158,28 +164,31 @@ export class Storages { return Promise.resolve(undefined); } - // Call get Method in specific Storage + // Call get method on specified Storage if (storageKey) { const storage = this.getStorage(storageKey); if (storage) return storage.get(storageItemKey); } - // Call get Method in default Storage + // Call get method on default Storage const defaultStorage = this.getStorage(this.config.defaultStorageKey); return ( defaultStorage?.get(storageItemKey) || Promise.resolve(undefined) ); } - //========================================================================================================= - // Set - //========================================================================================================= /** - * @internal - * Saves/Updates value at provided Key - * @param storageItemKey - Key of Storage property - * @param value - new Value that gets set at provided Key - * @param storageKeys - Key/Name of Storages where the Value gets set (if not provided default Storage will be used) + * Stores or updates the value at the specified Storage Item key + * in the defined external Storages (`storageKeys`). + * + * When no Storage has been specified, + * the value is stored/updated in the default Storage. + * + * @public + * @param storageItemKey - Key/Name identifier of the value to be stored. + * @param value - Value to be stored in an external Storage. + * @param storageKeys - Key/Name identifier of the external Storage + * where the value is to be stored. */ public set( storageItemKey: StorageItemKey, @@ -191,26 +200,29 @@ export class Storages { return; } - // Call set Method in specific Storages - if (storageKeys) { + // Call set method on specified Storages + if (storageKeys != null) { for (const storageKey of storageKeys) this.getStorage(storageKey)?.set(storageItemKey, value); return; } - // Call set Method in default Storage + // Call set method on default Storage const defaultStorage = this.getStorage(this.config.defaultStorageKey); defaultStorage?.set(storageItemKey, value); } - //========================================================================================================= - // Remove - //========================================================================================================= /** - * @internal - * Removes value at provided Key - * @param storageItemKey - Key of Storage property - * @param storageKeys - Key/Name of Storages where the Value gets removed (if not provided default Storage will be used) + * Removes the value at the specified Storage Item key + * from the defined external Storages (`storageKeys`). + * + * When no Storage has been specified, + * the value is removed from the default Storage. + * + * @public + * @param storageItemKey - Key/Name identifier of the value to be removed. + * @param storageKeys - Key/Name identifier of the external Storage + * from which the value is to be removed. */ public remove( storageItemKey: StorageItemKey, @@ -221,35 +233,34 @@ export class Storages { return; } - // Call remove Method in specific Storages + // Call remove method on specified Storages if (storageKeys) { for (const storageKey of storageKeys) this.getStorage(storageKey)?.remove(storageItemKey); return; } - // Call remove Method in default Storage + // Call remove method on default Storage const defaultStorage = this.getStorage(this.config.defaultStorageKey); defaultStorage?.remove(storageItemKey); } - //========================================================================================================= - // Has Storage - //========================================================================================================= /** - * @internal - * Check if at least one Storage got registered + * Returns a boolean indicating whether any Storage + * has been registered with the Agile Instance or not. + * + * @public */ public hasStorage(): boolean { return notEqual(this.storages, {}); } - //========================================================================================================= - // Local Storage Available - //========================================================================================================= /** - * @internal - * Checks if localStorage is available in this Environment + * Returns a boolean indication whether the + * [Local Storage](https://developer.mozilla.org/de/docs/Web/API/Window/localStorage) + * is available in the current environment. + * + * @public */ static localStorageAvailable(): boolean { try { @@ -262,25 +273,59 @@ export class Storages { } } -/** - * @param localStorage - If Local Storage should be instantiated - * @param defaultStorage - Default Storage Key - */ export interface CreateStoragesConfigInterface { + /** + * Whether to register the Local Storage by default. + * Note that the Local Storage is only available in a web environment. + * @default false + */ localStorage?: boolean; + /** + * Key/Name identifier of the default Storage. + * + * The default Storage represents the default Storage of the Storages Class, + * on which executed actions are performed if no specific Storage was specified. + * + * Also, the persisted value is loaded from the default Storage by default, + * since only one persisted value can be applied. + * If the loading of the value from the default Storage failed, + * an attempt is made to load the value from the remaining Storages. + * + * @default undefined + */ defaultStorageKey?: StorageKey; } -/** - * @param defaultStorage - Default Storage Key - */ export interface StoragesConfigInterface { + /** + * Key/Name identifier of the default Storage. + * + * The default Storage represents the default Storage of the Storages Class, + * on which executed actions are performed if no specific Storage was specified. + * + * Also, the persisted value is loaded from the default Storage by default, + * since only one persisted value can be applied. + * If the loading of the value from the default Storage failed, + * an attempt is made to load the value from the remaining Storages. + * + * @default undefined + */ defaultStorageKey: StorageKey | null; } -/** - * @param default - If the registered Storage gets the default Storage - */ export interface RegisterConfigInterface { + /** + * Whether the to register Storage should become the default Storage. + * + * The default Storage represents the default Storage of the Storages Class, + * on which executed actions are performed if no specific Storage was specified. + * + * Also, the persisted value is loaded from the default Storage by default, + * since only one persisted value can be applied. + * If the loading of the value from the default Storage failed, + * an attempt is made to load the value from the remaining Storages. + * + * @default false + */ default?: boolean; } diff --git a/packages/core/src/storages/persistent.ts b/packages/core/src/storages/persistent.ts index 19f4cc09..a335d982 100644 --- a/packages/core/src/storages/persistent.ts +++ b/packages/core/src/storages/persistent.ts @@ -7,25 +7,36 @@ import { } from '../internal'; export class Persistent { + // Agile Instance the Persistent belongs to public agileInstance: () => Agile; public static placeHolderKey = '__THIS_IS_A_PLACEHOLDER__'; + public config: PersistentConfigInterface; + // Key/Name identifier of the Persistent public _key: PersistentKey; + // Whether the Persistent is ready + // and is able to persist values in an external Storage public ready = false; - public isPersisted = false; // If Value is stored in Agile Storage - public onLoad: ((success: boolean) => void) | undefined; // Gets called if PersistValue got loaded for the first Time + // Whether the Persistent value is stored in a corresponding external Storage/s + public isPersisted = false; + // Callback that is called when the persisted value was loaded into the Persistent for the first time + public onLoad: ((success: boolean) => void) | undefined; - // Storages in which the Persisted Value is saved + // Key/Name identifier of the Storages the Persistent value is stored in public storageKeys: StorageKey[] = []; /** + * A Persistent manages the permanent persistence + * of an Agile Class such as the `State Class` in external Storages. + * + * Note that the Persistent itself is no standalone class + * and should be adapted to the Agile Class needs it belongs to. + * * @internal - * Persistent - Handles storing of Agile Instances - * Note: No stand alone class!! - * @param agileInstance - An instance of Agile - * @param config - Config + * @param agileInstance - Instance of Agile the Persistent belongs to. + * @param config - Configuration object */ constructor( agileInstance: Agile, @@ -52,74 +63,97 @@ export class Persistent { } /** - * @internal - * Set Key/Name of Persistent + * Updates the key/name identifier of the Persistent. + * + * @public + * @param value - New key/name identifier. */ public set key(value: StorageKey) { this.setKey(value); } /** - * @internal - * Get Key/Name of Persistent + * Returns the key/name identifier of the Persistent. + * + * @public */ public get key(): StorageKey { return this._key; } - //========================================================================================================= - // Set Key - //========================================================================================================= /** + * Updates the key/name identifier of the Persistent. + * * @public - * Sets Key/Name of Persistent - * @param value - New Key/Name of Persistent + * @param value - New key/name identifier. */ - public setKey(value: StorageKey): void { - this._key = value; + public async setKey(value?: StorageKey): Promise { + const oldKey = this._key; + const wasReady = this.ready; + + // Assign new key to Persistent + if (value === this._key) return; + this._key = value ?? Persistent.placeHolderKey; + + const isValid = this.validatePersistent(); + + // Try to initial load value if Persistent hasn't been ready before + if (!wasReady) { + if (isValid) await this.initialLoading(); + return; + } + + // Remove persisted value that is located at the old key + await this.removePersistedValue(oldKey); + + // Persist value at the new key + if (isValid) await this.persistValue(value); } - //========================================================================================================= - // Instantiate Persistent - //========================================================================================================= /** + * Instantiates the Persistent by assigning the specified Storage keys to it + * and validating it to make sure everything was setup correctly. + * + * This was moved out of the `constructor()` + * because some classes (that extend the Persistent) need to configure some + * things before they can properly instantiate the parent Persistent. + * * @internal - * Instantiates this Class - * Note: Had to outsource it from the constructor because some extending classes - * have to define some stuff before being able to instantiate the parent (this) - * @param config - Config + * @param config - Configuration object */ public instantiatePersistent( config: InstantiatePersistentConfigInterface = {} ) { - this._key = this.formatKey(config.key) || Persistent.placeHolderKey; + this._key = this.formatKey(config.key) ?? Persistent.placeHolderKey; this.assignStorageKeys(config.storageKeys, config.defaultStorageKey); this.validatePersistent(); } - //========================================================================================================= - // Validate Persistent - //========================================================================================================= /** + * Returns a boolean indicating whether the Persistent was setup correctly + * and is able to persist a value permanently in an external Storage. + * + * Based on the tapped boolean value, + * the Persistent's `ready` property is updated. + * * @internal - * Validates Persistent and updates its 'ready' property */ public validatePersistent(): boolean { let isValid = true; - // Validate Key + // Validate Persistent key/name identifier if (this._key === Persistent.placeHolderKey) { LogCodeManager.log('12:03:00'); isValid = false; } - // Validate StorageKeys + // Validate Storage keys if (!this.config.defaultStorageKey || this.storageKeys.length <= 0) { LogCodeManager.log('12:03:01'); isValid = false; } - // Check if Storages exist + // Check if the Storages exist at the specified Storage keys this.storageKeys.map((key) => { if (!this.agileInstance().storages.storages[key]) { LogCodeManager.log('12:03:02', [this._key, key]); @@ -131,14 +165,16 @@ export class Persistent { return isValid; } - //========================================================================================================= - // Assign StorageKeys - //========================================================================================================= /** + * Assigns the specified Storage identifiers to the Persistent + * and extracts the default Storage if necessary. + * + * When no Storage key was provided the default Storage + * of the Agile Instance is applied to the Persistent. + * * @internal - * Assign new StorageKeys to Persistent and overwrite the old ones - * @param storageKeys - New Storage Keys - * @param defaultStorageKey - Key of default Storage + * @param storageKeys - Key/Name identifier of the Storages to be assigned. + * @param defaultStorageKey - Key/Name identifier of the default Storage. */ public assignStorageKeys( storageKeys: StorageKey[] = [], @@ -147,27 +183,28 @@ export class Persistent { const storages = this.agileInstance().storages; const _storageKeys = copy(storageKeys); - // Add passed default Storage Key to 'storageKeys' + // Assign specified default Storage key to the 'storageKeys' array if (defaultStorageKey && !_storageKeys.includes(defaultStorageKey)) _storageKeys.push(defaultStorageKey); - // Add default Storage of AgileTs to storageKeys and assign it as default Storage Key of Persistent if no storageKeys provided + // Assign the default Storage key of the Agile Instance to the 'storageKeys' array + // and specify it as the Persistent's default Storage key + // if no valid Storage key was provided if (_storageKeys.length <= 0) { this.config.defaultStorageKey = storages.config.defaultStorageKey as any; _storageKeys.push(storages.config.defaultStorageKey as any); } else { - this.config.defaultStorageKey = defaultStorageKey || _storageKeys[0]; + this.config.defaultStorageKey = defaultStorageKey ?? _storageKeys[0]; } this.storageKeys = _storageKeys; } - //========================================================================================================= - // Initial Loading - //========================================================================================================= /** + * Stores or loads the Persistent value + * from the external Storages for the first time. + * * @internal - * Loads/Saves Storage Value for the first Time */ public async initialLoading(): Promise { const success = await this.loadPersistedValue(); @@ -175,52 +212,72 @@ export class Persistent { if (!success) await this.persistValue(); } - //========================================================================================================= - // Load Value - //========================================================================================================= /** + * Loads the Persistent value from the corresponding Storage. + * + * Note that this method should be overwritten + * to correctly apply the changes to the Agile Class + * the Persistent belongs to. + * * @internal - * Loads Value from Storage - * @return Success? + * @param storageItemKey - Storage key of the to load value. + * | default = Persistent.key | + * @return Whether the loading of the persisted value was successful. */ - public async loadPersistedValue(): Promise { + public async loadPersistedValue( + storageItemKey?: PersistentKey + ): Promise { LogCodeManager.log('00:03:00', ['loadPersistedValue', 'Persistent']); return false; } - //========================================================================================================= - // Update Value - //========================================================================================================= /** + * Persists the Persistent value in the corresponding Storage. + * + * Note that this method should be overwritten + * to correctly apply the changes to the Agile Class + * the Persistent belongs to. + * * @internal - * Saves/Updates Value in Storage - * @return Success? + * @param storageItemKey - Storage key of the to persist value + * | default = Persistent.key | + * @return Whether the persisting of the value was successful. */ - public async persistValue(): Promise { + public async persistValue(storageItemKey?: PersistentKey): Promise { LogCodeManager.log('00:03:00', ['persistValue', 'Persistent']); return false; } - //========================================================================================================= - // Remove Value - //========================================================================================================= /** + * Removes the Persistent value from the corresponding Storage. + * -> Persistent value is no longer persisted + * + * Note that this method should be overwritten + * to correctly apply the changes to the Agile Class + * the Persistent belongs to. + * * @internal - * Removes Value form Storage - * @return Success? + * @param storageItemKey - Storage key of the to remove value. + * | default = Persistent.key | + * @return Whether the removal of the persisted value was successful. */ - public async removePersistedValue(): Promise { + public async removePersistedValue( + storageItemKey?: PersistentKey + ): Promise { LogCodeManager.log('00:03:00', ['removePersistedValue', 'Persistent']); return false; } - //========================================================================================================= - // Format Key - //========================================================================================================= /** + * Formats the specified key so that it can be used as a valid Storage key + * and returns the formatted variant of it. + * + * Note that this method should be overwritten + * to correctly apply the changes to the Agile Class + * the Persistent belongs to. + * * @internal - * Validates Storage Key - * @param key - Key that gets validated + * @param key - Storage key to be formatted. */ public formatKey(key?: PersistentKey): PersistentKey | undefined { return key; @@ -229,33 +286,72 @@ export class Persistent { export type PersistentKey = string | number; -/** - * @param key - Key/Name of Persistent - * @param storageKeys - Keys of Storages in that the persisted Value gets saved - * @param defaultStorage - Default Storage Key - * @param instantiate - If Persistent gets Instantiated immediately - */ export interface CreatePersistentConfigInterface { + /** + * Key/Name identifier of the Persistent. + */ key?: PersistentKey; + /** + * Key/Name identifier of Storages + * in which the Persistent value is to be persisted + * or is already persisted. + * @default [`defaultStorageKey`] + */ storageKeys?: StorageKey[]; + /** + * Key/Name identifier of the default Storage of the specified Storage keys. + * + * The persisted value is loaded from the default Storage by default, + * since only one persisted value can be applied. + * If the loading of the value from the default Storage failed, + * an attempt is made to load the value from the remaining Storages. + * + * @default first index of the specified Storage keys or the Agile Instance's default Storage key + */ defaultStorageKey?: StorageKey; + /** + * Whether the Persistent should be instantiated immediately + * or whether this should be done manually. + * @default true + */ instantiate?: boolean; } -/** - * @param defaultStorageKey - Default Storage Key - */ export interface PersistentConfigInterface { + /** + * Key/Name identifier of the default Storage of the specified Storage keys. + * + * The persisted value is loaded from the default Storage by default, + * since only one persisted value can be applied. + * If the loading of the value from the default Storage failed, + * an attempt is made to load the value from the remaining Storages. + * + * @default first index of the specified Storage keys or the Agile Instance's default Storage key + */ defaultStorageKey: StorageKey | null; } -/** - * @param key - Key/Name of Persistent - * @param storageKeys - Keys of Storages in that the persisted Value gets saved - * @param defaultStorageKey - Default Storage Key (if not provided it takes the first index of storageKeys or the AgileTs default Storage) - */ export interface InstantiatePersistentConfigInterface { + /** + * Key/Name identifier of the Persistent. + */ key?: PersistentKey; + /** + * Key/Name identifier of Storages + * in which the Persistent value is to be persisted + * or is already persisted. + * @default [`defaultStorageKey`] + */ storageKeys?: StorageKey[]; + /** + * Key/Name identifier of the default Storage of the specified Storage keys. + * + * The persisted value is loaded from the default Storage by default, + * since only one persisted value can be applied. + * If the loading of the value from the default Storage failed, + * an attempt is made to load the value from the remaining Storages. + * + * @default first index of the specified Storage keys or the Agile Instance's default Storage key + */ defaultStorageKey?: StorageKey; } diff --git a/packages/core/src/storages/storage.ts b/packages/core/src/storages/storage.ts index ed8005be..894a2a6d 100644 --- a/packages/core/src/storages/storage.ts +++ b/packages/core/src/storages/storage.ts @@ -8,15 +8,24 @@ import { } from '../internal'; export class Storage { + public config: StorageConfigInterface; + + // Key/Name identifier of the Storage public key: StorageKey; + // Whether the Storage is ready and is able to store values public ready = false; + // Methods to interact with the external Storage (get, set, remove) public methods: StorageMethodsInterface; - public config: StorageConfigInterface; /** + * A Storage is an interface to an external Storage, + * and allows the easy interaction with that Storage. + * + * Due to the Storage, AgileTs can easily persist its Instances in almost any Storage + * without a huge overhead. + * * @public - * Storage - Interface for storing Items permanently - * @param config - Config + * @param config - Configuration object */ constructor(config: CreateStorageConfigInterface) { config = defineConfig(config, { @@ -41,12 +50,11 @@ export class Storage { this.config.async = true; } - //========================================================================================================= - // Validate - //========================================================================================================= /** + * Returns a boolean indicating whether the Storage is valid + * and can be used to persist Instances in it or not. + * * @public - * Validates Storage Methods */ public validate(): boolean { if (!isFunction(this.methods?.get)) { @@ -64,86 +72,117 @@ export class Storage { return true; } - //========================================================================================================= - // Normal Get - //========================================================================================================= /** - * @internal - * Gets value at provided Key (normal) - * Note: Only use this if you are 100% sure this Storage doesn't work async - * @param key - Key of Storage property + * Synchronously retrieves the stored value + * at the specified Storage Item key from the external Storage. + * + * When the retrieved value is a JSON-String it is parsed automatically. + * + * @public + * @param key - Key/Name identifier of the value to be retrieved. */ public normalGet(key: StorageItemKey): GetTpe | undefined { - if (!this.ready || !this.methods.get) return; + if (!this.ready || !this.methods.get) return undefined; if (isAsyncFunction(this.methods.get)) LogCodeManager.log('13:02:00'); - // Get Value + // Retrieve value const res = this.methods.get(this.getStorageKey(key)); - if (isJsonString(res)) return JSON.parse(res); - return res; + const _res = isJsonString(res) ? JSON.parse(res) : res; + + Agile.logger.if + .tag(['storage']) + .info( + LogCodeManager.getLog('13:01:00', [this.key, this.getStorageKey(key)]), + _res + ); + + return _res; } - //========================================================================================================= - // Async Get - //========================================================================================================= /** - * @internal - * Gets value at provided Key (async) - * @param key - Key of Storage property + * Asynchronously retrieves the stored value + * at the specified Storage Item key from the external Storage. + * + * When the retrieved value is a JSON-String it is parsed automatically. + * + * @public + * @param key - Key/Name identifier of the value to be retrieved. */ public get(key: StorageItemKey): Promise { if (!this.ready || !this.methods.get) return Promise.resolve(undefined); - // Get Value in 'dummy' promise if get method isn't async + // Retrieve value from not promise based Storage if (!isAsyncFunction(this.methods.get)) return Promise.resolve(this.normalGet(key)); - // Get Value (async) + // Retrieve value from promise based Storage return new Promise((resolve, reject) => { this.methods ?.get(this.getStorageKey(key)) .then((res: any) => { - if (isJsonString(res)) resolve(JSON.parse(res)); - resolve(res); + const _res = isJsonString(res) ? JSON.parse(res) : res; + + Agile.logger.if + .tag(['storage']) + .info( + LogCodeManager.getLog('13:01:00', [ + this.key, + this.getStorageKey(key), + ]), + _res + ); + + resolve(_res); }) .catch(reject); }); } - //========================================================================================================= - // Set - //========================================================================================================= /** + * Stores or updates the value at the specified Storage Item key + * in the external Storage. + * * @public - * Saves/Updates value at provided Key - * @param key - Key of Storage property - * @param value - new Value that gets set + * @param key - Key/Name identifier of the value to be stored or updated. + * @param value - Value to be stored. */ public set(key: StorageItemKey, value: any): void { if (!this.ready || !this.methods.set) return; + + Agile.logger.if + .tag(['storage']) + .info( + LogCodeManager.getLog('13:01:01', [this.key, this.getStorageKey(key)]), + value + ); + this.methods.set(this.getStorageKey(key), JSON.stringify(value)); } - //========================================================================================================= - // Remove - //========================================================================================================= /** + * Removes the value at the specified Storage Item key + * from the external Storage. + * * @public - * Removes value at provided Key - * @param key - Key of Storage property + * @param key - Key/Name identifier of the value to be removed. */ public remove(key: StorageItemKey): void { if (!this.ready || !this.methods.remove) return; + + Agile.logger.if + .tag(['storage']) + .info( + LogCodeManager.getLog('13:01:02', [this.key, this.getStorageKey(key)]) + ); + this.methods.remove(this.getStorageKey(key)); } - //========================================================================================================= - // Get Storage Key - //========================================================================================================= /** + * Generates and returns a valid Storage key based on the specified key. + * * @internal - * Creates Storage Key from provided key - * @param key - Key that gets converted into a Storage Key + * @param key - Key to be converted into a valid Storage key. */ public getStorageKey(key: StorageItemKey): string { return this.config.prefix @@ -155,31 +194,50 @@ export class Storage { export type StorageKey = string | number; export type StorageItemKey = string | number; -/** - * @param key - Key/Name of Storage - * @param methods - Storage methods like (get, set, remove) - */ export interface CreateStorageConfigInterface extends StorageConfigInterface { + /** + * Key/Name identifier of the Storage. + * @default undefined + */ key: string; + /** + * Storage methods for interacting with the external Storage. + * @default undefined + */ methods: StorageMethodsInterface; } -/** - * @param get - Get Method of Storage (gets items from storage) - * @param set - Set Method of Storage (saves/updates items in storage) - * @param remove - Remove Methods of Storage (removes items from storage) - */ export interface StorageMethodsInterface { + /** + * Method to retrieve a value at the specified key from the external Storage. + * + * @param key - Key/Name identifier of the value to be retrieved. + */ get: (key: string) => any; + /** + * Method to store or update a value at the specified key in the external Storage. + * + * @param key - Key/Name identifier of the value to be stored or updated. + * @param value - Value to be stored. + */ set: (key: string, value: any) => void; + /** + * Method to remove a value at the specified key from the external Storage. + * + * @param key - Key/Name identifier of the value to be removed. + */ remove: (key: string) => void; } -/** - * @param async - If its an async storage - * @param prefix - Prefix of Storage Property - */ export interface StorageConfigInterface { + /** + * Whether the external Storage represented by the Storage Class works async. + * @default Automatically detected via the `isAsyncFunction()` method + */ async?: boolean; + /** + * Prefix to be added before each persisted value key/name identifier. + * @default 'agile' + */ prefix?: string; } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 56f5000d..a36692dd 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -7,18 +7,17 @@ import { LogCodeManager, } from './internal'; -//========================================================================================================= -// Get Agile Instance -//========================================================================================================= /** + * Extracts an Instance of Agile from the specified Instance. + * When no valid Agile Instance was found, + * it returns the global bound Agile Instance or `undefined`. + * * @internal - * Tries to get an Instance of Agile from provided Instance - * If no agileInstance found it returns the global bound Agile Instance - * @param instance - Instance that might hold an Agile Instance + * @param instance - Instance to extract the Agile Instance from. */ export function getAgileInstance(instance: any): Agile | undefined { try { - // Try to get agileInstance from passed Instance + // Try to get Agile Instance from specified Instance if (instance) { const _agileInstance = isFunction(instance['agileInstance']) ? instance['agileInstance']() @@ -26,7 +25,7 @@ export function getAgileInstance(instance: any): Agile | undefined { if (_agileInstance) return _agileInstance; } - // Return global bound agileInstance + // Return global bound Agile Instance return globalThis[Agile.globalKey]; } catch (e) { LogCodeManager.log('20:03:00', [], instance); @@ -35,13 +34,11 @@ export function getAgileInstance(instance: any): Agile | undefined { return undefined; } -//========================================================================================================= -// Extract Observers -//========================================================================================================= /** - * @private - * Extract Observers from specific Instances - * @param instances - Instances that will be formatted + * Extracts the Observers from the specified Instances. + * + * @internal + * @param instances - Instances to extract the Observers from. */ export function extractObservers(instances: any): Array { const instancesArray: Array = []; @@ -51,13 +48,16 @@ export function extractObservers(instances: any): Array { // Get Observers from Instances for (const instance of tempInstancesArray) { - // If Instance is undefined (We have to add undefined to build a proper return value in for instance 'useAgile' later) - if (!instance) { + // 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 (instance == null) { instancesArray.push(undefined); continue; } - // If Instance is Collection + // If the Instance equals to a Collection if (instance instanceof Collection) { instancesArray.push( instance.getGroupWithReference(instance.config.defaultGroupKey).observer @@ -65,36 +65,40 @@ export function extractObservers(instances: any): Array { continue; } - // If Instance has property that is an Observer + // If the Instance contains a property that is an Observer if (instance['observer'] && instance['observer'] instanceof Observer) { instancesArray.push(instance['observer']); continue; } - // If Instance is Observer + // If the Instance equals to an Observer if (instance instanceof Observer) { instancesArray.push(instance); continue; } - // Push undefined if no Observer could be found (We have to add undefined to build a proper return value in for instance 'useAgile' later) + // 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); } return instancesArray; } -//========================================================================================================= -// Global Bind -//========================================================================================================= /** - * @internal - * Binds passed Instance globally at passed Key + * Binds the specified Instance globally at the provided key identifier. + * + * 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/ - * @param key - Key/Name of Instance - * @param instance - Instance - * @param overwrite - If already existing instance at passed Key gets overwritten + * + * @public + * @param key - Key/Name identifier of the specified Instance. + * @param instance - Instance to be bound globally. + * @param overwrite - When already an Instance exists globally at the specified key, + * whether to overwrite it with the new Instance. */ export function globalBind( key: string, @@ -106,7 +110,6 @@ export function globalBind( globalThis[key] = instance; return true; } - if (globalThis[key] == null) { globalThis[key] = instance; return true; diff --git a/packages/core/tests/integration/collection.persistent.integration.test.ts b/packages/core/tests/integration/collection.persistent.integration.test.ts index 20215777..203fbfd4 100644 --- a/packages/core/tests/integration/collection.persistent.integration.test.ts +++ b/packages/core/tests/integration/collection.persistent.integration.test.ts @@ -1,5 +1,5 @@ import { Agile, Item } from '../../src'; -import mockConsole from 'jest-mock-console'; +import { LogMock } from '../helper/logMock'; describe('Collection Persist Function Tests', () => { const myStorage: any = {}; @@ -34,8 +34,8 @@ describe('Collection Persist Function Tests', () => { } beforeEach(() => { + LogMock.mockLogs(); jest.clearAllMocks(); - mockConsole(['error', 'warn']); }); describe('Collection', () => { diff --git a/packages/core/tests/unit/agile.test.ts b/packages/core/tests/unit/agile.test.ts index e231a090..11b9e863 100644 --- a/packages/core/tests/unit/agile.test.ts +++ b/packages/core/tests/unit/agile.test.ts @@ -240,26 +240,28 @@ describe('Agile Tests', () => { }); it('should create Computed', () => { - const computed = agile.createComputed(computedFunction, []); + const computed = agile.createComputed(computedFunction, [ + 'dummyDep' as any, + ]); expect(computed).toBeInstanceOf(Computed); expect(ComputedMock).toHaveBeenCalledWith(agile, computedFunction, { - computedDeps: [], + computedDeps: ['dummyDep' as any], }); }); it('should create Computed with config', () => { - const computed = agile.createComputed( - computedFunction, - { key: 'jeff', isPlaceholder: false }, - [] - ); + const computed = agile.createComputed(computedFunction, { + key: 'jeff', + isPlaceholder: false, + computedDeps: ['dummyDep' as any], + }); expect(computed).toBeInstanceOf(Computed); expect(ComputedMock).toHaveBeenCalledWith(agile, computedFunction, { key: 'jeff', isPlaceholder: false, - computedDeps: [], + computedDeps: ['dummyDep' as any], }); }); }); diff --git a/packages/core/tests/unit/collection/collection.persistent.test.ts b/packages/core/tests/unit/collection/collection.persistent.test.ts index c3e4ebb3..764b482c 100644 --- a/packages/core/tests/unit/collection/collection.persistent.test.ts +++ b/packages/core/tests/unit/collection/collection.persistent.test.ts @@ -32,43 +32,42 @@ describe('CollectionPersistent Tests', () => { jest.spyOn(CollectionPersistent.prototype, 'initialLoading'); }); - it("should create CollectionPersistent and shouldn't call initialLoading if Persistent isn't ready (default config)", () => { - // Overwrite instantiatePersistent once to not call it and set ready property + it('should create CollectionPersistent and should call initialLoading if Persistent is ready (default config)', () => { + // Overwrite instantiatePersistent once to not call it jest .spyOn(CollectionPersistent.prototype, 'instantiatePersistent') .mockImplementationOnce(function () { // @ts-ignore - this.ready = false; + this.ready = true; }); const collectionPersistent = new CollectionPersistent(dummyCollection); expect(collectionPersistent).toBeInstanceOf(CollectionPersistent); - expect(collectionPersistent.collection()).toBe(dummyCollection); expect(collectionPersistent.instantiatePersistent).toHaveBeenCalledWith({ key: undefined, storageKeys: [], defaultStorageKey: null, }); - expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); + expect(collectionPersistent.initialLoading).toHaveBeenCalled(); expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); - expect(collectionPersistent.ready).toBeFalsy(); + expect(collectionPersistent.ready).toBeTruthy(); expect(collectionPersistent.isPersisted).toBeFalsy(); expect(collectionPersistent.onLoad).toBeUndefined(); expect(collectionPersistent.storageKeys).toStrictEqual([]); expect(collectionPersistent.config).toStrictEqual({ - defaultStorageKey: null, + defaultStorageKey: null, // is assigned in 'instantiatePersistent' which is mocked }); }); - it("should create CollectionPersistent and shouldn't call initialLoading if Persistent isn't ready (specific config)", () => { + it('should create CollectionPersistent and should call initialLoading if Persistent is ready (specific config)', () => { // Overwrite instantiatePersistent once to not call it and set ready property jest .spyOn(CollectionPersistent.prototype, 'instantiatePersistent') .mockImplementationOnce(function () { // @ts-ignore - this.ready = false; + this.ready = true; }); const collectionPersistent = new CollectionPersistent(dummyCollection, { @@ -83,30 +82,46 @@ describe('CollectionPersistent Tests', () => { storageKeys: ['test1', 'test2'], defaultStorageKey: 'test2', }); - expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); + expect(collectionPersistent.initialLoading).toHaveBeenCalled(); expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); - expect(collectionPersistent.ready).toBeFalsy(); + expect(collectionPersistent.ready).toBeTruthy(); expect(collectionPersistent.isPersisted).toBeFalsy(); expect(collectionPersistent.onLoad).toBeUndefined(); expect(collectionPersistent.storageKeys).toStrictEqual([]); expect(collectionPersistent.config).toStrictEqual({ - defaultStorageKey: null, // gets set in 'instantiatePersistent' which is mocked + defaultStorageKey: null, // is assigned in 'instantiatePersistent' which is mocked }); }); - it('should create CollectionPersistent and should call initialLoading if Persistent is ready (default config)', () => { - // Overwrite instantiatePersistent once to not call it + it("should create CollectionPersistent and shouldn't call initialLoading if Persistent isn't ready", () => { + // Overwrite instantiatePersistent once to not call it and set ready property jest .spyOn(CollectionPersistent.prototype, 'instantiatePersistent') .mockImplementationOnce(function () { // @ts-ignore - this.ready = true; + this.ready = false; }); const collectionPersistent = new CollectionPersistent(dummyCollection); - expect(collectionPersistent.initialLoading).toHaveBeenCalled(); + expect(collectionPersistent).toBeInstanceOf(CollectionPersistent); + expect(collectionPersistent.collection()).toBe(dummyCollection); + expect(collectionPersistent.instantiatePersistent).toHaveBeenCalledWith({ + key: undefined, + storageKeys: [], + defaultStorageKey: null, + }); + expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); + + expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); + expect(collectionPersistent.ready).toBeFalsy(); + expect(collectionPersistent.isPersisted).toBeFalsy(); + expect(collectionPersistent.onLoad).toBeUndefined(); + expect(collectionPersistent.storageKeys).toStrictEqual([]); + expect(collectionPersistent.config).toStrictEqual({ + defaultStorageKey: null, + }); }); it("should create CollectionPersistent and shouldn't call initialLoading if Persistent is ready (config.instantiate = false)", () => { @@ -122,7 +137,23 @@ describe('CollectionPersistent Tests', () => { instantiate: false, }); + expect(collectionPersistent).toBeInstanceOf(CollectionPersistent); + expect(collectionPersistent.collection()).toBe(dummyCollection); + expect(collectionPersistent.instantiatePersistent).toHaveBeenCalledWith({ + key: undefined, + storageKeys: [], + defaultStorageKey: null, + }); expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); + + expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); + expect(collectionPersistent.ready).toBeTruthy(); + expect(collectionPersistent.isPersisted).toBeFalsy(); + expect(collectionPersistent.onLoad).toBeUndefined(); + expect(collectionPersistent.storageKeys).toStrictEqual([]); + expect(collectionPersistent.config).toStrictEqual({ + defaultStorageKey: null, + }); }); describe('CollectionPersistent Function Tests', () => { @@ -152,18 +183,21 @@ describe('CollectionPersistent Tests', () => { name: 'frank', }); dummyItem1.persistent = new StatePersistent(dummyItem1); + dummyItem1.persist = jest.fn(); dummyItem2 = new Item(dummyCollection, { id: '2', name: 'dieter', }); dummyItem2.persistent = new StatePersistent(dummyItem2); + dummyItem2.persist = jest.fn(); dummyItem3 = new Item(dummyCollection, { id: '3', name: 'hans', }); dummyItem3.persistent = new StatePersistent(dummyItem3); + dummyItem3.persist = jest.fn(); dummyItem4WithoutPersistent = new Item(dummyCollection, { id: '4', @@ -171,89 +205,12 @@ describe('CollectionPersistent Tests', () => { }); }); - describe('setKey function tests', () => { - beforeEach(() => { - collectionPersistent.removePersistedValue = jest.fn(); - collectionPersistent.persistValue = jest.fn(); - collectionPersistent.initialLoading = jest.fn(); - jest.spyOn(collectionPersistent, 'validatePersistent'); - }); - - it('should update key with valid key in ready Persistent', async () => { - collectionPersistent.ready = true; - collectionPersistent._key = 'dummyKey'; - - await collectionPersistent.setKey('newKey'); - - expect(collectionPersistent._key).toBe('newKey'); - expect(collectionPersistent.ready).toBeTruthy(); - expect(collectionPersistent.validatePersistent).toHaveBeenCalled(); - expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); - expect(collectionPersistent.persistValue).toHaveBeenCalledWith( - 'newKey' - ); - expect(collectionPersistent.removePersistedValue).toHaveBeenCalledWith( - 'dummyKey' - ); - }); - - it('should update key with not valid key in ready Persistent', async () => { - collectionPersistent.ready = true; - collectionPersistent._key = 'dummyKey'; - - await collectionPersistent.setKey(); - - expect(collectionPersistent._key).toBe( - CollectionPersistent.placeHolderKey - ); - expect(collectionPersistent.ready).toBeFalsy(); - expect(collectionPersistent.validatePersistent).toHaveBeenCalled(); - expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); - expect(collectionPersistent.persistValue).not.toHaveBeenCalled(); - expect(collectionPersistent.removePersistedValue).toHaveBeenCalledWith( - 'dummyKey' - ); - }); - - it('should update key with valid key in not ready Persistent', async () => { - collectionPersistent.ready = false; - - await collectionPersistent.setKey('newKey'); - - expect(collectionPersistent._key).toBe('newKey'); - expect(collectionPersistent.ready).toBeTruthy(); - expect(collectionPersistent.validatePersistent).toHaveBeenCalled(); - expect(collectionPersistent.initialLoading).toHaveBeenCalled(); - expect(collectionPersistent.persistValue).not.toHaveBeenCalled(); - expect( - collectionPersistent.removePersistedValue - ).not.toHaveBeenCalled(); - }); - - it('should update key with not valid key in not ready Persistent', async () => { - collectionPersistent.ready = false; - - await collectionPersistent.setKey(); - - expect(collectionPersistent._key).toBe( - CollectionPersistent.placeHolderKey - ); - expect(collectionPersistent.ready).toBeFalsy(); - expect(collectionPersistent.validatePersistent).toHaveBeenCalled(); - expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); - expect(collectionPersistent.persistValue).not.toHaveBeenCalled(); - expect( - collectionPersistent.removePersistedValue - ).not.toHaveBeenCalled(); - }); - }); - describe('initialLoading function tests', () => { beforeEach(() => { jest.spyOn(Persistent.prototype, 'initialLoading'); }); - it('should initialLoad and set isPersisted in Collection to true', async () => { + it('should call initialLoad in parent and set Collection.isPersisted to true', async () => { await collectionPersistent.initialLoading(); expect(Persistent.prototype.initialLoading).toHaveBeenCalled(); @@ -263,276 +220,380 @@ describe('CollectionPersistent Tests', () => { describe('loadPersistedValue function tests', () => { let dummyDefaultGroup: Group; + let placeholderItem1: Item; + let placeholderItem2: Item; + let placeholderItem3: Item; beforeEach(() => { collectionPersistent.config.defaultStorageKey = 'test'; - dummyDefaultGroup = new Group(dummyCollection, ['1', '2', '3']); + placeholderItem1 = dummyCollection.createPlaceholderItem('1'); + placeholderItem1.persist = jest.fn(); + placeholderItem2 = dummyCollection.createPlaceholderItem('2'); + placeholderItem2.persist = jest.fn(); + placeholderItem3 = dummyCollection.createPlaceholderItem('3'); + placeholderItem3.persist = jest.fn(); + + dummyDefaultGroup = new Group(dummyCollection, ['1', '2', '3'], { + key: 'default', + }); + dummyDefaultGroup.persist = jest.fn(); dummyDefaultGroup.persistent = new StatePersistent(dummyDefaultGroup); - if (dummyDefaultGroup.persistent) + if (dummyDefaultGroup.persistent) { dummyDefaultGroup.persistent.ready = true; + dummyDefaultGroup.persistent.initialLoading = jest.fn(); + } - collectionPersistent.persistValue = jest.fn(); + collectionPersistent.setupSideEffects = jest.fn(); - dummyDefaultGroup.persist = jest.fn(); - if (dummyDefaultGroup.persistent) - dummyDefaultGroup.persistent.initialLoading = jest.fn(); + dummyCollection.getDefaultGroup = jest.fn( + () => dummyDefaultGroup as any + ); + dummyCollection.assignItem = jest.fn(); - dummyCollection.collect = jest.fn(); + dummyAgile.storages.get = jest.fn(); }); - it('should load default Group and its Items with the persistentKey and apply it to the Collection if loading was successful', async () => { + it('should load default Group and apply persisted value to Items that are already present in the Collection (persistentKey)', async () => { collectionPersistent.ready = true; + dummyCollection.data = { + ['3']: dummyItem3, + }; dummyAgile.storages.get = jest .fn() - .mockReturnValueOnce(Promise.resolve(true)) - .mockReturnValueOnce({ id: '1', name: 'hans' }) - .mockReturnValueOnce(undefined) - .mockReturnValueOnce({ id: '3', name: 'frank' }); - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); + .mockReturnValueOnce(Promise.resolve(true)); + dummyDefaultGroup._value = ['3']; const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeTruthy(); - - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.config.defaultStorageKey ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '1', - collectionPersistent._key - ), - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '2', + + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); + expect(dummyDefaultGroup.persist).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + dummyDefaultGroup._key, collectionPersistent._key ), - collectionPersistent.config.defaultStorageKey + { + loadValue: false, + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(dummyDefaultGroup.persistent?.initialLoading).toHaveBeenCalled(); + expect(dummyDefaultGroup.isPersisted).toBeTruthy(); + + expect(dummyItem3.persist).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey( '3', collectionPersistent._key ), - collectionPersistent.config.defaultStorageKey + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); - expect(dummyDefaultGroup.persist).toHaveBeenCalledWith({ - loadValue: false, - followCollectionPersistKeyPattern: true, - }); - expect(dummyDefaultGroup.persistent?.initialLoading).toHaveBeenCalled(); - expect(dummyDefaultGroup.isPersisted).toBeTruthy(); - - expect(dummyCollection.collect).toHaveBeenCalledWith({ - id: '1', - name: 'hans', - }); - expect(dummyCollection.collect).not.toHaveBeenCalledWith(undefined); - expect(dummyCollection.collect).toHaveBeenCalledWith({ - id: '3', - name: 'frank', - }); - - expect(collectionPersistent.persistValue).toHaveBeenCalledWith( + expect(collectionPersistent.setupSideEffects).toHaveBeenCalledWith( collectionPersistent._key ); }); - it("shouldn't load default Group and its Items with the persistentKey and shouldn't apply it to the Collection if loading wasn't successful", async () => { + it( + 'should load default Group ' + + "and create/add persisted Items that aren't present in the Collection yet (persistentKey)", + async () => { + collectionPersistent.ready = true; + dummyCollection.data = {}; + dummyAgile.storages.get = jest + .fn() + .mockReturnValueOnce(Promise.resolve(true)); + placeholderItem1.persist = jest.fn(function () { + placeholderItem1.persistent = new StatePersistent(placeholderItem1); + placeholderItem1.persistent.ready = true; + placeholderItem1.persistent.loadPersistedValue = jest + .fn() + .mockReturnValueOnce(true); + return null as any; + }); + placeholderItem2.persist = jest.fn(function () { + placeholderItem2.persistent = new StatePersistent(placeholderItem2); + placeholderItem2.persistent.ready = false; + placeholderItem2.persistent.loadPersistedValue = jest.fn(); + return null as any; + }); + placeholderItem3.persist = jest.fn(function () { + placeholderItem3.persistent = new StatePersistent(placeholderItem3); + placeholderItem3.persistent.ready = true; + placeholderItem3.persistent.loadPersistedValue = jest + .fn() + .mockReturnValueOnce(false); + return null as any; + }); + dummyCollection.createPlaceholderItem = jest + .fn() + .mockReturnValueOnce(placeholderItem1) + .mockReturnValueOnce(placeholderItem2) + .mockReturnValueOnce(placeholderItem3); + dummyDefaultGroup._value = ['1', '2', '3']; + + const response = await collectionPersistent.loadPersistedValue(); + + expect(response).toBeTruthy(); + expect(dummyAgile.storages.get).toHaveBeenCalledWith( + collectionPersistent._key, + collectionPersistent.config.defaultStorageKey + ); + + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); + expect(dummyDefaultGroup.persist).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + dummyDefaultGroup._key, + collectionPersistent._key + ), + { + loadValue: false, + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } + ); + expect( + dummyDefaultGroup.persistent?.initialLoading + ).toHaveBeenCalled(); + expect(dummyDefaultGroup.isPersisted).toBeTruthy(); + + expect(dummyCollection.createPlaceholderItem).toHaveBeenCalledWith( + '1' + ); + expect(dummyCollection.createPlaceholderItem).toHaveBeenCalledWith( + '2' + ); + expect(dummyCollection.createPlaceholderItem).toHaveBeenCalledWith( + '3' + ); + expect(placeholderItem1.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + '1', + collectionPersistent._key + ), + { + loadValue: false, + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } + ); + expect(placeholderItem2.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + '2', + collectionPersistent._key + ), + { + loadValue: false, + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } + ); + expect(placeholderItem3.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + '3', + collectionPersistent._key + ), + { + loadValue: false, + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } + ); + expect(dummyCollection.assignItem).toHaveBeenCalledWith( + placeholderItem1 + ); + expect(dummyCollection.assignItem).not.toHaveBeenCalledWith( + placeholderItem2 + ); // Because Item persistent isn't ready + expect(dummyCollection.assignItem).not.toHaveBeenCalledWith( + placeholderItem3 + ); // Because Item persistent 'leadPersistedValue()' returned false -> Item properly doesn't exist in Storage + + expect(collectionPersistent.setupSideEffects).toHaveBeenCalledWith( + collectionPersistent._key + ); + } + ); + + it( + 'should load default Group, ' + + "create/add persisted Items that aren't present in the Collection yet " + + 'and apply persisted value to Items that are already present in the Collection (specific key)', + async () => { + collectionPersistent.ready = true; + dummyCollection.data = { + ['3']: dummyItem3, + }; + dummyAgile.storages.get = jest + .fn() + .mockReturnValueOnce(Promise.resolve(true)); + placeholderItem1.persist = jest.fn(function () { + placeholderItem1.persistent = new StatePersistent(placeholderItem1); + placeholderItem1.persistent.ready = true; + placeholderItem1.persistent.loadPersistedValue = jest + .fn() + .mockReturnValueOnce(true); + return null as any; + }); + dummyCollection.createPlaceholderItem = jest + .fn() + .mockReturnValueOnce(placeholderItem1); + dummyDefaultGroup._value = ['1', '3']; + + const response = await collectionPersistent.loadPersistedValue( + 'dummyKey' + ); + + expect(response).toBeTruthy(); + expect(dummyAgile.storages.get).toHaveBeenCalledWith( + 'dummyKey', + collectionPersistent.config.defaultStorageKey + ); + + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); + expect(dummyDefaultGroup.persist).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + dummyDefaultGroup._key, + 'dummyKey' + ), + { + loadValue: false, + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } + ); + expect( + dummyDefaultGroup.persistent?.initialLoading + ).toHaveBeenCalled(); + expect(dummyDefaultGroup.isPersisted).toBeTruthy(); + + expect(dummyItem3.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey('3', 'dummyKey'), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } + ); + + expect( + dummyCollection.createPlaceholderItem + ).not.toHaveBeenCalledWith('3'); // Because Item 3 is already present in the Collection + expect(dummyCollection.createPlaceholderItem).toHaveBeenCalledWith( + '1' + ); + expect(placeholderItem1.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey('1', 'dummyKey'), + { + loadValue: false, + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } + ); + expect(dummyCollection.assignItem).toHaveBeenCalledWith( + placeholderItem1 + ); + expect(dummyCollection.assignItem).not.toHaveBeenCalledWith( + placeholderItem3 + ); // Because Item 3 is already present in the Collection + + expect(collectionPersistent.setupSideEffects).toHaveBeenCalledWith( + 'dummyKey' + ); + } + ); + + it("shouldn't load default Group and its Items if Collection flag isn't persisted", async () => { collectionPersistent.ready = true; dummyAgile.storages.get = jest .fn() .mockReturnValueOnce(Promise.resolve(undefined)); - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeFalsy(); - - expect(dummyCollection.getGroup).not.toHaveBeenCalled(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.config.defaultStorageKey ); - expect(dummyAgile.storages.get).not.toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '1', - collectionPersistent._key - ), - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).not.toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '2', - collectionPersistent._key - ), - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).not.toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '3', - collectionPersistent._key - ), - collectionPersistent.config.defaultStorageKey - ); + expect(dummyCollection.getDefaultGroup).not.toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); - expect( - dummyDefaultGroup.persistent?.initialLoading - ).not.toHaveBeenCalled(); - expect(dummyDefaultGroup.isPersisted).toBeFalsy(); - - expect(dummyCollection.collect).not.toHaveBeenCalled(); - - expect(collectionPersistent.persistValue).not.toHaveBeenCalled(); - }); - - it('should load default Group and its Items with a specific Key and should apply it to the Collection if loading was successful', async () => { - collectionPersistent.ready = true; - dummyAgile.storages.get = jest - .fn() - .mockReturnValueOnce(Promise.resolve(true)) - .mockReturnValueOnce({ id: '1', name: 'hans' }) - .mockReturnValueOnce(undefined) - .mockReturnValueOnce({ id: '3', name: 'frank' }); - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); - - const response = await collectionPersistent.loadPersistedValue( - 'dummyKey' - ); - - expect(response).toBeTruthy(); - - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); - - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - 'dummyKey', - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey('1', 'dummyKey'), - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey('2', 'dummyKey'), - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey('3', 'dummyKey'), - collectionPersistent.config.defaultStorageKey - ); - - expect(dummyDefaultGroup.persist).toHaveBeenCalledWith({ - loadValue: false, - followCollectionPersistKeyPattern: true, - }); - expect(dummyDefaultGroup.persistent?.initialLoading).toHaveBeenCalled(); - expect(dummyDefaultGroup.isPersisted).toBeTruthy(); - expect(dummyCollection.collect).toHaveBeenCalledWith({ - id: '1', - name: 'hans', - }); - expect(dummyCollection.collect).not.toHaveBeenCalledWith(undefined); - expect(dummyCollection.collect).toHaveBeenCalledWith({ - id: '3', - name: 'frank', - }); + expect(placeholderItem1.persist).not.toHaveBeenCalled(); + expect(placeholderItem2.persist).not.toHaveBeenCalled(); + expect(placeholderItem3.persist).not.toHaveBeenCalled(); + expect(dummyItem1.persist).not.toHaveBeenCalled(); + expect(dummyItem2.persist).not.toHaveBeenCalled(); + expect(dummyItem3.persist).not.toHaveBeenCalled(); - expect(collectionPersistent.persistValue).toHaveBeenCalledWith( - 'dummyKey' - ); + expect(collectionPersistent.setupSideEffects).not.toHaveBeenCalled(); }); it("shouldn't load default Group and its Items if Persistent isn't ready", async () => { collectionPersistent.ready = false; - dummyAgile.storages.get = jest.fn(() => - Promise.resolve(undefined as any) - ); - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeFalsy(); - - expect(dummyCollection.getGroup).not.toHaveBeenCalled(); - expect(dummyAgile.storages.get).not.toHaveBeenCalled(); + expect(dummyCollection.getDefaultGroup).not.toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); - expect( - dummyDefaultGroup.persistent?.initialLoading - ).not.toHaveBeenCalled(); - expect(dummyDefaultGroup.isPersisted).toBeFalsy(); - expect(dummyCollection.collect).not.toHaveBeenCalled(); + expect(placeholderItem1.persist).not.toHaveBeenCalled(); + expect(placeholderItem2.persist).not.toHaveBeenCalled(); + expect(placeholderItem3.persist).not.toHaveBeenCalled(); + expect(dummyItem1.persist).not.toHaveBeenCalled(); + expect(dummyItem2.persist).not.toHaveBeenCalled(); + expect(dummyItem3.persist).not.toHaveBeenCalled(); - expect(collectionPersistent.persistValue).not.toHaveBeenCalled(); + expect(collectionPersistent.setupSideEffects).not.toHaveBeenCalled(); }); it("shouldn't load default Group and its Items if Collection has no defaultGroup", async () => { collectionPersistent.ready = true; - dummyCollection.groups = {}; dummyAgile.storages.get = jest .fn() .mockReturnValueOnce(Promise.resolve(true)); - dummyCollection.getGroup = jest.fn(() => undefined); + dummyCollection.getDefaultGroup = jest.fn(() => undefined); const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeFalsy(); - - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.config.defaultStorageKey ); - expect(dummyAgile.storages.get).not.toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '1', - collectionPersistent._key - ), - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).not.toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '2', - collectionPersistent._key - ), - collectionPersistent.config.defaultStorageKey - ); - expect(dummyAgile.storages.get).not.toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey( - '3', - collectionPersistent._key - ), - collectionPersistent.config.defaultStorageKey - ); + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); - expect( - dummyDefaultGroup.persistent?.initialLoading - ).not.toHaveBeenCalled(); - expect(dummyDefaultGroup.isPersisted).toBeFalsy(); - expect(dummyCollection.collect).not.toHaveBeenCalled(); + expect(placeholderItem1.persist).not.toHaveBeenCalled(); + expect(placeholderItem2.persist).not.toHaveBeenCalled(); + expect(placeholderItem3.persist).not.toHaveBeenCalled(); + expect(dummyItem1.persist).not.toHaveBeenCalled(); + expect(dummyItem2.persist).not.toHaveBeenCalled(); + expect(dummyItem3.persist).not.toHaveBeenCalled(); - expect(collectionPersistent.persistValue).not.toHaveBeenCalled(); + expect(collectionPersistent.setupSideEffects).not.toHaveBeenCalled(); }); }); @@ -543,157 +604,210 @@ describe('CollectionPersistent Tests', () => { collectionPersistent.storageKeys = ['test1', 'test2']; collectionPersistent.isPersisted = undefined as any; - dummyDefaultGroup = new Group(dummyCollection, ['1', '2', '3']); dummyCollection.data = { ['1']: dummyItem1, ['3']: dummyItem3, }; + dummyDefaultGroup = new Group(dummyCollection, ['1', '2', '3'], { + key: 'default', + }); dummyDefaultGroup.persist = jest.fn(); - jest.spyOn(dummyDefaultGroup, 'addSideEffect'); dummyItem1.persist = jest.fn(); dummyItem3.persist = jest.fn(); - dummyCollection.collect = jest.fn(); + collectionPersistent.setupSideEffects = jest.fn(); + + dummyCollection.getDefaultGroup = jest.fn( + () => dummyDefaultGroup as any + ); dummyAgile.storages.set = jest.fn(); }); - it('should persist defaultGroup and its Items with persistentKey', async () => { + it('should persist default Group and its Items (persistentKey)', async () => { collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.persistValue(); expect(response).toBeTruthy(); - expect(dummyAgile.storages.set).toHaveBeenCalledWith( collectionPersistent._key, true, collectionPersistent.storageKeys ); - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); - expect(dummyDefaultGroup.persist).toHaveBeenCalledWith({ - followCollectionPersistKeyPattern: true, - }); - expect( - dummyDefaultGroup.addSideEffect - ).toHaveBeenCalledWith( - CollectionPersistent.defaultGroupSideEffectKey, - expect.any(Function), - { weight: 0 } + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); + expect(dummyDefaultGroup.persist).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + dummyDefaultGroup._key, + collectionPersistent._key + ), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); expect(dummyItem1.persist).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey( dummyItem1._key, collectionPersistent._key - ) + ), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); expect(dummyItem3.persist).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey( dummyItem3._key, collectionPersistent._key - ) + ), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); + expect(collectionPersistent.setupSideEffects).toHaveBeenCalled(); expect(collectionPersistent.isPersisted).toBeTruthy(); }); - it('should persist defaultGroup and its Items with specific Key', async () => { + it('should persist default Group and its Items (specific key)', async () => { collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.persistValue('dummyKey'); expect(response).toBeTruthy(); - expect(dummyAgile.storages.set).toHaveBeenCalledWith( 'dummyKey', true, collectionPersistent.storageKeys ); - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); - expect(dummyDefaultGroup.persist).toHaveBeenCalledWith({ - followCollectionPersistKeyPattern: true, - }); - expect( - dummyDefaultGroup.addSideEffect - ).toHaveBeenCalledWith( - CollectionPersistent.defaultGroupSideEffectKey, - expect.any(Function), - { weight: 0 } + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); + expect(dummyDefaultGroup.persist).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + dummyDefaultGroup._key, + 'dummyKey' + ), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); expect(dummyItem1.persist).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey(dummyItem1._key, 'dummyKey') + CollectionPersistent.getItemStorageKey(dummyItem1._key, 'dummyKey'), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); expect(dummyItem3.persist).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey(dummyItem3._key, 'dummyKey') + CollectionPersistent.getItemStorageKey(dummyItem3._key, 'dummyKey'), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); + expect(collectionPersistent.setupSideEffects).toHaveBeenCalled(); expect(collectionPersistent.isPersisted).toBeTruthy(); }); - it("shouldn't persist defaultGroup and its Items if Persistent isn't ready", async () => { + it("shouldn't persist default Group and its Items if Persistent isn't ready", async () => { collectionPersistent.ready = false; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.persistValue('dummyKey'); expect(response).toBeFalsy(); - expect(dummyAgile.storages.set).not.toHaveBeenCalled(); - expect(dummyCollection.getGroup).not.toHaveBeenCalled(); + expect(dummyCollection.getDefaultGroup).not.toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); - expect(dummyDefaultGroup.addSideEffect).not.toHaveBeenCalled(); expect(dummyItem1.persist).not.toHaveBeenCalled(); expect(dummyItem3.persist).not.toHaveBeenCalled(); + expect(collectionPersistent.setupSideEffects).not.toHaveBeenCalled(); expect(collectionPersistent.isPersisted).toBeUndefined(); }); - it("shouldn't persist defaultGroup and its Items if Collection has no defaultGroup", async () => { + it("shouldn't persist default Group and its Items if Collection has no default Group", async () => { collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => undefined as any); + dummyCollection.getDefaultGroup = jest.fn(() => undefined as any); const response = await collectionPersistent.persistValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.set).not.toHaveBeenCalled(); - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); - expect(dummyDefaultGroup.addSideEffect).not.toHaveBeenCalled(); expect(dummyItem1.persist).not.toHaveBeenCalled(); expect(dummyItem3.persist).not.toHaveBeenCalled(); + expect(collectionPersistent.setupSideEffects).not.toHaveBeenCalled(); expect(collectionPersistent.isPersisted).toBeUndefined(); }); + }); + + describe('setupSideEffects function tests', () => { + let dummyDefaultGroup: Group; + + beforeEach(() => { + dummyDefaultGroup = new Group(dummyCollection, ['1', '2', '3'], { + key: 'default', + }); + jest.spyOn(dummyDefaultGroup, 'addSideEffect'); + + collectionPersistent.rebuildStorageSideEffect = jest.fn(); + + dummyCollection.getDefaultGroup = jest.fn( + () => dummyDefaultGroup as any + ); + }); + + it("shouldn't add rebuild Storage side effect to the default Group", () => { + collectionPersistent.setupSideEffects(); + + expect( + dummyDefaultGroup.addSideEffect + ).toHaveBeenCalledWith( + CollectionPersistent.defaultGroupSideEffectKey, + expect.any(Function), + { weight: 0 } + ); + }); + + it("shouldn't add rebuild Storage side effect to the default Group if the Collection has no default Group", () => { + dummyCollection.getDefaultGroup = jest.fn(() => undefined as any); + + collectionPersistent.setupSideEffects(); + + expect(dummyDefaultGroup.addSideEffect).not.toHaveBeenCalled(); + }); - describe('test added sideEffect called CollectionPersistent.defaultGroupSideEffectKey', () => { + describe("test added sideEffect called 'CollectionPersistent.defaultGroupSideEffectKey'", () => { beforeEach(() => { collectionPersistent.rebuildStorageSideEffect = jest.fn(); + dummyCollection.getDefaultGroup = jest.fn( + () => dummyDefaultGroup as any + ); }); - it('should call rebuildStorageSideEffect with persistentKey', async () => { - collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); - - await collectionPersistent.persistValue(); + it('should call rebuildStorageSideEffect (persistentKey)', async () => { + await collectionPersistent.setupSideEffects(); dummyDefaultGroup.sideEffects[ CollectionPersistent.defaultGroupSideEffectKey @@ -704,11 +818,8 @@ describe('CollectionPersistent Tests', () => { ).toHaveBeenCalledWith(dummyDefaultGroup, collectionPersistent._key); }); - it('should call rebuildStorageSideEffect with specific Key', async () => { - collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); - - await collectionPersistent.persistValue('dummyKey'); + it('should call rebuildStorageSideEffect (specified key)', async () => { + await collectionPersistent.setupSideEffects('dummyKey'); dummyDefaultGroup.sideEffects[ CollectionPersistent.defaultGroupSideEffectKey @@ -728,16 +839,21 @@ describe('CollectionPersistent Tests', () => { collectionPersistent.storageKeys = ['test1', 'test2']; collectionPersistent.isPersisted = undefined as any; - dummyDefaultGroup = new Group(dummyCollection, ['1', '2', '3']); - dummyDefaultGroup.persistent = new StatePersistent(dummyDefaultGroup); dummyCollection.data = { ['1']: dummyItem1, ['3']: dummyItem3, }; + dummyDefaultGroup = new Group(dummyCollection, ['1', '2', '3']); + dummyDefaultGroup.persistent = new StatePersistent(dummyDefaultGroup); + dummyDefaultGroup.removeSideEffect = jest.fn(); + if (dummyDefaultGroup.persistent) dummyDefaultGroup.persistent.removePersistedValue = jest.fn(); - dummyDefaultGroup.removeSideEffect = jest.fn(); + + dummyCollection.getDefaultGroup = jest.fn( + () => dummyDefaultGroup as any + ); if (dummyItem1.persistent) dummyItem1.persistent.removePersistedValue = jest.fn(); @@ -747,77 +863,99 @@ describe('CollectionPersistent Tests', () => { dummyAgile.storages.remove = jest.fn(); }); - it('should remove persisted defaultGroup and its Items from Storage with persistentKey', async () => { + it('should remove persisted default Group and its Items from Storage (persistentKey)', async () => { collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.removePersistedValue(); expect(response).toBeTruthy(); - expect(dummyAgile.storages.remove).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.storageKeys ); - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); expect( dummyDefaultGroup.persistent?.removePersistedValue - ).toHaveBeenCalled(); + ).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + dummyDefaultGroup._key, + collectionPersistent._key + ) + ); expect(dummyDefaultGroup.removeSideEffect).toHaveBeenCalledWith( CollectionPersistent.defaultGroupSideEffectKey ); - expect(dummyItem1.persistent?.removePersistedValue).toHaveBeenCalled(); - expect(dummyItem3.persistent?.removePersistedValue).toHaveBeenCalled(); + expect( + dummyItem1.persistent?.removePersistedValue + ).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + dummyItem1._key, + collectionPersistent._key + ) + ); + expect( + dummyItem3.persistent?.removePersistedValue + ).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + dummyItem3._key, + collectionPersistent._key + ) + ); expect(collectionPersistent.isPersisted).toBeFalsy(); }); - it('should remove persisted defaultGroup and its Items from Storage with specific Key', async () => { + it('should remove persisted default Group and its Items from Storage (specific key)', async () => { collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.removePersistedValue( 'dummyKey' ); expect(response).toBeTruthy(); - expect(dummyAgile.storages.remove).toHaveBeenCalledWith( 'dummyKey', collectionPersistent.storageKeys ); - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); expect( dummyDefaultGroup.persistent?.removePersistedValue - ).toHaveBeenCalled(); + ).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + dummyDefaultGroup._key, + 'dummyKey' + ) + ); expect(dummyDefaultGroup.removeSideEffect).toHaveBeenCalledWith( CollectionPersistent.defaultGroupSideEffectKey ); - expect(dummyItem1.persistent?.removePersistedValue).toHaveBeenCalled(); - expect(dummyItem3.persistent?.removePersistedValue).toHaveBeenCalled(); + expect( + dummyItem1.persistent?.removePersistedValue + ).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey(dummyItem1._key, 'dummyKey') + ); + expect( + dummyItem3.persistent?.removePersistedValue + ).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey(dummyItem3._key, 'dummyKey') + ); expect(collectionPersistent.isPersisted).toBeFalsy(); }); - it("shouldn't remove persisted defaultGroup and its Items from Storage if Persistent isn't ready", async () => { + it("shouldn't remove persisted default Group and its Items from Storage if Persistent isn't ready", async () => { collectionPersistent.ready = false; - dummyCollection.getGroup = jest.fn(() => dummyDefaultGroup as any); const response = await collectionPersistent.removePersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.remove).not.toHaveBeenCalled(); - expect(dummyCollection.getGroup).not.toHaveBeenCalled(); + expect(dummyCollection.getDefaultGroup).not.toHaveBeenCalled(); expect( dummyDefaultGroup.persistent?.removePersistedValue ).not.toHaveBeenCalled(); @@ -833,19 +971,16 @@ describe('CollectionPersistent Tests', () => { expect(collectionPersistent.isPersisted).toBeUndefined(); }); - it("shouldn't remove persisted defaultGroup and its Items from Storage if Collection has no default Group", async () => { + it("shouldn't remove persisted default Group and its Items from Storage if Collection has no default Group", async () => { collectionPersistent.ready = true; - dummyCollection.getGroup = jest.fn(() => undefined as any); + dummyCollection.getDefaultGroup = jest.fn(() => undefined as any); const response = await collectionPersistent.removePersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.remove).not.toHaveBeenCalled(); - expect(dummyCollection.getGroup).toHaveBeenCalledWith( - dummyCollection.config.defaultGroupKey - ); + expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); expect( dummyDefaultGroup.persistent?.removePersistedValue ).not.toHaveBeenCalled(); @@ -863,15 +998,15 @@ describe('CollectionPersistent Tests', () => { }); describe('formatKey function tests', () => { - it('should return key of Collection if no key got passed', () => { + it('should return key of the Collection if no valid key was specified', () => { dummyCollection._key = 'coolKey'; - const response = collectionPersistent.formatKey(); + const response = collectionPersistent.formatKey(undefined); expect(response).toBe('coolKey'); }); - it('should return passed key', () => { + it('should return specified key', () => { dummyCollection._key = 'coolKey'; const response = collectionPersistent.formatKey('awesomeKey'); @@ -879,7 +1014,7 @@ describe('CollectionPersistent Tests', () => { expect(response).toBe('awesomeKey'); }); - it('should return and apply passed key to Collection if Collection had no own key before', () => { + it('should return and apply specified key to Collection if Collection had no own valid key before', () => { dummyCollection._key = undefined; const response = collectionPersistent.formatKey('awesomeKey'); @@ -888,10 +1023,10 @@ describe('CollectionPersistent Tests', () => { expect(dummyCollection._key).toBe('awesomeKey'); }); - it('should return undefined if no key got passed and Collection has no key', () => { + it('should return undefined if no valid key was specified and Collection has no valid key either', () => { dummyCollection._key = undefined; - const response = collectionPersistent.formatKey(); + const response = collectionPersistent.formatKey(undefined); expect(response).toBeUndefined(); }); @@ -921,13 +1056,6 @@ describe('CollectionPersistent Tests', () => { dummyItem2.persistent.removePersistedValue = jest.fn(); if (dummyItem3.persistent) dummyItem3.persistent.removePersistedValue = jest.fn(); - - if (dummyItem1.persistent) - dummyItem1.persistent.persistValue = jest.fn(); - if (dummyItem2.persistent) - dummyItem2.persistent.persistValue = jest.fn(); - if (dummyItem3.persistent) - dummyItem3.persistent.persistValue = jest.fn(); }); it('should return if no Item got added or removed', () => { @@ -950,13 +1078,9 @@ describe('CollectionPersistent Tests', () => { expect( dummyItem3.persistent?.removePersistedValue ).not.toHaveBeenCalled(); - - expect(dummyItem1.persistent?.persistValue).not.toHaveBeenCalled(); - expect(dummyItem2.persistent?.persistValue).not.toHaveBeenCalled(); - expect(dummyItem3.persistent?.persistValue).not.toHaveBeenCalled(); }); - it('should call removePersistedValue on Items that got removed from Group', () => { + it('should call removePersistedValue() on Items that got removed from Group', () => { dummyGroup.previousStateValue = ['1', '2', '3']; dummyGroup._value = ['2']; @@ -980,45 +1104,11 @@ describe('CollectionPersistent Tests', () => { ).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey('3', collectionPersistent._key) ); - - expect(dummyItem1.persistent?.persistValue).not.toHaveBeenCalled(); - expect(dummyItem2.persistent?.persistValue).not.toHaveBeenCalled(); - expect(dummyItem3.persistent?.persistValue).not.toHaveBeenCalled(); }); - it('should call persistValue on Items that have a persistent and got added to Group', () => { + it("should call persist on Items that got added to Group and hasn't been persisted yet", () => { dummyGroup.previousStateValue = ['1']; - dummyGroup._value = ['1', '2', '3']; - - collectionPersistent.rebuildStorageSideEffect(dummyGroup); - - expect(dummyItem1.persist).not.toHaveBeenCalled(); - expect(dummyItem2.persist).not.toHaveBeenCalled(); - expect(dummyItem3.persist).not.toHaveBeenCalled(); - expect(dummyItem4WithoutPersistent.persist).not.toHaveBeenCalled(); - - expect( - dummyItem1.persistent?.removePersistedValue - ).not.toHaveBeenCalled(); - expect( - dummyItem2.persistent?.removePersistedValue - ).not.toHaveBeenCalled(); - expect( - dummyItem3.persistent?.removePersistedValue - ).not.toHaveBeenCalled(); - - expect(dummyItem1.persistent?.persistValue).not.toHaveBeenCalled(); - expect(dummyItem2.persistent?.persistValue).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey('2', collectionPersistent._key) - ); - expect(dummyItem3.persistent?.persistValue).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey('3', collectionPersistent._key) - ); - }); - - it('should call persist on Items that have no persistent and got added to Group', () => { - dummyGroup.previousStateValue = ['1']; - dummyGroup._value = ['1', '4']; + dummyGroup._value = ['1', '4', '3']; collectionPersistent.rebuildStorageSideEffect(dummyGroup); @@ -1026,7 +1116,15 @@ describe('CollectionPersistent Tests', () => { expect(dummyItem2.persist).not.toHaveBeenCalled(); expect(dummyItem3.persist).not.toHaveBeenCalled(); expect(dummyItem4WithoutPersistent.persist).toHaveBeenCalledWith( - CollectionPersistent.getItemStorageKey('4', collectionPersistent._key) + CollectionPersistent.getItemStorageKey( + '4', + collectionPersistent._key + ), + { + defaultStorageKey: collectionPersistent.config.defaultStorageKey, + storageKeys: collectionPersistent.storageKeys, + followCollectionPersistKeyPattern: false, + } ); expect( @@ -1038,15 +1136,11 @@ describe('CollectionPersistent Tests', () => { expect( dummyItem3.persistent?.removePersistedValue ).not.toHaveBeenCalled(); - - expect(dummyItem1.persistent?.persistValue).not.toHaveBeenCalled(); - expect(dummyItem2.persistent?.persistValue).not.toHaveBeenCalled(); - expect(dummyItem3.persistent?.persistValue).not.toHaveBeenCalled(); }); }); describe('getItemStorageKey function tests', () => { - it('should build ItemStorageKey out of itemKey and collectionKey', () => { + it('should build ItemStorageKey based on itemKey and collectionKey', () => { const response = CollectionPersistent.getItemStorageKey( 'itemKey', 'collectionKey' @@ -1056,7 +1150,7 @@ describe('CollectionPersistent Tests', () => { LogMock.hasNotLogged('warn'); }); - it('should build ItemStorageKey out of collectionKey with warning', () => { + it('should build ItemStorageKey based on only collectionKey with warning', () => { const response = CollectionPersistent.getItemStorageKey( undefined, 'collectionKey' @@ -1066,7 +1160,7 @@ describe('CollectionPersistent Tests', () => { LogMock.hasLoggedCode('1A:02:00'); }); - it('should build ItemStorageKey out of itemKey with warning', () => { + it('should build ItemStorageKey based on only itemKey with warning', () => { const response = CollectionPersistent.getItemStorageKey( 'itemKey', undefined @@ -1076,7 +1170,7 @@ describe('CollectionPersistent Tests', () => { LogMock.hasLoggedCode('1A:02:00'); }); - it('should build ItemStorageKey out of nothing with warning', () => { + it('should build ItemStorageKey based on nothing with warning', () => { const response = CollectionPersistent.getItemStorageKey( undefined, undefined @@ -1088,7 +1182,7 @@ describe('CollectionPersistent Tests', () => { }); describe('getGroupStorageKey function tests', () => { - it('should build GroupStorageKey out of groupKey and collectionKey', () => { + it('should build GroupStorageKey based on groupKey and collectionKey', () => { const response = CollectionPersistent.getGroupStorageKey( 'groupKey', 'collectionKey' @@ -1098,7 +1192,7 @@ describe('CollectionPersistent Tests', () => { LogMock.hasNotLogged('warn'); }); - it('should build GroupStorageKey out of collectionKey with warning', () => { + it('should build GroupStorageKey based on only collectionKey with warning', () => { const response = CollectionPersistent.getGroupStorageKey( undefined, 'collectionKey' @@ -1108,7 +1202,7 @@ describe('CollectionPersistent Tests', () => { LogMock.hasLoggedCode('1A:02:01'); }); - it('should build GroupStorageKey out of groupKey with warning', () => { + it('should build GroupStorageKey based on only groupKey with warning', () => { const response = CollectionPersistent.getGroupStorageKey( 'groupKey', undefined @@ -1118,7 +1212,7 @@ describe('CollectionPersistent Tests', () => { LogMock.hasLoggedCode('1A:02:01'); }); - it('should build GroupStorageKey out of nothing with warning', () => { + it('should build GroupStorageKey based on nothing with warning', () => { const response = CollectionPersistent.getGroupStorageKey( undefined, undefined diff --git a/packages/core/tests/unit/collection/collection.test.ts b/packages/core/tests/unit/collection/collection.test.ts index 482e74e2..3ad578f8 100644 --- a/packages/core/tests/unit/collection/collection.test.ts +++ b/packages/core/tests/unit/collection/collection.test.ts @@ -447,11 +447,13 @@ describe('Collection Tests', () => { let dummyGroup1: Group; let dummyGroup2: Group; let defaultGroup: Group; + let dummyItem5: Item; beforeEach(() => { dummyGroup1 = new Group(collection); dummyGroup2 = new Group(collection); defaultGroup = new Group(collection); + dummyItem5 = new Item(collection, { id: '5', name: 'frank' }); collection.groups = { [collection.config.defaultGroupKey]: defaultGroup, @@ -459,7 +461,8 @@ describe('Collection Tests', () => { dummyGroup2: dummyGroup2, }; - collection.setData = jest.fn(); + collection.assignData = jest.fn(); + collection.assignItem = jest.fn(); collection.createSelector = jest.fn(); collection.createGroup = jest.fn(); @@ -468,12 +471,12 @@ describe('Collection Tests', () => { defaultGroup.add = jest.fn(); }); - it('should add Data to Collection and to default Group (default config)', () => { - collection.setData = jest.fn(() => true); + it('should add data object to Collection and to default Group (default config)', () => { + collection.assignData = jest.fn(() => true); collection.collect({ id: '1', name: 'frank' }); - expect(collection.setData).toHaveBeenCalledWith( + expect(collection.assignData).toHaveBeenCalledWith( { id: '1', name: 'frank', @@ -483,6 +486,8 @@ describe('Collection Tests', () => { background: false, } ); + expect(collection.assignItem).not.toHaveBeenCalled(); + expect(collection.createGroup).not.toHaveBeenCalled(); expect(dummyGroup1.add).not.toHaveBeenCalled(); @@ -495,8 +500,8 @@ describe('Collection Tests', () => { expect(collection.createSelector).not.toHaveBeenCalled(); }); - it('should add Data to Collection and to default Group (specific config)', () => { - collection.setData = jest.fn(() => true); + it('should add data object to Collection and to default Group (specific config)', () => { + collection.assignData = jest.fn(() => true); collection.collect({ id: '1', name: 'frank' }, [], { background: true, @@ -504,7 +509,7 @@ describe('Collection Tests', () => { patch: true, }); - expect(collection.setData).toHaveBeenCalledWith( + expect(collection.assignData).toHaveBeenCalledWith( { id: '1', name: 'frank', @@ -514,6 +519,8 @@ describe('Collection Tests', () => { background: true, } ); + expect(collection.assignItem).not.toHaveBeenCalled(); + expect(collection.createGroup).not.toHaveBeenCalled(); expect(dummyGroup1.add).not.toHaveBeenCalled(); @@ -526,18 +533,64 @@ describe('Collection Tests', () => { expect(collection.createSelector).not.toHaveBeenCalled(); }); - it('should add Data to Collection and to passed Groups + default Group (default config)', () => { - collection.setData = jest.fn(() => true); + it('should add Item to Collection and to default Group (default config)', () => { + collection.assignItem = jest.fn(() => true); + + collection.collect(dummyItem5); + + expect(collection.assignData).not.toHaveBeenCalled(); + expect(collection.assignItem).toHaveBeenCalledWith(dummyItem5, { + background: false, + }); + + expect(collection.createGroup).not.toHaveBeenCalled(); + + expect(dummyGroup1.add).not.toHaveBeenCalled(); + expect(dummyGroup2.add).not.toHaveBeenCalled(); + expect(defaultGroup.add).toHaveBeenCalledWith('5', { + method: 'push', + background: false, + }); + + expect(collection.createSelector).not.toHaveBeenCalled(); + }); + + it('should add Item to Collection and to default Group (specific config)', () => { + collection.assignItem = jest.fn(() => true); + + collection.collect(dummyItem5, [], { + background: true, + method: 'unshift', + patch: true, + }); + + expect(collection.assignData).not.toHaveBeenCalled(); + expect(collection.assignItem).toHaveBeenCalledWith(dummyItem5, { + background: true, + }); + + expect(collection.createGroup).not.toHaveBeenCalled(); + + expect(dummyGroup1.add).not.toHaveBeenCalled(); + expect(dummyGroup2.add).not.toHaveBeenCalled(); + expect(defaultGroup.add).toHaveBeenCalledWith('5', { + method: 'unshift', + background: true, + }); + + expect(collection.createSelector).not.toHaveBeenCalled(); + }); + + it('should add data/item to Collection and to given + default Group (default config)', () => { + collection.assignData = jest.fn(() => true); + collection.assignItem = jest.fn(() => true); collection.collect( - [ - { id: '1', name: 'frank' }, - { id: '2', name: 'hans' }, - ], + [{ id: '1', name: 'frank' }, dummyItem5, { id: '2', name: 'hans' }], ['dummyGroup1', 'dummyGroup2'] ); - expect(collection.setData).toHaveBeenCalledWith( + expect(collection.assignData).toHaveBeenCalledWith( { id: '1', name: 'frank', @@ -547,7 +600,7 @@ describe('Collection Tests', () => { background: false, } ); - expect(collection.setData).toHaveBeenCalledWith( + expect(collection.assignData).toHaveBeenCalledWith( { id: '2', name: 'hans', @@ -557,6 +610,10 @@ describe('Collection Tests', () => { background: false, } ); + expect(collection.assignItem).toHaveBeenCalledWith(dummyItem5, { + background: false, + }); + expect(collection.createGroup).not.toHaveBeenCalled(); expect(dummyGroup1.add).toHaveBeenCalledWith('1', { @@ -567,6 +624,10 @@ describe('Collection Tests', () => { method: 'push', background: false, }); + expect(dummyGroup1.add).toHaveBeenCalledWith('5', { + method: 'push', + background: false, + }); expect(dummyGroup2.add).toHaveBeenCalledWith('1', { method: 'push', background: false, @@ -575,6 +636,10 @@ describe('Collection Tests', () => { method: 'push', background: false, }); + expect(dummyGroup2.add).toHaveBeenCalledWith('5', { + method: 'push', + background: false, + }); expect(defaultGroup.add).toHaveBeenCalledWith('1', { method: 'push', background: false, @@ -583,19 +648,27 @@ describe('Collection Tests', () => { method: 'push', background: false, }); + expect(defaultGroup.add).toHaveBeenCalledWith('5', { + method: 'push', + background: false, + }); expect(collection.createSelector).not.toHaveBeenCalled(); }); - it("should call setData and shouldn't add Items to passed Groups if setData failed (default config)", () => { - collection.setData = jest.fn(() => false); + it("should try to add data/item to Collection and shouldn't add it to passed Groups if adding data/item failed (default config)", () => { + collection.assignData = jest + .fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + collection.assignItem = jest.fn(() => false); - collection.collect({ id: '1', name: 'frank' }, [ - 'dummyGroup1', - 'dummyGroup2', - ]); + collection.collect( + [{ id: '1', name: 'frank' }, dummyItem5, { id: '2', name: 'hans' }], + ['dummyGroup1', 'dummyGroup2'] + ); - expect(collection.setData).toHaveBeenCalledWith( + expect(collection.assignData).toHaveBeenCalledWith( { id: '1', name: 'frank', @@ -605,28 +678,74 @@ describe('Collection Tests', () => { background: false, } ); + expect(collection.assignData).toHaveBeenCalledWith( + { + id: '2', + name: 'hans', + }, + { + patch: false, + background: false, + } + ); + expect(collection.assignItem).toHaveBeenCalledWith(dummyItem5, { + background: false, + }); + expect(collection.createGroup).not.toHaveBeenCalled(); - expect(dummyGroup1.add).not.toHaveBeenCalled(); - expect(dummyGroup2.add).not.toHaveBeenCalled(); - expect(defaultGroup.add).not.toHaveBeenCalled(); + expect(dummyGroup1.add).not.toHaveBeenCalledWith('1', { + method: 'push', + background: false, + }); + expect(dummyGroup1.add).toHaveBeenCalledWith('2', { + method: 'push', + background: false, + }); + expect(dummyGroup1.add).not.toHaveBeenCalledWith('5', { + method: 'push', + background: false, + }); + expect(dummyGroup2.add).not.toHaveBeenCalledWith('1', { + method: 'push', + background: false, + }); + expect(dummyGroup2.add).toHaveBeenCalledWith('2', { + method: 'push', + background: false, + }); + expect(dummyGroup2.add).not.toHaveBeenCalledWith('5', { + method: 'push', + background: false, + }); + expect(defaultGroup.add).not.toHaveBeenCalledWith('1', { + method: 'push', + background: false, + }); + expect(defaultGroup.add).toHaveBeenCalledWith('2', { + method: 'push', + background: false, + }); + expect(defaultGroup.add).not.toHaveBeenCalledWith('5', { + method: 'push', + background: false, + }); expect(collection.createSelector).not.toHaveBeenCalled(); }); - it("should add Data to Collection and create Groups that doesn't exist yet (default config)", () => { - const notExistingGroup = new Group(collection); - notExistingGroup.add = jest.fn(); - collection.setData = jest.fn(() => true); + it("should add data object to Collection and create Groups that doesn't exist yet (default config)", () => { + const newGroup = new Group(collection); + newGroup.add = jest.fn(); + collection.assignData = jest.fn(() => true); collection.createGroup = jest.fn(function (groupKey) { - //@ts-ignore - this.groups[groupKey] = notExistingGroup; - return notExistingGroup as any; + collection.groups[groupKey] = newGroup; + return newGroup as any; }); - collection.collect({ id: '1', name: 'frank' }, 'notExistingGroup'); + collection.collect({ id: '1', name: 'frank' }, 'newGroup'); - expect(collection.setData).toHaveBeenCalledWith( + expect(collection.assignData).toHaveBeenCalledWith( { id: '1', name: 'frank', @@ -636,11 +755,11 @@ describe('Collection Tests', () => { background: false, } ); - expect(collection.createGroup).toHaveBeenCalledWith('notExistingGroup'); + expect(collection.createGroup).toHaveBeenCalledWith('newGroup'); expect(dummyGroup1.add).not.toHaveBeenCalled(); expect(dummyGroup2.add).not.toHaveBeenCalled(); - expect(notExistingGroup.add).toHaveBeenCalledWith('1', { + expect(newGroup.add).toHaveBeenCalledWith('1', { method: 'push', background: false, }); @@ -652,31 +771,31 @@ describe('Collection Tests', () => { expect(collection.createSelector).not.toHaveBeenCalled(); }); - it('should create Selector for each Item (config.select)', () => { - collection.setData = jest.fn(() => true); + it('should add data object to Collection and create Selector for each Item (config.select)', () => { + collection.assignData = jest.fn(() => true); + collection.assignItem = jest.fn(() => true); collection.collect( - [ - { id: '1', name: 'frank' }, - { id: '2', name: 'hans' }, - ], + [{ id: '1', name: 'frank' }, dummyItem5, { id: '2', name: 'hans' }], [], { select: true } ); expect(collection.createSelector).toHaveBeenCalledWith('1', '1'); + expect(collection.createSelector).toHaveBeenCalledWith('5', '5'); expect(collection.createSelector).toHaveBeenCalledWith('2', '2'); }); - it("should call 'forEachItem' for each Item (default config)", () => { - collection.setData = jest.fn(() => true); + it("should add data object to Collection and call 'forEachItem()' for each Item (config.forEachItem)", () => { + collection.assignData = jest + .fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + collection.assignItem = jest.fn(() => true); const forEachItemMock = jest.fn(); collection.collect( - [ - { id: '1', name: 'frank' }, - { id: '2', name: 'hans' }, - ], + [{ id: '1', name: 'frank' }, dummyItem5, { id: '2', name: 'hans' }], [], { forEachItem: forEachItemMock } ); @@ -684,12 +803,15 @@ describe('Collection Tests', () => { expect(forEachItemMock).toHaveBeenCalledWith( { id: '1', name: 'frank' }, '1', + false, 0 ); + expect(forEachItemMock).toHaveBeenCalledWith(dummyItem5, '5', true, 1); expect(forEachItemMock).toHaveBeenCalledWith( { id: '2', name: 'hans' }, '2', - 1 + true, + 2 ); }); }); @@ -1199,9 +1321,15 @@ describe('Collection Tests', () => { describe('getSelector function tests', () => { let dummySelector: Selector; + let dummyItem1: Item; beforeEach(() => { - dummySelector = new Selector(collection, 'dummyItem', { + dummyItem1 = new Item(collection, { id: 'dummyItem1', name: 'frank' }); + collection.data = { + ['dummyItem1']: dummyItem1, + }; + + dummySelector = new Selector(collection, 'dummyItem1', { key: 'dummySelector', }); collection.selectors = { @@ -1252,9 +1380,15 @@ describe('Collection Tests', () => { describe('getSelectorWithReference function tests', () => { let dummySelector: Selector; + let dummyItem1: Item; beforeEach(() => { - dummySelector = new Selector(collection, 'dummyItem', { + dummyItem1 = new Item(collection, { id: 'dummyItem1', name: 'frank' }); + collection.data = { + ['dummyItem1']: dummyItem1, + }; + + dummySelector = new Selector(collection, 'dummyItem1', { key: 'dummySelector', }); collection.selectors = { @@ -1407,12 +1541,18 @@ describe('Collection Tests', () => { describe('getItemWithReference function tests', () => { let dummyItem: Item; + let placeholderItem: Item; beforeEach(() => { dummyItem = new Item(collection, { id: '1', name: 'Jeff' }); + placeholderItem = collection.createPlaceholderItem('1'); + collection.data = { ['1']: dummyItem, }; + collection.createPlaceholderItem = jest + .fn() + .mockReturnValueOnce(placeholderItem); ComputedTracker.tracked = jest.fn(); }); @@ -1421,19 +1561,91 @@ describe('Collection Tests', () => { const response = collection.getItemWithReference('1'); expect(response).toBe(dummyItem); + expect(collection.createPlaceholderItem).not.toHaveBeenCalled(); expect(ComputedTracker.tracked).toHaveBeenCalledWith( dummyItem.observer ); }); - it("should return and track created reference Item if Item doesn't exist yet", () => { + it("should return and track created reference Item if searched Item doesn't exist yet", () => { const response = collection.getItemWithReference('notExistingItem'); - expect(response).toBeInstanceOf(Item); - expect(response.isPlaceholder).toBeTruthy(); - expect(response._key).toBe('notExistingItem'); - expect(collection.data['notExistingItem']).toBe(response); - expect(ComputedTracker.tracked).toHaveBeenCalledWith(response.observer); + expect(response).toBe(placeholderItem); + expect(collection.createPlaceholderItem).toHaveBeenCalledWith( + 'notExistingItem', + true + ); + expect(ComputedTracker.tracked).toHaveBeenCalledWith( + placeholderItem.observer + ); + }); + }); + + describe('createPlaceholderItem function tests', () => { + let dummyItem: Item; + + beforeEach(() => { + dummyItem = new Item(collection, { id: '1', name: 'Jeff' }); + + collection.data = { + ['1']: dummyItem, + }; + + ComputedTracker.tracked = jest.fn(); + }); + + it("should create placeholder Item and shouldn't add it to Collection (addToCollection = false)", () => { + const item = collection.createPlaceholderItem('2', false); + + expect(item).not.toBe(dummyItem); + expect(item.collection()).toBe(collection); + expect(item._key).toBe('2'); + expect(item._value).toStrictEqual({ id: '2', dummy: 'item' }); + expect(item.isPlaceholder).toBeTruthy(); + + expect(collection.data).not.toHaveProperty('2'); + + expect(ComputedTracker.tracked).toHaveBeenCalledTimes(1); + expect(ComputedTracker.tracked).not.toHaveBeenCalledWith( + dummyItem.observer + ); + }); + + it("should create placeholder Item and shouldn't add it to Collection if Item already exists (addToCollection = true)", () => { + const item = collection.createPlaceholderItem('1', false); + + expect(item).not.toBe(dummyItem); + expect(item.collection()).toBe(collection); + expect(item._key).toBe('1'); + expect(item._value).toStrictEqual({ id: '1', dummy: 'item' }); + expect(item.isPlaceholder).toBeTruthy(); + + expect(collection.data).toHaveProperty('1'); + expect(collection.data['1']).toBe(dummyItem); + + expect(ComputedTracker.tracked).toHaveBeenCalledTimes(1); + expect(ComputedTracker.tracked).not.toHaveBeenCalledWith( + dummyItem.observer + ); + }); + + it('should create placeholder Item and add it to Collection (addToCollection = true)', () => { + const item = collection.createPlaceholderItem('2', true); + + expect(item).not.toBe(dummyItem); + expect(item.collection()).toBe(collection); + expect(item._key).toBe('2'); + expect(item._value).toStrictEqual({ id: '2', dummy: 'item' }); + expect(item.isPlaceholder).toBeTruthy(); + + expect(collection.data).toHaveProperty('2'); + expect(collection.data['2']).toStrictEqual(expect.any(Item)); + expect(collection.data['2']._key).toBe('2'); + + expect(ComputedTracker.tracked).toHaveBeenCalledTimes(1); + expect(ComputedTracker.tracked).not.toHaveBeenCalledWith( + dummyItem.observer + ); }); }); @@ -1452,7 +1664,7 @@ describe('Collection Tests', () => { it('should return existing Item Value (default config)', () => { const response = collection.getItemValue('1'); - expect(response).toBe(dummyItem._value); + expect(response).toStrictEqual(dummyItem._value); expect(collection.getItem).toHaveBeenCalledWith('1', {}); }); @@ -1479,7 +1691,7 @@ describe('Collection Tests', () => { notExisting: true, }); - expect(response).toBe(dummyItem._value); + expect(response).toStrictEqual(dummyItem._value); expect(collection.getItem).toHaveBeenCalledWith('1', { notExisting: true, }); @@ -1628,19 +1840,17 @@ describe('Collection Tests', () => { }); }); - it('should overwrite existing persistent with a warning', () => { - collection.persistent = new CollectionPersistent(collection); + it("shouldn't overwrite existing persistent", () => { + const dummyPersistent = new CollectionPersistent(collection); + collection.persistent = dummyPersistent; + collection.isPersisted = true; + jest.clearAllMocks(); collection.persist('newPersistentKey'); - expect(collection.persistent).toBeInstanceOf(CollectionPersistent); + expect(collection.persistent).toBe(dummyPersistent); // expect(collection.persistent._key).toBe("newPersistentKey"); // Can not test because of Mocking Persistent - expect(CollectionPersistent).toHaveBeenCalledWith(collection, { - instantiate: true, - storageKeys: [], - key: 'newPersistentKey', - defaultStorageKey: null, - }); + expect(CollectionPersistent).not.toHaveBeenCalled(); }); }); @@ -1908,7 +2118,13 @@ describe('Collection Tests', () => { dummySelector3.reselect = jest.fn(); }); - it('should update ItemKey in Collection, Selectors and Groups (default config)', () => { + it('should update ItemKey in Collection, Selectors, Groups and Persistent (default config)', () => { + if (dummyItem1.persistent) + dummyItem1.persistent._key = CollectionPersistent.getItemStorageKey( + dummyItem1._key, + collection._key + ); + const response = collection.updateItemKey('dummyItem1', 'newDummyItem'); expect(response).toBeTruthy(); @@ -1946,7 +2162,13 @@ describe('Collection Tests', () => { LogMock.hasNotLogged('warn'); }); - it('should update ItemKey in Collection, Selectors and Groups (specific config)', () => { + it('should update ItemKey in Collection, Selectors, Groups and Persistent (specific config)', () => { + if (dummyItem1.persistent) + dummyItem1.persistent._key = CollectionPersistent.getItemStorageKey( + dummyItem1._key, + collection._key + ); + const response = collection.updateItemKey( 'dummyItem1', 'newDummyItem', @@ -1986,7 +2208,13 @@ describe('Collection Tests', () => { LogMock.hasNotLogged('warn'); }); - it('should update ItemKey in Collection, dummy Selectors and dummy Groups (default config)', () => { + it('should update ItemKey in Collection, dummy Selectors, dummy Groups and Persistent (default config)', () => { + if (dummyItem1.persistent) + dummyItem1.persistent._key = CollectionPersistent.getItemStorageKey( + dummyItem1._key, + collection._key + ); + dummyGroup1.isPlaceholder = true; dummySelector1.isPlaceholder = true; @@ -2023,6 +2251,49 @@ describe('Collection Tests', () => { LogMock.hasNotLogged('warn'); }); + it( + 'should update ItemKey in Collection, Selectors, Groups ' + + "and shouldn't update it in Persistent if persist key doesn't follow the Item Storage Key pattern (default config)", + () => { + if (dummyItem1.persistent) + dummyItem1.persistent._key = 'randomPersistKey'; + + const response = collection.updateItemKey( + 'dummyItem1', + 'newDummyItem' + ); + + expect(response).toBeTruthy(); + + expect(dummyItem1.setKey).toHaveBeenCalledWith('newDummyItem', { + background: false, + }); + expect(dummyItem2.setKey).not.toHaveBeenCalled(); + expect(dummyItem1.persistent?.setKey).not.toHaveBeenCalled(); + expect(dummyItem2.persistent?.setKey).not.toHaveBeenCalled(); + + expect(dummyGroup1.replace).toHaveBeenCalledWith( + 'dummyItem1', + 'newDummyItem', + { + background: false, + } + ); + expect(dummyGroup2.replace).not.toHaveBeenCalled(); + + expect(dummySelector1.select).toHaveBeenCalledWith('newDummyItem', { + background: false, + }); + expect(dummySelector2.select).not.toHaveBeenCalled(); + expect(dummySelector3.reselect).toHaveBeenCalledWith({ + force: true, + background: false, + }); + + LogMock.hasNotLogged('warn'); + } + ); + it("shouldn't update ItemKey of Item that doesn't exist (default config)", () => { const response = collection.updateItemKey( 'notExistingItem', @@ -2353,7 +2624,7 @@ describe('Collection Tests', () => { }); }); - describe('setData function tests', () => { + describe('assignData function tests', () => { let dummyItem1: Item; beforeEach(() => { @@ -2363,52 +2634,89 @@ describe('Collection Tests', () => { }; collection.size = 1; + jest.spyOn(collection, 'assignItem'); + dummyItem1.patch = jest.fn(); dummyItem1.set = jest.fn(); }); - it('should create new Item out of valid Data, rebuild Groups and increase size (default config)', () => { - const response = collection.setData({ id: 'dummyItem2', name: 'Hans' }); + it("should assign Item to Collection if it doesn't exist yet (default config)", () => { + const response = collection.assignData({ + id: 'dummyItem2', + name: 'Hans', + }); expect(response).toBeTruthy(); - expect(collection.data).toHaveProperty('dummyItem1'); + expect(collection.size).toBe(2); // Increased by assignItem() + expect(collection.assignItem).toHaveBeenCalledWith(expect.any(Item), { + background: false, + }); + + // Check if Item, assignItem() was called with, has the correct data expect(collection.data).toHaveProperty('dummyItem2'); - expect(collection.data['dummyItem2']).toBeInstanceOf(Item); expect(collection.data['dummyItem2']._value).toStrictEqual({ id: 'dummyItem2', name: 'Hans', }); - expect(collection.size).toBe(2); LogMock.hasNotLogged('error'); LogMock.hasNotLogged('warn'); }); - it("shouldn't create new Item if passed Data is no valid Object", () => { - const response = collection.setData('noObject' as any); + it("should assign Item to Collection if it doesn't exist yet (config.background = true)", () => { + const response = collection.assignData( + { + id: 'dummyItem2', + name: 'Hans', + }, + { background: true } + ); + + expect(response).toBeTruthy(); + expect(collection.size).toBe(2); // Increased by assignItem() + expect(collection.assignItem).toHaveBeenCalledWith(expect.any(Item), { + background: true, + }); + + // Check if Item, assignItem() was called with, has the correct data + expect(collection.data).toHaveProperty('dummyItem2'); + expect(collection.data['dummyItem2']._value).toStrictEqual({ + id: 'dummyItem2', + name: 'Hans', + }); + + LogMock.hasNotLogged('error'); + LogMock.hasNotLogged('warn'); + }); + + it("shouldn't assign or update Item if passed data is no valid object", () => { + const response = collection.assignData('noObject' as any); expect(response).toBeFalsy(); expect(collection.size).toBe(1); + expect(collection.assignItem).not.toHaveBeenCalled(); LogMock.hasLoggedCode('1B:03:05', [collection._key]); LogMock.hasNotLogged('warn'); }); - it('should create new Item with random primaryKey if passed Data has no primaryKey', () => { + it("should assign Item to Collection with random itemKey if data object doesn't contain valid itemKey", () => { jest.spyOn(Utils, 'generateId').mockReturnValueOnce('randomDummyId'); - const response = collection.setData({ name: 'Frank' } as any); + const response = collection.assignData({ name: 'Frank' } as any); expect(response).toBeTruthy(); - expect(response).toBeTruthy(); - expect(collection.data).toHaveProperty('dummyItem1'); + expect(collection.size).toBe(2); // Increased by assignItem() + expect(collection.assignItem).toHaveBeenCalledWith(expect.any(Item), { + background: false, + }); + + // Check if Item, assignItem() was called with, has the correct data expect(collection.data).toHaveProperty('randomDummyId'); - expect(collection.data['randomDummyId']).toBeInstanceOf(Item); expect(collection.data['randomDummyId']._value).toStrictEqual({ id: 'randomDummyId', name: 'Frank', }); - expect(collection.size).toBe(2); LogMock.hasNotLogged('error'); LogMock.hasLoggedCode('1B:02:05', [ @@ -2417,17 +2725,15 @@ describe('Collection Tests', () => { ]); }); - it("should update Item with valid Data, shouldn't rebuild Groups and shouldn't increase size (default config)", () => { - const response = collection.setData({ + it('should update existing Item with valid data via set (default config)', () => { + const response = collection.assignData({ id: 'dummyItem1', name: 'Dieter', }); expect(response).toBeTruthy(); - - expect(collection.data).toHaveProperty('dummyItem1'); - expect(collection.data['dummyItem1']).toBeInstanceOf(Item); expect(collection.size).toBe(1); + expect(collection.assignItem).not.toHaveBeenCalled(); expect(dummyItem1.set).toHaveBeenCalledWith( { id: 'dummyItem1', name: 'Dieter' }, @@ -2439,8 +2745,8 @@ describe('Collection Tests', () => { LogMock.hasNotLogged('warn'); }); - it("should update Item with valid Data, shouldn't rebuild Groups and shouldn't increase size (config.background = true)", () => { - const response = collection.setData( + it('should update existing Item with valid data via set (config.background = true)', () => { + const response = collection.assignData( { id: 'dummyItem1', name: 'Dieter', @@ -2449,10 +2755,8 @@ describe('Collection Tests', () => { ); expect(response).toBeTruthy(); - - expect(collection.data).toHaveProperty('dummyItem1'); - expect(collection.data['dummyItem1']).toBeInstanceOf(Item); expect(collection.size).toBe(1); + expect(collection.assignItem).not.toHaveBeenCalled(); expect(dummyItem1.set).toHaveBeenCalledWith( { id: 'dummyItem1', name: 'Dieter' }, @@ -2464,8 +2768,8 @@ describe('Collection Tests', () => { LogMock.hasNotLogged('warn'); }); - it("should update Item with valid Data, shouldn't rebuild Groups and shouldn't increase size (config.patch = true, background: true)", () => { - const response = collection.setData( + it('should update existing Item with valid data via patch (config.patch = true, background: true)', () => { + const response = collection.assignData( { id: 'dummyItem1', name: 'Dieter', @@ -2474,10 +2778,8 @@ describe('Collection Tests', () => { ); expect(response).toBeTruthy(); - - expect(collection.data).toHaveProperty('dummyItem1'); - expect(collection.data['dummyItem1']).toBeInstanceOf(Item); expect(collection.size).toBe(1); + expect(collection.assignItem).not.toHaveBeenCalled(); expect(dummyItem1.set).not.toHaveBeenCalled(); expect(dummyItem1.patch).toHaveBeenCalledWith( @@ -2489,25 +2791,183 @@ describe('Collection Tests', () => { LogMock.hasNotLogged('warn'); }); - it("should update placeholder Item with valid Data, shouldn't rebuild Groups and should increase size (default config)", () => { + it('should update placeholder Item with valid data and increase Collection size (default config)', () => { dummyItem1.isPlaceholder = true; - collection.size = 0; - const response = collection.setData({ + const response = collection.assignData({ id: 'dummyItem1', name: 'Dieter', }); expect(response).toBeTruthy(); - - expect(collection.data).toHaveProperty('dummyItem1'); - expect(collection.data['dummyItem1']).toBeInstanceOf(Item); - expect(collection.size).toBe(1); + expect(collection.size).toBe(2); + expect(collection.assignItem).not.toHaveBeenCalled(); expect(dummyItem1.set).toHaveBeenCalledWith( { id: 'dummyItem1', name: 'Dieter' }, { background: false } ); + expect(dummyItem1.patch).not.toHaveBeenCalled(); + + LogMock.hasNotLogged('error'); + LogMock.hasNotLogged('warn'); + }); + }); + + describe('assignItem function tests', () => { + let dummyItem1: Item; + let toAddDummyItem2: Item; + + beforeEach(() => { + dummyItem1 = new Item(collection, { id: 'dummyItem1', name: 'Jeff' }); + toAddDummyItem2 = new Item(collection, { + id: 'dummyItem2', + name: 'Frank', + }); + collection.data = { + dummyItem1: dummyItem1, + }; + collection.size = 1; + + dummyItem1.patch = jest.fn(); + toAddDummyItem2.patch = jest.fn(); + collection.rebuildGroupsThatIncludeItemKey = jest.fn(); + }); + + it('should assign valid Item to Collection (default config)', () => { + const response = collection.assignItem(toAddDummyItem2); + + expect(response).toBeTruthy(); + expect(collection.size).toBe(2); + expect(collection.data).toHaveProperty('dummyItem2'); + expect(collection.data['dummyItem2']).toBe(toAddDummyItem2); + expect(collection.rebuildGroupsThatIncludeItemKey).toHaveBeenCalledWith( + 'dummyItem2', + { + background: false, + } + ); + + expect(toAddDummyItem2.patch).not.toHaveBeenCalled(); + expect(toAddDummyItem2._key).toBe('dummyItem2'); + + LogMock.hasNotLogged('error'); + LogMock.hasNotLogged('warn'); + }); + + it('should assign valid Item to Collection (config.background = true)', () => { + const response = collection.assignItem(toAddDummyItem2, { + background: true, + }); + + expect(response).toBeTruthy(); + expect(collection.size).toBe(2); + expect(collection.data).toHaveProperty('dummyItem2'); + expect(collection.data['dummyItem2']).toBe(toAddDummyItem2); + expect(collection.rebuildGroupsThatIncludeItemKey).toHaveBeenCalledWith( + 'dummyItem2', + { + background: true, + } + ); + + expect(toAddDummyItem2.patch).not.toHaveBeenCalled(); + expect(toAddDummyItem2._key).toBe('dummyItem2'); + + LogMock.hasNotLogged('error'); + LogMock.hasNotLogged('warn'); + }); + + it("should assign Item to Collection with random itemKey if data object doesn't contain valid itemKey (default config)", () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('randomDummyId'); + toAddDummyItem2._value = { dummy: 'data' } as any; + toAddDummyItem2._key = undefined; + + const response = collection.assignItem(toAddDummyItem2); + + expect(response).toBeTruthy(); + expect(collection.size).toBe(2); + expect(collection.data).toHaveProperty('randomDummyId'); + expect(collection.data['randomDummyId']).toBe(toAddDummyItem2); + expect(collection.rebuildGroupsThatIncludeItemKey).toHaveBeenCalledWith( + 'randomDummyId', + { + background: false, + } + ); + + expect(toAddDummyItem2.patch).toHaveBeenCalledWith( + { id: 'randomDummyId' }, + { background: false } + ); + expect(toAddDummyItem2._key).toBe('randomDummyId'); + + LogMock.hasNotLogged('error'); + LogMock.hasLoggedCode('1B:02:05', [ + collection._key, + collection.config.primaryKey, + ]); + }); + + it("shouldn't assign Item to Collection that belongs to another Collection", () => { + const anotherCollection = new Collection(dummyAgile, { + key: 'anotherCollection', + }); + toAddDummyItem2.collection = () => anotherCollection; + + const response = collection.assignItem(toAddDummyItem2); + + expect(response).toBeFalsy(); + expect(collection.size).toBe(1); + expect(collection.data).not.toHaveProperty('dummyItem2'); + expect( + collection.rebuildGroupsThatIncludeItemKey + ).not.toHaveBeenCalled(); + + expect(toAddDummyItem2.patch).not.toHaveBeenCalled(); + expect(toAddDummyItem2._key).toBe('dummyItem2'); + + LogMock.hasLoggedCode('1B:03:06', [ + collection._key, + anotherCollection._key, + ]); + LogMock.hasNotLogged('warn'); + }); + + it("shouldn't assign Item to Collection if an Item at itemKey already exists (default config)", () => { + const response = collection.assignItem(dummyItem1); + + expect(response).toBeTruthy(); + expect(collection.size).toBe(1); + expect(collection.data).toHaveProperty('dummyItem1'); + expect(collection.data['dummyItem1']).toBe(dummyItem1); + expect( + collection.rebuildGroupsThatIncludeItemKey + ).not.toHaveBeenCalled(); + + expect(dummyItem1.patch).not.toHaveBeenCalled(); + expect(dummyItem1._key).toBe('dummyItem1'); + + LogMock.hasNotLogged('error'); + LogMock.hasNotLogged('warn'); + }); + + it('should assign Item to Collection if an Item at itemKey already exists (config.overwrite = true)', () => { + const response = collection.assignItem(dummyItem1, { overwrite: true }); + + expect(response).toBeTruthy(); + expect(collection.size).toBe(1); + expect(collection.data).toHaveProperty('dummyItem1'); + expect(collection.data['dummyItem1']).toBe(dummyItem1); + expect(collection.rebuildGroupsThatIncludeItemKey).toHaveBeenCalledWith( + 'dummyItem1', + { + background: false, + } + ); + + expect(dummyItem1.patch).not.toHaveBeenCalled(); + expect(dummyItem1._key).toBe('dummyItem1'); LogMock.hasNotLogged('error'); LogMock.hasNotLogged('warn'); diff --git a/packages/core/tests/unit/collection/group.test.ts b/packages/core/tests/unit/collection/group.test.ts index 01ef3502..72ea153b 100644 --- a/packages/core/tests/unit/collection/group.test.ts +++ b/packages/core/tests/unit/collection/group.test.ts @@ -182,16 +182,16 @@ describe('Group Tests', () => { }); describe('output set function tests', () => { - it('should set output to passed value', () => { + it("shouldn't set output to passed value and print error", () => { + group._output = null as any; + group.output = [ { id: '12', name: 'Hans der 3' }, { id: '99', name: 'Frank' }, ]; - expect(group._output).toStrictEqual([ - { id: '12', name: 'Hans der 3' }, - { id: '99', name: 'Frank' }, - ]); + expect(group._output).toStrictEqual(null); + expect(LogMock.hasLoggedCode('1C:03:00', [group._key])); }); }); @@ -211,12 +211,13 @@ describe('Group Tests', () => { }); describe('item set function tests', () => { - it('should set items to passed value', () => { + it("shouldn't set items to passed value and print error", () => { + group._items = null as any; + group.items = [dummyItem1, dummyItem2]; - expect(group._items.length).toBe(2); - expect(group._items[0]()).toBe(dummyItem1); - expect(group._items[1]()).toBe(dummyItem2); + expect(group._items).toStrictEqual(null); + expect(LogMock.hasLoggedCode('1C:03:01', [group._key])); }); }); @@ -418,60 +419,48 @@ describe('Group Tests', () => { jest.spyOn(State.prototype, 'persist'); }); - it('should persist Group with GroupKey (default config)', () => { + it('should persist Group with formatted groupKey (default config)', () => { group.persist(); - expect(State.prototype.persist).toHaveBeenCalledWith(group._key, { - loadValue: true, - storageKeys: [], - defaultStorageKey: null, - }); + expect(State.prototype.persist).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + group._key, + dummyCollection._key + ), + { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + } + ); }); - it('should persist Group with GroupKey (specific config)', () => { + it('should persist Group with formatted groupKey (specific config)', () => { group.persist({ loadValue: false, storageKeys: ['test1', 'test2'], defaultStorageKey: 'test1', }); - expect(State.prototype.persist).toHaveBeenCalledWith(group._key, { - loadValue: false, - storageKeys: ['test1', 'test2'], - defaultStorageKey: 'test1', - }); + expect(State.prototype.persist).toHaveBeenCalledWith( + CollectionPersistent.getGroupStorageKey( + group._key, + dummyCollection._key + ), + { + loadValue: false, + storageKeys: ['test1', 'test2'], + defaultStorageKey: 'test1', + } + ); }); - it('should persist Group with passed Key (default config)', () => { + it('should persist Group with formatted specified key (default config)', () => { group.persist('dummyKey'); - expect(State.prototype.persist).toHaveBeenCalledWith('dummyKey', { - loadValue: true, - storageKeys: [], - defaultStorageKey: null, - }); - }); - - it('should persist Group with passed Key (specific config)', () => { - group.persist('dummyKey', { - loadValue: false, - storageKeys: ['test1', 'test2'], - defaultStorageKey: 'test1', - }); - - expect(State.prototype.persist).toHaveBeenCalledWith('dummyKey', { - loadValue: false, - storageKeys: ['test1', 'test2'], - defaultStorageKey: 'test1', - }); - }); - - it('should persist Group with formatted GroupKey (config.followCollectionPersistKeyPattern)', () => { - group.persist({ followCollectionPersistKeyPattern: true }); - expect(State.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getGroupStorageKey( - group._key, + 'dummyKey', dummyCollection._key ), { @@ -482,8 +471,12 @@ describe('Group Tests', () => { ); }); - it('should persist Group with formatted passed Key (config.followCollectionPersistKeyPattern)', () => { - group.persist('dummyKey', { followCollectionPersistKeyPattern: true }); + it('should persist Group with formatted specified key (specific config)', () => { + group.persist('dummyKey', { + loadValue: false, + storageKeys: ['test1', 'test2'], + defaultStorageKey: 'test1', + }); expect(State.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getGroupStorageKey( @@ -491,12 +484,32 @@ describe('Group Tests', () => { dummyCollection._key ), { - loadValue: true, - storageKeys: [], - defaultStorageKey: null, + loadValue: false, + storageKeys: ['test1', 'test2'], + defaultStorageKey: 'test1', } ); }); + + it('should persist Group with groupKey (config.followCollectionPersistKeyPattern = false)', () => { + group.persist({ followCollectionPersistKeyPattern: false }); + + expect(State.prototype.persist).toHaveBeenCalledWith(group._key, { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + }); + }); + + it('should persist Group with specified key (config.followCollectionPersistKeyPattern = false)', () => { + group.persist('dummyKey', { followCollectionPersistKeyPattern: false }); + + expect(State.prototype.persist).toHaveBeenCalledWith('dummyKey', { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + }); + }); }); describe('rebuild function tests', () => { diff --git a/packages/core/tests/unit/collection/item.test.ts b/packages/core/tests/unit/collection/item.test.ts index 32f61557..3e55cff0 100644 --- a/packages/core/tests/unit/collection/item.test.ts +++ b/packages/core/tests/unit/collection/item.test.ts @@ -1,16 +1,28 @@ -import { Item, Collection, Agile, StateObserver, State } from '../../../src'; +import { + Item, + Collection, + Agile, + StateObserver, + State, + CollectionPersistent, +} from '../../../src'; import { LogMock } from '../../helper/logMock'; describe('Item Tests', () => { + interface ItemInterface { + id: string; + name: string; + } + let dummyAgile: Agile; - let dummyCollection: Collection; + let dummyCollection: Collection; beforeEach(() => { jest.clearAllMocks(); LogMock.mockLogs(); dummyAgile = new Agile({ localStorage: false }); - dummyCollection = new Collection(dummyAgile); + dummyCollection = new Collection(dummyAgile); jest.spyOn(Item.prototype, 'addRebuildGroupThatIncludeItemKeySideEffect'); }); @@ -89,8 +101,44 @@ describe('Item Tests', () => { expect(item.selectedBy.size).toBe(0); }); + it("should create Item and shouldn't add rebuild Group side effect to it if no itemKey was provided (default config)", () => { + // Overwrite addRebuildGroupThatIncludeItemKeySideEffect once to not call it + jest + .spyOn(Item.prototype, 'addRebuildGroupThatIncludeItemKeySideEffect') + .mockReturnValueOnce(undefined); + + const dummyData = { name: 'dummyName' }; + const item = new Item(dummyCollection, dummyData as any); + + expect(item.collection()).toBe(dummyCollection); + expect( + item.addRebuildGroupThatIncludeItemKeySideEffect + ).not.toHaveBeenCalled(); + + expect(item._key).toBeUndefined(); + expect(item.valueType).toBeUndefined(); + expect(item.isSet).toBeFalsy(); + expect(item.isPlaceholder).toBeFalsy(); + expect(item.initialStateValue).toStrictEqual(dummyData); + 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( + dummyData[dummyCollection.config.primaryKey] + ); + expect(item.sideEffects).toStrictEqual({}); + expect(item.computeValueMethod).toBeUndefined(); + expect(item.computeExistsMethod).toBeInstanceOf(Function); + expect(item.isPersisted).toBeFalsy(); + expect(item.persistent).toBeUndefined(); + expect(item.watchers).toStrictEqual({}); + expect(item.selectedBy.size).toBe(0); + }); + describe('Item Function Tests', () => { - let item: Item; + let item: Item; beforeEach(() => { item = new Item(dummyCollection, { id: 'dummyId', name: 'dummyName' }); @@ -163,6 +211,104 @@ describe('Item Tests', () => { }); }); + describe('persist function tests', () => { + beforeEach(() => { + jest.spyOn(State.prototype, 'persist'); + }); + + it('should persist Item with formatted itemKey (default config)', () => { + item.persist(); + + expect(State.prototype.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + item._key, + dummyCollection._key + ), + { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + } + ); + }); + + it('should persist Item with formatted itemKey (specific config)', () => { + item.persist({ + loadValue: false, + storageKeys: ['test1', 'test2'], + defaultStorageKey: 'test1', + }); + + expect(State.prototype.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + item._key, + dummyCollection._key + ), + { + loadValue: false, + storageKeys: ['test1', 'test2'], + defaultStorageKey: 'test1', + } + ); + }); + + it('should persist Item with formatted specified key (default config)', () => { + item.persist('dummyKey'); + + expect(State.prototype.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + 'dummyKey', + dummyCollection._key + ), + { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + } + ); + }); + + it('should persist Item with formatted specified key (specific config)', () => { + item.persist('dummyKey', { + loadValue: false, + storageKeys: ['test1', 'test2'], + defaultStorageKey: 'test1', + }); + + expect(State.prototype.persist).toHaveBeenCalledWith( + CollectionPersistent.getItemStorageKey( + 'dummyKey', + dummyCollection._key + ), + { + loadValue: false, + storageKeys: ['test1', 'test2'], + defaultStorageKey: 'test1', + } + ); + }); + + it('should persist Item with itemKey (config.followCollectionPersistKeyPattern = false)', () => { + item.persist({ followCollectionPersistKeyPattern: false }); + + expect(State.prototype.persist).toHaveBeenCalledWith(item._key, { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + }); + }); + + it('should persist Item with specified key (config.followCollectionPersistKeyPattern = false)', () => { + item.persist('dummyKey', { followCollectionPersistKeyPattern: false }); + + expect(State.prototype.persist).toHaveBeenCalledWith('dummyKey', { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + }); + }); + }); + describe('addRebuildGroupThatIncludeItemKeySideEffect function tests', () => { beforeEach(() => { dummyCollection.rebuildGroupsThatIncludeItemKey = jest.fn(); diff --git a/packages/core/tests/unit/collection/selector.test.ts b/packages/core/tests/unit/collection/selector.test.ts index 03f088d0..cb4b2f87 100644 --- a/packages/core/tests/unit/collection/selector.test.ts +++ b/packages/core/tests/unit/collection/selector.test.ts @@ -29,7 +29,7 @@ describe('Selector Tests', () => { const selector = new Selector(dummyCollection, 'dummyItemKey'); expect(selector.collection()).toBe(dummyCollection); - expect(selector.item).toBeUndefined(); + expect(selector._item).toBeUndefined(); expect(selector._itemKey).toBe('dummyItemKey'); expect(selector.select).toHaveBeenCalledWith('dummyItemKey', { overwrite: true, @@ -65,7 +65,7 @@ describe('Selector Tests', () => { }); expect(selector.collection()).toBe(dummyCollection); - expect(selector.item).toBeUndefined(); + expect(selector._item).toBeUndefined(); expect(selector._itemKey).toBe('dummyItemKey'); expect(selector.select).toHaveBeenCalledWith('dummyItemKey', { overwrite: true, @@ -101,7 +101,7 @@ describe('Selector Tests', () => { }); expect(selector.collection()).toBe(dummyCollection); - expect(selector.item).toBeUndefined(); + expect(selector._item).toBeUndefined(); expect(selector._itemKey).toBe(Selector.unknownItemPlaceholderKey); expect(selector.select).not.toHaveBeenCalled(); @@ -141,23 +141,47 @@ describe('Selector Tests', () => { }); describe('itemKey set function tests', () => { - it('should call select function with passed value', () => { + it('should call the select() method with the passed value', () => { selector.select = jest.fn(); + selector._itemKey = null as any; selector.itemKey = 'newItemKey'; expect(selector.select).toHaveBeenCalledWith('newItemKey'); + expect(selector._itemKey).toBeNull(); }); }); describe('itemKey get function tests', () => { - it('should return current ItemKey of Selector', () => { + it('should return the identifier of the Item currently selected by the Selector', () => { selector._itemKey = 'coolItemKey'; expect(selector.itemKey).toBe('coolItemKey'); }); }); + describe('item set function tests', () => { + it('should call the select() method with the Item identifier of the specified Item', () => { + selector.select = jest.fn(); + selector._item = null as any; + + dummyItem1._key = 'AReallyCoolKey'; + + selector.item = dummyItem1; + + expect(selector.select).toHaveBeenCalledWith('AReallyCoolKey'); + expect(selector._item).toBeNull(); + }); + }); + + describe('item get function tests', () => { + it('should return the currently selected Item of the Selector', () => { + selector._item = dummyItem1; + + expect(selector.item).toBe(dummyItem1); + }); + }); + describe('select function tests', () => { let dummyItem2: Item; @@ -185,7 +209,7 @@ describe('Selector Tests', () => { ); expect(selector._itemKey).toBe('dummyItem2'); - expect(selector.item).toBe(dummyItem2); + expect(selector._item).toBe(dummyItem2); expect(selector.unselect).toHaveBeenCalledWith({ background: true }); expect(selector.rebuildSelector).toHaveBeenCalledWith({ background: false, @@ -233,7 +257,7 @@ describe('Selector Tests', () => { ); expect(selector._itemKey).toBe('dummyItem2'); - expect(selector.item).toBe(dummyItem2); + expect(selector._item).toBe(dummyItem2); expect(selector.unselect).toHaveBeenCalledWith({ background: true }); expect(selector.rebuildSelector).toHaveBeenCalledWith({ background: true, @@ -270,7 +294,7 @@ describe('Selector Tests', () => { expect(dummyCollection.getItemWithReference).not.toHaveBeenCalled(); expect(selector._itemKey).toBe('dummyItem1'); - expect(selector.item).toBe(dummyItem1); + expect(selector._item).toBe(dummyItem1); expect(selector.unselect).not.toHaveBeenCalled(); expect(selector.rebuildSelector).not.toHaveBeenCalled(); expect(selector.addSideEffect).not.toHaveBeenCalled(); @@ -292,7 +316,7 @@ describe('Selector Tests', () => { ); expect(selector._itemKey).toBe('dummyItem1'); - expect(selector.item).toBe(dummyItem1); + expect(selector._item).toBe(dummyItem1); expect(selector.unselect).toHaveBeenCalledWith({ background: true }); expect(selector.rebuildSelector).toHaveBeenCalledWith({ background: false, @@ -331,7 +355,7 @@ describe('Selector Tests', () => { expect(dummyCollection.getItemWithReference).not.toHaveBeenCalled(); expect(selector._itemKey).toBe('dummyItem1'); - expect(selector.item).toBe(dummyItem1); + expect(selector._item).toBe(dummyItem1); expect(selector.unselect).not.toHaveBeenCalled(); expect(selector.rebuildSelector).not.toHaveBeenCalled(); expect(selector.addSideEffect).not.toHaveBeenCalled(); @@ -353,7 +377,7 @@ describe('Selector Tests', () => { ); expect(selector._itemKey).toBe('dummyItem2'); - expect(selector.item).toBe(dummyItem2); + expect(selector._item).toBe(dummyItem2); expect(selector.unselect).toHaveBeenCalledWith({ background: true }); expect(selector.rebuildSelector).toHaveBeenCalledWith({ background: false, @@ -394,7 +418,7 @@ describe('Selector Tests', () => { 'dummyItem2' ); expect(selector._itemKey).toBe('dummyItem2'); - expect(selector.item).toBe(dummyItem2); + expect(selector._item).toBe(dummyItem2); expect(selector.unselect).toHaveBeenCalledWith({ background: true }); expect(selector.rebuildSelector).toHaveBeenCalledWith({ background: false, @@ -435,7 +459,7 @@ describe('Selector Tests', () => { 'dummyItem2' ); expect(selector._itemKey).toBe('dummyItem2'); - expect(selector.item).toBe(dummyItem2); + expect(selector._item).toBe(dummyItem2); expect(selector.unselect).toHaveBeenCalledWith({ background: true }); expect(selector.rebuildSelector).toHaveBeenCalledWith({ background: false, @@ -589,7 +613,7 @@ describe('Selector Tests', () => { Selector.rebuildSelectorSideEffectKey ); - expect(selector.item).toBeUndefined(); + expect(selector._item).toBeUndefined(); expect(selector._itemKey).toBe(Selector.unknownItemPlaceholderKey); expect(selector.isPlaceholder).toBeTruthy(); expect(selector.rebuildSelector).toHaveBeenCalledWith({}); @@ -609,7 +633,7 @@ describe('Selector Tests', () => { Selector.rebuildSelectorSideEffectKey ); - expect(selector.item).toBeUndefined(); + expect(selector._item).toBeUndefined(); expect(selector._itemKey).toBe(Selector.unknownItemPlaceholderKey); expect(selector.isPlaceholder).toBeTruthy(); expect(selector.rebuildSelector).toHaveBeenCalledWith({ @@ -631,7 +655,7 @@ describe('Selector Tests', () => { Selector.rebuildSelectorSideEffectKey ); - expect(selector.item).toBeUndefined(); + expect(selector._item).toBeUndefined(); expect(selector._itemKey).toBe(Selector.unknownItemPlaceholderKey); expect(selector.isPlaceholder).toBeTruthy(); expect(selector.rebuildSelector).toHaveBeenCalledWith({}); @@ -646,7 +670,7 @@ describe('Selector Tests', () => { }); it('should return true if Selector has selected itemKey correctly and Item isSelected', () => { - if (selector.item) selector.item.selectedBy.add(selector._key as any); + if (selector._item) selector._item.selectedBy.add(selector._key as any); expect(selector.hasSelected('dummyItemKey')).toBeTruthy(); }); @@ -660,25 +684,25 @@ describe('Selector Tests', () => { }); it("should return false if Selector hasn't selected itemKey correctly (item = undefined)", () => { - selector.item = undefined; + selector._item = undefined; expect(selector.hasSelected('dummyItemKey')).toBeFalsy(); }); it("should return true if Selector hasn't selected itemKey correctly (item = undefined, correctlySelected = false)", () => { - selector.item = undefined; + selector._item = undefined; expect(selector.hasSelected('dummyItemKey', false)).toBeTruthy(); }); it("should return false if Selector has selected itemKey correctly and Item isn't isSelected", () => { - if (selector.item) selector.item.selectedBy = new Set(); + if (selector._item) selector._item.selectedBy = new Set(); expect(selector.hasSelected('dummyItemKey')).toBeFalsy(); }); it("should return true if Selector has selected itemKey correctly and Item isn't isSelected (correctlySelected = false)", () => { - if (selector.item) selector.item.selectedBy = new Set(); + if (selector._item) selector._item.selectedBy = new Set(); expect(selector.hasSelected('dummyItemKey', false)).toBeTruthy(); }); @@ -690,15 +714,15 @@ describe('Selector Tests', () => { }); it('should set selector value to item value (default config)', () => { - selector.item = dummyItem1; + selector._item = dummyItem1; selector.rebuildSelector(); - expect(selector.set).toHaveBeenCalledWith(selector.item._value, {}); + expect(selector.set).toHaveBeenCalledWith(selector._item._value, {}); }); it('should set selector value to item value (specific config)', () => { - selector.item = dummyItem1; + selector._item = dummyItem1; selector.rebuildSelector({ sideEffects: { @@ -708,7 +732,7 @@ describe('Selector Tests', () => { force: true, }); - expect(selector.set).toHaveBeenCalledWith(selector.item._value, { + expect(selector.set).toHaveBeenCalledWith(selector._item._value, { sideEffects: { enabled: false, }, @@ -718,7 +742,7 @@ describe('Selector Tests', () => { }); it('should set selector value to undefined if Item is undefined (default config)', () => { - selector.item = undefined; + selector._item = undefined; selector.rebuildSelector(); diff --git a/packages/core/tests/unit/computed/computed.test.ts b/packages/core/tests/unit/computed/computed.test.ts index 9f645220..2dd8a4d3 100644 --- a/packages/core/tests/unit/computed/computed.test.ts +++ b/packages/core/tests/unit/computed/computed.test.ts @@ -232,9 +232,9 @@ describe('Computed Tests', () => { computed.hardCodedDeps = [dummyObserver3]; computed.deps = [dummyObserver3]; // normally the hardCodedDeps get automatically added to the deps.. but this time we set the hardCodedProperty after the instantiation - dummyObserver1.depend = jest.fn(); - dummyObserver2.depend = jest.fn(); - dummyObserver3.depend = jest.fn(); + dummyObserver1.addDependent = jest.fn(); + dummyObserver2.addDependent = jest.fn(); + dummyObserver3.addDependent = jest.fn(); jest.spyOn(ComputedTracker, 'track').mockClear(); // mockClear because otherwise the static mock doesn't get reset after each 'it' test jest.spyOn(ComputedTracker, 'getTrackedObservers').mockClear(); }); @@ -257,9 +257,15 @@ describe('Computed Tests', () => { dummyObserver1, dummyObserver2, ]); - expect(dummyObserver1.depend).toHaveBeenCalledWith(computed.observer); - expect(dummyObserver2.depend).toHaveBeenCalledWith(computed.observer); - expect(dummyObserver3.depend).toHaveBeenCalledWith(computed.observer); + expect(dummyObserver1.addDependent).toHaveBeenCalledWith( + computed.observer + ); + expect(dummyObserver2.addDependent).toHaveBeenCalledWith( + computed.observer + ); + expect(dummyObserver3.addDependent).toHaveBeenCalledWith( + computed.observer + ); }); it("should call computeFunction and shouldn't track dependencies the computeFunction depends on (autodetect false)", () => { @@ -273,9 +279,9 @@ describe('Computed Tests', () => { expect(ComputedTracker.getTrackedObservers).not.toHaveBeenCalled(); expect(computed.hardCodedDeps).toStrictEqual([dummyObserver3]); expect(computed.deps).toStrictEqual([dummyObserver3]); - expect(dummyObserver1.depend).not.toHaveBeenCalled(); - expect(dummyObserver2.depend).not.toHaveBeenCalled(); - expect(dummyObserver3.depend).not.toHaveBeenCalled(); + expect(dummyObserver1.addDependent).not.toHaveBeenCalled(); + expect(dummyObserver2.addDependent).not.toHaveBeenCalled(); + expect(dummyObserver3.addDependent).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/tests/unit/runtime/observer.test.ts b/packages/core/tests/unit/runtime/observer.test.ts index b833ac97..7d246abd 100644 --- a/packages/core/tests/unit/runtime/observer.test.ts +++ b/packages/core/tests/unit/runtime/observer.test.ts @@ -4,6 +4,7 @@ import { SubscriptionContainer, RuntimeJob, } from '../../../src'; +import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../helper/logMock'; describe('Observer Tests', () => { @@ -20,20 +21,22 @@ describe('Observer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); - dummySubscription1 = new SubscriptionContainer(); - dummySubscription2 = new SubscriptionContainer(); + dummySubscription1 = new SubscriptionContainer([]); + dummySubscription2 = new SubscriptionContainer([]); - jest.spyOn(Observer.prototype, 'subscribe'); + jest.spyOn(dummySubscription1, 'addSubscription'); + jest.spyOn(dummySubscription2, 'addSubscription'); }); it('should create Observer (default config)', () => { const observer = new Observer(dummyAgile); + expect(observer.agileInstance()).toBe(dummyAgile); expect(observer._key).toBeUndefined(); + expect(Array.from(observer.dependents)).toStrictEqual([]); + expect(Array.from(observer.subscribedTo)).toStrictEqual([]); expect(observer.value).toBeUndefined(); expect(observer.previousValue).toBeUndefined(); - expect(observer.dependents.size).toBe(0); - expect(observer.subscribedTo.size).toBe(0); }); it('should create Observer (specific config)', () => { @@ -44,18 +47,21 @@ describe('Observer Tests', () => { value: 'coolValue', }); + expect(observer.agileInstance()).toBe(dummyAgile); expect(observer._key).toBe('testKey'); + expect(Array.from(observer.dependents)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(observer.subscribedTo)).toStrictEqual([ + dummySubscription1, + dummySubscription2, + ]); expect(observer.value).toBe('coolValue'); expect(observer.previousValue).toBe('coolValue'); - expect(observer.dependents.size).toBe(2); - expect(observer.dependents.has(dummyObserver2)).toBeTruthy(); - expect(observer.dependents.has(dummyObserver1)).toBeTruthy(); - expect(observer.subscribedTo.size).toBe(2); - expect(observer.subscribedTo.has(dummySubscription1)).toBeTruthy(); - expect(observer.subscribedTo.has(dummySubscription2)).toBeTruthy(); - - expect(observer.subscribe).toHaveBeenCalledWith(dummySubscription1); - expect(observer.subscribe).toHaveBeenCalledWith(dummySubscription2); + + expect(dummySubscription1.addSubscription).toHaveBeenCalledWith(observer); + expect(dummySubscription2.addSubscription).toHaveBeenCalledWith(observer); }); describe('Observer Function Tests', () => { @@ -82,9 +88,11 @@ describe('Observer Tests', () => { }); describe('ingest function tests', () => { - it('should create RuntimeJob and ingest Observer into the Runtime (default config)', () => { + it('should create RuntimeJob containing the Observer and ingest it into the Runtime (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); + dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { - expect(job._key).toBe(observer._key); + expect(job._key).toBe(`${observer._key}_generatedKey`); expect(job.observer).toBe(observer); expect(job.config).toStrictEqual({ background: false, @@ -93,7 +101,7 @@ describe('Observer Tests', () => { exclude: [], }, force: false, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); }); @@ -107,7 +115,7 @@ describe('Observer Tests', () => { ); }); - it('should create RuntimeJob and ingest Observer into the Runtime (specific config)', () => { + it('should create RuntimeJob containing the Observer and ingest it into the Runtime (specific config)', () => { dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { expect(job._key).toBe('coolKey'); expect(job.observer).toBe(observer); @@ -118,7 +126,7 @@ describe('Observer Tests', () => { exclude: [], }, force: true, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); }); @@ -148,7 +156,7 @@ describe('Observer Tests', () => { }); }); - describe('depend function tests', () => { + describe('addDependent function tests', () => { let dummyObserver1: Observer; let dummyObserver2: Observer; @@ -157,77 +165,21 @@ describe('Observer Tests', () => { dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); }); - it('should add passed Observer to deps', () => { - observer.depend(dummyObserver1); + it('should add specified Observer to the dependents array', () => { + observer.addDependent(dummyObserver1); expect(observer.dependents.size).toBe(1); expect(observer.dependents.has(dummyObserver2)); }); - it("shouldn't add the same Observer twice to deps", () => { - observer.depend(dummyObserver1); + it("shouldn't add specified Observer twice to the dependents array", () => { + observer.addDependent(dummyObserver1); - observer.depend(dummyObserver1); + observer.addDependent(dummyObserver1); expect(observer.dependents.size).toBe(1); expect(observer.dependents.has(dummyObserver1)); }); }); - - describe('subscribe function tests', () => { - let dummySubscriptionContainer1: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer1 = new SubscriptionContainer(); - }); - - it('should add subscriptionContainer to subs and this(Observer) to SubscriptionContainer subs', () => { - observer.subscribe(dummySubscriptionContainer1); - - expect(observer.subscribedTo.size).toBe(1); - expect(observer.subscribedTo.has(dummySubscriptionContainer1)); - expect(dummySubscriptionContainer1.subscribers.size).toBe(1); - expect( - dummySubscriptionContainer1.subscribers.has(observer) - ).toBeTruthy(); - }); - - it("shouldn't add same subscriptionContainer twice to subs", () => { - observer.subscribe(dummySubscriptionContainer1); - - observer.subscribe(dummySubscriptionContainer1); - - expect(observer.subscribedTo.size).toBe(1); - expect(observer.subscribedTo.has(dummySubscriptionContainer1)); - expect(dummySubscriptionContainer1.subscribers.size).toBe(1); - expect( - dummySubscriptionContainer1.subscribers.has(observer) - ).toBeTruthy(); - }); - }); - - describe('unsubscribe function tests', () => { - let dummySubscriptionContainer1: SubscriptionContainer; - let dummySubscriptionContainer2: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer1 = new SubscriptionContainer(); - dummySubscriptionContainer2 = new SubscriptionContainer(); - observer.subscribe(dummySubscriptionContainer1); - observer.subscribe(dummySubscriptionContainer2); - }); - - it('should remove subscriptionContainer from subs and this(Observer) from SubscriptionContainer subs', () => { - observer.unsubscribe(dummySubscriptionContainer1); - - expect(observer.subscribedTo.size).toBe(1); - expect(observer.subscribedTo.has(dummySubscriptionContainer1)); - expect(dummySubscriptionContainer1.subscribers.size).toBe(0); - expect(dummySubscriptionContainer2.subscribers.size).toBe(1); - expect( - dummySubscriptionContainer2.subscribers.has(observer) - ).toBeTruthy(); - }); - }); }); }); diff --git a/packages/core/tests/unit/runtime/runtime.job.test.ts b/packages/core/tests/unit/runtime/runtime.job.test.ts index e9fd39df..4793df21 100644 --- a/packages/core/tests/unit/runtime/runtime.job.test.ts +++ b/packages/core/tests/unit/runtime/runtime.job.test.ts @@ -17,94 +17,120 @@ describe('RuntimeJob Tests', () => { dummyObserver = new Observer(dummyAgile); }); - it('should create RuntimeJob with Agile that has integrations (default config)', () => { - dummyAgile.integrate(dummyIntegration); - - const job = new RuntimeJob(dummyObserver); - - expect(job._key).toBeUndefined(); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - force: false, - numberOfTriesToUpdate: 3, - }); - expect(job.rerender).toBeTruthy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - expect(job.triesToUpdate).toBe(0); - }); - - it('should create RuntimeJob with Agile that has integrations (specific config)', () => { - dummyAgile.integrate(dummyIntegration); - - const job = new RuntimeJob(dummyObserver, { - key: 'dummyJob', - sideEffects: { - enabled: false, - }, - force: true, - numberOfTriesToUpdate: 10, - }); - - expect(job._key).toBe('dummyJob'); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: false, - }, - force: true, - numberOfTriesToUpdate: 10, - }); - expect(job.rerender).toBeTruthy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - }); - - it('should create RuntimeJob with Agile that has no integrations (default config)', () => { - const job = new RuntimeJob(dummyObserver); - - expect(job._key).toBeUndefined(); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - force: false, - numberOfTriesToUpdate: 3, - }); - expect(job.rerender).toBeFalsy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - }); + it( + 'should create RuntimeJob ' + + 'with a specified Agile Instance that has a registered Integration (default config)', + () => { + dummyAgile.integrate(dummyIntegration); + + const job = new RuntimeJob(dummyObserver); + + expect(job._key).toBeUndefined(); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + maxTriesToUpdate: 3, + }); + expect(job.rerender).toBeTruthy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.performed).toBeFalsy(); + } + ); + + it( + 'should create RuntimeJob ' + + 'with a specified Agile Instance that has a registered Integration (specific config)', + () => { + dummyAgile.integrate(dummyIntegration); + + const job = new RuntimeJob(dummyObserver, { + key: 'dummyJob', + sideEffects: { + enabled: false, + exclude: ['jeff'], + }, + force: true, + maxTriesToUpdate: 10, + }); - it('should create RuntimeJob and Agile that has integrations (config.background = true)', () => { - dummyAgile.integrate(dummyIntegration); - - const job = new RuntimeJob(dummyObserver, { background: true }); - - expect(job._key).toBeUndefined(); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: true, - sideEffects: { - enabled: true, - exclude: [], - }, - force: false, - numberOfTriesToUpdate: 3, - }); - expect(job.rerender).toBeFalsy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - }); + expect(job._key).toBe('dummyJob'); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: false, + exclude: ['jeff'], + }, + force: true, + maxTriesToUpdate: 10, + }); + expect(job.rerender).toBeTruthy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.performed).toBeFalsy(); + } + ); + + it( + 'should create RuntimeJob ' + + 'with a specified Agile Instance that has no registered Integration (default config)', + () => { + const job = new RuntimeJob(dummyObserver); + + expect(job._key).toBeUndefined(); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + maxTriesToUpdate: 3, + }); + expect(job.rerender).toBeFalsy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.performed).toBeFalsy(); + } + ); + + it( + 'should create RuntimeJob ' + + 'with a specified Agile Instance that has a registered Integrations (config.background = true)', + () => { + dummyAgile.integrate(dummyIntegration); + + const job = new RuntimeJob(dummyObserver, { background: true }); + + expect(job._key).toBeUndefined(); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: true, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + maxTriesToUpdate: 3, + }); + expect(job.rerender).toBeFalsy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.performed).toBeFalsy(); + } + ); describe('RuntimeJob Function Tests', () => { let job: RuntimeJob; diff --git a/packages/core/tests/unit/runtime/runtime.test.ts b/packages/core/tests/unit/runtime/runtime.test.ts index 95781f72..a3609ef8 100644 --- a/packages/core/tests/unit/runtime/runtime.test.ts +++ b/packages/core/tests/unit/runtime/runtime.test.ts @@ -8,7 +8,6 @@ import { SubscriptionContainer, } from '../../../src'; import * as Utils from '@agile-ts/utils'; -import testIntegration from '../../helper/test.integration'; import { LogMock } from '../../helper/logMock'; describe('Runtime Tests', () => { @@ -24,10 +23,12 @@ describe('Runtime Tests', () => { it('should create Runtime', () => { const runtime = new Runtime(dummyAgile); + expect(runtime.agileInstance()).toBe(dummyAgile); expect(runtime.currentJob).toBeNull(); expect(runtime.jobQueue).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); expect(runtime.jobsToRerender).toStrictEqual([]); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect(runtime.isPerformingJobs).toBeFalsy(); }); describe('Runtime Function Tests', () => { @@ -52,18 +53,37 @@ describe('Runtime Tests', () => { runtime.perform = jest.fn(); }); - it('should perform passed Job (default config)', () => { + it("should perform specified Job immediately if jobQueue isn't being processed (default config)", () => { + runtime.isPerformingJobs = false; + runtime.ingest(dummyJob); - expect(runtime.jobQueue.length).toBe(0); + expect(runtime.jobQueue).toStrictEqual([]); expect(runtime.perform).toHaveBeenCalledWith(dummyJob); }); - it("shouldn't perform passed Job (config.perform = false)", () => { + it("shouldn't perform specified Job immediately if jobQueue is being processed (default config)", () => { + runtime.isPerformingJobs = true; + + runtime.ingest(dummyJob); + + expect(runtime.jobQueue).toStrictEqual([dummyJob]); + expect(runtime.perform).not.toHaveBeenCalled(); + }); + + it('should perform specified Job immediately (config.perform = true)', () => { + runtime.isPerformingJobs = true; + runtime.ingest(dummyJob, { perform: true }); + + expect(runtime.jobQueue).toStrictEqual([]); + expect(runtime.perform).toHaveBeenCalledWith(dummyJob); + }); + + it("shouldn't perform specified Job immediately (config.perform = false)", () => { + runtime.isPerformingJobs = false; runtime.ingest(dummyJob, { perform: false }); - expect(runtime.jobQueue.length).toBe(1); - expect(runtime.jobQueue[0]).toBe(dummyJob); + expect(runtime.jobQueue).toStrictEqual([dummyJob]); expect(runtime.perform).not.toHaveBeenCalled(); }); }); @@ -88,32 +108,34 @@ describe('Runtime Tests', () => { dummyObserver2.ingest = jest.fn(); }); - it('should perform passed and all in jobQueue remaining Jobs and call updateSubscribers', async () => { - runtime.jobQueue.push(dummyJob2); - runtime.jobQueue.push(dummyJob3); + it( + "should perform specified Job and all remaining Jobs in the 'jobQueue' " + + "and call 'updateSubscribers' if at least one performed Job needs to rerender", + async () => { + runtime.jobQueue.push(dummyJob2); + runtime.jobQueue.push(dummyJob3); - runtime.perform(dummyJob1); + runtime.perform(dummyJob1); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); - expect(dummyJob1.performed).toBeTruthy(); - expect(dummyObserver2.perform).toHaveBeenCalledWith(dummyJob2); - expect(dummyJob2.performed).toBeTruthy(); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); - expect(dummyJob3.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); + expect(dummyJob1.performed).toBeTruthy(); + expect(dummyObserver2.perform).toHaveBeenCalledWith(dummyJob2); + expect(dummyJob2.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); + expect(dummyJob3.performed).toBeTruthy(); - expect(runtime.jobQueue.length).toBe(0); - expect(runtime.jobsToRerender.length).toBe(2); - expect(runtime.jobsToRerender.includes(dummyJob1)).toBeTruthy(); - expect(runtime.jobsToRerender.includes(dummyJob2)).toBeTruthy(); - expect(runtime.jobsToRerender.includes(dummyJob3)).toBeFalsy(); + expect(runtime.isPerformingJobs).toBeFalsy(); // because Jobs were performed + expect(runtime.jobQueue).toStrictEqual([]); + expect(runtime.jobsToRerender).toStrictEqual([dummyJob1, dummyJob2]); - // Sleep 5ms because updateSubscribers get called in Timeout - await new Promise((resolve) => setTimeout(resolve, 5)); + // Sleep 5ms because updateSubscribers is called in a timeout + await new Promise((resolve) => setTimeout(resolve, 5)); - expect(runtime.updateSubscribers).toHaveBeenCalledTimes(1); - }); + expect(runtime.updateSubscribers).toHaveBeenCalledTimes(1); + } + ); - it('should perform passed Job and update it dependents', async () => { + it('should perform specified Job and ingest its dependents into the runtime', async () => { dummyJob1.observer.dependents.add(dummyObserver2); dummyJob1.observer.dependents.add(dummyObserver1); @@ -122,139 +144,90 @@ describe('Runtime Tests', () => { expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); expect(dummyJob1.performed).toBeTruthy(); + expect(dummyObserver1.ingest).toHaveBeenCalledTimes(1); expect(dummyObserver1.ingest).toHaveBeenCalledWith({ perform: false, }); - expect(dummyObserver1.ingest).toHaveBeenCalledTimes(1); + expect(dummyObserver2.ingest).toHaveBeenCalledTimes(1); expect(dummyObserver2.ingest).toHaveBeenCalledWith({ perform: false, }); - expect(dummyObserver2.ingest).toHaveBeenCalledTimes(1); }); - it("should perform passed and all in jobQueue remaining Jobs and shouldn't call updateSubscribes if no job needs to rerender", async () => { - dummyJob1.rerender = false; - runtime.jobQueue.push(dummyJob3); + it( + "should perform specified Job and all remaining Jobs in the 'jobQueue' " + + "and shouldn't call 'updateSubscribes' if no performed Job needs to rerender", + async () => { + dummyJob1.rerender = false; + runtime.jobQueue.push(dummyJob3); - runtime.perform(dummyJob1); + runtime.perform(dummyJob1); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); - expect(dummyJob1.performed).toBeTruthy(); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); - expect(dummyJob3.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); + expect(dummyJob1.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); + expect(dummyJob3.performed).toBeTruthy(); - expect(runtime.jobQueue.length).toBe(0); - expect(runtime.jobsToRerender.length).toBe(0); + expect(runtime.isPerformingJobs).toBeFalsy(); // because Jobs were performed + expect(runtime.jobQueue).toStrictEqual([]); + expect(runtime.jobsToRerender).toStrictEqual([]); - // Sleep 5ms because updateSubscribers get called in Timeout - await new Promise((resolve) => setTimeout(resolve, 5)); + // Sleep 5ms because updateSubscribers is called in a timeout + await new Promise((resolve) => setTimeout(resolve, 5)); - expect(runtime.updateSubscribers).not.toHaveBeenCalled(); - }); + expect(runtime.updateSubscribers).not.toHaveBeenCalled(); + } + ); }); describe('updateSubscribers function tests', () => { - let dummyObserver4: Observer; - let rCallbackSubJob: RuntimeJob; - let nrArCallbackSubJob: RuntimeJob; - let rComponentSubJob: RuntimeJob; - let nrArComponentSubJob: RuntimeJob; - let rCallbackSubContainer: CallbackSubscriptionContainer; - const rCallbackSubContainerCallbackFunction = () => { - /* empty function */ - }; - let nrCallbackSubContainer: CallbackSubscriptionContainer; - const nrCallbackSubContainerCallbackFunction = () => { + let dummyJob1: RuntimeJob; + let dummyJob2: RuntimeJob; + let dummyJob3: RuntimeJob; + const dummySubscriptionContainer1IntegrationInstance = () => { /* empty function */ }; - let rComponentSubContainer: ComponentSubscriptionContainer; - const rComponentSubContainerComponent = { + let dummySubscriptionContainer1: SubscriptionContainer; + const dummySubscriptionContainer2IntegrationInstance = { my: 'cool component', }; - let nrComponentSubContainer: ComponentSubscriptionContainer; - const nrComponentSubContainerComponent = { - my: 'second cool component', - }; - const dummyProxyKeyMap = { myState: { paths: [['a', 'b']] } }; + let dummySubscriptionContainer2: SubscriptionContainer; beforeEach(() => { - dummyAgile.integrate(testIntegration); - dummyObserver4 = new Observer(dummyAgile, { key: 'dummyObserver4' }); - - dummyObserver1.value = 'dummyObserverValue1'; - dummyObserver2.value = 'dummyObserverValue2'; - dummyObserver3.value = 'dummyObserverValue3'; - dummyObserver4.value = 'dummyObserverValue4'; - - // Create Ready Callback Subscription - rCallbackSubContainer = dummyAgile.subController.subscribeWithSubsArray( - rCallbackSubContainerCallbackFunction, - [dummyObserver1, dummyObserver2] - ) as CallbackSubscriptionContainer; - rCallbackSubContainer.callback = jest.fn(); - rCallbackSubContainer.ready = true; - rCallbackSubContainer.key = 'rCallbackSubContainerKey'; + dummySubscriptionContainer1 = dummyAgile.subController.subscribe( + dummySubscriptionContainer1IntegrationInstance, + [dummyObserver1] + ); + dummySubscriptionContainer2 = dummyAgile.subController.subscribe( + dummySubscriptionContainer2IntegrationInstance, + [dummyObserver2, dummyObserver3] + ); - // Create Not Ready Callback Subscription - nrCallbackSubContainer = dummyAgile.subController.subscribeWithSubsArray( - nrCallbackSubContainerCallbackFunction, - [dummyObserver2] - ) as CallbackSubscriptionContainer; - nrCallbackSubContainer.callback = jest.fn(); - nrCallbackSubContainer.ready = false; - nrCallbackSubContainer.key = 'nrCallbackSubContainerKey'; + dummyJob1 = new RuntimeJob(dummyObserver1); + dummyJob2 = new RuntimeJob(dummyObserver2); + dummyJob3 = new RuntimeJob(dummyObserver3); - // Create Ready Component Subscription - rComponentSubContainer = dummyAgile.subController.subscribeWithSubsObject( - rComponentSubContainerComponent, - { - observer3: dummyObserver3, - observer4: dummyObserver4, - } - ).subscriptionContainer as ComponentSubscriptionContainer; - rComponentSubContainer.ready = true; - rComponentSubContainer.key = 'rComponentSubContainerKey'; - - // Create Not Ready Component Subscription - nrComponentSubContainer = dummyAgile.subController.subscribeWithSubsObject( - nrComponentSubContainerComponent, - { - observer4: dummyObserver4, - } - ).subscriptionContainer as ComponentSubscriptionContainer; - nrComponentSubContainer.ready = false; - nrComponentSubContainer.key = 'nrComponentSubContainerKey'; - - rComponentSubJob = new RuntimeJob(dummyObserver3, { key: 'dummyJob3' }); // Job with ready Component Subscription - rCallbackSubJob = new RuntimeJob(dummyObserver1, { key: 'dummyJob1' }); // Job with ready CallbackSubscription - nrArComponentSubJob = new RuntimeJob(dummyObserver4, { - key: 'dummyJob4', - }); // Job with not ready and ready Component Subscription - nrArCallbackSubJob = new RuntimeJob(dummyObserver2, { - key: 'dummyJob2', - }); // Job with not ready and ready Callback Subscription - - jest.spyOn(dummyAgile.integrations, 'update'); - jest.spyOn(runtime, 'handleObjectBasedSubscription'); - jest.spyOn(runtime, 'handleProxyBasedSubscription'); + runtime.updateSubscriptionContainer = jest.fn(); + jest.spyOn(runtime, 'extractToUpdateSubscriptionContainer'); }); - it('should return false if agile has no integration', () => { + it('should return false if Agile has no registered Integration', () => { dummyAgile.hasIntegration = jest.fn(() => false); - runtime.jobsToRerender.push(rCallbackSubJob); - runtime.jobsToRerender.push(nrArCallbackSubJob); + runtime.jobsToRerender = [dummyJob1, dummyJob2]; const response = runtime.updateSubscribers(); expect(response).toBeFalsy(); + expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(dummyAgile.integrations.update).not.toHaveBeenCalled(); - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(nrCallbackSubContainer.callback).not.toHaveBeenCalled(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect( + runtime.extractToUpdateSubscriptionContainer + ).not.toHaveBeenCalled(); + expect(runtime.updateSubscriptionContainer).not.toHaveBeenCalled(); }); - it('should return false if no Jobs in jobsToRerender and notReadyJobsToRerender left', () => { + it('should return false if jobsToRerender and notReadyJobsToRerender queue are both empty', () => { dummyAgile.hasIntegration = jest.fn(() => true); runtime.jobsToRerender = []; runtime.notReadyJobsToRerender = new Set(); @@ -262,358 +235,425 @@ describe('Runtime Tests', () => { const response = runtime.updateSubscribers(); expect(response).toBeFalsy(); - }); - - it('should update ready component based SubscriptionContainer', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - runtime.jobsToRerender.push(rComponentSubJob); - - const response = runtime.updateSubscribers(); - - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).not.toHaveBeenCalled(); - - expect(dummyAgile.integrations.update).toHaveBeenCalledTimes(1); - expect(dummyAgile.integrations.update).toHaveBeenCalledWith( - rComponentSubContainerComponent, - { - observer3: 'dummyObserverValue3', - } - ); - expect(runtime.handleObjectBasedSubscription).toHaveBeenCalledWith( - rComponentSubContainer, - rComponentSubJob - ); - expect(rComponentSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver3.subscribedTo.size).toBe(1); - - expect(response).toBeTruthy(); - }); - - it('should update ready callback based SubscriptionContainer', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - runtime.jobsToRerender.push(rCallbackSubJob); - - const response = runtime.updateSubscribers(); + expect(response).toBeFalsy(); expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).not.toHaveBeenCalled(); - - expect(rCallbackSubContainer.callback).toHaveBeenCalledTimes(1); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); - - expect(response).toBeTruthy(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect( + runtime.extractToUpdateSubscriptionContainer + ).not.toHaveBeenCalled(); + expect(runtime.updateSubscriptionContainer).not.toHaveBeenCalled(); }); - it('should update ready proxy, callback based SubscriptionContainer if handleProxyBasedSubscriptions() returns true', () => { - jest - .spyOn(runtime, 'handleProxyBasedSubscription') - .mockReturnValueOnce(true); + it('should return false if no Subscription Container of the Jobs to rerender queue needs to update', () => { dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubContainer.proxyBased = true; - rCallbackSubContainer.proxyKeyMap = dummyProxyKeyMap; - runtime.jobsToRerender.push(rCallbackSubJob); - - const response = runtime.updateSubscribers(); - - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).toHaveBeenCalledWith( - rCallbackSubContainer, - rCallbackSubJob - ); - - expect(rCallbackSubContainer.callback).toHaveBeenCalledTimes(1); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); - - expect(response).toBeTruthy(); - }); - - it("shouldn't update ready proxy, callback based SubscriptionContainer if handleProxyBasedSubscriptions() returns false", () => { jest - .spyOn(runtime, 'handleProxyBasedSubscription') - .mockReturnValueOnce(false); - dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubContainer.proxyBased = true; - rCallbackSubContainer.proxyKeyMap = dummyProxyKeyMap; - runtime.jobsToRerender.push(rCallbackSubJob); + .spyOn(runtime, 'extractToUpdateSubscriptionContainer') + .mockReturnValueOnce([]); + runtime.jobsToRerender = [dummyJob1, dummyJob2]; + runtime.notReadyJobsToRerender = new Set([dummyJob3]); const response = runtime.updateSubscribers(); - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).toHaveBeenCalledWith( - rCallbackSubContainer, - rCallbackSubJob - ); - - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); - expect(response).toBeFalsy(); + expect(runtime.jobsToRerender).toStrictEqual([]); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect( + runtime.extractToUpdateSubscriptionContainer + ).toHaveBeenCalledWith([dummyJob1, dummyJob2, dummyJob3]); + expect(runtime.updateSubscriptionContainer).not.toHaveBeenCalled(); }); - it("shouldn't update not ready SubscriptionContainers but it should update ready SubscriptionContainers", () => { + it('should return true if at least one Subscription Container of the Jobs to rerender queue needs to update', () => { dummyAgile.hasIntegration = jest.fn(() => true); - runtime.jobsToRerender.push(nrArCallbackSubJob); - runtime.jobsToRerender.push(nrArComponentSubJob); + jest + .spyOn(runtime, 'extractToUpdateSubscriptionContainer') + .mockReturnValueOnce([ + dummySubscriptionContainer1, + dummySubscriptionContainer2, + ]); + runtime.jobsToRerender = [dummyJob1, dummyJob2]; + runtime.notReadyJobsToRerender = new Set([dummyJob3]); const response = runtime.updateSubscribers(); + expect(response).toBeTruthy(); expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(2); - expect( - runtime.notReadyJobsToRerender.has(nrArCallbackSubJob) - ).toBeTruthy(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); expect( - runtime.notReadyJobsToRerender.has(nrArComponentSubJob) - ).toBeTruthy(); - - expect(nrArCallbackSubJob.subscriptionContainersToUpdate.size).toBe(1); - expect( - nrArCallbackSubJob.subscriptionContainersToUpdate.has( - nrCallbackSubContainer - ) - ).toBeTruthy(); - expect(nrArComponentSubJob.subscriptionContainersToUpdate.size).toBe(1); - expect( - nrArComponentSubJob.subscriptionContainersToUpdate.has( - nrComponentSubContainer - ) - ).toBeTruthy(); - - expect(rCallbackSubContainer.callback).toHaveBeenCalledTimes(1); - expect(nrCallbackSubContainer.callback).not.toHaveBeenCalled(); - - expect(dummyAgile.integrations.update).toHaveBeenCalledTimes(1); - expect(dummyAgile.integrations.update).toHaveBeenCalledWith( - rComponentSubContainerComponent, - { - observer4: 'dummyObserverValue4', - } - ); - expect(dummyAgile.integrations.update).not.toHaveBeenCalledWith( - nrComponentSubContainerComponent, - { - observer4: 'dummyObserverValue4', - } - ); - - expect(dummyObserver2.subscribedTo.size).toBe(2); - expect(dummyObserver4.subscribedTo.size).toBe(2); - - expect(runtime.handleObjectBasedSubscription).toHaveBeenCalledWith( - rComponentSubContainer, - nrArComponentSubJob - ); - expect(runtime.handleObjectBasedSubscription).not.toHaveBeenCalledWith( - nrComponentSubContainer, - nrArComponentSubJob - ); + runtime.extractToUpdateSubscriptionContainer + ).toHaveBeenCalledWith([dummyJob1, dummyJob2, dummyJob3]); + expect(runtime.updateSubscriptionContainer).toHaveBeenCalledWith([ + dummySubscriptionContainer1, + dummySubscriptionContainer2, + ]); + }); + }); - expect(nrArComponentSubJob.triesToUpdate).toBe(1); - expect(nrArCallbackSubJob.triesToUpdate).toBe(1); + describe('extractToUpdateSubscriptionContainer function tests', () => { + let dummyJob1: RuntimeJob; + let dummyJob2: RuntimeJob; + const dummySubscriptionContainer1IntegrationInstance = () => { + /* empty function */ + }; + let dummySubscriptionContainer1: SubscriptionContainer; + const dummySubscriptionContainer2IntegrationInstance = { + my: 'cool component', + }; + let dummySubscriptionContainer2: SubscriptionContainer; - LogMock.hasLoggedCode( - '16:02:00', - [nrCallbackSubContainer.key], - nrCallbackSubContainer + beforeEach(() => { + dummySubscriptionContainer1 = dummyAgile.subController.subscribe( + dummySubscriptionContainer1IntegrationInstance, + [dummyObserver1] ); - LogMock.hasLoggedCode( - '16:02:00', - [nrComponentSubContainer.key], - nrComponentSubContainer + dummySubscriptionContainer2 = dummyAgile.subController.subscribe( + dummySubscriptionContainer2IntegrationInstance, + [dummyObserver2] ); - expect(response).toBeTruthy(); // because 2 SubscriptionContainer were ready - }); - - it('should try to update in the past not ready SubscriptionContainers from the notReadyJobsToUpdate queue', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - runtime.notReadyJobsToRerender.add(rCallbackSubJob); - - const response = runtime.updateSubscribers(); - - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - - expect(rCallbackSubContainer.callback).toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); + dummyJob1 = new RuntimeJob(dummyObserver1); + dummyJob2 = new RuntimeJob(dummyObserver2); - expect(response).toBeTruthy(); + jest.spyOn(runtime, 'handleSelectors'); }); it( - "shouldn't update not ready SubscriptionContainers from the notReadyJobsToUpdate queue " + - 'and completely remove them from the runtime when it exceeded numberOfTriesToUpdate', + "shouldn't extract not ready Subscription Container from the specified Jobs, " + + "should add it to the 'notReadyJobsToRerender' queue and print a warning", () => { - dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubJob.config.numberOfTriesToUpdate = 2; - rCallbackSubJob.triesToUpdate = 2; - rCallbackSubContainer.ready = false; - runtime.notReadyJobsToRerender.add(rCallbackSubJob); + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = false; + + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); + + expect(response).toStrictEqual([dummySubscriptionContainer1]); + + // Called with Job that ran through + expect(runtime.handleSelectors).toHaveBeenCalledTimes(1); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 + ); - const response = runtime.updateSubscribers(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([ + dummyJob2, + ]); - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); + // Job that ran through + expect( + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.timesTriedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([dummyObserver1]); - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(1); + // Job that didn't ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([dummySubscriptionContainer2]); + expect(dummyJob2.timesTriedToUpdateCount).toBe(1); expect( - rCallbackSubJob.subscriptionContainersToUpdate.has( - rCallbackSubContainer - ) - ).toBeTruthy(); - expect(dummyObserver1.subscribedTo.size).toBe(1); - expect(rCallbackSubJob.triesToUpdate).toBe(2); + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); + // Called with Job that didn't ran through + expect(console.warn).toHaveBeenCalledTimes(1); LogMock.hasLoggedCode( - '16:02:01', - [rCallbackSubJob.config.numberOfTriesToUpdate], - rCallbackSubContainer + '16:02:00', + [dummySubscriptionContainer2.key], + dummySubscriptionContainer2 ); - - expect(response).toBeFalsy(); } ); it( - "shouldn't update not ready SubscriptionContainer from the notReadyJobsToUpdate queue " + - 'and add it again to the notReadyJobsToUpdate queue if numberOfTriesToUpdate is null', + "shouldn't extract not ready Subscription Container from the specified Jobs, " + + "should remove the Job when it exceeded the max 'maxTriesToUpdate' " + + 'and print a warning', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubJob.config.numberOfTriesToUpdate = null; - rCallbackSubJob.triesToUpdate = 2; - rCallbackSubContainer.ready = false; - runtime.notReadyJobsToRerender.add(rCallbackSubJob); + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = false; + const numberOfTries = (dummyJob2.config.maxTriesToUpdate ?? 0) + 1; + dummyJob2.timesTriedToUpdateCount = numberOfTries; + + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); + + expect(response).toStrictEqual([dummySubscriptionContainer1]); + + // Called with Job that ran through + expect(runtime.handleSelectors).toHaveBeenCalledTimes(1); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 + ); - const response = runtime.updateSubscribers(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); // Because exceeded Job was removed - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(1); + // Job that ran through expect( - runtime.notReadyJobsToRerender.has(rCallbackSubJob) - ).toBeTruthy(); + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.timesTriedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([dummyObserver1]); - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(1); + // Job that didn't ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([dummySubscriptionContainer2]); + expect(dummyJob2.timesTriedToUpdateCount).toBe(numberOfTries); expect( - rCallbackSubJob.subscriptionContainersToUpdate.has( - rCallbackSubContainer - ) - ).toBeTruthy(); - expect(dummyObserver1.subscribedTo.size).toBe(1); - expect(rCallbackSubJob.triesToUpdate).toBe(3); + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); + // Called with Job that didn't ran through + expect(console.warn).toHaveBeenCalledTimes(1); LogMock.hasLoggedCode( - '16:02:00', - [rCallbackSubContainer.key], - rCallbackSubContainer + '16:02:01', + [dummyJob2.config.maxTriesToUpdate], + dummySubscriptionContainer2 ); - - expect(response).toBeFalsy(); } ); - }); - describe('handleObjectBasedSubscription function tests', () => { - let arraySubscriptionContainer: SubscriptionContainer; - const dummyComponent = { - my: 'cool component', - }; - let objectSubscriptionContainer: SubscriptionContainer; - const dummyComponent2 = { - my: 'second cool component', - }; - let arrayJob: RuntimeJob; - let objectJob1: RuntimeJob; - let objectJob2: RuntimeJob; + it("shouldn't extract Subscription Container if the selected property hasn't changed", () => { + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = true; - beforeEach(() => { - arraySubscriptionContainer = dummyAgile.subController.subscribeWithSubsArray( - dummyComponent, - [dummyObserver1, dummyObserver2, dummyObserver3] + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); + + expect(response).toStrictEqual([dummySubscriptionContainer2]); + + expect(runtime.handleSelectors).toHaveBeenCalledTimes(2); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 + ); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer2, + dummyJob2 ); - arrayJob = new RuntimeJob(dummyObserver1, { key: 'dummyArrayJob' }); - objectSubscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( - dummyComponent2, - { - observer1: dummyObserver1, - observer2: dummyObserver2, - observer3: dummyObserver3, - } - ).subscriptionContainer; - objectJob1 = new RuntimeJob(dummyObserver1, { key: 'dummyObjectJob1' }); - objectJob2 = new RuntimeJob(dummyObserver3, { key: 'dummyObjectJob2' }); + // Since the Job is ready but the Observer value simply hasn't changed + // -> no point in trying to update it again + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + + // Job that didn't ran through + expect( + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.timesTriedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([]); + + // Job that ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob2.timesTriedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([dummyObserver2]); + + expect(console.warn).toHaveBeenCalledTimes(0); }); - it('should ignore not object based SubscriptionContainer', () => { - runtime.handleObjectBasedSubscription( - arraySubscriptionContainer, - arrayJob - ); + it('should extract ready and to update Subscription Containers', () => { + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = true; + + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); - expect(arraySubscriptionContainer.observerKeysToUpdate).toStrictEqual( - [] + expect(response).toStrictEqual([ + dummySubscriptionContainer1, + dummySubscriptionContainer2, + ]); + + expect(runtime.handleSelectors).toHaveBeenCalledTimes(2); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 ); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer2, + dummyJob2 + ); + + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + + // Job that ran through + expect( + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.timesTriedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([dummyObserver1]); + + // Job that ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob2.timesTriedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([dummyObserver2]); + + expect(console.warn).not.toHaveBeenCalled(); }); + }); - it('should add Job Observer to changedObjectKeys in SubscriptionContainer', () => { - runtime.handleObjectBasedSubscription( - objectSubscriptionContainer, - objectJob1 - ); + describe('updateSubscriptionContainer function tests', () => { + const dummyIntegration1 = { dummy: 'component' }; + let componentSubscriptionContainer1: ComponentSubscriptionContainer; + const dummyIntegration2 = jest.fn(); + let callbackSubscriptionContainer2: CallbackSubscriptionContainer; + const dummyIntegration3 = jest.fn(); + let callbackSubscriptionContainer3: CallbackSubscriptionContainer; + + beforeEach(() => { + componentSubscriptionContainer1 = dummyAgile.subController.subscribe( + dummyIntegration1, + [dummyObserver1] + ) as ComponentSubscriptionContainer; + componentSubscriptionContainer1.updatedSubscribers = new Set([ + dummyObserver1, + ]); + callbackSubscriptionContainer2 = dummyAgile.subController.subscribe( + dummyIntegration2, + [dummyObserver2] + ) as CallbackSubscriptionContainer; + callbackSubscriptionContainer2.updatedSubscribers = new Set([ + dummyObserver2, + ]); + callbackSubscriptionContainer3 = dummyAgile.subController.subscribe( + dummyIntegration3, + [dummyObserver3] + ) as CallbackSubscriptionContainer; + callbackSubscriptionContainer3.updatedSubscribers = new Set([ + dummyObserver3, + ]); - expect(objectSubscriptionContainer.observerKeysToUpdate).toStrictEqual([ - 'observer1', + dummyAgile.integrations.update = jest.fn(); + }); + + it('should update the specified Subscription Container', () => { + jest + .spyOn(runtime, 'getUpdatedObserverValues') + .mockReturnValueOnce('propsBasedOnUpdatedObservers' as any); + + runtime.updateSubscriptionContainer([ + componentSubscriptionContainer1, + callbackSubscriptionContainer2, + callbackSubscriptionContainer3, ]); + + // Component Subscription Container 1 + expect(dummyAgile.integrations.update).toHaveBeenCalledTimes(1); + expect(dummyAgile.integrations.update).toHaveBeenCalledWith( + dummyIntegration1, + 'propsBasedOnUpdatedObservers' + ); + expect( + Array.from(componentSubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([]); + + // Callback Subscription Container 2 + expect(callbackSubscriptionContainer2.callback).toHaveBeenCalledTimes( + 1 + ); + expect( + Array.from(callbackSubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); + + // Callback Subscription Container 3 + expect(callbackSubscriptionContainer3.callback).toHaveBeenCalledTimes( + 1 + ); + expect( + Array.from(callbackSubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); }); }); - describe('getObjectBasedProps function tests', () => { + describe('getUpdatedObserverValues function tests', () => { let subscriptionContainer: SubscriptionContainer; const dummyFunction = () => { /* empty function */ }; beforeEach(() => { - subscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( + subscriptionContainer = dummyAgile.subController.subscribe( dummyFunction, - { - observer1: dummyObserver1, - observer2: dummyObserver2, - observer3: dummyObserver3, - } - ).subscriptionContainer; + [dummyObserver1, dummyObserver2, dummyObserver3] + ); dummyObserver1.value = 'dummyObserverValue1'; dummyObserver3.value = 'dummyObserverValue3'; + + dummyObserver1._key = 'dummyObserver1KeyInObserver'; + dummyObserver2._key = undefined; + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver2, + 'dummyObserver2KeyInWeakMap' + ); + dummyObserver3._key = 'dummyObserver3KeyInObserver'; + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver3, + 'dummyObserver3KeyInWeakMap' + ); }); - it('should build Observer Value Object out of observerKeysToUpdate and Value of Observer', () => { - subscriptionContainer.observerKeysToUpdate.push('observer1'); - subscriptionContainer.observerKeysToUpdate.push('observer2'); - subscriptionContainer.observerKeysToUpdate.push('observer3'); + it('should map the values of the updated Observers into an object and return it', () => { + subscriptionContainer.updatedSubscribers.add(dummyObserver1); + subscriptionContainer.updatedSubscribers.add(dummyObserver2); + subscriptionContainer.updatedSubscribers.add(dummyObserver3); - const props = runtime.getObjectBasedProps(subscriptionContainer); + const props = runtime.getUpdatedObserverValues(subscriptionContainer); expect(props).toStrictEqual({ - observer1: 'dummyObserverValue1', - observer2: undefined, - observer3: 'dummyObserverValue3', + dummyObserver1KeyInObserver: 'dummyObserverValue1', + dummyObserver2KeyInWeakMap: undefined, + dummyObserver3KeyInWeakMap: 'dummyObserverValue3', }); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); + expect( + Array.from(subscriptionContainer.updatedSubscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2, dummyObserver3]); }); }); - describe('handleProxyBasedSubscription function tests', () => { + describe('handleSelector function tests', () => { let objectSubscriptionContainer: SubscriptionContainer; const dummyFunction = () => { /* empty function */ @@ -627,96 +667,69 @@ describe('Runtime Tests', () => { let arrayJob: RuntimeJob; beforeEach(() => { - // Create Job with Object value - objectSubscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( + // Create Job with object based value + objectSubscriptionContainer = dummyAgile.subController.subscribe( dummyFunction, - { observer1: dummyObserver1 } - ).subscriptionContainer; + [dummyObserver1] + ); dummyObserver1.value = { - key: 'dummyObserverValue1', data: { name: 'jeff' }, }; dummyObserver1.previousValue = { - key: 'dummyObserverValue1', data: { name: 'jeff' }, }; - objectSubscriptionContainer.proxyBased = true; - objectSubscriptionContainer.proxyKeyMap = { - [dummyObserver1._key || 'unknown']: { paths: [['data', 'name']] }, - }; + objectSubscriptionContainer.selectorsWeakMap.set(dummyObserver1, { + methods: [(value) => value?.data?.name], + }); objectJob = new RuntimeJob(dummyObserver1, { key: 'dummyObjectJob1' }); - // Create Job with Array value - arraySubscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( + // Create Job with array based value + arraySubscriptionContainer = dummyAgile.subController.subscribe( dummyFunction2, - { observer2: dummyObserver2 } + { dummyObserver2: dummyObserver2 } ).subscriptionContainer; dummyObserver2.value = [ { - key: 'dummyObserver2Value1', data: { name: 'jeff' }, }, { - key: 'dummyObserver2Value2', data: { name: 'hans' }, }, + { + data: { name: 'frank' }, + }, ]; dummyObserver2.previousValue = [ { - key: 'dummyObserver2Value1', data: { name: 'jeff' }, }, { - key: 'dummyObserver2Value2', data: { name: 'hans' }, }, - ]; - arraySubscriptionContainer.proxyBased = true; - arraySubscriptionContainer.proxyKeyMap = { - [dummyObserver2._key || 'unknown']: { - paths: [['0', 'data', 'name']], + { + data: { name: 'frank' }, }, - }; + ]; + arraySubscriptionContainer.selectorsWeakMap.set(dummyObserver2, { + methods: [ + (value) => value[0]?.data?.name, + (value) => value[2]?.data?.name, + ], + }); arrayJob = new RuntimeJob(dummyObserver2, { key: 'dummyObjectJob2' }); jest.spyOn(Utils, 'notEqual'); - // Because not equals is called once during the creation of the subscriptionContainer + // Because not equals is called once during the creation of the Subscription Containers jest.clearAllMocks(); }); - it("should return true if subscriptionContainer isn't proxy based", () => { - objectSubscriptionContainer.proxyBased = false; - - const response = runtime.handleProxyBasedSubscription( - objectSubscriptionContainer, - objectJob - ); - - expect(response).toBeTruthy(); - expect(Utils.notEqual).not.toHaveBeenCalled(); - }); - - it('should return true if observer the job represents has no key', () => { - objectJob.observer._key = undefined; - - const response = runtime.handleProxyBasedSubscription( - objectSubscriptionContainer, - objectJob - ); - - expect(response).toBeTruthy(); - expect(Utils.notEqual).not.toHaveBeenCalled(); - }); - - it("should return true if the observer key isn't represented in the proxyKeyMap", () => { - objectSubscriptionContainer.proxyKeyMap = { - unknownKey: { paths: [['a', 'b']] }, - }; + it('should return true if Subscription Container has no selector methods', () => { + objectSubscriptionContainer.selectorsWeakMap.delete(dummyObserver1); - const response = runtime.handleProxyBasedSubscription( + const response = runtime.handleSelectors( objectSubscriptionContainer, objectJob ); @@ -725,90 +738,119 @@ describe('Runtime Tests', () => { expect(Utils.notEqual).not.toHaveBeenCalled(); }); - it('should return true if used property has changed (object value)', () => { + it('should return true if selected property has changed (object value)', () => { dummyObserver1.value = { - key: 'dummyObserverValue1', - data: { name: 'hans' }, + data: { name: 'changedName' }, }; - const response = runtime.handleProxyBasedSubscription( + const response = runtime.handleSelectors( objectSubscriptionContainer, objectJob ); expect(response).toBeTruthy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(1); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver1.value.data.name, dummyObserver1.previousValue.data.name ); }); - it("should return false if used property hasn't changed (object value)", () => { - const response = runtime.handleProxyBasedSubscription( + it("should return false if selected property hasn't changed (object value)", () => { + const response = runtime.handleSelectors( objectSubscriptionContainer, objectJob ); expect(response).toBeFalsy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(1); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver1.value.data.name, dummyObserver1.previousValue.data.name ); }); - it('should return true if used property has changed in the deepness (object value)', () => { - dummyObserver1.value = { - key: 'dummyObserverValue1', - }; - dummyObserver1.previousValue = { - key: 'dummyObserverValue1', - data: { name: undefined }, - }; - - const response = runtime.handleProxyBasedSubscription( - objectSubscriptionContainer, - objectJob - ); - - expect(response).toBeTruthy(); - expect(Utils.notEqual).toHaveBeenCalledWith(undefined, undefined); - }); - - it('should return true if used property has changed (array value)', () => { + // TODO the deepness check isn't possible with the current way of handling selector methods + // it('should return true if selected property has changed in the deepness (object value)', () => { + // dummyObserver1.value = { + // key: 'dummyObserverValue1', + // }; + // dummyObserver1.previousValue = { + // key: 'dummyObserverValue1', + // data: { name: undefined }, + // }; + // + // const response = runtime.handleSelectors( + // objectSubscriptionContainer, + // objectJob + // ); + // + // expect(response).toBeTruthy(); + // expect(Utils.notEqual).toHaveBeenCalledWith(undefined, undefined); + // }); + + it('should return true if a selected property has changed (array value)', () => { dummyObserver2.value = [ { - key: 'dummyObserver2Value1', - data: { name: 'frank' }, + data: { name: 'jeff' }, }, { - key: 'dummyObserver2Value2', data: { name: 'hans' }, }, + { + data: { name: 'changedName' }, + }, ]; - const response = runtime.handleProxyBasedSubscription( + const response = runtime.handleSelectors( arraySubscriptionContainer, arrayJob ); expect(response).toBeTruthy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(2); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver2.value['0'].data.name, dummyObserver2.previousValue['0'].data.name ); + expect(Utils.notEqual).toHaveBeenCalledWith( + dummyObserver2.value['2'].data.name, + dummyObserver2.previousValue['2'].data.name + ); }); it("should return false if used property hasn't changed (array value)", () => { - const response = runtime.handleProxyBasedSubscription( + dummyObserver2.value = [ + { + data: { name: 'jeff' }, + }, + { + data: { name: 'changedName (but not selected)' }, + }, + { + data: { name: 'frank' }, + }, + ]; + + const response = runtime.handleSelectors( arraySubscriptionContainer, arrayJob ); expect(response).toBeFalsy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(2); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver2.value['0'].data.name, dummyObserver2.previousValue['0'].data.name ); + expect(Utils.notEqual).toHaveBeenCalledWith( + dummyObserver2.value['2'].data.name, + dummyObserver2.previousValue['2'].data.name + ); }); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts b/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts index 080c0439..e8bea19e 100644 --- a/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts +++ b/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts @@ -2,6 +2,8 @@ import { Agile, CallbackSubscriptionContainer, Observer, + ProxyWeakMapType, + SelectorWeakMapType, } from '../../../../../src'; import { LogMock } from '../../../../helper/logMock'; @@ -9,6 +11,8 @@ describe('CallbackSubscriptionContainer Tests', () => { let dummyAgile: Agile; let dummyObserver1: Observer; let dummyObserver2: Observer; + let dummySelectorWeakMap: SelectorWeakMapType; + let dummyProxyWeakMap: ProxyWeakMapType; beforeEach(() => { jest.clearAllMocks(); @@ -17,6 +21,8 @@ describe('CallbackSubscriptionContainer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); + dummySelectorWeakMap = new WeakMap(); + dummyProxyWeakMap = new WeakMap(); }); it('should create CallbackSubscriptionContainer', () => { @@ -27,22 +33,36 @@ describe('CallbackSubscriptionContainer Tests', () => { const subscriptionContainer = new CallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], - { key: 'dummyKey', proxyKeyMap: { myState: { paths: [['hi']] } } } + { + key: 'dummyKey', + proxyWeakMap: dummyProxyWeakMap, + selectorWeakMap: dummySelectorWeakMap, + componentId: 'testID', + } ); expect(subscriptionContainer.callback).toBe(dummyIntegration); + // Check if SubscriptionContainer was called with correct parameters expect(subscriptionContainer.key).toBe('dummyKey'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(2); - expect(subscriptionContainer.subscribers.has(dummyObserver1)).toBeTruthy(); - expect(subscriptionContainer.subscribers.has(dummyObserver2)).toBeTruthy(); + expect(subscriptionContainer.componentId).toBe('testID'); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({ - myState: { paths: [['hi']] }, - }); - expect(subscriptionContainer.proxyBased).toBeTruthy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).not.toBe( + dummySelectorWeakMap + ); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts b/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts index 04362b53..4401cf88 100644 --- a/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts +++ b/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts @@ -2,6 +2,8 @@ import { Agile, ComponentSubscriptionContainer, Observer, + ProxyWeakMapType, + SelectorWeakMapType, } from '../../../../../src'; import { LogMock } from '../../../../helper/logMock'; @@ -9,6 +11,8 @@ describe('ComponentSubscriptionContainer Tests', () => { let dummyAgile: Agile; let dummyObserver1: Observer; let dummyObserver2: Observer; + let dummySelectorWeakMap: SelectorWeakMapType; + let dummyProxyWeakMap: ProxyWeakMapType; beforeEach(() => { jest.clearAllMocks(); @@ -17,6 +21,8 @@ describe('ComponentSubscriptionContainer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); + dummySelectorWeakMap = new WeakMap(); + dummyProxyWeakMap = new WeakMap(); }); it('should create ComponentSubscriptionContainer', () => { @@ -25,22 +31,36 @@ describe('ComponentSubscriptionContainer Tests', () => { const subscriptionContainer = new ComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], - { key: 'dummyKey', proxyKeyMap: { myState: { paths: [['hi']] } } } + { + key: 'dummyKey', + proxyWeakMap: dummyProxyWeakMap, + selectorWeakMap: dummySelectorWeakMap, + componentId: 'testID', + } ); expect(subscriptionContainer.component).toStrictEqual(dummyIntegration); + // Check if SubscriptionContainer was called with correct parameters expect(subscriptionContainer.key).toBe('dummyKey'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(2); - expect(subscriptionContainer.subscribers.has(dummyObserver1)).toBeTruthy(); - expect(subscriptionContainer.subscribers.has(dummyObserver2)).toBeTruthy(); + expect(subscriptionContainer.componentId).toBe('testID'); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({ - myState: { paths: [['hi']] }, - }); - expect(subscriptionContainer.proxyBased).toBeTruthy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).not.toBe( + dummySelectorWeakMap + ); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts b/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts index 3c9c566a..c3aa012d 100644 --- a/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts +++ b/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts @@ -1,4 +1,10 @@ -import { Agile, Observer, SubscriptionContainer } from '../../../../../src'; +import { + Agile, + Observer, + ProxyWeakMapType, + SelectorWeakMapType, + SubscriptionContainer, +} from '../../../../../src'; import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../../../helper/logMock'; @@ -6,6 +12,8 @@ describe('SubscriptionContainer Tests', () => { let dummyAgile: Agile; let dummyObserver1: Observer; let dummyObserver2: Observer; + let dummySelectorWeakMap: SelectorWeakMapType; + let dummyProxyWeakMap: ProxyWeakMapType; beforeEach(() => { jest.clearAllMocks(); @@ -14,40 +22,310 @@ describe('SubscriptionContainer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); + dummySelectorWeakMap = new WeakMap(); + dummyProxyWeakMap = new WeakMap(); }); - it('should create SubscriptionContainer (default config)', () => { - jest.spyOn(Utils, 'generateId').mockReturnValue('generatedId'); + it('should create SubscriptionContainer with passed subs array (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedId'); + jest + .spyOn(SubscriptionContainer.prototype, 'addSubscription') + .mockReturnValueOnce() + .mockReturnValueOnce(); - const subscriptionContainer = new SubscriptionContainer(); + const subscriptionContainer = new SubscriptionContainer([ + dummyObserver1, + dummyObserver2, + ]); + + expect(subscriptionContainer.addSubscription).toHaveBeenCalledTimes(2); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver1, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: undefined, + } + ); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver2, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: undefined, + } + ); expect(subscriptionContainer.key).toBe('generatedId'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(0); + expect(subscriptionContainer.componentId).toBeUndefined(); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([]); // because of mocking addSubscription + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({}); - expect(subscriptionContainer.proxyBased).toBeFalsy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + }); + + it('should create SubscriptionContainer with passed subs object (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedId'); + jest + .spyOn(SubscriptionContainer.prototype, 'addSubscription') + .mockReturnValueOnce() + .mockReturnValueOnce(); + + const subscriptionContainer = new SubscriptionContainer({ + dummyObserver1: dummyObserver1, + dummyObserver2: dummyObserver2, + }); + + expect(subscriptionContainer.addSubscription).toHaveBeenCalledTimes(2); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver1, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: 'dummyObserver1', + } + ); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver2, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: 'dummyObserver2', + } + ); + + expect(subscriptionContainer.key).toBe('generatedId'); + expect(subscriptionContainer.ready).toBeFalsy(); + expect(subscriptionContainer.componentId).toBeUndefined(); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([]); // because of mocking addSubscription + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); + expect(subscriptionContainer.isObjectBased).toBeTruthy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); }); - it('should create SubscriptionContainer (specific config)', () => { + it('should create SubscriptionContainer with passed subs array (specific config)', () => { + jest + .spyOn(SubscriptionContainer.prototype, 'addSubscription') + .mockReturnValueOnce() + .mockReturnValueOnce(); + + dummyProxyWeakMap.set(dummyObserver1, { + paths: 'dummyObserver1_paths' as any, + }); + dummyProxyWeakMap.set(dummyObserver2, { + paths: 'dummyObserver2_paths' as any, + }); + dummySelectorWeakMap.set(dummyObserver2, { + methods: 'dummyObserver2_selectors' as any, + }); + const subscriptionContainer = new SubscriptionContainer( [dummyObserver1, dummyObserver2], - { key: 'dummyKey', proxyKeyMap: { myState: { paths: [['a', 'b']] } } } + { + key: 'dummyKey', + proxyWeakMap: dummyProxyWeakMap, + selectorWeakMap: dummySelectorWeakMap, + componentId: 'testID', + } + ); + + expect(subscriptionContainer.addSubscription).toHaveBeenCalledTimes(2); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver1, + { + proxyPaths: 'dummyObserver1_paths', + selectorMethods: undefined, + key: undefined, + } + ); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver2, + { + proxyPaths: 'dummyObserver2_paths', + selectorMethods: 'dummyObserver2_selectors', + key: undefined, + } ); expect(subscriptionContainer.key).toBe('dummyKey'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(2); - expect(subscriptionContainer.subscribers.has(dummyObserver1)).toBeTruthy(); - expect(subscriptionContainer.subscribers.has(dummyObserver2)).toBeTruthy(); + expect(subscriptionContainer.componentId).toBe('testID'); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([]); // because of mocking addSubscription + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({ - myState: { paths: [['a', 'b']] }, + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).not.toBe( + dummySelectorWeakMap + ); + }); + + describe('Subscription Container Function Tests', () => { + let subscriptionContainer: SubscriptionContainer; + + beforeEach(() => { + subscriptionContainer = new SubscriptionContainer([]); + }); + + describe('addSubscription function tests', () => { + it( + 'should create selector methods based on the specified proxy paths, ' + + "assign newly created and provided selector methods to the 'selectorsWeakMap' " + + 'and subscribe the specified Observer to the Subscription Container', + () => { + dummyObserver1.value = { + das: { haus: { vom: 'nikolaus' } }, + alle: { meine: 'entchien' }, + test1: 'test1Value', + test2: 'test2Value', + test3: 'test3Value', + }; + subscriptionContainer.selectorsWeakMap.set(dummyObserver1, { + methods: [(value) => value.test3], + }); + subscriptionContainer.selectorsWeakMap.set(dummyObserver2, { + methods: [(value) => 'doesNotMatter'], + }); + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver2, + 'dummyObserver2' + ); + + subscriptionContainer.addSubscription(dummyObserver1, { + key: 'dummyObserver1', + proxyPaths: [['das', 'haus', 'vom'], ['test1']], + selectorMethods: [ + (value) => value.alle.meine, + (value) => value.test2, + ], + }); + + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver1, + ]); + expect(Array.from(dummyObserver1.subscribedTo)).toStrictEqual([ + subscriptionContainer, + ]); + + // should assign specified selectors/(and selectors created from proxy paths) to the 'selectorsWeakMap' + const observer1Selector = subscriptionContainer.selectorsWeakMap.get( + dummyObserver1 + ) as any; + expect(observer1Selector.methods.length).toBe(5); + expect(observer1Selector.methods[0](dummyObserver1.value)).toBe( + 'test3Value' + ); + expect(observer1Selector.methods[1](dummyObserver1.value)).toBe( + 'entchien' + ); + expect(observer1Selector.methods[2](dummyObserver1.value)).toBe( + 'test2Value' + ); + expect(observer1Selector.methods[3](dummyObserver1.value)).toBe( + 'nikolaus' + ); + expect(observer1Selector.methods[4](dummyObserver1.value)).toBe( + 'test1Value' + ); + + // shouldn't overwrite already set values in 'selectorsWeakMap' (Observer2) + const observer2Selector = subscriptionContainer.selectorsWeakMap.get( + dummyObserver2 + ) as any; + expect(observer2Selector.methods.length).toBe(1); + expect(observer2Selector.methods[0](null)).toBe('doesNotMatter'); + + // should assign specified key to the 'subscriberKeysWeakMap' + const observer1Key = subscriptionContainer.subscriberKeysWeakMap.get( + dummyObserver1 + ); + expect(observer1Key).toBe('dummyObserver1'); + + // shouldn't overwrite already set values in 'subscriberKeysWeakMap' (Observer2) + const observer2Key = subscriptionContainer.subscriberKeysWeakMap.get( + dummyObserver2 + ); + expect(observer2Key).toBe('dummyObserver2'); + } + ); + }); + + describe('removeSubscription function tests', () => { + let subscriptionContainer: SubscriptionContainer; + + beforeEach(() => { + subscriptionContainer = new SubscriptionContainer([]); + + subscriptionContainer.subscribers = new Set([ + dummyObserver1, + dummyObserver2, + ]); + dummyObserver1.subscribedTo = new Set([subscriptionContainer]); + dummyObserver2.subscribedTo = new Set([subscriptionContainer]); + + subscriptionContainer.selectorsWeakMap.set(dummyObserver1, { + methods: [], + }); + subscriptionContainer.selectorsWeakMap.set(dummyObserver2, { + methods: [], + }); + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver1, + 'dummyObserver1' + ); + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver2, + 'dummyObserver2' + ); + }); + + it('should remove subscribed Observer from Subscription Container', () => { + subscriptionContainer.removeSubscription(dummyObserver1); + + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver2, + ]); + + expect( + subscriptionContainer.selectorsWeakMap.get(dummyObserver1) + ).toBeUndefined(); + expect( + subscriptionContainer.selectorsWeakMap.get(dummyObserver2) + ).not.toBeUndefined(); + + expect( + subscriptionContainer.subscriberKeysWeakMap.get(dummyObserver1) + ).toBeUndefined(); + expect( + subscriptionContainer.subscriberKeysWeakMap.get(dummyObserver2) + ).toBe('dummyObserver2'); + + expect(Array.from(dummyObserver1.subscribedTo)).toStrictEqual([]); + expect(Array.from(dummyObserver2.subscribedTo)).toStrictEqual([ + subscriptionContainer, + ]); + }); }); - expect(subscriptionContainer.proxyBased).toBeTruthy(); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts b/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts index 0dee1506..e52e81a1 100644 --- a/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts +++ b/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts @@ -4,7 +4,6 @@ import { ComponentSubscriptionContainer, Observer, SubController, - SubscriptionContainer, } from '../../../../src'; import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../../helper/logMock'; @@ -22,8 +21,9 @@ describe('SubController Tests', () => { it('should create SubController', () => { const subController = new SubController(dummyAgile); - expect(subController.callbackSubs.size).toBe(0); - expect(subController.callbackSubs.size).toBe(0); + expect(subController.agileInstance()).toBe(dummyAgile); + expect(Array.from(subController.callbackSubs)).toStrictEqual([]); + expect(Array.from(subController.componentSubs)).toStrictEqual([]); }); describe('SubController Function Tests', () => { @@ -32,430 +32,394 @@ describe('SubController Tests', () => { let dummyObserver2: Observer; beforeEach(() => { - dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); - dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); - subController = new SubController(dummyAgile); - }); - - describe('subscribeWithSubsObject function tests', () => { - const dummyIntegration = 'myDummyIntegration'; - let dummySubscriptionContainer: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer = new SubscriptionContainer(); - dummyObserver1.value = 'myCoolValue'; - - subController.registerSubscription = jest.fn( - () => dummySubscriptionContainer - ); - jest.spyOn(dummyObserver1, 'subscribe'); - jest.spyOn(dummyObserver2, 'subscribe'); + dummyObserver1 = new Observer(dummyAgile, { + key: 'dummyObserver1', + value: 'dummyObserver1Value', }); - - it('should create subscriptionContainer and add in Object shape passed Observers to it', () => { - const subscribeWithSubsResponse = subController.subscribeWithSubsObject( - dummyIntegration, - { - dummyObserver1: dummyObserver1, - dummyObserver2: dummyObserver2, - }, - { - key: 'subscribeWithSubsObjectKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(subscribeWithSubsResponse).toStrictEqual({ - props: { - dummyObserver1: 'myCoolValue', - }, - subscriptionContainer: dummySubscriptionContainer, - }); - - expect(subController.registerSubscription).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { - key: 'subscribeWithSubsObjectKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(dummySubscriptionContainer.isObjectBased).toBeTruthy(); - expect(dummySubscriptionContainer.subsObject).toStrictEqual({ - dummyObserver1: dummyObserver1, - dummyObserver2: dummyObserver2, - }); - - expect(dummySubscriptionContainer.subscribers.size).toBe(2); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(dummyObserver1.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); - expect(dummyObserver2.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); + dummyObserver2 = new Observer(dummyAgile, { + key: 'dummyObserver2', + value: 'dummyObserver2Value', }); + subController = new SubController(dummyAgile); }); - describe('subscribeWithSubsArray function tests', () => { - const dummyIntegration = 'myDummyIntegration'; - let dummySubscriptionContainer: SubscriptionContainer; - + describe('subscribe function tests', () => { beforeEach(() => { - dummySubscriptionContainer = new SubscriptionContainer(); - - subController.registerSubscription = jest.fn( - () => dummySubscriptionContainer - ); - jest.spyOn(dummyObserver1, 'subscribe'); - jest.spyOn(dummyObserver2, 'subscribe'); + jest.spyOn(subController, 'createCallbackSubscriptionContainer'); + jest.spyOn(subController, 'createComponentSubscriptionContainer'); }); - it('should create subscriptionContainer and add in Array Shape passed Observers to it', () => { - const subscribeWithSubsArrayResponse = subController.subscribeWithSubsArray( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { - key: 'subscribeWithSubsArrayKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(subscribeWithSubsArrayResponse).toBe(dummySubscriptionContainer); - - expect(subController.registerSubscription).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { - key: 'subscribeWithSubsArrayKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(dummySubscriptionContainer.isObjectBased).toBeFalsy(); - expect(dummySubscriptionContainer.subsObject).toBeUndefined(); - - expect(dummySubscriptionContainer.subscribers.size).toBe(2); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(dummyObserver1.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); - expect(dummyObserver2.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); - }); + it( + 'should create a Component based Subscription Container with specified Component Instance ' + + 'and assign the in an object specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration: any = { + dummy: 'integration', + }; + + const returnValue = subController.subscribe( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: true, + } + ); + + expect(returnValue.subscriptionContainer).toBeInstanceOf( + ComponentSubscriptionContainer + ); + expect(returnValue.props).toStrictEqual({ + observer1: dummyObserver1.value, + observer2: dummyObserver2.value, + }); + + expect( + subController.createComponentSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: true, + } + ); + expect( + subController.createCallbackSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); + + it( + 'should create a Component based Subscription Container with specified Component Instance ' + + 'and assign the in an array specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration: any = { + dummy: 'integration', + }; + + const returnValue = subController.subscribe( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { key: 'subscriptionContainerKey', componentId: 'testID' } + ); + + expect(returnValue).toBeInstanceOf(ComponentSubscriptionContainer); + + expect( + subController.createComponentSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: 'aFakeBoolean', + } + ); + expect( + subController.createCallbackSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); + + it( + 'should create a Callback based Subscription Container with specified callback function ' + + 'and assign the in an object specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration = () => { + /* empty function */ + }; + + const returnValue = subController.subscribe( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + } + ); + + expect(returnValue.subscriptionContainer).toBeInstanceOf( + CallbackSubscriptionContainer + ); + expect(returnValue.props).toStrictEqual({ + observer1: dummyObserver1.value, + observer2: dummyObserver2.value, + }); + + expect( + subController.createCallbackSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: 'aFakeBoolean', + } + ); + expect( + subController.createComponentSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); + + it( + 'should create a Callback based Subscription Container with specified callback function ' + + 'and assign the in an array specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration = () => { + /* empty function */ + }; + + const returnValue = subController.subscribe( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: false, + } + ); + + expect(returnValue).toBeInstanceOf(CallbackSubscriptionContainer); + + expect( + subController.createCallbackSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: false, + } + ); + expect( + subController.createComponentSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); }); describe('unsubscribe function tests', () => { - beforeEach(() => { - jest.spyOn(dummyObserver1, 'unsubscribe'); - jest.spyOn(dummyObserver2, 'unsubscribe'); - }); - - it('should unsubscribe callbackSubscriptionContainer', () => { + it('should unsubscribe Callback based Subscription Container', () => { const dummyIntegration = () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.registerCallbackSubscription( + const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); + callbackSubscriptionContainer.removeSubscription = jest.fn(); subController.unsubscribe(callbackSubscriptionContainer); - expect(subController.callbackSubs.size).toBe(0); + expect(Array.from(subController.callbackSubs)).toStrictEqual([]); expect(callbackSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - callbackSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - callbackSubscriptionContainer - ); + expect( + callbackSubscriptionContainer.removeSubscription + ).toHaveBeenCalledTimes(2); + expect( + callbackSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); + expect( + callbackSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); }); - it('should unsubscribe componentSubscriptionContainer', () => { + it('should unsubscribe Component Subscription Container', () => { const dummyIntegration: any = { dummy: 'integration', }; - const componentSubscriptionContainer = subController.registerComponentSubscription( + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); + componentSubscriptionContainer.removeSubscription = jest.fn(); subController.unsubscribe(componentSubscriptionContainer); - expect(subController.componentSubs.size).toBe(0); - expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - }); - - it('should unsubscribe componentSubscriptionContainer from passed Object that hold an instance of componentSubscriptionContainer', () => { - const dummyIntegration: any = { - dummy: 'integration', - }; - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - subController.unsubscribe(dummyIntegration); - - expect(subController.componentSubs.size).toBe(0); - expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - }); - - it('should unsubscribe componentSubscriptionContainers from passed Object that hold an Array of componentSubscriptionContainers', () => { - const dummyIntegration: any = { - dummy: 'integration', - componentSubscriptionContainers: [], - }; - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - const componentSubscriptionContainer2 = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - subController.unsubscribe(dummyIntegration); - - expect(subController.componentSubs.size).toBe(0); - + expect(Array.from(subController.componentSubs)).toStrictEqual([]); expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - - expect(componentSubscriptionContainer2.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer2 - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer2 - ); - }); - }); - - describe('registerSubscription function tests', () => { - let dummySubscriptionContainer: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer = new SubscriptionContainer(); - dummyAgile.config.waitForMount = 'dummyWaitForMount' as any; - - subController.registerCallbackSubscription = jest.fn( - () => dummySubscriptionContainer as CallbackSubscriptionContainer - ); - subController.registerComponentSubscription = jest.fn( - () => dummySubscriptionContainer as ComponentSubscriptionContainer - ); - }); - - it('should call registerCallbackSubscription if passed integrationInstance is a Function (default config)', () => { - const dummyIntegration = () => { - /* empty function */ - }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); expect( - subController.registerCallbackSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: dummyAgile.config.waitForMount } - ); - expect( - subController.registerComponentSubscription - ).not.toHaveBeenCalled(); - }); - - it('should call registerCallbackSubscription if passed integrationInstance is a Function (specific config)', () => { - const dummyIntegration = () => { - /* empty function */ - }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledTimes(2); expect( - subController.registerCallbackSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); expect( - subController.registerComponentSubscription - ).not.toHaveBeenCalled(); + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); }); - it('should call registerComponentSubscription if passed integrationInstance is not a Function (default config)', () => { - const dummyIntegration = { dummy: 'integration' }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); - expect( - subController.registerComponentSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: dummyAgile.config.waitForMount } - ); - expect( - subController.registerCallbackSubscription - ).not.toHaveBeenCalled(); - }); - - it('should call registerComponentSubscription if passed integrationInstance is not a Function (specific config)', () => { - const dummyIntegration = { dummy: 'integration' }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); - expect( - subController.registerComponentSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); - expect( - subController.registerCallbackSubscription - ).not.toHaveBeenCalled(); - }); + it( + 'should unsubscribe Component based Subscription Container ' + + 'from specified object (UI-Component) that contains an instance of the Component Subscription Container', + () => { + const dummyIntegration: any = { + dummy: 'integration', + componentSubscriptionContainers: [], + }; + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2] + ); + componentSubscriptionContainer.removeSubscription = jest.fn(); + const componentSubscriptionContainer2 = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2] + ); + componentSubscriptionContainer2.removeSubscription = jest.fn(); + + subController.unsubscribe(dummyIntegration); + + expect(Array.from(subController.componentSubs)).toStrictEqual([]); + + expect(componentSubscriptionContainer.ready).toBeFalsy(); + expect( + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledTimes(2); + expect( + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); + expect( + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); + + expect(componentSubscriptionContainer2.ready).toBeFalsy(); + expect( + componentSubscriptionContainer2.removeSubscription + ).toHaveBeenCalledTimes(2); + expect( + componentSubscriptionContainer2.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); + expect( + componentSubscriptionContainer2.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); + } + ); }); - describe('registerComponentSubscription function tests', () => { - it('should return ready componentSubscriptionContainer and add it to dummyIntegration at componentSubscriptionContainer (config.waitForMount = false)', () => { - const dummyIntegration: any = { dummy: 'integration' }; - - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: false } - ); - - expect(componentSubscriptionContainer).toBeInstanceOf( - ComponentSubscriptionContainer - ); - expect(componentSubscriptionContainer.component).toStrictEqual( - dummyIntegration - ); - expect(componentSubscriptionContainer.ready).toBeTruthy(); - - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(subController.componentSubs.size).toBe(1); - expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); - - expect(dummyIntegration.componentSubscriptionContainer).toBe( - componentSubscriptionContainer - ); - }); - - it('should return ready componentSubscriptionContainer and add it to componentSubscriptions in dummyIntegration (config.waitForMount = false)', () => { - const dummyIntegration: any = { - dummy: 'integration', - componentSubscriptionContainers: [], - }; - - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: false } - ); - - expect(componentSubscriptionContainer).toBeInstanceOf( - ComponentSubscriptionContainer - ); - expect(componentSubscriptionContainer.component).toStrictEqual( - dummyIntegration - ); - expect(componentSubscriptionContainer.ready).toBeTruthy(); - - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(subController.componentSubs.size).toBe(1); - expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); - - expect(dummyIntegration.componentSubscriptionContainers.length).toBe(1); - expect(dummyIntegration.componentSubscriptionContainers[0]).toBe( - componentSubscriptionContainer - ); - expect(dummyIntegration.componentSubscriptionContainer).toBeUndefined(); - }); - - it("should return not ready componentSubscriptionContainer if componentInstance isn't mounted (waitForMount = true)", () => { + describe('createComponentSubscriptionContainer function tests', () => { + it( + 'should return ready Component based Subscription Container ' + + "and add an instance of it to the not existing 'componentSubscriptions' property " + + 'in the dummyIntegration (default config)', + () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); + const dummyIntegration: any = { + dummy: 'integration', + }; + + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false } + ); + + expect(componentSubscriptionContainer).toBeInstanceOf( + ComponentSubscriptionContainer + ); + expect(componentSubscriptionContainer.component).toStrictEqual( + dummyIntegration + ); + expect(componentSubscriptionContainer.ready).toBeTruthy(); + + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); + + expect( + dummyIntegration.componentSubscriptionContainers + ).toStrictEqual([componentSubscriptionContainer]); + + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('generatedKey'); + expect(componentSubscriptionContainer.componentId).toBeUndefined(); + expect( + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); + } + ); + + it( + 'should return ready Component based Subscription Container ' + + "and add an instance of it to the existing 'componentSubscriptions' property " + + 'in the dummyIntegration (default config)', + () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); + const dummyIntegration: any = { + dummy: 'integration', + componentSubscriptionContainers: [], + }; + + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false } + ); + + expect( + dummyIntegration.componentSubscriptionContainers + ).toStrictEqual([componentSubscriptionContainer]); + } + ); + + it( + 'should return ready Component based Subscription Container ' + + "and add an instance of it to the not existing 'componentSubscriptions' property " + + 'in the dummyIntegration (specific config)', + () => { + const dummyIntegration: any = { + dummy: 'integration', + }; + + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false, componentId: 'testID', key: 'dummyKey' } + ); + + expect(componentSubscriptionContainer).toBeInstanceOf( + ComponentSubscriptionContainer + ); + expect(componentSubscriptionContainer.component).toStrictEqual( + dummyIntegration + ); + expect(componentSubscriptionContainer.ready).toBeTruthy(); + + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); + + expect( + dummyIntegration.componentSubscriptionContainers + ).toStrictEqual([componentSubscriptionContainer]); + + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('dummyKey'); + expect(componentSubscriptionContainer.componentId).toBe('testID'); + expect( + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); + } + ); + + it("should return not ready Component based Subscription Container if componentInstance isn't mounted (config.waitForMount = true)", () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); const dummyIntegration: any = { dummy: 'integration', }; - const componentSubscriptionContainer = subController.registerComponentSubscription( + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], { waitForMount: true } @@ -469,27 +433,26 @@ describe('SubController Tests', () => { ); expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); - expect(subController.componentSubs.size).toBe(1); + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('generatedKey'); + expect(componentSubscriptionContainer.componentId).toBeUndefined(); expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); - it('should return ready componentSubscriptionContainer if componentInstance is mounted (config.waitForMount = true)', () => { + it('should return ready Component based Subscription Container if componentInstance is mounted (config.waitForMount = true)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); const dummyIntegration: any = { dummy: 'integration', }; subController.mount(dummyIntegration); - const componentSubscriptionContainer = subController.registerComponentSubscription( + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], { waitForMount: true } @@ -503,29 +466,27 @@ describe('SubController Tests', () => { ); expect(componentSubscriptionContainer.ready).toBeTruthy(); - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); - expect(subController.componentSubs.size).toBe(1); + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('generatedKey'); + expect(componentSubscriptionContainer.componentId).toBeUndefined(); expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); }); describe('registerCallbackSubscription function tests', () => { - it('should return callbackSubscriptionContainer (default config)', () => { - jest.spyOn(Utils, 'generateId').mockReturnValueOnce('randomKey'); + it('should return Callback based Subscription Container (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); const dummyIntegration = () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.registerCallbackSubscription( + const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); @@ -536,42 +497,35 @@ describe('SubController Tests', () => { expect(callbackSubscriptionContainer.callback).toBe(dummyIntegration); expect(callbackSubscriptionContainer.ready).toBeTruthy(); - // TODO find a way to spy on a class constructor without overwriting it - // https://stackoverflow.com/questions/48219267/how-to-spy-on-a-class-constructor-jest/48486214 - // Because the below tests are not really related to this test, - // they are checking if the CallbackSubscriptionContainer got called with the right parameters - // by checking if CallbackSubscriptionContainer has set its properties correctly - // Note:This 'issue' happens in multiple parts of the AgileTs test - expect(callbackSubscriptionContainer.key).toBe('randomKey'); - expect(callbackSubscriptionContainer.proxyKeyMap).toStrictEqual({}); - expect(callbackSubscriptionContainer.proxyBased).toBeFalsy(); - - expect(callbackSubscriptionContainer.subscribers.size).toBe(2); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.callbackSubs)).toStrictEqual([ + callbackSubscriptionContainer, + ]); - expect(subController.callbackSubs.size).toBe(1); + // TODO find a way to spy on a class constructor without overwriting it. + // https://stackoverflow.com/questions/48219267/how-to-spy-on-a-class-constructor-jest/48486214 + // Because the below tests are not really related to this test. + // They are checking if the CallbackSubscriptionContainer was called with the correct parameters + // by checking if the CallbackSubscriptionContainer has correctly set properties. + // Note: This 'issue' happens in multiple parts of the AgileTs test! + expect(callbackSubscriptionContainer.key).toBe('generatedKey'); + expect(callbackSubscriptionContainer.componentId).toBeUndefined(); expect( - subController.callbackSubs.has(callbackSubscriptionContainer) - ).toBeTruthy(); + Array.from(callbackSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); - it('should return callbackSubscriptionContainer (specific config)', () => { + it('should return Callback based Subscription Container (specific config)', () => { const dummyIntegration = () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.registerCallbackSubscription( + const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], { waitForMount: false, - proxyKeyMap: { jeff: { paths: [[]] } }, - key: 'jeff', + componentId: 'testID', + key: 'dummyKey', } ); @@ -580,24 +534,17 @@ describe('SubController Tests', () => { ); expect(callbackSubscriptionContainer.callback).toBe(dummyIntegration); expect(callbackSubscriptionContainer.ready).toBeTruthy(); - expect(callbackSubscriptionContainer.key).toBe('jeff'); - expect(callbackSubscriptionContainer.proxyKeyMap).toStrictEqual({ - jeff: { paths: [[]] }, - }); - expect(callbackSubscriptionContainer.proxyBased).toBeTruthy(); - expect(callbackSubscriptionContainer.subscribers.size).toBe(2); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.callbackSubs)).toStrictEqual([ + callbackSubscriptionContainer, + ]); - expect(subController.callbackSubs.size).toBe(1); + // Check if CallbackSubscriptionContainer was called with correct parameters + expect(callbackSubscriptionContainer.key).toBe('dummyKey'); + expect(callbackSubscriptionContainer.componentId).toBe('testID'); expect( - subController.callbackSubs.has(callbackSubscriptionContainer) - ).toBeTruthy(); + Array.from(callbackSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); }); @@ -609,21 +556,24 @@ describe('SubController Tests', () => { beforeEach(() => { dummyAgile.config.waitForMount = true; - componentSubscriptionContainer = subController.registerComponentSubscription( + componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); }); - it('should add componentInstance to mountedComponents and set its subscriptionContainer to ready', () => { - subController.mount(dummyIntegration); - - expect(componentSubscriptionContainer.ready).toBeTruthy(); - expect(subController.mountedComponents.size).toBe(1); - expect( - subController.mountedComponents.has(dummyIntegration) - ).toBeTruthy(); - }); + it( + "should add specified 'componentInstance' to the 'mountedComponents' " + + 'and set the Subscription Container representing the mounted Component to ready', + () => { + subController.mount(dummyIntegration); + + expect(componentSubscriptionContainer.ready).toBeTruthy(); + expect(Array.from(subController.mountedComponents)).toStrictEqual([ + dummyIntegration, + ]); + } + ); }); describe('unmount function tests', () => { @@ -634,19 +584,23 @@ describe('SubController Tests', () => { beforeEach(() => { dummyAgile.config.waitForMount = true; - componentSubscriptionContainer = subController.registerComponentSubscription( + componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); subController.mount(dummyIntegration); }); - it('should remove componentInstance from mountedComponents and set its subscriptionContainer to not ready', () => { - subController.unmount(dummyIntegration); + it( + "should remove specified 'componentInstance' to the 'mountedComponents' " + + 'and set the Subscription Container representing the mounted Component to not ready', + () => { + subController.unmount(dummyIntegration); - expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(subController.mountedComponents.size).toBe(0); - }); + expect(componentSubscriptionContainer.ready).toBeFalsy(); + expect(Array.from(subController.mountedComponents)).toStrictEqual([]); + } + ); }); }); }); diff --git a/packages/core/tests/unit/state/state.observer.test.ts b/packages/core/tests/unit/state/state.observer.test.ts index e187a34b..a8a102d3 100644 --- a/packages/core/tests/unit/state/state.observer.test.ts +++ b/packages/core/tests/unit/state/state.observer.test.ts @@ -8,6 +8,7 @@ import { StatePersistent, SubscriptionContainer, } from '../../../src'; +import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../helper/logMock'; describe('StateObserver Tests', () => { @@ -22,7 +23,7 @@ describe('StateObserver Tests', () => { dummyState = new State(dummyAgile, 'dummyValue', { key: 'dummyState' }); }); - it('should create StateObserver (default config)', () => { + it('should create State Observer (default config)', () => { const stateObserver = new StateObserver(dummyState); expect(stateObserver).toBeInstanceOf(StateObserver); @@ -31,15 +32,15 @@ describe('StateObserver Tests', () => { expect(stateObserver.value).toBe('dummyValue'); expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver._key).toBeUndefined(); - expect(stateObserver.dependents.size).toBe(0); - expect(stateObserver.subscribedTo.size).toBe(0); + expect(Array.from(stateObserver.dependents)).toStrictEqual([]); + expect(Array.from(stateObserver.subscribedTo)).toStrictEqual([]); }); - it('should create StateObserver (specific config)', () => { + it('should create State 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 dummySubscription1 = new SubscriptionContainer([]); + const dummySubscription2 = new SubscriptionContainer([]); const stateObserver = new StateObserver(dummyState, { key: 'testKey', @@ -53,15 +54,17 @@ describe('StateObserver Tests', () => { expect(stateObserver.value).toBe('dummyValue'); expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver._key).toBe('testKey'); - expect(stateObserver.dependents.size).toBe(2); - expect(stateObserver.dependents.has(dummyObserver2)).toBeTruthy(); - expect(stateObserver.dependents.has(dummyObserver1)).toBeTruthy(); - expect(stateObserver.subscribedTo.size).toBe(2); - expect(stateObserver.subscribedTo.has(dummySubscription1)).toBeTruthy(); - expect(stateObserver.subscribedTo.has(dummySubscription2)).toBeTruthy(); + expect(Array.from(stateObserver.dependents)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(stateObserver.subscribedTo)).toStrictEqual([ + dummySubscription1, + dummySubscription2, + ]); }); - describe('StateObserver Function Tests', () => { + describe('State Observer Function Tests', () => { let stateObserver: StateObserver; beforeEach(() => { @@ -100,7 +103,7 @@ describe('StateObserver Tests', () => { expect(stateObserver.ingestValue).toHaveBeenCalledWith('nextValue', {}); }); - it('should call ingestValue with nextStateValue (specific config)', () => { + it("should call 'ingestValue' with the 'nextStateValue' (specific config)", () => { dummyState.nextStateValue = 'nextValue'; stateObserver.ingest({ @@ -126,17 +129,21 @@ describe('StateObserver Tests', () => { }); }); - it('should call ingestValue with computedValue if Observer belongs to a ComputedState (default config)', () => { - dummyComputed.compute = jest.fn(() => 'computedValue'); - - computedObserver.ingest(); - - expect(computedObserver.ingestValue).toHaveBeenCalledWith( - 'computedValue', - {} - ); - expect(dummyComputed.compute).toHaveBeenCalled(); - }); + it( + "should call 'ingestValue' with computed value " + + 'if Observer belongs to a Computed State (default config)', + () => { + dummyComputed.compute = jest.fn(() => 'computedValue'); + + computedObserver.ingest(); + + expect(computedObserver.ingestValue).toHaveBeenCalledWith( + 'computedValue', + {} + ); + expect(dummyComputed.compute).toHaveBeenCalled(); + } + ); }); describe('ingestValue function tests', () => { @@ -144,107 +151,127 @@ describe('StateObserver Tests', () => { dummyAgile.runtime.ingest = jest.fn(); }); - it("should ingest State into Runtime if newValue isn't equal to currentValue (default config)", () => { - dummyAgile.runtime.ingest = jest.fn((job: StateRuntimeJob) => { - expect(job._key).toBe(stateObserver._key); - expect(job.observer).toBe(stateObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - force: false, - storage: true, - overwrite: false, + it( + 'should ingest the State 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: StateRuntimeJob) => { + expect(job._key).toBe(`${stateObserver._key}_randomKey`); + expect(job.observer).toBe(stateObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + storage: true, + overwrite: false, + }); }); - }); - - stateObserver.ingestValue('updatedDummyValue'); - expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); - expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( - expect.any(StateRuntimeJob), - { - perform: true, - } - ); - }); + stateObserver.ingestValue('updatedDummyValue'); + + expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(StateRuntimeJob), + { + perform: true, + } + ); + } + ); + + it( + 'should ingest the State into the Runtime ' + + "if the new value isn't equal to the current value (specific config)", + () => { + dummyAgile.runtime.ingest = jest.fn((job: StateRuntimeJob) => { + expect(job._key).toBe('dummyJob'); + expect(job.observer).toBe(stateObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: false, + }, + force: true, + storage: true, + overwrite: true, + }); + }); - it("should ingest State into Runtime if newValue isn't equal to currentValue (specific config)", () => { - dummyAgile.runtime.ingest = jest.fn((job: StateRuntimeJob) => { - expect(job._key).toBe('dummyJob'); - expect(job.observer).toBe(stateObserver); - expect(job.config).toStrictEqual({ - background: false, + stateObserver.ingestValue('updatedDummyValue', { + perform: false, + force: true, sideEffects: { enabled: false, }, - force: true, - storage: true, overwrite: true, + key: 'dummyJob', }); - }); - - stateObserver.ingestValue('updatedDummyValue', { - perform: false, - force: true, - sideEffects: { - enabled: false, - }, - overwrite: true, - key: 'dummyJob', - }); - - expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); - expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( - expect.any(StateRuntimeJob), - { - perform: false, - } - ); - }); - - it("shouldn't ingest State into Runtime if newValue is equal to currentValue (default config)", () => { - dummyState._value = 'updatedDummyValue'; - - stateObserver.ingestValue('updatedDummyValue'); - expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); - expect(dummyAgile.runtime.ingest).not.toHaveBeenCalled(); - }); - - it('should ingest State into Runtime if newValue is equal to currentValue (config.force = true)', () => { - dummyState._value = 'updatedDummyValue'; - dummyAgile.runtime.ingest = jest.fn((job: StateRuntimeJob) => { - expect(job._key).toBe(stateObserver._key); - expect(job.observer).toBe(stateObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - force: true, - storage: true, - overwrite: false, + expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(StateRuntimeJob), + { + perform: false, + } + ); + } + ); + + it( + "shouldn't ingest the State into the Runtime " + + 'if the new value is equal to the current value (default config)', + () => { + dummyState._value = 'updatedDummyValue'; + + stateObserver.ingestValue('updatedDummyValue'); + + expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); + expect(dummyAgile.runtime.ingest).not.toHaveBeenCalled(); + } + ); + + it( + 'should ingest the State into the Runtime ' + + 'if the new value is equal to the current value (config.force = true)', + () => { + 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.observer).toBe(stateObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: true, + storage: true, + overwrite: false, + }); }); - }); - stateObserver.ingestValue('updatedDummyValue', { force: true }); + stateObserver.ingestValue('updatedDummyValue', { force: true }); - expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); - expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( - expect.any(StateRuntimeJob), - { - perform: true, - } - ); - }); + expect(stateObserver.nextStateValue).toBe('updatedDummyValue'); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(StateRuntimeJob), + { + perform: true, + } + ); + } + ); - it('should ingest placeholder State into Runtime (default config)', () => { + 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); + expect(job._key).toBe(`${stateObserver._key}_randomKey`); expect(job.observer).toBe(stateObserver); expect(job.config).toStrictEqual({ background: false, @@ -270,21 +297,25 @@ describe('StateObserver Tests', () => { ); }); - it('should ingest State into Runtime and compute newStateValue if State compute Function is set (default config)', () => { - dummyState.computeValueMethod = (value) => `cool value '${value}'`; - - stateObserver.ingestValue('updatedDummyValue'); - - expect(stateObserver.nextStateValue).toBe( - "cool value 'updatedDummyValue'" - ); - expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( - expect.any(StateRuntimeJob), - { - perform: true, - } - ); - }); + it( + 'should ingest the State into the Runtime and compute the new value ' + + 'if the State compute function is set (default config)', + () => { + dummyState.computeValueMethod = (value) => `cool value '${value}'`; + + stateObserver.ingestValue('updatedDummyValue'); + + expect(stateObserver.nextStateValue).toBe( + "cool value 'updatedDummyValue'" + ); + expect(dummyAgile.runtime.ingest).toHaveBeenCalledWith( + expect.any(StateRuntimeJob), + { + perform: true, + } + ); + } + ); }); describe('perform function tests', () => { @@ -300,13 +331,13 @@ describe('StateObserver Tests', () => { stateObserver.sideEffects = jest.fn(); }); - it('should perform Job', () => { + it('should perform the specified Job', () => { dummyJob.observer.nextStateValue = 'newValue'; + dummyJob.observer.value = 'dummyValue'; dummyState.initialStateValue = 'initialValue'; dummyState._value = 'dummyValue'; dummyState.getPublicValue = jest .fn() - .mockReturnValueOnce('previousPublicValue') .mockReturnValueOnce('newPublicValue'); stateObserver.perform(dummyJob); @@ -316,20 +347,21 @@ describe('StateObserver Tests', () => { expect(dummyState._value).toBe('newValue'); expect(dummyState.nextStateValue).toBe('newValue'); expect(dummyState.isSet).toBeTruthy(); + expect(stateObserver.value).toBe('newPublicValue'); - expect(stateObserver.previousValue).toBe('previousPublicValue'); + expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver.sideEffects).toHaveBeenCalledWith(dummyJob); }); - it('should perform Job and overwrite State (job.config.overwrite = true)', () => { + it('should perform the specified Job and overwrite the State it represents (job.config.overwrite = true)', () => { dummyJob.observer.nextStateValue = 'newValue'; + dummyJob.observer.value = 'dummyValue'; dummyJob.config.overwrite = true; dummyState.isPlaceholder = true; dummyState.initialStateValue = 'overwriteValue'; dummyState._value = 'dummyValue'; dummyState.getPublicValue = jest .fn() - .mockReturnValueOnce('previousPublicValue') .mockReturnValueOnce('newPublicValue'); stateObserver.perform(dummyJob); @@ -340,31 +372,37 @@ describe('StateObserver Tests', () => { expect(dummyState.nextStateValue).toBe('newValue'); expect(dummyState.isSet).toBeFalsy(); expect(dummyState.isPlaceholder).toBeFalsy(); - expect(stateObserver.value).toBe('newPublicValue'); - expect(stateObserver.previousValue).toBe('previousPublicValue'); - expect(stateObserver.sideEffects).toHaveBeenCalledWith(dummyJob); - }); - it('should perform Job and set isSet to false if initialStateValue equals to newStateValue', () => { - dummyJob.observer.nextStateValue = 'newValue'; - dummyState.initialStateValue = 'newValue'; - dummyState._value = 'dummyValue'; - dummyState.getPublicValue = jest - .fn() - .mockReturnValueOnce('previousPublicValue') - .mockReturnValueOnce('newPublicValue'); - - stateObserver.perform(dummyJob); - - expect(dummyState.previousStateValue).toBe('dummyValue'); - expect(dummyState.initialStateValue).toBe('newValue'); - expect(dummyState._value).toBe('newValue'); - expect(dummyState.nextStateValue).toBe('newValue'); - expect(dummyState.isSet).toBeFalsy(); expect(stateObserver.value).toBe('newPublicValue'); - expect(stateObserver.previousValue).toBe('previousPublicValue'); + expect(stateObserver.previousValue).toBe('dummyValue'); expect(stateObserver.sideEffects).toHaveBeenCalledWith(dummyJob); }); + + it( + "should perform the specified Job and set 'isSet' to false " + + 'if the initial State value is equal to the new State value', + () => { + dummyJob.observer.nextStateValue = 'newValue'; + dummyJob.observer.value = 'dummyValue'; + dummyState.initialStateValue = 'newValue'; + dummyState._value = 'dummyValue'; + dummyState.getPublicValue = jest + .fn() + .mockReturnValueOnce('newPublicValue'); + + stateObserver.perform(dummyJob); + + expect(dummyState.previousStateValue).toBe('dummyValue'); + expect(dummyState.initialStateValue).toBe('newValue'); + expect(dummyState._value).toBe('newValue'); + expect(dummyState.nextStateValue).toBe('newValue'); + expect(dummyState.isSet).toBeFalsy(); + + expect(stateObserver.value).toBe('newPublicValue'); + expect(stateObserver.previousValue).toBe('dummyValue'); + expect(stateObserver.sideEffects).toHaveBeenCalledWith(dummyJob); + } + ); }); describe('sideEffects function tests', () => { @@ -398,8 +436,9 @@ describe('StateObserver Tests', () => { }; }); - it('should call watchers, sideEffects and ingest dependencies of State', () => { + it('should call watcher callbacks and State side effect', () => { dummyState._value = 'dummyValue'; + stateObserver.sideEffects(dummyJob); expect(dummyState.watchers['dummyWatcher']).toHaveBeenCalledWith( @@ -422,27 +461,64 @@ describe('StateObserver Tests', () => { ]); }); - it("should call watchers, ingest dependencies of State and shouldn't call sideEffects (job.config.sideEffects = false)", () => { - dummyState._value = 'dummyValue'; - dummyJob.config.sideEffects = { - enabled: false, - }; - stateObserver.sideEffects(dummyJob); - - expect(dummyState.watchers['dummyWatcher']).toHaveBeenCalledWith( - 'dummyValue', - 'dummyWatcher' - ); - expect( - dummyState.sideEffects['dummySideEffect'].callback - ).not.toHaveBeenCalled(); - expect( - dummyState.sideEffects['dummySideEffect2'].callback - ).not.toHaveBeenCalled(); - expect( - dummyState.sideEffects['dummySideEffect3'].callback - ).not.toHaveBeenCalled(); - }); + it( + 'should call watcher callbacks ' + + "and shouldn't call State side effects (job.config.sideEffects.enabled = false)", + () => { + dummyState._value = 'dummyValue'; + dummyJob.config.sideEffects = { + enabled: false, + }; + + stateObserver.sideEffects(dummyJob); + + expect(dummyState.watchers['dummyWatcher']).toHaveBeenCalledWith( + 'dummyValue', + 'dummyWatcher' + ); + expect( + dummyState.sideEffects['dummySideEffect'].callback + ).not.toHaveBeenCalled(); + expect( + dummyState.sideEffects['dummySideEffect2'].callback + ).not.toHaveBeenCalled(); + expect( + dummyState.sideEffects['dummySideEffect3'].callback + ).not.toHaveBeenCalled(); + } + ); + + it( + 'should call watcher callbacks ' + + "and shouldn't call all State side effects (job.config.sideEffects.exclude = ['dummySideEffect2'])", + () => { + dummyState._value = 'dummyValue'; + dummyJob.config.sideEffects = { + enabled: true, + exclude: ['dummySideEffect2'], + }; + + stateObserver.sideEffects(dummyJob); + + expect(dummyState.watchers['dummyWatcher']).toHaveBeenCalledWith( + 'dummyValue', + 'dummyWatcher' + ); + expect( + dummyState.sideEffects['dummySideEffect'].callback + ).toHaveBeenCalledWith(dummyState, dummyJob.config); + expect( + dummyState.sideEffects['dummySideEffect2'].callback + ).not.toHaveBeenCalled(); + expect( + dummyState.sideEffects['dummySideEffect3'].callback + ).toHaveBeenCalledWith(dummyState, dummyJob.config); + expect(sideEffectCallOrder).toStrictEqual([ + 'dummySideEffect3', + 'dummySideEffect', + ]); + } + ); }); }); }); diff --git a/packages/core/tests/unit/state/state.persistent.test.ts b/packages/core/tests/unit/state/state.persistent.test.ts index ded895be..1fc7d061 100644 --- a/packages/core/tests/unit/state/state.persistent.test.ts +++ b/packages/core/tests/unit/state/state.persistent.test.ts @@ -133,73 +133,6 @@ describe('StatePersistent Tests', () => { ); }); - describe('setKey function tests', () => { - beforeEach(() => { - statePersistent.removePersistedValue = jest.fn(); - statePersistent.persistValue = jest.fn(); - statePersistent.initialLoading = jest.fn(); - jest.spyOn(statePersistent, 'validatePersistent'); - }); - - it('should update key with valid key in ready Persistent', async () => { - statePersistent.ready = true; - statePersistent._key = 'dummyKey'; - - await statePersistent.setKey('newKey'); - - expect(statePersistent._key).toBe('newKey'); - expect(statePersistent.ready).toBeTruthy(); - expect(statePersistent.validatePersistent).toHaveBeenCalled(); - expect(statePersistent.initialLoading).not.toHaveBeenCalled(); - expect(statePersistent.persistValue).toHaveBeenCalledWith('newKey'); - expect(statePersistent.removePersistedValue).toHaveBeenCalledWith( - 'dummyKey' - ); - }); - - it('should update key with not valid key in ready Persistent', async () => { - statePersistent.ready = true; - statePersistent._key = 'dummyKey'; - - await statePersistent.setKey(); - - expect(statePersistent._key).toBe(StatePersistent.placeHolderKey); - expect(statePersistent.ready).toBeFalsy(); - expect(statePersistent.validatePersistent).toHaveBeenCalled(); - expect(statePersistent.initialLoading).not.toHaveBeenCalled(); - expect(statePersistent.persistValue).not.toHaveBeenCalled(); - expect(statePersistent.removePersistedValue).toHaveBeenCalledWith( - 'dummyKey' - ); - }); - - it('should update key with valid key in not ready Persistent', async () => { - statePersistent.ready = false; - - await statePersistent.setKey('newKey'); - - expect(statePersistent._key).toBe('newKey'); - expect(statePersistent.ready).toBeTruthy(); - expect(statePersistent.validatePersistent).toHaveBeenCalled(); - expect(statePersistent.initialLoading).toHaveBeenCalled(); - expect(statePersistent.persistValue).not.toHaveBeenCalled(); - expect(statePersistent.removePersistedValue).not.toHaveBeenCalled(); - }); - - it('should update key with not valid key in not ready Persistent', async () => { - statePersistent.ready = false; - - await statePersistent.setKey(); - - expect(statePersistent._key).toBe(StatePersistent.placeHolderKey); - expect(statePersistent.ready).toBeFalsy(); - expect(statePersistent.validatePersistent).toHaveBeenCalled(); - expect(statePersistent.initialLoading).not.toHaveBeenCalled(); - expect(statePersistent.persistValue).not.toHaveBeenCalled(); - expect(statePersistent.removePersistedValue).not.toHaveBeenCalled(); - }); - }); - describe('initialLoading function tests', () => { beforeEach(() => { jest.spyOn(Persistent.prototype, 'initialLoading'); @@ -216,101 +149,117 @@ describe('StatePersistent Tests', () => { describe('loadPersistedValue function tests', () => { beforeEach(() => { dummyState.set = jest.fn(); - statePersistent.persistValue = jest.fn(); + statePersistent.setupSideEffects = jest.fn(); }); - it('should load State Value with persistentKey and apply it to the State if loading was successful', async () => { - statePersistent.ready = true; - dummyAgile.storages.get = jest.fn(() => - Promise.resolve('dummyValue' as any) - ); + it( + 'should load State value with Persistent key from the corresponding Storage ' + + 'and apply it to the State if the loading was successful', + async () => { + statePersistent.ready = true; + dummyAgile.storages.get = jest.fn(() => + Promise.resolve('dummyValue' as any) + ); - const response = await statePersistent.loadPersistedValue(); + const response = await statePersistent.loadPersistedValue(); - expect(response).toBeTruthy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - statePersistent._key, - statePersistent.config.defaultStorageKey - ); - expect(dummyState.set).toHaveBeenCalledWith('dummyValue', { - storage: false, - }); - expect(statePersistent.persistValue).toHaveBeenCalledWith( - statePersistent._key - ); - }); + expect(response).toBeTruthy(); + expect(dummyAgile.storages.get).toHaveBeenCalledWith( + statePersistent._key, + statePersistent.config.defaultStorageKey + ); + expect(dummyState.set).toHaveBeenCalledWith('dummyValue', { + storage: false, + overwrite: true, + }); + expect(statePersistent.setupSideEffects).toHaveBeenCalledWith( + statePersistent._key + ); + } + ); - it("should load State Value with persistentKey and shouldn't apply it to the State if loading wasn't successful", async () => { - statePersistent.ready = true; - dummyAgile.storages.get = jest.fn(() => - Promise.resolve(undefined as any) - ); + it( + "shouldn't load State value with Persistent key from the corresponding Storage " + + "and apply it to the State if the loading wasn't successful", + async () => { + statePersistent.ready = true; + dummyAgile.storages.get = jest.fn(() => + Promise.resolve(undefined as any) + ); - const response = await statePersistent.loadPersistedValue(); + const response = await statePersistent.loadPersistedValue(); - expect(response).toBeFalsy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - statePersistent._key, - statePersistent.config.defaultStorageKey - ); - expect(dummyState.set).not.toHaveBeenCalled(); - expect(statePersistent.persistValue).not.toHaveBeenCalled(); - }); + expect(response).toBeFalsy(); + expect(dummyAgile.storages.get).toHaveBeenCalledWith( + statePersistent._key, + statePersistent.config.defaultStorageKey + ); + expect(dummyState.set).not.toHaveBeenCalled(); + expect(statePersistent.setupSideEffects).not.toHaveBeenCalled(); + } + ); - it('should load State Value with specific Key and apply it to the State if loading was successful', async () => { - statePersistent.ready = true; - dummyAgile.storages.get = jest.fn(() => - Promise.resolve('dummyValue' as any) - ); + it( + 'should load State value with specified key from the corresponding Storage ' + + 'and apply it to the State if the loading was successful', + async () => { + statePersistent.ready = true; + dummyAgile.storages.get = jest.fn(() => + Promise.resolve('dummyValue' as any) + ); - const response = await statePersistent.loadPersistedValue('coolKey'); + const response = await statePersistent.loadPersistedValue('coolKey'); - expect(response).toBeTruthy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( - 'coolKey', - statePersistent.config.defaultStorageKey - ); - expect(dummyState.set).toHaveBeenCalledWith('dummyValue', { - storage: false, - }); - expect(statePersistent.persistValue).toHaveBeenCalledWith('coolKey'); - }); + expect(response).toBeTruthy(); + expect(dummyAgile.storages.get).toHaveBeenCalledWith( + 'coolKey', + statePersistent.config.defaultStorageKey + ); + expect(dummyState.set).toHaveBeenCalledWith('dummyValue', { + storage: false, + overwrite: true, + }); + expect(statePersistent.setupSideEffects).toHaveBeenCalledWith( + 'coolKey' + ); + } + ); - it("shouldn't load State Value if Persistent isn't ready", async () => { - statePersistent.ready = false; - dummyAgile.storages.get = jest.fn(() => - Promise.resolve(undefined as any) - ); + it( + "shouldn't load State value from the corresponding Storage " + + "if Persistent isn't ready yet", + async () => { + statePersistent.ready = false; + dummyAgile.storages.get = jest.fn(() => + Promise.resolve(undefined as any) + ); - const response = await statePersistent.loadPersistedValue(); + const response = await statePersistent.loadPersistedValue(); - expect(response).toBeFalsy(); - expect(dummyAgile.storages.get).not.toHaveBeenCalled(); - expect(dummyState.set).not.toHaveBeenCalled(); - expect(statePersistent.persistValue).not.toHaveBeenCalled(); - }); + expect(response).toBeFalsy(); + expect(dummyAgile.storages.get).not.toHaveBeenCalled(); + expect(dummyState.set).not.toHaveBeenCalled(); + expect(statePersistent.setupSideEffects).not.toHaveBeenCalled(); + } + ); }); describe('persistValue function tests', () => { beforeEach(() => { - dummyState.addSideEffect = jest.fn(); + statePersistent.setupSideEffects = jest.fn(); statePersistent.rebuildStorageSideEffect = jest.fn(); statePersistent.isPersisted = false; }); - it('should persist State with persistentKey', async () => { + it('should persist State value with Persistent key', async () => { statePersistent.ready = true; const response = await statePersistent.persistValue(); expect(response).toBeTruthy(); - expect( - dummyState.addSideEffect - ).toHaveBeenCalledWith( - StatePersistent.storeValueSideEffectKey, - expect.any(Function), - { weight: 0 } + expect(statePersistent.setupSideEffects).toHaveBeenCalledWith( + statePersistent._key ); expect(statePersistent.rebuildStorageSideEffect).toHaveBeenCalledWith( dummyState, @@ -319,18 +268,14 @@ describe('StatePersistent Tests', () => { expect(statePersistent.isPersisted).toBeTruthy(); }); - it('should persist State with specific Key', async () => { + it('should persist State value with specified key', async () => { statePersistent.ready = true; const response = await statePersistent.persistValue('coolKey'); expect(response).toBeTruthy(); - expect( - dummyState.addSideEffect - ).toHaveBeenCalledWith( - StatePersistent.storeValueSideEffectKey, - expect.any(Function), - { weight: 0 } + expect(statePersistent.setupSideEffects).toHaveBeenCalledWith( + 'coolKey' ); expect(statePersistent.rebuildStorageSideEffect).toHaveBeenCalledWith( dummyState, @@ -339,24 +284,46 @@ describe('StatePersistent Tests', () => { expect(statePersistent.isPersisted).toBeTruthy(); }); - it("shouldn't persist State if Persistent isn't ready", async () => { + it("shouldn't persist State if Persistent isn't ready yet", async () => { statePersistent.ready = false; const response = await statePersistent.persistValue(); expect(response).toBeFalsy(); - expect(dummyState.addSideEffect).not.toHaveBeenCalled(); + expect(statePersistent.setupSideEffects).not.toHaveBeenCalled(); expect(statePersistent.rebuildStorageSideEffect).not.toHaveBeenCalled(); expect(statePersistent.isPersisted).toBeFalsy(); }); + }); + + describe('setupSideEffects function tests', () => { + beforeEach(() => { + jest.spyOn(dummyState, 'addSideEffect'); + }); + + it( + 'should add side effects for keeping the State value in sync ' + + 'with the Storage value to the State', + () => { + statePersistent.setupSideEffects(); - describe('test added sideEffect called StatePersistent.storeValueSideEffectKey', () => { + expect( + dummyState.addSideEffect + ).toHaveBeenCalledWith( + StatePersistent.storeValueSideEffectKey, + expect.any(Function), + { weight: 0 } + ); + } + ); + + describe("test added sideEffect called 'StatePersistent.storeValueSideEffectKey'", () => { beforeEach(() => { statePersistent.rebuildStorageSideEffect = jest.fn(); }); - it('should call rebuildStorageSideEffect', async () => { - await statePersistent.persistValue(); + it("should call 'rebuildStorageSideEffect' (persistentKey)", async () => { + await statePersistent.setupSideEffects(); dummyState.sideEffects[ StatePersistent.storeValueSideEffectKey @@ -372,6 +339,24 @@ describe('StatePersistent Tests', () => { } ); }); + + it("should call 'rebuildStorageSideEffect' (specified key)", async () => { + await statePersistent.setupSideEffects('dummyKey'); + + dummyState.sideEffects[ + StatePersistent.storeValueSideEffectKey + ].callback(dummyState, { + dummy: 'property', + }); + + expect(statePersistent.rebuildStorageSideEffect).toHaveBeenCalledWith( + dummyState, + 'dummyKey', + { + dummy: 'property', + } + ); + }); }); }); @@ -383,7 +368,7 @@ describe('StatePersistent Tests', () => { statePersistent.isPersisted = true; }); - it('should remove persisted State from Storage with persistentKey', async () => { + it('should remove persisted State value from the corresponding Storage with Persistent key', async () => { statePersistent.ready = true; const response = await statePersistent.removePersistedValue(); @@ -399,7 +384,7 @@ describe('StatePersistent Tests', () => { expect(statePersistent.isPersisted).toBeFalsy(); }); - it('should remove persisted State from Storage with specific Key', async () => { + it('should remove persisted State from the corresponding Storage with specified key', async () => { statePersistent.ready = true; const response = await statePersistent.removePersistedValue('coolKey'); @@ -415,7 +400,7 @@ describe('StatePersistent Tests', () => { expect(statePersistent.isPersisted).toBeFalsy(); }); - it("shouldn't remove State from Storage if Persistent isn't ready", async () => { + it("shouldn't remove State from the corresponding Storage if Persistent isn't ready yet", async () => { statePersistent.ready = false; const response = await statePersistent.removePersistedValue('coolKey'); @@ -428,15 +413,15 @@ describe('StatePersistent Tests', () => { }); describe('formatKey function tests', () => { - it('should return key of State if no key got passed', () => { + it('should return key of the State if no valid key was specified', () => { dummyState._key = 'coolKey'; - const response = statePersistent.formatKey(); + const response = statePersistent.formatKey(undefined); expect(response).toBe('coolKey'); }); - it('should return passed key', () => { + it('should return specified key', () => { dummyState._key = 'coolKey'; const response = statePersistent.formatKey('awesomeKey'); @@ -444,7 +429,7 @@ describe('StatePersistent Tests', () => { expect(response).toBe('awesomeKey'); }); - it('should return and apply passed key to State if State had no own key before', () => { + it('should return and apply specified key to State if State had no own valid key before', () => { dummyState._key = undefined; const response = statePersistent.formatKey('awesomeKey'); @@ -453,10 +438,10 @@ describe('StatePersistent Tests', () => { expect(dummyState._key).toBe('awesomeKey'); }); - it('should return undefined if no key got passed and State has no key', () => { + it('should return undefined if no valid key was specified and State has no valid key either', () => { dummyState._key = undefined; - const response = statePersistent.formatKey(); + const response = statePersistent.formatKey(undefined); expect(response).toBeUndefined(); }); @@ -467,7 +452,7 @@ describe('StatePersistent Tests', () => { dummyAgile.storages.set = jest.fn(); }); - it('should save State Value in Storage (default config)', () => { + it('should store current State value in the corresponding Storage (default config)', () => { statePersistent.rebuildStorageSideEffect(dummyState, 'coolKey'); expect(dummyAgile.storages.set).toHaveBeenCalledWith( @@ -477,7 +462,7 @@ describe('StatePersistent Tests', () => { ); }); - it("shouldn't save State Value in Storage (config.storage = false)", () => { + it("shouldn't store State value in the corresponding Storage (config.storage = false)", () => { statePersistent.rebuildStorageSideEffect(dummyState, 'coolKey', { storage: false, }); 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 24ded7f3..c5465182 100644 --- a/packages/core/tests/unit/state/state.runtime.job.test.ts +++ b/packages/core/tests/unit/state/state.runtime.job.test.ts @@ -27,94 +27,118 @@ describe('RuntimeJob Tests', () => { dummyObserver = new StateObserver(dummyState); }); - it('should create RuntimeJob with Agile that has integrations (default config)', () => { - dummyAgile.integrate(dummyIntegration); + it( + 'should create StateRuntimeJob ' + + 'with a specified Agile Instance that has a registered Integration (default config)', + () => { + dummyAgile.integrate(dummyIntegration); - const job = new StateRuntimeJob(dummyObserver); + const job = new StateRuntimeJob(dummyObserver); - expect(job._key).toBeUndefined(); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - force: false, - storage: true, - overwrite: false, - }); - expect(job.rerender).toBeTruthy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - }); + expect(job._key).toBeUndefined(); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + storage: true, + overwrite: false, + }); + expect(job.rerender).toBeTruthy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.performed).toBeFalsy(); + } + ); - it('should create RuntimeJob with Agile that has integrations (specific config)', () => { - dummyAgile.integrate(dummyIntegration); + it( + 'should create StateRuntimeJob ' + + 'with a specified Agile Instance that has a registered Integration (specific config)', + () => { + dummyAgile.integrate(dummyIntegration); - const job = new StateRuntimeJob(dummyObserver, { - key: 'dummyJob', - sideEffects: { - enabled: false, - }, - force: true, - }); + const job = new StateRuntimeJob(dummyObserver, { + key: 'dummyJob', + sideEffects: { + enabled: false, + }, + force: true, + }); - expect(job._key).toBe('dummyJob'); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: false, - }, - force: true, - storage: true, - overwrite: false, - }); - expect(job.rerender).toBeTruthy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - }); + expect(job._key).toBe('dummyJob'); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: false, + }, + force: true, + storage: true, + overwrite: false, + }); + expect(job.rerender).toBeTruthy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.performed).toBeFalsy(); + } + ); - it('should create RuntimeJob with Agile that has no integrations (default config)', () => { - const job = new StateRuntimeJob(dummyObserver); + it( + 'should create StateRuntimeJob ' + + 'with a specified Agile Instance that has no registered Integration (default config)', + () => { + const job = new StateRuntimeJob(dummyObserver); - expect(job._key).toBeUndefined(); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - force: false, - storage: true, - overwrite: false, - }); - expect(job.rerender).toBeFalsy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - }); + expect(job._key).toBeUndefined(); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: false, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + storage: true, + overwrite: false, + }); + expect(job.rerender).toBeFalsy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + expect(job.performed).toBeFalsy(); + } + ); - it('should create RuntimeJob and Agile that has integrations (config.background = true)', () => { - dummyAgile.integrate(dummyIntegration); + it( + 'should create StateRuntimeJob ' + + 'with a specified Agile Instance that has a registered Integrations (config.background = true)', + () => { + dummyAgile.integrate(dummyIntegration); - const job = new StateRuntimeJob(dummyObserver, { background: true }); + const job = new StateRuntimeJob(dummyObserver, { background: true }); - expect(job._key).toBeUndefined(); - expect(job.observer).toBe(dummyObserver); - expect(job.config).toStrictEqual({ - background: true, - sideEffects: { - enabled: true, - exclude: [], - }, - force: false, - storage: true, - overwrite: false, - }); - expect(job.rerender).toBeFalsy(); - expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - }); + expect(job._key).toBeUndefined(); + expect(job.observer).toBe(dummyObserver); + expect(job.config).toStrictEqual({ + background: true, + sideEffects: { + enabled: true, + exclude: [], + }, + force: false, + storage: true, + overwrite: false, + }); + expect(job.rerender).toBeFalsy(); + expect(job.performed).toBeFalsy(); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.timesTriedToUpdateCount).toBe(0); + 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 66793053..b3914e79 100644 --- a/packages/core/tests/unit/state/state.test.ts +++ b/packages/core/tests/unit/state/state.test.ts @@ -404,24 +404,25 @@ describe('State Tests', () => { beforeEach(() => { objectState.ingest = jest.fn(); numberState.ingest = jest.fn(); + arrayState.ingest = jest.fn(); jest.spyOn(Utils, 'flatMerge'); }); - it("shouldn't patch and ingest passed object based value into a not object based State (default config)", () => { + it("shouldn't patch specified object value into a not object based State (default config)", () => { numberState.patch({ changed: 'object' }); LogMock.hasLoggedCode('14:03:02'); expect(objectState.ingest).not.toHaveBeenCalled(); }); - it("shouldn't patch and ingest passed not object based value into object based State (default config)", () => { + it("shouldn't patch specified non object value into a object based State (default config)", () => { objectState.patch('number' as any); LogMock.hasLoggedCode('00:03:01', ['TargetWithChanges', 'object']); expect(objectState.ingest).not.toHaveBeenCalled(); }); - it('should patch and ingest passed object based value into a object based State (default config)', () => { + it('should patch specified object value into a object based State (default config)', () => { objectState.patch({ name: 'frank' }); expect(Utils.flatMerge).toHaveBeenCalledWith( @@ -436,7 +437,7 @@ describe('State Tests', () => { expect(objectState.ingest).toHaveBeenCalledWith({}); }); - it('should patch and ingest passed object based value into a object based State (specific config)', () => { + it('should patch specified object value into a object based State (specific config)', () => { objectState.patch( { name: 'frank' }, { @@ -468,6 +469,30 @@ describe('State Tests', () => { }, }); }); + + it('should patch specified array value into a array based State (default config)', () => { + arrayState.patch(['hi']); + + expect(Utils.flatMerge).not.toHaveBeenCalled(); + expect(arrayState.nextStateValue).toStrictEqual(['jeff', 'hi']); + expect(arrayState.ingest).toHaveBeenCalledWith({}); + }); + + it('should patch specified array value into a object based State', () => { + objectState.patch(['hi'], { addNewProperties: true }); + + expect(Utils.flatMerge).toHaveBeenCalledWith( + { age: 10, name: 'jeff' }, + ['hi'], + { addNewProperties: true } + ); + expect(objectState.nextStateValue).toStrictEqual({ + 0: 'hi', + age: 10, + name: 'jeff', + }); + expect(objectState.ingest).toHaveBeenCalledWith({}); + }); }); describe('watch function tests', () => { @@ -507,17 +532,6 @@ describe('State Tests', () => { expect(numberState.watchers).not.toHaveProperty('dummyKey'); LogMock.hasLoggedCode('00:03:01', ['Watcher Callback', 'function']); }); - - it("shouldn't add passed watcherFunction to watchers at passed key if passed key is already occupied", () => { - numberState.watchers['dummyKey'] = dummyCallbackFunction2; - - const response = numberState.watch('dummyKey', dummyCallbackFunction1); - - expect(response).toBe(numberState); - expect(numberState.watchers).toHaveProperty('dummyKey'); - expect(numberState.watchers['dummyKey']).toBe(dummyCallbackFunction2); - LogMock.hasLoggedCode('14:03:03', ['dummyKey']); - }); }); describe('removeWatcher function tests', () => { @@ -638,20 +652,17 @@ describe('State Tests', () => { }); }); - it('should overwrite existing Persistent', () => { - const oldPersistent = new StatePersistent(numberState); - numberState.persistent = oldPersistent; + it("shouldn't overwrite existing Persistent", () => { + const dummyPersistent = new StatePersistent(numberState); + numberState.persistent = dummyPersistent; + numberState.isPersisted = true; + jest.clearAllMocks(); numberState.persist('newPersistentKey'); - expect(numberState.persistent).toBeInstanceOf(StatePersistent); + expect(numberState.persistent).toBe(dummyPersistent); // expect(numberState.persistent._key).toBe("newPersistentKey"); // Can not test because of Mocking Persistent - expect(StatePersistent).toHaveBeenCalledWith(numberState, { - instantiate: true, - storageKeys: [], - key: 'newPersistentKey', - defaultStorageKey: null, - }); + expect(StatePersistent).not.toHaveBeenCalled(); }); }); @@ -767,7 +778,7 @@ describe('State Tests', () => { 3000 ); expect(numberState.currentInterval).toStrictEqual(currentInterval); - LogMock.hasLoggedCode('14:03:04', [], numberState.currentInterval); + LogMock.hasLoggedCode('14:03:03', [], numberState.currentInterval); }); it("shouldn't set invalid interval callback function", () => { @@ -810,16 +821,6 @@ describe('State Tests', () => { }); }); - describe('copy function tests', () => { - it('should return a reference free copy of the current State Value', () => { - jest.spyOn(Utils, 'copy'); - const value = numberState.copy(); - - expect(value).toBe(10); - expect(Utils.copy).toHaveBeenCalledWith(10); - }); - }); - describe('exists get function tests', () => { it('should return true if State is no placeholder and computeExistsMethod returns true', () => { numberState.computeExistsMethod = jest.fn().mockReturnValueOnce(true); @@ -912,22 +913,55 @@ describe('State Tests', () => { }); describe('invert function tests', () => { + let dummyState: State; + beforeEach(() => { - numberState.set = jest.fn(); - booleanState.set = jest.fn(); + dummyState = new State(dummyAgile, null); + + dummyState.set = jest.fn(); }); - it('should invert current value of a boolean based State', () => { - booleanState.invert(); + it('should invert value of the type boolean', () => { + dummyState.nextStateValue = false; - expect(booleanState.set).toHaveBeenCalledWith(true); + dummyState.invert(); + + expect(dummyState.set).toHaveBeenCalledWith(true); }); - it("shouldn't invert current value if not boolean based State and should print a error", () => { - numberState.invert(); + it('should invert value of the type number', () => { + dummyState.nextStateValue = 10; - expect(numberState.set).not.toHaveBeenCalled(); - LogMock.hasLoggedCode('14:03:05'); + dummyState.invert(); + + expect(dummyState.set).toHaveBeenCalledWith(-10); + }); + + it('should invert value of the type array', () => { + dummyState.nextStateValue = ['1', '2', '3']; + + dummyState.invert(); + + expect(dummyState.set).toHaveBeenCalledWith(['3', '2', '1']); + }); + + it('should invert value of the type string', () => { + dummyState.nextStateValue = 'jeff'; + + dummyState.invert(); + + expect(dummyState.set).toHaveBeenCalledWith('ffej'); + }); + + it("shouldn't invert not invertible types like function, null, undefined, object", () => { + dummyState.nextStateValue = () => { + // empty + }; + + dummyState.invert(); + + expect(dummyState.set).not.toHaveBeenCalled(); + LogMock.hasLoggedCode('14:03:04', ['function']); }); }); diff --git a/packages/core/tests/unit/storages/persistent.test.ts b/packages/core/tests/unit/storages/persistent.test.ts index 5377a12e..79ca7ca2 100644 --- a/packages/core/tests/unit/storages/persistent.test.ts +++ b/packages/core/tests/unit/storages/persistent.test.ts @@ -118,6 +118,74 @@ describe('Persistent Tests', () => { }); }); + describe('setKey function tests', () => { + beforeEach(() => { + persistent.removePersistedValue = jest.fn(); + persistent.persistValue = jest.fn(); + persistent.initialLoading = jest.fn(); + }); + + it('should update key with valid key in ready Persistent', async () => { + persistent.ready = true; + persistent._key = 'dummyKey'; + jest.spyOn(persistent, 'validatePersistent').mockReturnValueOnce(true); + + await persistent.setKey('newKey'); + + expect(persistent._key).toBe('newKey'); + expect(persistent.validatePersistent).toHaveBeenCalled(); + expect(persistent.initialLoading).not.toHaveBeenCalled(); + expect(persistent.persistValue).toHaveBeenCalledWith('newKey'); + expect(persistent.removePersistedValue).toHaveBeenCalledWith( + 'dummyKey' + ); + }); + + it('should update key with not valid key in ready Persistent', async () => { + persistent.ready = true; + persistent._key = 'dummyKey'; + jest.spyOn(persistent, 'validatePersistent').mockReturnValueOnce(false); + + await persistent.setKey(); + + expect(persistent._key).toBe(Persistent.placeHolderKey); + expect(persistent.validatePersistent).toHaveBeenCalled(); + expect(persistent.initialLoading).not.toHaveBeenCalled(); + expect(persistent.persistValue).not.toHaveBeenCalled(); + expect(persistent.removePersistedValue).toHaveBeenCalledWith( + 'dummyKey' + ); + }); + + it('should update key with valid key in not ready Persistent', async () => { + persistent.ready = false; + persistent._key = 'dummyKey'; + jest.spyOn(persistent, 'validatePersistent').mockReturnValueOnce(true); + + await persistent.setKey('newKey'); + + expect(persistent._key).toBe('newKey'); + expect(persistent.validatePersistent).toHaveBeenCalled(); + expect(persistent.initialLoading).toHaveBeenCalled(); + expect(persistent.persistValue).not.toHaveBeenCalled(); + expect(persistent.removePersistedValue).not.toHaveBeenCalled(); + }); + + it('should update key with not valid key in not ready Persistent', async () => { + persistent.ready = false; + persistent._key = 'dummyKey'; + jest.spyOn(persistent, 'validatePersistent').mockReturnValueOnce(false); + + await persistent.setKey(); + + expect(persistent._key).toBe(Persistent.placeHolderKey); + expect(persistent.validatePersistent).toHaveBeenCalled(); + expect(persistent.initialLoading).not.toHaveBeenCalled(); + expect(persistent.persistValue).not.toHaveBeenCalled(); + expect(persistent.removePersistedValue).not.toHaveBeenCalled(); + }); + }); + describe('instantiatePersistent function tests', () => { it('should call assign key to formatKey and call assignStorageKeys, validatePersistent', () => { jest.spyOn(persistent, 'formatKey'); diff --git a/packages/cra-template-agile-typescript/template.json b/packages/cra-template-agile-typescript/template.json index bac07209..21245663 100644 --- a/packages/cra-template-agile-typescript/template.json +++ b/packages/cra-template-agile-typescript/template.json @@ -3,18 +3,20 @@ "dependencies": { "@agile-ts/core": "^0.0.13", "@agile-ts/react": "^0.0.13", + "typescript": "^4.1.2", + "web-vitals": "^1.0.1" + }, + "devDependencies": { "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", - "@types/jest": "^26.0.15", - "typescript": "^4.1.2", - "web-vitals": "^1.0.1" + "@types/jest": "^26.0.15" }, "eslintConfig": { "extends": ["react-app", "react-app/jest"] } } -} \ No newline at end of file +} diff --git a/packages/cra-template-agile/template.json b/packages/cra-template-agile/template.json index 7371aa91..64697fb5 100644 --- a/packages/cra-template-agile/template.json +++ b/packages/cra-template-agile/template.json @@ -3,13 +3,15 @@ "dependencies": { "@agile-ts/core": "^0.0.13", "@agile-ts/react": "^0.0.13", + "web-vitals": "^1.0.1" + }, + "devDependencies": { "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", - "@testing-library/user-event": "^12.1.10", - "web-vitals": "^1.0.1" + "@testing-library/user-event": "^12.1.10" }, "eslintConfig": { "extends": ["react-app", "react-app/jest"] } } -} \ No newline at end of file +} diff --git a/packages/event/src/hooks/useEvent.ts b/packages/event/src/hooks/useEvent.ts index 1bb4e371..905d6413 100644 --- a/packages/event/src/hooks/useEvent.ts +++ b/packages/event/src/hooks/useEvent.ts @@ -27,7 +27,7 @@ export function useEvent>( } // Create Callback based Subscription - const subscriptionContainer = agileInstance.subController.subscribeWithSubsArray( + const subscriptionContainer = agileInstance.subController.subscribe( () => { forceRender(); }, diff --git a/packages/event/tests/unit/event.observer.test.ts b/packages/event/tests/unit/event.observer.test.ts index be77973c..221bd2b9 100644 --- a/packages/event/tests/unit/event.observer.test.ts +++ b/packages/event/tests/unit/event.observer.test.ts @@ -28,8 +28,8 @@ describe('EventObserver Tests', () => { it('should create EventObserver (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 dummySubscription1 = new SubscriptionContainer([]); + const dummySubscription2 = new SubscriptionContainer([]); const eventObserver = new EventObserver(dummyEvent, { key: 'testKey', diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index d00a98ea..e3a2a889 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -124,7 +124,7 @@ export class Logger { // Tag //========================================================================================================= /** - * @private + * @internal * Only executes following 'command' if all given tags are included in allowedTags * @param tags - Tags */ diff --git a/packages/multieditor/src/multieditor.ts b/packages/multieditor/src/multieditor.ts index 573ca0ce..107a98b9 100644 --- a/packages/multieditor/src/multieditor.ts +++ b/packages/multieditor/src/multieditor.ts @@ -394,7 +394,7 @@ export class MultiEditor< // Get Validator //========================================================================================================= /** - * @private + * @internal * Get Validator of Item based on validateMethods * @param key - Key/Name of Item */ @@ -429,7 +429,7 @@ export class MultiEditor< // Validate //========================================================================================================= /** - * @private + * @internal * Validates Editor and updates its 'isValid' property */ public validate(): boolean { @@ -451,7 +451,7 @@ export class MultiEditor< // Can Assign Status To Item On Change //========================================================================================================= /** - * @private + * @internal * If Status can be assigned on Change * @param item - Item to which the Status should get applied */ @@ -470,7 +470,7 @@ export class MultiEditor< // Can Assign Status To Item On Submit //========================================================================================================= /** - * @private + * @internal * If Status can be assigned on Submit * @param item - Item to which the Status should get applied */ diff --git a/packages/proxytree/src/branch.ts b/packages/proxytree/src/branch.ts index f9c05f3e..bb13449e 100644 --- a/packages/proxytree/src/branch.ts +++ b/packages/proxytree/src/branch.ts @@ -46,7 +46,7 @@ export class Branch { } /** - * @private + * @internal * Record usage of an accessed property in the passed target object. * @param target - Target object in which a property at key was accessed * @param key - Key that was accessed in the target object diff --git a/packages/react/src/hocs/AgileHOC.ts b/packages/react/src/hocs/AgileHOC.ts index 30bbde0d..189271d9 100644 --- a/packages/react/src/hocs/AgileHOC.ts +++ b/packages/react/src/hocs/AgileHOC.ts @@ -74,7 +74,7 @@ export function AgileHOC( // Create HOC //========================================================================================================= /** - * @private + * @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 @@ -109,16 +109,14 @@ const createHOC = ( 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) if (depsWithoutIndicator.length > 0) { - this.agileInstance.subController.subscribeWithSubsArray( - this, - depsWithoutIndicator, - { waitForMount: this.waitForMount } - ); + 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') if (depsWithIndicator) { - const response = this.agileInstance.subController.subscribeWithSubsObject( + const response = this.agileInstance.subController.subscribe( this, depsWithIndicator, { waitForMount: this.waitForMount } @@ -151,7 +149,7 @@ const createHOC = ( // Format Deps With No Safe Indicator //========================================================================================================= /** - * @private + * @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 @@ -187,7 +185,7 @@ const formatDepsWithNoSafeIndicator = ( // Format Deps With Indicator //========================================================================================================= /** - * @private + * @internal * Extract Observers from dependencies which have an indicator through the object property key. * @param deps - Dependencies to be formatted */ diff --git a/packages/react/src/hooks/useAgile.ts b/packages/react/src/hooks/useAgile.ts index f38d04d3..bafcce1d 100644 --- a/packages/react/src/hooks/useAgile.ts +++ b/packages/react/src/hooks/useAgile.ts @@ -10,8 +10,9 @@ import { SubscriptionContainerKeyType, defineConfig, isValidObject, - ProxyKeyMapInterface, generateId, + ProxyWeakMapType, + ComponentIdType, } from '@agile-ts/core'; import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; import { ProxyTree } from '@agile-ts/proxytree'; @@ -47,37 +48,30 @@ export function useAgile< config: AgileHookConfigInterface = {} ): AgileHookArrayType | AgileHookType { const depsArray = extractObservers(deps); - const proxyTreeMap: ProxyTreeMapInterface = {}; + const proxyTreeWeakMap = new WeakMap(); config = defineConfig(config, { proxyBased: false, key: generateId(), agileInstance: null, }); - // Creates Return Value of Hook, depending if deps are in Array shape or not + // Creates Return Value of Hook, depending whether deps are in Array shape or not const getReturnValue = ( - depsArray: (State | Observer | undefined)[] + depsArray: (Observer | undefined)[] ): AgileHookArrayType | AgileHookType => { - const handleReturn = ( - dep: State | Observer | undefined - ): AgileHookType => { - const value = dep?.value; - const depKey = dep?.key; + const handleReturn = (dep: Observer | undefined): AgileHookType => { + if (dep == null) return undefined as any; + const value = dep.value; - // If proxyBased and value is object wrap Proxy around it to track used properties + // 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)) { - if (depKey) { - const proxyTree = new ProxyTree(value); - proxyTreeMap[depKey] = proxyTree; - return proxyTree.proxy; - } - Agile.logger.warn( - 'Keep in mind that without a key no Proxy can be wrapped around the dependency value!', - dep - ); + const proxyTree = new ProxyTree(value); + proxyTreeWeakMap.set(dep, proxyTree); + return proxyTree.proxy; } - return dep?.value; + return value; }; // Handle single dep @@ -85,7 +79,7 @@ export function useAgile< return handleReturn(depsArray[0]); } - // Handle dep array + // Handle deps array return depsArray.map((dep) => { return handleReturn(dep); }) as AgileHookArrayType; @@ -112,24 +106,35 @@ export function useAgile< (dep): dep is Observer => dep !== undefined ); - // Build Proxy Key Map - const proxyKeyMap: ProxyKeyMapInterface = {}; + // Build Proxy Path WeakMap Map based on the Proxy Tree WeakMap + // by extracting the routes of the Tree + // Building the Path WeakMap in the 'useIsomorphicLayoutEffect' + // because the 'useIsomorphicLayoutEffect' is called after the rerender + // -> In the Component used paths got successfully tracked + const proxyWeakMap: ProxyWeakMapType = new WeakMap(); if (config.proxyBased) { - for (const proxyTreeKey in proxyTreeMap) { - const proxyTree = proxyTreeMap[proxyTreeKey]; - proxyKeyMap[proxyTreeKey] = { - paths: proxyTree.getUsedRoutes() as any, - }; + for (const observer of observers) { + const proxyTree = proxyTreeWeakMap.get(observer); + if (proxyTree != null) { + proxyWeakMap.set(observer, { + paths: proxyTree.getUsedRoutes() as any, + }); + } } } // Create Callback based Subscription - const subscriptionContainer = agileInstance.subController.subscribeWithSubsArray( + const subscriptionContainer = agileInstance.subController.subscribe( () => { forceRender(); }, observers, - { key: config.key, proxyKeyMap, waitForMount: false } + { + key: config.key, + proxyWeakMap, + waitForMount: false, + componentId: config.componentId, + } ); // Unsubscribe Callback based Subscription on Unmount @@ -185,6 +190,7 @@ interface AgileHookConfigInterface { key?: SubscriptionContainerKeyType; agileInstance?: Agile; proxyBased?: boolean; + componentId?: ComponentIdType; } interface ProxyTreeMapInterface { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 4746c07c..e9104543 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -198,13 +198,16 @@ export function flatMerge( // Copy Source to avoid References const _source = copy(source); - if (!_source) return _source; + if (_source == null) return _source; // Merge Changes Object into Source Object const keys = Object.keys(changes); keys.forEach((property) => { - if (!config.addNewProperties && !_source[property]) return; - _source[property] = changes[property]; + if ( + (!config.addNewProperties && _source[property] != null) || + config.addNewProperties + ) + _source[property] = changes[property]; }); return _source; diff --git a/packages/vue/src/bindAgileInstances.ts b/packages/vue/src/bindAgileInstances.ts index 1d439b44..451c759d 100644 --- a/packages/vue/src/bindAgileInstances.ts +++ b/packages/vue/src/bindAgileInstances.ts @@ -27,20 +27,16 @@ export function bindAgileInstances( // Create Subscription with Observer that have no Indicator and can't be merged into the 'sharedState' (Rerender will be caused via force Update) if (depsWithoutIndicator.length > 0) { - agile.subController.subscribeWithSubsArray( - vueComponent, - depsWithoutIndicator, - { waitForMount: false } - ); + agile.subController.subscribe(vueComponent, depsWithoutIndicator, { + waitForMount: false, + }); } // Create Subscription with Observer that have an Indicator (Rerender will be cause via mutating 'this.$data.sharedState') if (depsWithIndicator) { - return agile.subController.subscribeWithSubsObject( - vueComponent, - depsWithIndicator, - { waitForMount: false } - ).props; + return agile.subController.subscribe(vueComponent, depsWithIndicator, { + waitForMount: false, + }).props; } return {}; @@ -50,7 +46,7 @@ export function bindAgileInstances( // Format Deps With No Safe Indicator //========================================================================================================= /** - * @private + * @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 @@ -86,7 +82,7 @@ const formatDepsWithNoSafeIndicator = ( // Format Deps With Indicator //========================================================================================================= /** - * @private + * @internal * Extract Observers from dependencies which have an indicator through the object property key. * @param deps - Dependencies to be formatted */ diff --git a/yarn.lock b/yarn.lock index de94593e..024bf2af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,10 +3,10 @@ "@agile-ts/core@file:packages/core": - version "0.0.16" + version "0.0.17" dependencies: - "@agile-ts/logger" "^0.0.3" - "@agile-ts/utils" "^0.0.3" + "@agile-ts/logger" "^0.0.4" + "@agile-ts/utils" "^0.0.4" "@akryum/winattr@^3.0.0": version "3.0.0" @@ -2788,6 +2788,13 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@size-limit/file@^4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-4.12.0.tgz#50166eca7b9b5aa15f51a72b3d9d31e2d8530e6f" + integrity sha512-csgGSAG3s2y9eOl/taahojXY91AXpNgqLs9HJ5c/Qmrs+6UHgXbwJ4vo475NfZmt1Y9simircb1ygqupauNUyA== + dependencies: + semver "7.3.5" + "@surma/rollup-plugin-off-main-thread@^1.1.1": version "1.4.2" resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-1.4.2.tgz#e6786b6af5799f82f7ab3a82e53f6182d2b91a58" @@ -4735,6 +4742,15 @@ bl@^1.0.0: readable-stream "^2.3.5" safe-buffer "^5.1.1" +bl@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -5006,7 +5022,7 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.2.1: +buffer@^5.2.1, buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -5046,6 +5062,11 @@ byte-size@^5.0.1: resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-5.0.1.tgz#4b651039a5ecd96767e71a3d7ed380e48bed4191" integrity sha512-/XuKeqWocKsYa/cBY1YbSJSWWqTi4cFgr9S6OyM7PBaPbr9zvNGwWP33vt0uqGhwDdN+y3yhbXVILEUpnwEWGw== +bytes-iec@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/bytes-iec/-/bytes-iec-3.1.1.tgz#94cd36bf95c2c22a82002c247df8772d1d591083" + integrity sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA== + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -5358,7 +5379,7 @@ chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" -chokidar@^3.2.2, chokidar@^3.4.1: +chokidar@^3.2.2, chokidar@^3.4.1, chokidar@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== @@ -5393,6 +5414,11 @@ ci-info@^2.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== +ci-job-number@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/ci-job-number/-/ci-job-number-1.2.2.tgz#f4e5918fcaeeda95b604f214be7d7d4a961fe0c0" + integrity sha512-CLOGsVDrVamzv8sXJGaILUVI6dsuAkouJP/n6t+OxLPeeA4DDby7zn9SB6EUpa1H7oIKoE+rMmkW80zYsFfUjA== + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -5464,7 +5490,7 @@ cli-highlight@^2.1.4: parse5-htmlparser2-tree-adapter "^6.0.0" yargs "^16.0.0" -cli-spinners@^2.0.0: +cli-spinners@^2.0.0, cli-spinners@^2.5.0: version "2.6.0" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.0.tgz#36c7dc98fb6a9a76bd6238ec3f77e2425627e939" integrity sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q== @@ -8608,7 +8634,7 @@ globby@11.0.1: merge2 "^1.3.0" slash "^3.0.0" -globby@^11.0.0, globby@^11.0.1, globby@^11.0.2: +globby@^11.0.0, globby@^11.0.1, globby@^11.0.2, globby@^11.0.3: version "11.0.3" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.3.tgz#9b1f0cb523e171dd1ad8c7b2a9fb4b644b9593cb" integrity sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg== @@ -9671,6 +9697,11 @@ is-installed-globally@^0.3.1: global-dirs "^2.0.1" is-path-inside "^3.0.1" +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + is-lambda@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" @@ -9860,6 +9891,11 @@ is-typedarray@^1.0.0, is-typedarray@~1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -10774,6 +10810,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lilconfig@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd" + integrity sha512-EHKqr/+ZvdKCifpNrJCKxBTgk5XupZA3y/aCPY9mxfgBzmgh93Mt/WqjjQ38oMxXuvDokaKiM3lAgvSH2sjtHg== + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -10990,6 +11031,14 @@ log-symbols@^2.2.0: dependencies: chalk "^2.0.1" +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + loglevel@^1.6.7, loglevel@^1.6.8: version "1.7.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" @@ -12283,6 +12332,21 @@ ora@^3.4.0: strip-ansi "^5.2.0" wcwidth "^1.0.1" +ora@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + original@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" @@ -14194,7 +14258,7 @@ read@1, read@~1.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -"readable-stream@2 || 3", readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.6.0: +"readable-stream@2 || 3", readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -14882,7 +14946,7 @@ semver@7.3.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== -semver@7.x, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: +semver@7.3.5, semver@7.x, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: version "7.3.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== @@ -15067,6 +15131,20 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== +size-limit@^4.12.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-4.12.0.tgz#ecc9c0448c049a40b10e76b5e1b4a20f99a54468" + integrity sha512-LwlUDPxFJbJDIJsBE5bKo8kFMuxmuewBMDjgfSoQwnO27V8DSK+j6881nsrX3GoM3bJMFIeEq56thqBEdYC8bw== + dependencies: + bytes-iec "^3.1.1" + chokidar "^3.5.1" + ci-job-number "^1.2.2" + colorette "^1.2.2" + globby "^11.0.3" + lilconfig "^2.0.3" + ora "^5.4.1" + read-pkg-up "^7.0.1" + slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"