diff --git a/.changeset/calm-pets-attend.md b/.changeset/calm-pets-attend.md new file mode 100644 index 00000000..fc5ca2b8 --- /dev/null +++ b/.changeset/calm-pets-attend.md @@ -0,0 +1,27 @@ +--- +'@agile-ts/api': patch +'@agile-ts/core': patch +'cra-template-agile': patch +'cra-template-agile-typescript': patch +'@agile-ts/event': patch +'@agile-ts/logger': patch +'@agile-ts/multieditor': patch +'@agile-ts/proxytree': patch +'@agile-ts/react': patch +'@agile-ts/utils': patch +'@agile-ts/vue': patch +--- + +#### :rocket: New Feature +* `core`, `event`, `logger`, `multieditor`, `react`, `utils` + * [#188](https://github.com/agile-ts/agile/pull/188) Tree shakeable support ([@bennodev19](https://github.com/bennodev19)) + +#### :nail_care: Polish +* `core` + * [#189](https://github.com/agile-ts/agile/pull/189) Optimize collection rebuilds ([@bennodev19](https://github.com/bennodev19)) +* `api`, `core`, `cra-template-agile-typescript`, `cra-template-agile`, `event`, `logger`, `multieditor`, `proxytree`, `react`, `utils`, `vue` + * [#187](https://github.com/agile-ts/agile/pull/187) Tree shakeable support ([@bennodev19](https://github.com/bennodev19)) + +#### Committers: 1 +- BennoDev ([@bennodev19](https://github.com/bennodev19)) + diff --git a/README.md b/README.md index e684906b..3a02e780 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ AgileTs is a global State and Logic Library implemented in Typescript. It offers a reimagined API that focuses on **developer experience** and allows you to **easily** and **flexible** manage your application States. Besides [States](https://agile-ts.org/docs/core/state), -AgileTs offers some other powerful APIs that make your life easier, +AgileTs offers some other powerful and tree shakable APIs that make your life easier, such as [Collections](https://agile-ts.org/docs/core/collection) and [Computed States](https://agile-ts.org/docs/core/computed). The philosophy behind AgileTs is simple: @@ -88,19 +88,19 @@ The philosophy behind AgileTs is simple: Write minimalistic, boilerplate-free code that captures your intent. ```ts -// Create State with inital value 'frank' +// Create State with the inital value 'frank' const MY_STATE = createState('frank'); -// Update State value from 'frank' to 'jeff' +// Update the State value from 'frank' to 'jeff' MY_STATE.set('jeff'); -// Undo latest State value change +// Undo the latest State value change MY_STATE.undo(); -// Reset State value to its initial value +// Reset the State value to its initial value MY_STATE.reset(); -// Permanently store State value in an external Storage +// Permanently store the State value in an external Storage MY_STATE.persist("storage-key"); ``` @@ -206,6 +206,14 @@ To find out more about contributing, check out the [CONTRIBUTING.md](https://git Maintainability +### ♥️ Contributors + + + + + +[Become a contributor](https://github.com/agile-ts/agile/blob/master/CONTRIBUTING.md) +
diff --git a/benchmark/.env b/benchmark/.env index 21903adb..41ee6b18 100644 --- a/benchmark/.env +++ b/benchmark/.env @@ -1 +1 @@ -MANUAL_BENCHMARK=false +DEV=false diff --git a/benchmark/README.md b/benchmark/README.md index ce2e9db2..e4754a4d 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -121,10 +121,18 @@ Execute the benchmark located in `./benchmarks/react/counter`. ```ts yarn run test:counter ``` +If you want to test it manually, enable the `dev mode`. +```ts +yarn run test:counter --dev +``` +The difference to the 'normal' mode is that: +- the executed bundle isn't `minified` +- the test has to be started manually by opening `localhost:3003` +- the test results are printed in the `browser console` ## ⭐️ Contribute -Get a part of AgileTs and start contributing. We welcome any meaningful contribution. 😀 +Feel free to add more tests and State Managers to be tested. We welcome any meaningful contribution. 😀 To find out more about contributing, check out the [CONTRIBUTING.md](https://github.com/agile-ts/agile/blob/master/CONTRIBUTING.md). diff --git a/benchmark/benchmarkManager.ts b/benchmark/benchmarks/benchmarkManager.ts similarity index 100% rename from benchmark/benchmarkManager.ts rename to benchmark/benchmarks/benchmarkManager.ts diff --git a/benchmark/benchmarks/react/1000fields/bench/agilets/collection.tsx b/benchmark/benchmarks/react/1000fields/bench/agilets/collection.tsx index 7269c0a0..22155afa 100644 --- a/benchmark/benchmarks/react/1000fields/bench/agilets/collection.tsx +++ b/benchmark/benchmarks/react/1000fields/bench/agilets/collection.tsx @@ -1,9 +1,10 @@ import React from 'react'; import ReactDom from 'react-dom'; -import { createCollection, LogCodeManager } from '@agile-ts/core'; -import { useAgile, useValue } from '@agile-ts/react'; +import { createCollection, LogCodeManager, shared } from '@agile-ts/core'; +import reactIntegration, { useAgile, useValue } from '@agile-ts/react'; -LogCodeManager.getLogger().isActive = false; +LogCodeManager.setAllowLogging(false); +shared.integrate(reactIntegration); export default function (target: HTMLElement, fieldsCount: number) { const FIELDS = createCollection({ diff --git a/benchmark/benchmarks/react/1000fields/bench/agilets/nestedState.tsx b/benchmark/benchmarks/react/1000fields/bench/agilets/nestedState.tsx index 377b6b01..213f2fe4 100644 --- a/benchmark/benchmarks/react/1000fields/bench/agilets/nestedState.tsx +++ b/benchmark/benchmarks/react/1000fields/bench/agilets/nestedState.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import * as ReactDom from 'react-dom'; -import { createState, LogCodeManager, State } from '@agile-ts/core'; -import { useAgile } from '@agile-ts/react'; +import { createState, LogCodeManager, shared, State } from '@agile-ts/core'; +import reactIntegration, { useAgile } from '@agile-ts/react'; -LogCodeManager.getLogger().isActive = false; +LogCodeManager.setAllowLogging(false); +shared.integrate(reactIntegration); export default function (target: HTMLElement, fieldsCount: number) { const FIELDS = createState( diff --git a/benchmark/benchmarks/react/1000fields/bench/agilets/state.tsx b/benchmark/benchmarks/react/1000fields/bench/agilets/state.tsx index 93f9798a..6190049b 100644 --- a/benchmark/benchmarks/react/1000fields/bench/agilets/state.tsx +++ b/benchmark/benchmarks/react/1000fields/bench/agilets/state.tsx @@ -1,9 +1,10 @@ import React from 'react'; import ReactDom from 'react-dom'; -import { createState, LogCodeManager } from '@agile-ts/core'; -import { useAgile } from '@agile-ts/react'; +import { createState, LogCodeManager, shared } from '@agile-ts/core'; +import reactIntegration, { useAgile } from '@agile-ts/react'; -LogCodeManager.getLogger().isActive = false; +LogCodeManager.setAllowLogging(false); +shared.integrate(reactIntegration); export default function (target: HTMLElement, fieldsCount: number) { const FIELDS = createState( diff --git a/benchmark/benchmarks/react/1000fields/index.ts b/benchmark/benchmarks/react/1000fields/index.ts index 9b251c1d..3bb8218a 100644 --- a/benchmark/benchmarks/react/1000fields/index.ts +++ b/benchmark/benchmarks/react/1000fields/index.ts @@ -6,7 +6,7 @@ import { endBenchmarkLog, getCycleResult, startBenchmarkLog, -} from '../../../benchmarkManager'; +} from '../../benchmarkManager'; // Files to run the Benchmark on import agileCollection from './bench/agilets/collection'; @@ -81,8 +81,8 @@ suite .add('Agile State', configTest(agileState)) .add('Agile nested State', configTest(agileNestedState)) .add('Pulse Collection', configTest(pulseCollection)) - // .add('Pulse State', configTest(pulseState)) - // .add('Pulse nested State', configTest(pulseNestedState)) + .add('Pulse State', configTest(pulseState)) + .add('Pulse nested State', configTest(pulseNestedState)) .add('Hookstate', configTest(hookstate)) .add('Jotai', configTest(jotai)) .add('Mobx', configTest(mobx)) diff --git a/benchmark/benchmarks/react/computed/bench/agilets/autoTracking.tsx b/benchmark/benchmarks/react/computed/bench/agilets/autoTracking.tsx index 0b596405..d6f93317 100644 --- a/benchmark/benchmarks/react/computed/bench/agilets/autoTracking.tsx +++ b/benchmark/benchmarks/react/computed/bench/agilets/autoTracking.tsx @@ -1,9 +1,16 @@ import React from 'react'; import ReactDom from 'react-dom'; -import { createComputed, createState, LogCodeManager } from '@agile-ts/core'; -import { useAgile } from '@agile-ts/react'; +import { + createComputed, + createState, + LogCodeManager, + shared, +} from '@agile-ts/core'; +import reactIntegration, { useAgile } from '@agile-ts/react'; + +LogCodeManager.setAllowLogging(false); +shared.integrate(reactIntegration); -LogCodeManager.getLogger().isActive = false; const COUNT = createState(0); const COMPUTED_COUNT = createComputed(() => { return COUNT.value * 5; diff --git a/benchmark/benchmarks/react/computed/bench/agilets/hardCoded.tsx b/benchmark/benchmarks/react/computed/bench/agilets/hardCoded.tsx index 59cc57a8..e206c482 100644 --- a/benchmark/benchmarks/react/computed/bench/agilets/hardCoded.tsx +++ b/benchmark/benchmarks/react/computed/bench/agilets/hardCoded.tsx @@ -1,9 +1,16 @@ import React from 'react'; import ReactDom from 'react-dom'; -import { createComputed, createState, LogCodeManager } from '@agile-ts/core'; -import { useAgile } from '@agile-ts/react'; +import { + createComputed, + createState, + LogCodeManager, + shared, +} from '@agile-ts/core'; +import reactIntegration, { useAgile } from '@agile-ts/react'; + +LogCodeManager.setAllowLogging(false); +shared.integrate(reactIntegration); -LogCodeManager.getLogger().isActive = false; const COUNT = createState(0); const COMPUTED_COUNT = createComputed( () => { diff --git a/benchmark/benchmarks/react/computed/index.ts b/benchmark/benchmarks/react/computed/index.ts index e70b9c70..eb9d2fed 100644 --- a/benchmark/benchmarks/react/computed/index.ts +++ b/benchmark/benchmarks/react/computed/index.ts @@ -6,7 +6,7 @@ import { endBenchmarkLog, getCycleResult, startBenchmarkLog, -} from '../../../benchmarkManager'; +} from '../../benchmarkManager'; // Files to run the Benchmark on import agileAutoTracking from './bench/agilets/autoTracking'; diff --git a/benchmark/benchmarks/react/counter/bench/agilets.tsx b/benchmark/benchmarks/react/counter/bench/agilets.tsx index 309b2f2a..9445ae0f 100644 --- a/benchmark/benchmarks/react/counter/bench/agilets.tsx +++ b/benchmark/benchmarks/react/counter/bench/agilets.tsx @@ -1,13 +1,15 @@ import React from 'react'; import ReactDom from 'react-dom'; -import { createState, LogCodeManager } from '@agile-ts/core'; -import { useAgile } from '@agile-ts/react'; +import { createState, LogCodeManager, shared } from '@agile-ts/core'; +import reactIntegration, { useAgile } from '@agile-ts/react'; + +LogCodeManager.setAllowLogging(false); +shared.integrate(reactIntegration); -LogCodeManager.getLogger().isActive = false; const COUNT = createState(0); const App = () => { - const count = useAgile(COUNT, undefined); + const count = useAgile(COUNT); return

COUNT.set((state) => state + 1)}>{count}

; }; diff --git a/benchmark/benchmarks/react/counter/index.ts b/benchmark/benchmarks/react/counter/index.ts index f3e2e78f..4580a0d2 100644 --- a/benchmark/benchmarks/react/counter/index.ts +++ b/benchmark/benchmarks/react/counter/index.ts @@ -6,7 +6,7 @@ import { endBenchmarkLog, getCycleResult, startBenchmarkLog, -} from '../../../benchmarkManager'; +} from '../../benchmarkManager'; // Files to run the Benchmark on import agilets from './bench/agilets'; diff --git a/benchmark/benchmarks/typescript/cloneDeep/bench/lodash.ts b/benchmark/benchmarks/typescript/cloneDeep/bench/lodash.ts new file mode 100644 index 00000000..16e2fa76 --- /dev/null +++ b/benchmark/benchmarks/typescript/cloneDeep/bench/lodash.ts @@ -0,0 +1,6 @@ +// @ts-ignore +import _ from 'lodash'; + +export function cloneDeep(value: T): T { + return _.cloneDeep(value); +} diff --git a/benchmark/benchmarks/typescript/cloneDeep/bench/looper.ts b/benchmark/benchmarks/typescript/cloneDeep/bench/looper.ts new file mode 100644 index 00000000..146c5c7e --- /dev/null +++ b/benchmark/benchmarks/typescript/cloneDeep/bench/looper.ts @@ -0,0 +1,19 @@ +export function cloneDeep(value: T): T { + // Extra checking 'value == null' because 'typeof null === object' + if (value == null || typeof value !== 'object') return value; + + // Ignore everything that is no object or array but has the type of an object (e.g. classes) + const valConstructorName = Object.getPrototypeOf( + value + ).constructor.name.toLowerCase(); + if (valConstructorName !== 'object' && valConstructorName !== 'array') + return value; + + let temp; + const newObject: any = Array.isArray(value) ? [] : {}; + for (const property in value) { + temp = value[property]; + newObject[property] = cloneDeep(temp); + } + return newObject as T; +} diff --git a/benchmark/benchmarks/typescript/cloneDeep/bench/stringify.ts b/benchmark/benchmarks/typescript/cloneDeep/bench/stringify.ts new file mode 100644 index 00000000..87cee1d2 --- /dev/null +++ b/benchmark/benchmarks/typescript/cloneDeep/bench/stringify.ts @@ -0,0 +1,3 @@ +export function cloneDeep(value: T): T { + return JSON.parse(JSON.stringify(value)); +} diff --git a/benchmark/benchmarks/typescript/cloneDeep/index.ts b/benchmark/benchmarks/typescript/cloneDeep/index.ts new file mode 100644 index 00000000..1ecda695 --- /dev/null +++ b/benchmark/benchmarks/typescript/cloneDeep/index.ts @@ -0,0 +1,56 @@ +import Benchmark, { Suite } from 'benchmark'; +import { + cycleLog, + CycleResultInterface, + endBenchmarkLog, + getCycleResult, + startBenchmarkLog, +} from '../../benchmarkManager'; + +// Files to run the Benchmark on +import * as lodash from './bench/lodash'; +import * as looper from './bench/looper'; +import * as stringify from './bench/stringify'; + +const toClone = { x1: true, x2: undefined }; + +// @ts-ignore +// Benchmark.js requires an instance of itself globally +window.Benchmark = Benchmark; + +// Create new Benchmark Test Suite +const suite = new Suite('clone deep'); + +const results: CycleResultInterface[] = []; + +// Add Tests to the Benchmark Test Suite +suite + .add('Lodash', function () { + lodash.cloneDeep(toClone); + }) + .add('Looper', function () { + looper.cloneDeep(toClone); + }) + .add('Stringify', function () { + stringify.cloneDeep(toClone); + }) + + // Add Listener + .on('start', function (this: any) { + startBenchmarkLog(this.name); + }) + .on('cycle', (event: any) => { + const cycleResult = getCycleResult(event); + cycleLog(cycleResult); + results.push(cycleResult); + }) + .on('complete', function (this: any) { + endBenchmarkLog(this.name, results, this.filter('fastest').map('name')); + + // @ts-ignore + // Notify server that the Benchmark Test Suite has ended + window.TEST.ended = true; + }) + + // Run Benchmark Test Suite + .run({ async: true }); diff --git a/benchmark/benchmarks/typescript/defineConfig/index.ts b/benchmark/benchmarks/typescript/defineConfig/index.ts index 29caa427..79846e99 100644 --- a/benchmark/benchmarks/typescript/defineConfig/index.ts +++ b/benchmark/benchmarks/typescript/defineConfig/index.ts @@ -5,7 +5,7 @@ import { endBenchmarkLog, getCycleResult, startBenchmarkLog, -} from '../../../benchmarkManager'; +} from '../../benchmarkManager'; // Files to run the Benchmark on import * as referencer from './bench/referencer'; diff --git a/benchmark/package.json b/benchmark/package.json index d7cf2247..006b4707 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -7,11 +7,12 @@ "homepage": "https://agile-ts.org/", "description": "Benchmark Tests", "scripts": { - "test": "node -r esbuild-register run.ts", + "test": "node -r esbuild-register runtime/run.ts", "test:counter": "yarn test ./benchmarks/react/counter", "test:1000fields": "yarn test ./benchmarks/react/1000fields", "test:computed": "yarn test ./benchmarks/react/computed", "test:defineConfig": "yarn test ./benchmarks/typescript/defineConfig", + "test:cloneDeep": "yarn test ./benchmarks/typescript/cloneDeep", "install:dev:agile": "yalc add @agile-ts/core @agile-ts/react & yarn install", "install:prod:agile": "yarn add @agile-ts/core @agile-ts/react & yarn install" }, @@ -21,8 +22,9 @@ }, "dependencies": { "@agile-ts/core": "file:.yalc/@agile-ts/core", + "@agile-ts/logger": "file:.yalc/@agile-ts/logger", "@agile-ts/react": "file:.yalc/@agile-ts/react", - "@hookstate/core": "^3.0.8", + "@hookstate/core": "^3.0.11", "@pulsejs/core": "^4.0.0-beta.3", "@pulsejs/react": "^4.0.0-beta.3", "@reduxjs/toolkit": "^1.6.0", @@ -45,13 +47,15 @@ "redux": "^4.1.0", "typescript": "^4.3.5", "valtio": "^1.0.6", + "yargs": "^17.1.0", "zustand": "^3.5.5" }, "devDependencies": { "@types/benchmark": "^2.1.0", "@types/node": "^16.0.0", "@types/react": "^17.0.13", - "@types/react-dom": "^17.0.8" + "@types/react-dom": "^17.0.8", + "@types/yargs": "^17.0.2" }, "bugs": { "url": "https://github.com/agile-ts/agile/issues" diff --git a/benchmark/run.ts b/benchmark/runtime/benchmarkTypes.ts similarity index 59% rename from benchmark/run.ts rename to benchmark/runtime/benchmarkTypes.ts index 2bd03642..43783052 100644 --- a/benchmark/run.ts +++ b/benchmark/runtime/benchmarkTypes.ts @@ -1,23 +1,10 @@ -import dotenv from 'dotenv'; +import chalk from 'chalk'; import esbuild from 'esbuild'; import playwright from 'playwright'; -import chalk from 'chalk'; - -// Loads environment variables from the '.env' file -dotenv.config(); - -// https://nodejs.org/docs/latest/api/process.html#process_process_argv -// Extract entry (at third parameter) from the executed command -// yarn run ./path/to/entry -> './path/to/entry' is extracted -const entry = process.argv.slice(2)[0]; -if (entry == null) { - throw new Error( - "No valid entry was provided! Valid entry example: 'yarn run ./benchmarks/react/counter'" - ); -} +import fs from 'fs'; -const startBenchmark = async () => { - console.log(chalk.blue('Starting the benchmark server..\n')); +export const startSpeedBench = async (entry: string, isDev: boolean) => { + console.log(chalk.blue('Starting the speed benchmark server..\n')); // Bundle Benchmark Test Suite // and launch the server on which the Test Suite is executed @@ -32,9 +19,9 @@ const startBenchmark = async () => { entryPoints: [entry], // https://esbuild.github.io/api/#entry-points outfile: './public/bundle.js', target: 'es2015', - format: 'cjs', // https://esbuild.github.io/api/#format-commonjs + format: 'esm', // https://esbuild.github.io/api/#format-commonjs platform: 'browser', - minify: true, // https://esbuild.github.io/api/#minify + minify: !isDev, // https://esbuild.github.io/api/#minify bundle: true, // https://esbuild.github.io/api/#bundle sourcemap: 'external', // https://esbuild.github.io/api/#sourcemap// https://github.com/evanw/esbuild/issues/69 } @@ -53,7 +40,12 @@ const startBenchmark = async () => { const page = await context.newPage(); // Option to open and test the Benchmark Test Suite in the browser manually - if (process.env.MANUAL_BENCHMARK === 'true') { + if (isDev) { + console.log( + `${chalk.blue('[i]')} ${chalk.gray( + `Development mode is ${chalk.green(`active`)}` + )}` + ); console.log( `${chalk.blue('[i]')} ${chalk.gray( `Benchmark is running at ${chalk.blueBright.bold(serverUrl)}` @@ -107,5 +99,52 @@ const startBenchmark = async () => { server.stop(); }; -// Execute the Benchmark -startBenchmark(); +export const startBundleBench = async (entry: string, isDev: boolean) => { + const bundle = await esbuild.build({ + inject: ['./lodash.ts'], // https://esbuild.github.io/api/#inject + entryPoints: [entry], // https://esbuild.github.io/api/#entry-points + outfile: './public/bundle.js', + target: 'es2015', + format: 'esm', // https://esbuild.github.io/api/#format-commonjs + platform: 'browser', + minify: !isDev, // https://esbuild.github.io/api/#minify + bundle: true, // https://esbuild.github.io/api/#bundle + sourcemap: 'external', // https://esbuild.github.io/api/#sourcemap// https://github.com/evanw/esbuild/issues/69 + metafile: true, // https://esbuild.github.io/api/#metafile + }); + + console.log( + `${chalk.blue('[i]')} ${chalk.gray( + `Entry was ${chalk.green(`successfully`)} bundled` + )}` + ); + + if (isDev) { + console.log( + `${chalk.blue('[i]')} ${chalk.gray( + `Development mode is ${chalk.green(`active`)}` + )}` + ); + } + + // Extract metafile from bundle (https://esbuild.github.io/api/#metafile) + const metafile = bundle.metafile; + + // Calculate bundle file size + let bundleSize = 0; + bundle.outputFiles?.map((file) => { + const stats = fs.statSync(file.path); + const fileSizeInBytes = stats.size; + const fileSizeInKilobytes = fileSizeInBytes / 1024; + bundleSize += fileSizeInKilobytes; + }); + + console.log( + `${chalk.blue('[i]')} ${chalk.gray( + `Total bundle size of the bundle is ${chalk.blueBright.bold(bundleSize)}` + )}` + ); + + console.log(metafile); + // TODO analyze metafile +}; diff --git a/benchmark/runtime/run.ts b/benchmark/runtime/run.ts new file mode 100644 index 00000000..b4a7946b --- /dev/null +++ b/benchmark/runtime/run.ts @@ -0,0 +1,51 @@ +import dotenv from 'dotenv'; +import { startBundleBench, startSpeedBench } from './benchmarkTypes'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +// Loads environment variables from the '.env' file +dotenv.config(); + +// hideBind handles the 'process.argv.slice' logic +const argv = yargs(hideBin(process.argv)) + .option('_', { + type: 'string', + // default: ['./benchmarks/react/counter'], + description: 'What benchmark to execute', + }) + .option('dev', { + type: 'boolean', + default: false, + description: + 'Whether to start the benchmark/s in developer mode for better debugging.', + }) + .option('type', { + type: 'string', + default: 'speed', + description: 'What type of benchmark to be executed', + }).argv as any; + +const entry = argv._[0]; +const isDev = argv.dev || process.env.DEV === 'true'; +const benchmarkType = argv.type; + +if (entry == null) { + throw new Error( + "No valid entry was provided! Valid entry example: 'yarn run ./benchmarks/react/counter'" + ); +} + +// Benchmarks that can be executed marked with a unique identifier +const benchmarks: { + [key: string]: (entry: string, isDev: boolean) => Promise; +} = { + speed: startSpeedBench, + bundle: startBundleBench, +}; + +// Execute Benchmark based on the specified Benchmark type +const toExecuteBenchmark = benchmarks[benchmarkType]; +if (toExecuteBenchmark != null) toExecuteBenchmark(entry, isDev); +else { + benchmarks['speed'](entry, isDev); +} diff --git a/benchmark/yarn.lock b/benchmark/yarn.lock index 16d56ebc..fdae0a71 100644 --- a/benchmark/yarn.lock +++ b/benchmark/yarn.lock @@ -3,17 +3,22 @@ "@agile-ts/core@file:.yalc/@agile-ts/core": - version "0.1.0" + version "0.2.0-alpha.5" dependencies: - "@agile-ts/utils" "^0.0.5" + "@agile-ts/utils" "^0.0.7" + +"@agile-ts/logger@file:.yalc/@agile-ts/logger": + version "0.0.7" + dependencies: + "@agile-ts/utils" "^0.0.7" "@agile-ts/react@file:.yalc/@agile-ts/react": - version "0.1.0" + version "0.1.2" -"@agile-ts/utils@^0.0.5": - version "0.0.5" - resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.5.tgz#23cc83e60eb6b15734247fac1d77f1fd629ffdb6" - integrity sha512-R86X9MjMty14eoQ4djulZSdHf9mIF9dPcj4g+SABqdA6AqbewS0/BQGNGR5p6gXhqc4+mT8rzkutywdPnMUNfA== +"@agile-ts/utils@^0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.7.tgz#3dd1add6b9f63d0a5bf35e71f54ac46448ae047f" + integrity sha512-OviTDC+ZbfyiUx8Gy8veS6YymC/tT6UeP23nT8V0EQV4F2MmuWqZ2yiKk+AYxZx8h74Ey8BVEUX6/ntpxhSNPw== "@babel/runtime@^7.12.1", "@babel/runtime@^7.9.2": version "7.14.6" @@ -22,10 +27,10 @@ dependencies: regenerator-runtime "^0.13.4" -"@hookstate/core@^3.0.8": - version "3.0.8" - resolved "https://registry.yarnpkg.com/@hookstate/core/-/core-3.0.8.tgz#d6838153d6d43c2f35cfca475c31248192564e62" - integrity sha512-blQagGIVIbNoUiNCRrvaXqFmUe7WGMY35ok/LENfl2pcIsLBjkreYIZiaSFi83tkycwq7ZOmcQz/R1nvLKhH8w== +"@hookstate/core@^3.0.11": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@hookstate/core/-/core-3.0.11.tgz#515f2748b3741f3d7e90cbc1acff8d6ac84e5494" + integrity sha512-cbI711aFWX4d8+xkLxikmLnR+f55ePHrBMWFA4gTLEoCa1+Cg0pfNw7h7zSodYMeYt8Y5A5TVSh7a5p8lBC89A== "@pulsejs/core@^4.0.0-beta.3": version "4.0.0-beta.3" @@ -101,6 +106,18 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== +"@types/yargs-parser@*": + version "20.2.1" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" + integrity sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw== + +"@types/yargs@^17.0.2": + version "17.0.2" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.2.tgz#8fb2e0f4cdc7ab2a1a570106e56533f31225b584" + integrity sha512-JhZ+pNdKMfB0rXauaDlrIvm+U7V4m03PPOSVoPS66z8gf+G4Z/UW8UlrVIj2MRQOBzuoEvYtjS0bqYwnpZaS9Q== + dependencies: + "@types/yargs-parser" "*" + "@types/yauzl@^2.9.1": version "2.9.2" resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.2.tgz#c48e5d56aff1444409e39fa164b0b4d4552a7b7a" @@ -115,7 +132,12 @@ agent-base@6: dependencies: debug "4" -ansi-styles@^4.1.0: +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -156,6 +178,15 @@ chalk@^4.1.1: ansi-styles "^4.1.0" supports-color "^7.1.0" +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -200,6 +231,11 @@ dotenv@^10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -220,6 +256,11 @@ esbuild@^0.12.14, esbuild@^0.12.8: resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.14.tgz#43157dbd0b36d939247d4eb4909a4886ac40f82e" integrity sha512-z8p+6FGiplR7a3pPonXREbm+8IeXjBGvDpVidZmGB/AJMsJSfGCU+n7KOMCazA9AwvagadRWBhiKorC0w9WJvw== +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + escape-string-regexp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" @@ -248,6 +289,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + get-stream@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" @@ -315,6 +361,11 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + jotai@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.1.2.tgz#3f211e0c03c74e95ea6fd7a69c1d2b65731009bf" @@ -537,6 +588,11 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + reselect@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" @@ -574,6 +630,22 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" + integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -593,6 +665,15 @@ valtio@^1.0.6: dependencies: proxy-compare "2.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -603,6 +684,29 @@ ws@^7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.1.tgz#44fc000d87edb1d9c53e51fbc69a0ac1f6871d66" integrity sha512-2c6faOUH/nhoQN6abwMloF7Iyl0ZS2E9HGtsiLrWn0zOOMWlhtDmdf/uihDt6jnuCxgtwGBNy6Onsoy2s2O2Ow== +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs@^17.1.0: + version "17.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.1.0.tgz#0cd9827a0572c9a1795361c4d1530e53ada168cf" + integrity sha512-SQr7qqmQ2sNijjJGHL4u7t8vyDZdZ3Ahkmo4sc1w5xI9TBX0QDdG/g4SFnxtWOsGLjwHQue57eFALfwFCnixgg== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" diff --git a/examples/plainjs/develop/tree-shaking/.gitignore b/examples/plainjs/develop/tree-shaking/.gitignore new file mode 100644 index 00000000..6f1daf39 --- /dev/null +++ b/examples/plainjs/develop/tree-shaking/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.yalc +yalc.lock diff --git a/examples/plainjs/develop/tree-shaking/package.json b/examples/plainjs/develop/tree-shaking/package.json new file mode 100644 index 00000000..2bd5d547 --- /dev/null +++ b/examples/plainjs/develop/tree-shaking/package.json @@ -0,0 +1,22 @@ +{ + "name": "tree-shiking-test", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "webpack", + "install:dev:agile": "yalc add @agile-ts/core @agile-ts/logger & yarn install", + "install:prod:agile": "yarn add @agile-ts/core @agile-ts/logger & yarn install" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "webpack": "^5.47.0", + "webpack-cli": "^4.8.0" + }, + "dependencies": { + "@agile-ts/core": "file:.yalc/@agile-ts/core", + "@agile-ts/logger": "file:.yalc/@agile-ts/logger" + } +} diff --git a/examples/plainjs/develop/tree-shaking/src/index.js b/examples/plainjs/develop/tree-shaking/src/index.js new file mode 100644 index 00000000..ed9cc3c7 --- /dev/null +++ b/examples/plainjs/develop/tree-shaking/src/index.js @@ -0,0 +1,5 @@ +import { createLightState } from '@agile-ts/core'; + +const MY_STATE = createLightState('hi'); + +console.log(MY_STATE.value); diff --git a/examples/plainjs/develop/tree-shaking/webpack.config.js b/examples/plainjs/develop/tree-shaking/webpack.config.js new file mode 100644 index 00000000..64c455e3 --- /dev/null +++ b/examples/plainjs/develop/tree-shaking/webpack.config.js @@ -0,0 +1,16 @@ +const path = require('path'); + +module.exports = { + entry: './src/index.js', + output: { + filename: 'main.js', + path: path.resolve(__dirname, 'dist'), + }, + mode: 'development', + optimization: { + usedExports: true, + innerGraph: true, + sideEffects: true, + }, + devtool: false, +}; diff --git a/examples/react-native/develop/AwesomeTSProject/core/index.ts b/examples/react-native/develop/AwesomeTSProject/core/index.ts index 011ceb0e..3d4dc07f 100644 --- a/examples/react-native/develop/AwesomeTSProject/core/index.ts +++ b/examples/react-native/develop/AwesomeTSProject/core/index.ts @@ -1,20 +1,16 @@ -import { Agile } from '@agile-ts/core'; -import { Event } from '@agile-ts/event'; +import { createState, createComputed, createCollection } from '@agile-ts/core'; +import { createEvent, Event } from '@agile-ts/event'; import { Alert } from 'react-native'; -export const App = new Agile({ - logConfig: { active: true }, -}); - -export const MY_STATE = App.createState('MyState', { key: 'my-state' }); //.persist(); -export const MY_STATE_2 = App.createState('MyState2'); //.persist("my-state2"); -export const MY_STATE_3 = App.createState(1); //.persist("my-state2"); +export const MY_STATE = createState('MyState', { key: 'my-state' }); //.persist(); +export const MY_STATE_2 = createState('MyState2'); //.persist("my-state2"); +export const MY_STATE_3 = createState(1); //.persist("my-state2"); MY_STATE.watch('test', (value: any) => { console.log('Watch ' + value); }); -export const MY_COMPUTED = App.createComputed(() => { +export const MY_COMPUTED = createComputed(() => { return 'test' + MY_STATE.value + '_computed_' + MY_STATE_2.value; }); @@ -23,7 +19,7 @@ interface collectionValueInterface { name: string; } -export const MY_COLLECTION = App.createCollection( +export const MY_COLLECTION = createCollection( (collection) => ({ key: 'my-collection', groups: { @@ -43,7 +39,7 @@ MY_COLLECTION.getGroup('myGroup')?.persist({ console.log('Initial: myCollection ', MY_COLLECTION); -export const MY_EVENT = new Event<{ name: string }>(App); +export const MY_EVENT = createEvent<{ name: string }>(); MY_EVENT.on('Test', (payload) => { Alert.alert( diff --git a/examples/react/develop/class-component-ts/src/core/index.ts b/examples/react/develop/class-component-ts/src/core/index.ts index 1e8c9784..81b85b00 100644 --- a/examples/react/develop/class-component-ts/src/core/index.ts +++ b/examples/react/develop/class-component-ts/src/core/index.ts @@ -1,25 +1,25 @@ -import { Agile, clone, Logger } from '@agile-ts/core'; -import { Event } from '@agile-ts/event'; +import { + clone, + createState, + createComputed, + createCollection, +} from '@agile-ts/core'; +import { createEvent, Event } from '@agile-ts/event'; -export const App = new Agile({ - logConfig: { level: Logger.level.DEBUG, timestamp: true }, - waitForMount: false, -}); - -export const MY_STATE = App.createState('MyState'); //.persist(); -export const MY_STATE_2 = App.createState('MyState2', { +export const MY_STATE = createState('MyState'); //.persist(); +export const MY_STATE_2 = createState('MyState2', { key: 'myState2', }).persist(); MY_STATE_2.onLoad(() => { console.log('On Load'); }); -export const MY_STATE_3 = App.createState(1); //.persist("my-state2"); +export const MY_STATE_3 = createState(1); //.persist("my-state2"); MY_STATE.watch('test', (value: any) => { console.log('Watch ' + value); }); -export const MY_COMPUTED = App.createComputed(() => { +export const MY_COMPUTED = createComputed(() => { return 'test' + MY_STATE.value + '_computed_' + MY_STATE_2.value; }, []).setKey('myComputed'); @@ -28,7 +28,7 @@ interface collectionValueInterface { name: string; } -export const MY_COLLECTION = App.createCollection( +export const MY_COLLECTION = createCollection( (collection) => ({ key: 'my-collection', groups: { @@ -48,7 +48,7 @@ MY_COLLECTION.getGroup('myGroup')?.persist({ console.log('Initial: myCollection ', clone(MY_COLLECTION)); -export const MY_EVENT = new Event<{ name: string }>(App, { +export const MY_EVENT = createEvent<{ name: string }>({ delay: 3000, key: 'myEvent', }); diff --git a/examples/react/develop/fields/.env b/examples/react/develop/fields/.env new file mode 100644 index 00000000..6f809cc2 --- /dev/null +++ b/examples/react/develop/fields/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true diff --git a/examples/react/develop/fields/.gitignore b/examples/react/develop/fields/.gitignore new file mode 100644 index 00000000..4d29575d --- /dev/null +++ b/examples/react/develop/fields/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/develop/fields/README.md b/examples/react/develop/fields/README.md new file mode 100644 index 00000000..02aac3f6 --- /dev/null +++ b/examples/react/develop/fields/README.md @@ -0,0 +1,70 @@ +# Getting Started with Create React App + +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `yarn start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `yarn build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `yarn eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). + +### Code Splitting + +This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) + +### Analyzing the Bundle Size + +This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) + +### Making a Progressive Web App + +This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) + +### Advanced Configuration + +This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) + +### Deployment + +This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) + +### `yarn build` fails to minify + +This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) diff --git a/examples/react/develop/fields/package.json b/examples/react/develop/fields/package.json new file mode 100644 index 00000000..ad32f1c1 --- /dev/null +++ b/examples/react/develop/fields/package.json @@ -0,0 +1,38 @@ +{ + "name": "fields", + "version": "0.1.0", + "private": true, + "dependencies": { + "@agile-ts/core": "file:.yalc/@agile-ts/core", + "@agile-ts/react": "file:.yalc/@agile-ts/react", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-scripts": "4.0.3" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "install:dev:agile": "yalc add @agile-ts/core @agile-ts/react & yarn install", + "install:prod:agile": "yarn add @agile-ts/core @agile-ts/react & yarn install" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react/develop/fields/public/favicon.ico b/examples/react/develop/fields/public/favicon.ico new file mode 100644 index 00000000..a11777cc Binary files /dev/null and b/examples/react/develop/fields/public/favicon.ico differ diff --git a/examples/react/develop/fields/public/index.html b/examples/react/develop/fields/public/index.html new file mode 100644 index 00000000..aa069f27 --- /dev/null +++ b/examples/react/develop/fields/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/examples/react/develop/fields/public/logo192.png b/examples/react/develop/fields/public/logo192.png new file mode 100644 index 00000000..fc44b0a3 Binary files /dev/null and b/examples/react/develop/fields/public/logo192.png differ diff --git a/examples/react/develop/fields/public/logo512.png b/examples/react/develop/fields/public/logo512.png new file mode 100644 index 00000000..a4e47a65 Binary files /dev/null and b/examples/react/develop/fields/public/logo512.png differ diff --git a/examples/react/develop/fields/public/manifest.json b/examples/react/develop/fields/public/manifest.json new file mode 100644 index 00000000..080d6c77 --- /dev/null +++ b/examples/react/develop/fields/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/react/develop/fields/public/robots.txt b/examples/react/develop/fields/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/examples/react/develop/fields/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/react/develop/fields/src/App.js b/examples/react/develop/fields/src/App.js new file mode 100644 index 00000000..5796839d --- /dev/null +++ b/examples/react/develop/fields/src/App.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { createCollection, LogCodeManager, shared } from '@agile-ts/core'; +import reactIntegration, { useAgile, useValue } from '@agile-ts/react'; + +LogCodeManager.setAllowLogging(false); +shared.integrate(reactIntegration); + +const FIELDS = createCollection({ + initialData: Array.from(Array(5000).keys()).map((i) => ({ + id: i, + name: `Field #${i + 1}`, + })), +}); + +let renderFieldsCount = 0; + +function Field({ index }) { + const ITEM = FIELDS.getItem(index); + const item = useAgile(ITEM); + + console.log(`Rerender Fields at '${index}':`, ++renderFieldsCount); + + return ( +
+ Last {``} render at: {new Date().toISOString()} +   + { + ITEM?.patch({ name: e.target.value }); + }} + /> +
+ ); +} + +export default function App() { + const fieldKeys = useValue(FIELDS); + + return ( +
+
+ Last {``} render at: {new Date().toISOString()} +
+
+ {fieldKeys.map((key, i) => ( + + ))} + +
+ ); +} diff --git a/examples/react/develop/fields/src/index.js b/examples/react/develop/fields/src/index.js new file mode 100644 index 00000000..c1f31c5f --- /dev/null +++ b/examples/react/develop/fields/src/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/examples/react/develop/functional-component-ts/src/App.tsx b/examples/react/develop/functional-component-ts/src/App.tsx index 4c4d8f35..2321584a 100644 --- a/examples/react/develop/functional-component-ts/src/App.tsx +++ b/examples/react/develop/functional-component-ts/src/App.tsx @@ -1,7 +1,6 @@ import React, { useEffect } from 'react'; import './App.css'; import { useAgile, useWatcher, useProxy, useSelector } from '@agile-ts/react'; -import { useEvent } from '@agile-ts/event'; import { COUNTUP, externalCreatedItem, @@ -13,13 +12,15 @@ import { MY_STATE_3, STATE_OBJECT, } from './core'; -import { generateId, globalBind, Item } from '@agile-ts/core'; +import { generateId } from '@agile-ts/utils'; +import { globalBind } from '@agile-ts/core'; +import { useEvent } from '@agile-ts/event'; let rerenderCount = 0; let rerenderCountInCountupView = 0; const App = (props: any) => { - // Note: Rerenders twice because of React Strickt Mode (also useState does trigger a rerender twice) + // Note: re-renders twice because of React strict Mode (also useState does trigger a rerender twice) // https://stackoverflow.com/questions/54927622/usestate-do-double-render rerenderCount++; @@ -43,9 +44,12 @@ const App = (props: any) => { ]); const [myGroup] = useAgile([MY_COLLECTION.getGroupWithReference('myGroup')]); - const selectedObjectItem = useSelector(STATE_OBJECT, (value) => { - return value.age; - }); + const stateObjectAge = useSelector( + STATE_OBJECT, + (value) => { + return value.age; + } + ); const [stateObject, item2, collection2] = useProxy( [STATE_OBJECT, MY_COLLECTION.getItem('id2'), MY_COLLECTION], @@ -55,8 +59,6 @@ const App = (props: any) => { console.log('Item1: ', item2?.name); console.log('Collection: ', collection2.slice(0, 2)); - // const myCollection2 = useAgile(MY_COLLECTION); - const mySelector = useAgile(MY_COLLECTION.getSelector('mySelector')); useEvent(MY_EVENT, () => { @@ -125,6 +127,13 @@ const App = (props: any) => { }}> Change shallow name +

Age: {stateObjectAge}

+
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 c98da0af..5d74ad72 100644 --- a/examples/react/develop/functional-component-ts/src/core/index.ts +++ b/examples/react/develop/functional-component-ts/src/core/index.ts @@ -1,17 +1,29 @@ -import { Agile, clone, Item } from '@agile-ts/core'; -import Event from '@agile-ts/event'; +import Agile, { + assignSharedAgileInstance, + createCollection, + createComputed, + createState, + createStorage, + createStorageManager, + Item, + assignSharedAgileStorageManager, +} from '@agile-ts/core'; +import { createEvent } from '@agile-ts/event'; import { assignSharedAgileLoggerConfig, Logger } from '@agile-ts/logger'; +import { clone } from '@agile-ts/utils'; export const myStorage: any = {}; assignSharedAgileLoggerConfig({ level: Logger.level.DEBUG }); -export const App = new Agile({ - localStorage: true, -}); +export const App = new Agile(); +assignSharedAgileInstance(App); + +export const storageManager = createStorageManager({ localStorage: true }); +assignSharedAgileStorageManager(storageManager); // Register custom second Storage -App.registerStorage( - App.createStorage({ +storageManager.register( + createStorage({ key: 'myStorage', methods: { get: (key: string) => { @@ -30,7 +42,7 @@ App.registerStorage( }) ); -export const STATE_OBJECT = App.createState( +export const STATE_OBJECT = createState( { name: 'frank', age: 10, @@ -43,9 +55,9 @@ export const STATE_OBJECT = App.createState( }, { key: 'state-object' } ); -export const COUNTUP = App.createState(1); // .interval((value) => value + 1, 1000); -export const MY_STATE = App.createState('MyState', { key: 'my-state' }); //.persist(); -export const MY_STATE_2 = App.createState('MyState2', { +export const COUNTUP = createState(1); // .interval((value) => value + 1, 1000); +export const MY_STATE = createState('MyState', { key: 'my-state' }); //.persist(); +export const MY_STATE_2 = createState('MyState2', { key: 'my-state2', }).persist({ storageKeys: ['myStorage', 'localStorage'], @@ -54,13 +66,13 @@ export const MY_STATE_2 = App.createState('MyState2', { MY_STATE_2.onLoad(() => { console.log('On Load MY_STATE_2'); }); -export const MY_STATE_3 = App.createState(1); //.persist("my-state2"); +export const MY_STATE_3 = createState(1); //.persist("my-state2"); MY_STATE.watch('test', (value: any) => { console.log('Watch ' + value); }); -export const MY_COMPUTED = App.createComputed(() => { +export const MY_COMPUTED = createComputed(() => { return 'test' + MY_STATE.value + '_computed_' + MY_STATE_2.value; }, []).setKey('myComputed'); @@ -69,7 +81,7 @@ interface collectionValueInterface { name: string; } -export const MY_COLLECTION = App.createCollection( +export const MY_COLLECTION = createCollection( (collection) => ({ key: 'my-collection', primaryKey: 'key', @@ -102,7 +114,7 @@ export const externalCreatedItem = new Item(MY_COLLECTION, { console.log('Initial: myCollection ', clone(MY_COLLECTION)); -export const MY_EVENT = new Event<{ name: string }>(App, { +export const MY_EVENT = createEvent<{ name: string }>({ delay: 3000, key: 'myEvent', }); diff --git a/examples/react/develop/functional-component-ts/yarn.lock b/examples/react/develop/functional-component-ts/yarn.lock index b44f5283..d760f47c 100644 --- a/examples/react/develop/functional-component-ts/yarn.lock +++ b/examples/react/develop/functional-component-ts/yarn.lock @@ -3,36 +3,36 @@ "@agile-ts/api@file:.yalc/@agile-ts/api": - version "0.0.19" + version "0.0.21" dependencies: - "@agile-ts/utils" "^0.0.5" + "@agile-ts/utils" "^0.0.7" "@agile-ts/core@file:.yalc/@agile-ts/core": - version "0.1.0" + version "0.2.0-alpha.4" dependencies: - "@agile-ts/utils" "^0.0.5" + "@agile-ts/utils" "^0.0.7" "@agile-ts/event@file:.yalc/@agile-ts/event": - version "0.0.8" + version "0.0.10" "@agile-ts/logger@file:.yalc/@agile-ts/logger": - version "0.0.5" + version "0.0.7" dependencies: - "@agile-ts/utils" "^0.0.5" + "@agile-ts/utils" "^0.0.7" "@agile-ts/multieditor@file:.yalc/@agile-ts/multieditor": - version "0.0.18" + version "0.0.20" "@agile-ts/proxytree@file:.yalc/@agile-ts/proxytree": - version "0.0.4" + version "0.0.5" "@agile-ts/react@file:.yalc/@agile-ts/react": - version "0.1.0" + version "0.2.0-alpha.1" -"@agile-ts/utils@^0.0.5": - version "0.0.5" - resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.5.tgz#23cc83e60eb6b15734247fac1d77f1fd629ffdb6" - integrity sha512-R86X9MjMty14eoQ4djulZSdHf9mIF9dPcj4g+SABqdA6AqbewS0/BQGNGR5p6gXhqc4+mT8rzkutywdPnMUNfA== +"@agile-ts/utils@^0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.7.tgz#3dd1add6b9f63d0a5bf35e71f54ac46448ae047f" + integrity sha512-OviTDC+ZbfyiUx8Gy8veS6YymC/tT6UeP23nT8V0EQV4F2MmuWqZ2yiKk+AYxZx8h74Ey8BVEUX6/ntpxhSNPw== "@babel/code-frame@7.8.3": version "7.8.3" diff --git a/examples/react/develop/multieditor-ts/src/core/agile.ts b/examples/react/develop/multieditor-ts/src/core/agile.ts index e212a41d..d0e37e07 100644 --- a/examples/react/develop/multieditor-ts/src/core/agile.ts +++ b/examples/react/develop/multieditor-ts/src/core/agile.ts @@ -1,10 +1,6 @@ import { Agile, globalBind } from '@agile-ts/core'; -const App = new Agile({ - logConfig: { - active: true, - }, -}); +const App = new Agile(); export default App; diff --git a/examples/react/develop/simple-counter/package.json b/examples/react/develop/simple-counter/package.json index 8113e04e..0a89113d 100644 --- a/examples/react/develop/simple-counter/package.json +++ b/examples/react/develop/simple-counter/package.json @@ -2,25 +2,35 @@ "name": "counter", "version": "0.1.0", "private": true, - "dependencies": { - "@agile-ts/core": "^0.0.17", - "@agile-ts/react": "^0.1.0", - "react": "17.0.2", - "react-dom": "17.0.2", - "react-scripts": "4.0.0" - }, - "devDependencies": { - "source-map-explorer": "^2.5.2" - }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "analyze": "source-map-explorer 'build/static/js/*.js'", + "analyze": "yarn run build && source-map-explorer 'build/static/js/*.js'", + "analyze:webpack": "node scripts/analyze.js", "install:dev:agile": "yalc add @agile-ts/core @agile-ts/react @agile-ts/logger & yarn install", "install:prod:agile": "yarn add @agile-ts/core @agile-ts/react @agile-ts/logger & yarn install" }, + "dependencies": { + "@agile-ts/core": "file:.yalc/@agile-ts/core", + "@agile-ts/logger": "file:.yalc/@agile-ts/logger", + "@agile-ts/react": "file:.yalc/@agile-ts/react", + "@hookstate/core": "^3.0.8", + "@reduxjs/toolkit": "^1.6.1", + "dotenv": "^10.0.0", + "jotai": "^1.2.2", + "nanostores": "^0.4.1", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-redux": "^7.2.4", + "react-scripts": "4.0.0", + "recoil": "^0.4.0" + }, + "devDependencies": { + "source-map-explorer": "^2.5.2", + "webpack-bundle-analyzer": "^4.4.2" + }, "browserslist": { "production": [ ">0.2%", diff --git a/examples/react/develop/simple-counter/scripts/analyze.js b/examples/react/develop/simple-counter/scripts/analyze.js new file mode 100644 index 00000000..59a7bb13 --- /dev/null +++ b/examples/react/develop/simple-counter/scripts/analyze.js @@ -0,0 +1,38 @@ +// https://medium.com/@hamidihamza/optimize-react-web-apps-with-webpack-bundle-analyzer-6ecb9f162c76 +// Note: Webpack Bundle Analyzer doesn't show accurately which bundles were tree shaken +// (See: https://github.com/webpack-contrib/webpack-bundle-analyzer/issues/161) + +const dotenv = require('dotenv'); + +// Loads environment variables from the '.env' file +dotenv.config(); + +// https://nodejs.org/docs/latest/api/process.html#process_process_argv +const isDev = process.argv.includes('--dev') || process.env.DEV === 'true'; + +console.log( + `Start bundling a '${isDev ? 'development' : 'production'}' build!` +); + +process.env.NODE_ENV = isDev ? 'development' : 'production'; + +const webpack = require('webpack'); +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') + .BundleAnalyzerPlugin; +const webpackConfigProd = require('react-scripts/config/webpack.config')( + 'production' +); +const webpackConfigDev = require('react-scripts/config/webpack.config')( + 'development' +); + +// Add Bundle Analyzer Plugin to React webpack config +webpackConfigProd.plugins.push(new BundleAnalyzerPlugin()); +webpackConfigDev.plugins.push(new BundleAnalyzerPlugin()); + +// Build project with webpack +webpack(isDev ? webpackConfigDev : webpackConfigProd, (err, stats) => { + if (err || stats.hasErrors()) { + console.error(err); + } +}); diff --git a/examples/react/develop/simple-counter/src/index.js b/examples/react/develop/simple-counter/src/index.js index dd1c38f9..6e17f3fd 100644 --- a/examples/react/develop/simple-counter/src/index.js +++ b/examples/react/develop/simple-counter/src/index.js @@ -1,11 +1,21 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import App from './App'; +import * as Agile from './state-manager/Agile'; +import * as Jotai from './state-manager/Jotai'; +import * as NanoStores from './state-manager/NanoStores'; +import * as Recoil from './state-manager/Recoil'; +import * as ReduxToolkit from './state-manager/ReduxToolkit'; +import * as Hookstate from './state-manager/Hookstate'; ReactDOM.render( - + + + + + + , document.getElementById('root') ); diff --git a/examples/react/develop/simple-counter/src/App.js b/examples/react/develop/simple-counter/src/state-manager/Agile.js similarity index 65% rename from examples/react/develop/simple-counter/src/App.js rename to examples/react/develop/simple-counter/src/state-manager/Agile.js index 36c4a037..8ec4ad49 100644 --- a/examples/react/develop/simple-counter/src/App.js +++ b/examples/react/develop/simple-counter/src/state-manager/Agile.js @@ -1,11 +1,12 @@ -import { Agile } from '@agile-ts/core'; +import React from 'react'; +import { createLightState } from '@agile-ts/core'; import { useAgile } from '@agile-ts/react'; -const AgileApp = new Agile(); - -const COUNTER_A = AgileApp.createState(1); -const COUNTER_B = AgileApp.createState(2); -const COUNTER_C = AgileApp.createState(3); +// registerStorageManager(createStorageManager({ localStorage: true })); +// const COUNTER_A = createState(1).persist('persistKey'); +const COUNTER_A = createLightState(1); +const COUNTER_B = createLightState(2); +const COUNTER_C = createLightState(3); const CounterA = () => { const count = useAgile(COUNTER_A); @@ -34,8 +35,9 @@ const CounterC = () => { ); }; -const App = () => ( +export const App = () => (
+

Agile

@@ -44,5 +46,3 @@ const App = () => (
); - -export default App; diff --git a/examples/react/develop/simple-counter/src/state-manager/Hookstate.js b/examples/react/develop/simple-counter/src/state-manager/Hookstate.js new file mode 100644 index 00000000..0d9b86d4 --- /dev/null +++ b/examples/react/develop/simple-counter/src/state-manager/Hookstate.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { createState, useState } from '@hookstate/core'; + +const COUNTER_A = createState(1); +const COUNTER_B = createState(2); +const COUNTER_C = createState(3); + +const CounterA = () => { + const count = useState(COUNTER_A); + return ( +
+ A: {count.get()}{' '} + +
+ ); +}; + +const CounterB = () => { + const count = useState(COUNTER_B); + return ( +
+ B: {count.get()}{' '} + +
+ ); +}; + +const CounterC = () => { + const count = useState(COUNTER_C); + return ( +
+ C: {count.get()}{' '} + +
+ ); +}; + +export const App = () => ( +
+

Hookstate

+ + + + + + +
+); diff --git a/examples/react/develop/simple-counter/src/state-manager/Jotai.js b/examples/react/develop/simple-counter/src/state-manager/Jotai.js new file mode 100644 index 00000000..093bb7b1 --- /dev/null +++ b/examples/react/develop/simple-counter/src/state-manager/Jotai.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { atom, useAtom } from 'jotai'; + +const COUNTER_A = atom(1); +const COUNTER_B = atom(2); +const COUNTER_C = atom(3); + +const CounterA = () => { + const [count, setCount] = useAtom(COUNTER_A); + return ( +
+ A: {count} +
+ ); +}; + +const CounterB = () => { + const [count, setCount] = useAtom(COUNTER_B); + return ( +
+ B: {count} +
+ ); +}; + +const CounterC = () => { + const [count, setCount] = useAtom(COUNTER_C); + return ( +
+ C: {count} +
+ ); +}; + +export const App = () => ( +
+

Jotai

+ + + + + + +
+); diff --git a/examples/react/develop/simple-counter/src/state-manager/NanoStores.js b/examples/react/develop/simple-counter/src/state-manager/NanoStores.js new file mode 100644 index 00000000..f62e8067 --- /dev/null +++ b/examples/react/develop/simple-counter/src/state-manager/NanoStores.js @@ -0,0 +1,55 @@ +import React from 'react'; +import { createStore, update } from 'nanostores'; +import { useStore } from 'nanostores/react'; + +const COUNTER_A = createStore(() => { + COUNTER_A.set(1); +}); +const COUNTER_B = createStore(() => { + COUNTER_B.set(1); +}); +const COUNTER_C = createStore(() => { + COUNTER_C.set(1); +}); + +const CounterA = () => { + const count = useStore(COUNTER_A); + return ( +
+ A: {count}{' '} + +
+ ); +}; + +const CounterB = () => { + const count = useStore(COUNTER_B); + return ( +
+ B: {count}{' '} + +
+ ); +}; + +const CounterC = () => { + const count = useStore(COUNTER_C); + return ( +
+ C: {count}{' '} + +
+ ); +}; + +export const App = () => ( +
+

Nano Stores

+ + + + + + +
+); diff --git a/examples/react/develop/simple-counter/src/state-manager/Recoil.js b/examples/react/develop/simple-counter/src/state-manager/Recoil.js new file mode 100644 index 00000000..04af2cd9 --- /dev/null +++ b/examples/react/develop/simple-counter/src/state-manager/Recoil.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { atom, RecoilRoot, useRecoilState } from 'recoil'; + +const COUNTER_A = atom({ + key: 'counterA', + default: 1, +}); +const COUNTER_B = atom({ key: 'counterB', default: 2 }); +const COUNTER_C = atom({ key: 'counterC', default: 3 }); + +const CounterA = () => { + const [count, setCount] = useRecoilState(COUNTER_A); + return ( +
+ A: {count} +
+ ); +}; + +const CounterB = () => { + const [count, setCount] = useRecoilState(COUNTER_B); + return ( +
+ B: {count} +
+ ); +}; + +const CounterC = () => { + const [count, setCount] = useRecoilState(COUNTER_C); + return ( +
+ C: {count} +
+ ); +}; + +export const App = () => ( + +

Recoil

+ + + + + + +
+); diff --git a/examples/react/develop/simple-counter/src/state-manager/ReduxToolkit.js b/examples/react/develop/simple-counter/src/state-manager/ReduxToolkit.js new file mode 100644 index 00000000..ae022656 --- /dev/null +++ b/examples/react/develop/simple-counter/src/state-manager/ReduxToolkit.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import { Provider, useSelector, useDispatch } from 'react-redux'; + +const counterSlice_A = createSlice({ + name: 'counterA', + initialState: { + value: 0, + }, + reducers: { + increment: (state) => { + state.value += 1; + }, + }, +}); + +const counterSlice_B = createSlice({ + name: 'counterB', + initialState: { + value: 0, + }, + reducers: { + increment: (state) => { + state.value += 1; + }, + }, +}); + +const counterSlice_C = createSlice({ + name: 'counterC', + initialState: { + value: 0, + }, + reducers: { + increment: (state) => { + state.value += 1; + }, + }, +}); + +const store = configureStore({ + reducer: { + counterA: counterSlice_A.reducer, + counterB: counterSlice_B.reducer, + counterC: counterSlice_C.reducer, + }, +}); + +const CounterA = () => { + const count = useSelector((state) => state.counterA?.value); + const dispatch = useDispatch(); + return ( +
+ A: {count}{' '} + +
+ ); +}; + +const CounterB = () => { + const count = useSelector((state) => state.counterB?.value); + const dispatch = useDispatch(); + return ( +
+ B: {count}{' '} + +
+ ); +}; + +const CounterC = () => { + const count = useSelector((state) => state.counterC?.value); + const dispatch = useDispatch(); + return ( +
+ C: {count}{' '} + +
+ ); +}; + +export const App = () => ( + +

Redux Toolkit

+ + + + + + +
+); diff --git a/examples/react/develop/simple-counter/yarn.lock b/examples/react/develop/simple-counter/yarn.lock index e0c5ba2c..2505ba28 100644 --- a/examples/react/develop/simple-counter/yarn.lock +++ b/examples/react/develop/simple-counter/yarn.lock @@ -2,37 +2,23 @@ # yarn lockfile v1 -"@agile-ts/core@^0.0.17": - version "0.0.17" - resolved "https://registry.yarnpkg.com/@agile-ts/core/-/core-0.0.17.tgz#410ab31ff6279567ff0266a5a55818744598f2c2" - integrity sha512-EVWqf6PkBwDS/gdTTVwo9juyGPrnnKlq8dna3diXGZdDpwEzMc09nGCmLThYM5sEkDQGzir6enn3Oo2l+7Zp2Q== +"@agile-ts/core@file:.yalc/@agile-ts/core": + version "0.2.0-alpha.3" dependencies: - "@agile-ts/logger" "^0.0.4" - "@agile-ts/utils" "^0.0.4" + "@agile-ts/utils" "^0.0.7" -"@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== +"@agile-ts/logger@file:.yalc/@agile-ts/logger": + version "0.0.7" dependencies: - "@agile-ts/utils" "^0.0.4" - -"@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/utils" "^0.0.7" -"@agile-ts/react@^0.0.18": - version "0.0.18" - resolved "https://registry.yarnpkg.com/@agile-ts/react/-/react-0.0.18.tgz#db1a617ad535f7a70254d62980d97350d4a85718" - integrity sha512-K2FO3Odqaw/XkU3DO/mWSLkxLn45W7pXk/UlZl5E/CQPFFWlWsjuxtH/C/kfK+E6rnaNoToTjGscmcoeN/bLjQ== - dependencies: - "@agile-ts/proxytree" "^0.0.3" +"@agile-ts/react@file:.yalc/@agile-ts/react": + version "0.1.2" -"@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/utils@^0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.7.tgz#3dd1add6b9f63d0a5bf35e71f54ac46448ae047f" + integrity sha512-OviTDC+ZbfyiUx8Gy8veS6YymC/tT6UeP23nT8V0EQV4F2MmuWqZ2yiKk+AYxZx8h74Ey8BVEUX6/ntpxhSNPw== "@babel/code-frame@7.10.4": version "7.10.4" @@ -1664,6 +1650,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.1", "@babel/runtime@^7.9.2": + version "7.14.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.8.tgz#7119a56f421018852694290b9f9148097391b446" + integrity sha512-twj3L8Og5SaCRCErB4x4ajbvBIVV77CGeFglHpeg5WC5FF8TZzBWXtTJ4MqaD9QszLYTtr+IsaAL2rEUevb+eg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.12.13", "@babel/template@^7.3.3": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" @@ -1790,6 +1783,11 @@ dependencies: "@hapi/hoek" "^8.3.0" +"@hookstate/core@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@hookstate/core/-/core-3.0.8.tgz#d6838153d6d43c2f35cfca475c31248192564e62" + integrity sha512-blQagGIVIbNoUiNCRrvaXqFmUe7WGMY35ok/LENfl2pcIsLBjkreYIZiaSFi83tkycwq7ZOmcQz/R1nvLKhH8w== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -2018,6 +2016,21 @@ schema-utils "^2.6.5" source-map "^0.7.3" +"@polka/url@^1.0.0-next.20": + version "1.0.0-next.20" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.20.tgz#111b5db0f501aa89b05076fa31f0ea0e0c292cd3" + integrity sha512-88p7+M0QGxKpmnkfXjS4V26AnoC/eiqZutE8GLdaI5X12NY75bXSdTY9NkmYb2Xyk1O+MmkuO6Frmsj84V6I8Q== + +"@reduxjs/toolkit@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.6.1.tgz#7bc83b47352a663bf28db01e79d17ba54b98ade9" + integrity sha512-pa3nqclCJaZPAyBhruQtiRwtTjottRrVJqziVZcWzI73i6L3miLTtUyWfauwv08HWtiXLx1xGyGt+yLFfW/d0A== + dependencies: + immer "^9.0.1" + redux "^4.1.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@rollup/plugin-node-resolve@^7.1.1": version "7.1.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca" @@ -2242,6 +2255,14 @@ dependencies: "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/html-minifier-terser@^5.0.0": version "5.1.1" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#3c9ee980f1a10d6021ae6632ca3e79ca2ec4fb50" @@ -2301,11 +2322,35 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.1.tgz#374e31645d58cb18a07b3ecd8e9dede4deb2cccd" integrity sha512-DxZZbyMAM9GWEzXL+BMZROWz9oo6A9EilwwOMET2UVu2uZTqMWS5S69KVtuVKaRjCUpcrOXRalet86/OpG4kqw== +"@types/prop-types@*": + version "15.7.4" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" + integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + "@types/q@^1.5.1": version "1.5.4" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@types/react-redux@^7.1.16": + version "7.1.18" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.18.tgz#2bf8fd56ebaae679a90ebffe48ff73717c438e04" + integrity sha512-9iwAsPyJ9DLTRH+OFeIrm9cAbIj1i2ANL3sKQFATqnPWRbg+jEFXyZOKHiQK/N86pNRXbb4HRxAxo0SIX1XwzQ== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + +"@types/react@*": + version "17.0.15" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.15.tgz#c7533dc38025677e312606502df7656a6ea626d0" + integrity sha512-uTKHDK9STXFHLaKv6IMnwp52fm0hwU+N89w/p9grdUqcFA6WuqDyPhaWopbNyE1k/VhgzmHl8pu1L4wITtmlLw== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/resolve@0.0.8": version "0.0.8" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194" @@ -2313,6 +2358,11 @@ dependencies: "@types/node" "*" +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/source-list-map@*": version "0.1.2" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" @@ -2661,6 +2711,11 @@ acorn-walk@^7.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.1.1.tgz#3ddab7f84e4a7e2313f6c414c5b7dac85f4e3ebc" + integrity sha512-FbJdceMlPHEAWJOILDk1fXD8lnTlEIWFkqtfk+MvmL5q/qlHfN7GEHcsFZWt/Tea9jRNPWUZG4G976nqAAmU9w== + acorn@^6.4.1: version "6.4.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" @@ -2671,6 +2726,11 @@ acorn@^7.1.0, acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.0.4: + version "8.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c" + integrity sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA== + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -3916,6 +3976,11 @@ commander@^4.1.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== +commander@^6.2.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + common-tags@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" @@ -4396,6 +4461,11 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.0.8" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" + integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -4724,6 +4794,11 @@ dotenv@8.2.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== +dotenv@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" + integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== + duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -5897,6 +5972,11 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" +hamt_plus@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/hamt_plus/-/hamt_plus-1.0.2.tgz#e21c252968c7e33b20f6a1b094cd85787a265601" + integrity sha1-4hwlKWjH4zsg9qGwlM2FeHomVgE= + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -6009,6 +6089,13 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + hoopy@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" @@ -6227,6 +6314,11 @@ immer@8.0.1: resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== +immer@^9.0.1: + version "9.0.5" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.5.tgz#a7154f34fe7064f15f00554cc94c66cc0bf453ec" + integrity sha512-2WuIehr2y4lmYz9gaQzetPR2ECniCifk4ORaQbU3g5EalLt+0IVTosEPJ5BoYl/75ky2mivzdRzV8wWgQGOSYQ== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -7192,6 +7284,11 @@ jest@26.6.0: import-local "^3.0.2" jest-cli "^26.6.0" +jotai@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.2.2.tgz#631fd7ad44e9ac26cdf9874d52282c1cfe032807" + integrity sha512-iqkkUdWsH2Mk4HY1biba/8kA77+8liVBy8E0d8Nce29qow4h9mzdDhpTasAruuFYPycw6JvfZgL5RB0JJuIZjw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -7721,7 +7818,7 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.4.4: +mime@^2.3.1, mime@^2.4.4: version "2.5.2" resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== @@ -7907,6 +8004,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +nanostores@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/nanostores/-/nanostores-0.4.1.tgz#61c7a4aadca063bd1992e80f9a0e8b20d55dee0f" + integrity sha512-GfrWjngWVTBa3YSPijFGrxyYYEE017ONA/t6d6X6G99ccfED+eZEIciH+KKCqbvZhvRbqGH0aHDhRYNBwFw8hg== + native-url@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" @@ -8225,6 +8327,11 @@ open@^7.0.2, open@^7.3.1: is-docker "^2.0.0" is-wsl "^2.1.1" +opener@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== + opn@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" @@ -9570,7 +9677,7 @@ react-error-overlay@^6.0.9: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== -react-is@^16.8.1: +react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -9580,6 +9687,18 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== +react-redux@^7.2.4: + version "7.2.4" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225" + integrity sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA== + dependencies: + "@babel/runtime" "^7.12.1" + "@types/react-redux" "^7.1.16" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.13.1" + react-refresh@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" @@ -9732,6 +9851,13 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" +recoil@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/recoil/-/recoil-0.4.0.tgz#2a933ba7c043cbf6f50f52da0828a879c7cd7d69" + integrity sha512-FZ2ljI4ldZU820V0APbKOtS4bPwPJHvpDBQEl+Cf47DMaM35wuLXl2u37E0TSgdvKAZBOUsIwcBnzE+ncODRxQ== + dependencies: + hamt_plus "1.0.2" + recursive-readdir@2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" @@ -9739,6 +9865,18 @@ recursive-readdir@2.2.2: dependencies: minimatch "3.0.4" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@^4.0.0, redux@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.1.tgz#76f1c439bb42043f985fbd9bf21990e60bd67f47" + integrity sha512-hZQZdDEM25UY2P493kPYuKqviVwZ58lEmGQNeQ+gXa+U0gYPUBf7NKYazbe3m+bs/DzM/ahN12DbF+NG8i0CWw== + dependencies: + "@babel/runtime" "^7.9.2" + regenerate-unicode-properties@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" @@ -9911,6 +10049,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +reselect@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" + integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -10393,6 +10536,15 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +sirv@^1.0.7: + version "1.0.17" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.17.tgz#86e2c63c612da5a1dace1c16c46f524aaa26ac45" + integrity sha512-qx9go5yraB7ekT7bCMqUHJ5jEaOC/GXBxUWv+jeWnb7WzHUFdcQPGWk7YmAwFBaQBrogpuSqd/azbC2lZRqqmw== + dependencies: + "@polka/url" "^1.0.0-next.20" + mime "^2.3.1" + totalist "^1.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -11112,6 +11264,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +totalist@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" + integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== + tough-cookie@^2.3.3, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -11557,6 +11714,21 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webpack-bundle-analyzer@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.2.tgz#39898cf6200178240910d629705f0f3493f7d666" + integrity sha512-PIagMYhlEzFfhMYOzs5gFT55DkUdkyrJi/SxJp8EF3YMWhS+T9vvs2EoTetpk5qb6VsCq02eXTlRDOydRhDFAQ== + dependencies: + acorn "^8.0.4" + acorn-walk "^8.0.0" + chalk "^4.1.0" + commander "^6.2.0" + gzip-size "^6.0.0" + lodash "^4.17.20" + opener "^1.5.2" + sirv "^1.0.7" + ws "^7.3.1" + webpack-dev-middleware@^3.7.2: version "3.7.3" resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz#0639372b143262e2b84ab95d3b91a7597061c2c5" @@ -11954,6 +12126,11 @@ ws@^7.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.3.tgz#1f9643de34a543b8edb124bdcbc457ae55a6e5cd" integrity sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA== +ws@^7.3.1: + version "7.5.4" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.4.tgz#56bfa20b167427e138a7795de68d134fe92e21f9" + integrity sha512-zP9z6GXm6zC27YtspwH99T3qTG7bBFv2VIkeHstMLrLlDJuzA7tQ5ls3OJ1hOGGCzTQPniNJoHXIAOS0Jljohg== + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" diff --git a/examples/react/develop/simple-todo-list/.env b/examples/react/develop/simple-todo-list/.env new file mode 100644 index 00000000..6f809cc2 --- /dev/null +++ b/examples/react/develop/simple-todo-list/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true diff --git a/examples/react/develop/simple-todo-list/.gitignore b/examples/react/develop/simple-todo-list/.gitignore new file mode 100644 index 00000000..4d29575d --- /dev/null +++ b/examples/react/develop/simple-todo-list/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/develop/simple-todo-list/README.md b/examples/react/develop/simple-todo-list/README.md new file mode 100644 index 00000000..02aac3f6 --- /dev/null +++ b/examples/react/develop/simple-todo-list/README.md @@ -0,0 +1,70 @@ +# Getting Started with Create React App + +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `yarn start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `yarn build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `yarn eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). + +### Code Splitting + +This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) + +### Analyzing the Bundle Size + +This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) + +### Making a Progressive Web App + +This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) + +### Advanced Configuration + +This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) + +### Deployment + +This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) + +### `yarn build` fails to minify + +This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) diff --git a/examples/react/develop/simple-todo-list/package.json b/examples/react/develop/simple-todo-list/package.json new file mode 100644 index 00000000..c034b229 --- /dev/null +++ b/examples/react/develop/simple-todo-list/package.json @@ -0,0 +1,39 @@ +{ + "name": "simple-todo-list", + "version": "0.1.0", + "private": true, + "dependencies": { + "@agile-ts/core": "file:.yalc/@agile-ts/core", + "@agile-ts/logger": "file:.yalc/@agile-ts/logger", + "@agile-ts/react": "file:.yalc/@agile-ts/react", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-scripts": "4.0.3" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "install:dev:agile": "yalc add @agile-ts/core @agile-ts/react @agile-ts/logger & yarn install", + "install:prod:agile": "yarn add @agile-ts/core @agile-ts/react @agile-ts/logger & yarn install" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react/develop/simple-todo-list/public/favicon.ico b/examples/react/develop/simple-todo-list/public/favicon.ico new file mode 100644 index 00000000..a11777cc Binary files /dev/null and b/examples/react/develop/simple-todo-list/public/favicon.ico differ diff --git a/examples/react/develop/simple-todo-list/public/index.html b/examples/react/develop/simple-todo-list/public/index.html new file mode 100644 index 00000000..aa069f27 --- /dev/null +++ b/examples/react/develop/simple-todo-list/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/examples/react/develop/simple-todo-list/public/logo192.png b/examples/react/develop/simple-todo-list/public/logo192.png new file mode 100644 index 00000000..fc44b0a3 Binary files /dev/null and b/examples/react/develop/simple-todo-list/public/logo192.png differ diff --git a/examples/react/develop/simple-todo-list/public/logo512.png b/examples/react/develop/simple-todo-list/public/logo512.png new file mode 100644 index 00000000..a4e47a65 Binary files /dev/null and b/examples/react/develop/simple-todo-list/public/logo512.png differ diff --git a/examples/react/develop/simple-todo-list/public/manifest.json b/examples/react/develop/simple-todo-list/public/manifest.json new file mode 100644 index 00000000..080d6c77 --- /dev/null +++ b/examples/react/develop/simple-todo-list/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/react/develop/simple-todo-list/public/robots.txt b/examples/react/develop/simple-todo-list/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/examples/react/develop/simple-todo-list/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/react/develop/simple-todo-list/src/App.js b/examples/react/develop/simple-todo-list/src/App.js new file mode 100644 index 00000000..7844b8c2 --- /dev/null +++ b/examples/react/develop/simple-todo-list/src/App.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { generateId } from '@agile-ts/core'; +import { useAgile } from '@agile-ts/react'; +import { TODOS } from './core'; + +const App = () => { + // With the 'useAgile' Hook we bind our first Collection to the 'RandomComponent' for reactivity + const todos = useAgile(TODOS); + + // Current Input of Name Form + const [currentInput, setCurrentInput] = React.useState(''); + + return ( +
+

Simple TODOS

+ { + setCurrentInput(event.target.value); + }} + /> + + {todos.map((value) => ( +
+
{value.name}
+ +
+ ))} +
+ ); +}; + +export default App; diff --git a/examples/react/develop/simple-todo-list/src/core.js b/examples/react/develop/simple-todo-list/src/core.js new file mode 100644 index 00000000..881290a6 --- /dev/null +++ b/examples/react/develop/simple-todo-list/src/core.js @@ -0,0 +1,11 @@ +import { createCollection, globalBind } from '@agile-ts/core'; +import { assignSharedAgileLoggerConfig, Logger } from '@agile-ts/logger'; + +assignSharedAgileLoggerConfig({ level: Logger.level.DEBUG }); + +// Create Collection +export const TODOS = createCollection({ + initialData: [{ id: 1, name: 'Clean Bathroom' }], +}).persist('todos'); // persist does store the Collection in the Local Storage + +globalBind('__core__', { TODOS }); diff --git a/examples/react/develop/simple-todo-list/src/index.js b/examples/react/develop/simple-todo-list/src/index.js new file mode 100644 index 00000000..c1f31c5f --- /dev/null +++ b/examples/react/develop/simple-todo-list/src/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/examples/react/develop/tree-shaking/package.json b/examples/react/develop/tree-shaking/package.json new file mode 100644 index 00000000..05202eaf --- /dev/null +++ b/examples/react/develop/tree-shaking/package.json @@ -0,0 +1,25 @@ +{ + "name": "tree-shaking", + "version": "1.0.0", + "main": "src/index.js", + "license": "MIT", + "scripts": { + "build": "npx webpack --config webpack.config.js", + "install:dev:agile": "yalc add @agile-ts/core @agile-ts/react @agile-ts/logger & yarn install", + "install:prod:agile": "yarn add @agile-ts/core @agile-ts/react @agile-ts/logger & yarn install" + }, + "devDependencies": { + "babel-core": "^6.26.3", + "babel-loader": "^8.2.2", + "babel-preset-env": "^1.7.0", + "babel-preset-react": "^6.24.1", + "webpack": "^5.51.1", + "webpack-cli": "^4.8.0" + }, + "dependencies": { + "@agile-ts/core": "file:.yalc/@agile-ts/core", + "@agile-ts/logger": "file:.yalc/@agile-ts/logger", + "@agile-ts/react": "file:.yalc/@agile-ts/react", + "react": "^17.0.2" + } +} diff --git a/examples/react/develop/tree-shaking/src/App.jsx b/examples/react/develop/tree-shaking/src/App.jsx new file mode 100644 index 00000000..ac5157ce --- /dev/null +++ b/examples/react/develop/tree-shaking/src/App.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export const FooComponent = ({ name }) => ( +
Hello from FooComponent, {name ?? 'unknown'}!
+); + +export const BarComponent = ({ name }) => ( +
Hello from BarComponent, {name ?? 'unknown'}!
+); diff --git a/examples/react/develop/tree-shaking/src/core.js b/examples/react/develop/tree-shaking/src/core.js new file mode 100644 index 00000000..6dcfb7c0 --- /dev/null +++ b/examples/react/develop/tree-shaking/src/core.js @@ -0,0 +1,3 @@ +import { createLightState } from '@agile-ts/core'; + +export const MY_STATE = createLightState('jeff'); diff --git a/examples/react/develop/tree-shaking/src/index.js b/examples/react/develop/tree-shaking/src/index.js new file mode 100644 index 00000000..7fb253f5 --- /dev/null +++ b/examples/react/develop/tree-shaking/src/index.js @@ -0,0 +1,9 @@ +import { BarComponent } from './App'; +import { MY_STATE } from './core'; + +MY_STATE.set('jeff'); + +// we could do something with BarComponent here, +// like ReactDOM.render, but let's just dump it to +// console for simplicity +console.log(BarComponent); diff --git a/examples/react/develop/tree-shaking/webpack.config.js b/examples/react/develop/tree-shaking/webpack.config.js new file mode 100644 index 00000000..1b3fb79c --- /dev/null +++ b/examples/react/develop/tree-shaking/webpack.config.js @@ -0,0 +1,31 @@ +const path = require('path'); + +module.exports = { + mode: 'development', + entry: './src/index.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'app.js', + }, + resolve: { extensions: ['.js', '.jsx'] }, + module: { + rules: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: [['@babel/env', { modules: false }], '@babel/react'], + }, + }, + }, + ], + }, + optimization: { + usedExports: true, + innerGraph: true, + sideEffects: true, + }, + devtool: false, +}; diff --git a/examples/react/release/boxes/package.json b/examples/react/release/boxes/package.json index ff3c03f1..078a67a8 100644 --- a/examples/react/release/boxes/package.json +++ b/examples/react/release/boxes/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "dependencies": { - "@agile-ts/core": "^0.1.2", - "@agile-ts/logger": "^0.0.7", - "@agile-ts/proxytree": "^0.0.5", - "@agile-ts/react": "^0.1.2", + "@agile-ts/core": "file:.yalc/@agile-ts/core", + "@agile-ts/logger": "file:.yalc/@agile-ts/logger", + "@agile-ts/proxytree": "file:.yalc/@agile-ts/proxytree", + "@agile-ts/react": "file:.yalc/@agile-ts/react", "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/examples/react/release/boxes/src/core/entities/ui/ui.actions.ts b/examples/react/release/boxes/src/core/entities/ui/ui.actions.ts index c3d537cd..2fe86360 100644 --- a/examples/react/release/boxes/src/core/entities/ui/ui.actions.ts +++ b/examples/react/release/boxes/src/core/entities/ui/ui.actions.ts @@ -8,7 +8,6 @@ import { SCREEN, } from './ui.controller'; import core from '../../index'; -import { copy } from '@agile-ts/utils'; export const addDefaultElement = (image: boolean = false) => { if (image) addElement(defaultElementStyle, getRandomImage()); diff --git a/examples/react/release/boxes/src/core/entities/ui/ui.controller.ts b/examples/react/release/boxes/src/core/entities/ui/ui.controller.ts index c92dcf93..071de3b3 100644 --- a/examples/react/release/boxes/src/core/entities/ui/ui.controller.ts +++ b/examples/react/release/boxes/src/core/entities/ui/ui.controller.ts @@ -1,22 +1,22 @@ -import { App } from '../../app'; import { CanvasInterface, ElementInterface, ScreenInterface, } from './ui.interfaces'; +import { createCollection, createState } from '@agile-ts/core'; export const defaultElementStyle = { position: { top: 0, left: 0 }, size: { width: 200, height: 200 }, }; -export const CANVAS = App.createState({ +export const CANVAS = createState({ width: 5000, height: 5000, }); -export const SCREEN = App.createState({ width: 0, height: 0 }); +export const SCREEN = createState({ width: 0, height: 0 }); -export const ELEMENTS = App.createCollection(); +export const ELEMENTS = createCollection(); export const SELECTED_ELEMENT = ELEMENTS.createSelector( 'selectedElement', diff --git a/examples/react/release/boxes/yarn.lock b/examples/react/release/boxes/yarn.lock index 18e63613..07d40055 100644 --- a/examples/react/release/boxes/yarn.lock +++ b/examples/react/release/boxes/yarn.lock @@ -2,29 +2,21 @@ # yarn lockfile v1 -"@agile-ts/core@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@agile-ts/core/-/core-0.1.2.tgz#5a3974ba0c57a51a19bcdf81b2055e091c884f5e" - integrity sha512-9031MGUrPpg/ZL1ErpwUlHX751HKEtOfbc5Ae7W7x/POGH89Gka09hMAhqQlDrKF2+olVs3sf6PAsAHRv6paGw== +"@agile-ts/core@file:.yalc/@agile-ts/core": + version "0.2.0-alpha.4" dependencies: "@agile-ts/utils" "^0.0.7" -"@agile-ts/logger@^0.0.7": +"@agile-ts/logger@file:.yalc/@agile-ts/logger": version "0.0.7" - resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.7.tgz#9e89e8d80f80a46901a508432696860f88d5e878" - integrity sha512-6N9qyooo/a7ibyl9L7HnBX0LyMlSwaEYgObYs58KzR19JGF00PX/sUFfQAVplXXsMfT/8HvLyI+4TssmyI6DdQ== dependencies: "@agile-ts/utils" "^0.0.7" -"@agile-ts/proxytree@^0.0.5": +"@agile-ts/proxytree@file:.yalc/@agile-ts/proxytree": version "0.0.5" - resolved "https://registry.yarnpkg.com/@agile-ts/proxytree/-/proxytree-0.0.5.tgz#81c40970707271822a176ee59f93b9230df6311d" - integrity sha512-KODknVD30ld9xPCyt0UCf0yGcroy/0CHEncAdmTFwEvDSMipMaqFQRsAYZ0tgB4bMfFzab40aUmYTK8XDkwdHw== -"@agile-ts/react@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@agile-ts/react/-/react-0.1.2.tgz#d07f6b935d9322cd60d2e9e3871da554b04460af" - integrity sha512-W4u2+X6KCeXPdkjit/NsMJG5nBsa7dNFaEzyfTsp5Cqbs99zLqY6dO8LUIYyhRt/+HBvEW9o64i/6Kqd59WM1Q== +"@agile-ts/react@file:.yalc/@agile-ts/react": + version "0.2.0-alpha.1" "@agile-ts/utils@^0.0.7": version "0.0.7" diff --git a/examples/react/release/stopwatch-query-url/src/core/index.ts b/examples/react/release/stopwatch-query-url/src/core/index.ts index b9e3cf5e..8ec09cb5 100644 --- a/examples/react/release/stopwatch-query-url/src/core/index.ts +++ b/examples/react/release/stopwatch-query-url/src/core/index.ts @@ -1,4 +1,9 @@ -import { createState, globalBind, shared } from '@agile-ts/core'; +import { + createState, + globalBind, + createStorage, + getStorageManager, +} from '@agile-ts/core'; import queryString from 'query-string'; export type StopwatchStateType = @@ -7,7 +12,7 @@ export type StopwatchStateType = | 'initial'; // Stopwatch is reset // Create Query Storage to store the State in the query (url) -const queryUrlStorage = shared.createStorage({ +const queryUrlStorage = createStorage({ key: 'query-url', methods: { set: (key, value) => { @@ -30,7 +35,7 @@ const queryUrlStorage = shared.createStorage({ }); // Register Query Storage to the shared Agile Instance and set it as default -shared.registerStorage(queryUrlStorage, { default: true }); +getStorageManager().register(queryUrlStorage, { default: true }); // State to keep track of the current time of the Stopwatch const TIME = createState( diff --git a/examples/vue/develop/my-project/src/core.js b/examples/vue/develop/my-project/src/core.js index 0512ec9b..8606056e 100644 --- a/examples/vue/develop/my-project/src/core.js +++ b/examples/vue/develop/my-project/src/core.js @@ -1,24 +1,24 @@ -import { Agile, assignSharedAgileInstance, globalBind } from '@agile-ts/core'; +import { + globalBind, + createState, + createComputed, + createCollection, +} from '@agile-ts/core'; import { Logger, assignSharedAgileLoggerConfig } from '@agile-ts/logger'; import '@agile-ts/vue'; assignSharedAgileLoggerConfig({ level: Logger.level.DEBUG }); -// Create Agile Instance -export const App = new Agile({ localStorage: true }); -assignSharedAgileInstance(App); - // console.debug('hi'); // Doesn't work here idk why // Create State -export const MY_STATE = App.createState('World', { +export const MY_STATE = createState('World', { key: 'my-state', -}) - .computeValue((v) => { - return `Hello ${v}`; - }); +}).computeValue((v) => { + return `Hello ${v}`; +}); -export const MY_COMPUTED = App.createComputed( +export const MY_COMPUTED = createComputed( async () => { await new Promise((resolve) => setTimeout(resolve, 3000)); return `${MY_STATE.value} Frank`; @@ -27,7 +27,7 @@ export const MY_COMPUTED = App.createComputed( ); // Create Collection -export const TODOS = App.createCollection({ +export const TODOS = createCollection({ initialData: [{ id: 1, name: 'Clean Bathroom' }], selectors: [1], }).persist('todos'); diff --git a/package.json b/package.json index 7511c703..7a77e31f 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "release": "lerna run release && changeset publish", "prettier": "prettier --config .prettierrc --write \"**/*.{js,ts}\"", "lint": "eslint --cache \"**/*.{js,jsx,ts,tsx}\"", - "pack": "lerna run prepare && lerna run preview", - "pack:core": "cd packages/core && yarn run prepare && yarn run preview", - "pack:react": "cd packages/react && yarn run prepare && yarn run preview", - "pack:multieditor": "cd packages/multieditor && yarn run prepare && yarn run preview", - "pack:api": "cd packages/api && yarn run prepare && yarn run preview", - "pack:event": "cd packages/event && yarn run prepare && yarn run preview" + "pack": "lerna run prepare && lerna run pack", + "pack:core": "cd packages/core && yarn run prepare && yarn run pack", + "pack:react": "cd packages/react && yarn run prepare && yarn run pack", + "pack:multieditor": "cd packages/multieditor && yarn run prepare && yarn run pack", + "pack:api": "cd packages/api && yarn run prepare && yarn run pack", + "pack:event": "cd packages/event && yarn run prepare && yarn run pack" }, "repository": { "type": "git", diff --git a/packages/api/package.json b/packages/api/package.json index 8fcfd3eb..c223a02a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,16 +13,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*" @@ -48,5 +52,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/api/tsconfig.esm.json b/packages/api/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/api/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/api/tsconfig.production.json b/packages/api/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/api/tsconfig.production.json +++ b/packages/api/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/core/package.json b/packages/core/package.json index 833a6df5..88d0e1e0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@agile-ts/core", - "version": "0.1.3", + "version": "0.2.0-alpha.5", "author": "BennoDev", "license": "MIT", "homepage": "https://agile-ts.org/", @@ -24,16 +24,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "node ./scripts/prepublish.js && yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish && git checkout README.md", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*", @@ -69,5 +73,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/core/src/agile.ts b/packages/core/src/agile.ts index 6a8d34fa..82fe3bab 100644 --- a/packages/core/src/agile.ts +++ b/packages/core/src/agile.ts @@ -1,28 +1,10 @@ import { Runtime, Integration, - State, - Storage, - Collection, - CollectionConfig, - DefaultItem, - Computed, Integrations, SubController, globalBind, - Storages, - CreateStorageConfigInterface, - RegisterConfigInterface, - StateConfigInterface, - flatMerge, LogCodeManager, - DependableAgileInstancesType, - CreateComputedConfigInterface, - ComputeFunctionType, - createStorage, - createState, - createCollection, - createComputed, IntegrationsConfigInterface, defineConfig, } from './internal'; @@ -37,8 +19,6 @@ export class Agile { 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 (UI-Frameworks) that are integrated into the Agile Instance public integrations: Integrations; @@ -62,7 +42,6 @@ export class Agile { * 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. @@ -90,9 +69,6 @@ export class Agile { }); this.runtime = new Runtime(this); this.subController = new SubController(this); - this.storages = new Storages(this, { - localStorage: config.localStorage, - }); LogCodeManager.log('10:00:00', [], this); @@ -105,143 +81,6 @@ export class Agile { } } - /** - * 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 - * @param config - Configuration object - */ - public createStorage(config: CreateStorageConfigInterface): Storage { - return createStorage(config); - } - - /** - * 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 - * @param initialValue - Initial value of the State. - * @param config - Configuration object - */ - public createState( - initialValue: ValueType, - config: StateConfigInterface = {} - ): State { - return createState( - initialValue, - defineConfig(config, { - agileInstance: this, - }) - ); - } - - /** - * 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 - * @param config - Configuration object - */ - public createCollection( - config?: CollectionConfig - ): Collection { - return createCollection(config, this); - } - - /** - * 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 - * @param computeFunction - Function to compute the computed value. - * @param config - Configuration object - */ - public createComputed( - computeFunction: ComputeFunctionType, - config?: CreateComputedConfigInterface - ): 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 - * @param computeFunction - Function to compute the computed value. - * @param deps - Hard-coded dependencies on which the Computed Class should depend. - */ - public createComputed( - computeFunction: ComputeFunctionType, - deps?: Array - ): Computed; - public createComputed( - computeFunction: ComputeFunctionType, - configOrDeps?: - | CreateComputedConfigInterface - | Array - ): Computed { - let _config: CreateComputedConfigInterface = {}; - - if (Array.isArray(configOrDeps)) { - _config = defineConfig(_config, { - computedDeps: configOrDeps, - agileInstance: this, - }); - } else { - if (configOrDeps) - _config = defineConfig(configOrDeps, { - agileInstance: this, - }); - } - - return createComputed(computeFunction, _config); - } - /** * Registers the specified Integration with AgileTs. * @@ -259,27 +98,6 @@ export class Agile { return this; } - /** - * 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 - * @param storage - Storage to be registered. - * @param config - Configuration object - */ - public registerStorage( - storage: Storage, - config: RegisterConfigInterface = {} - ): this { - this.storages.register(storage, config); - return this; - } - /** * Returns a boolean indicating whether any Integration * has been registered with AgileTs or not. @@ -291,18 +109,6 @@ export class Agile { public hasIntegration(): boolean { return this.integrations.hasIntegration(); } - - /** - * 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 - */ - public hasStorage(): boolean { - return this.storages.hasStorage(); - } } export type AgileKey = string | number; @@ -315,11 +121,6 @@ export interface CreateAgileConfigInterface * @default true */ waitForMount?: boolean; - /** - * Whether the Local Storage should be registered as a Agile Storage by default. - * @default false - */ - localStorage?: boolean; /** * Whether the Agile Instance should be globally bound (globalThis) * and thus be globally available. diff --git a/packages/core/src/collection/collection.persistent.ts b/packages/core/src/collection/collection.persistent.ts index 2f2b7136..0cbf3987 100644 --- a/packages/core/src/collection/collection.persistent.ts +++ b/packages/core/src/collection/collection.persistent.ts @@ -11,6 +11,7 @@ import { Persistent, PersistentKey, StorageKey, + getStorageManager, } from '../internal'; export class CollectionPersistent< @@ -35,10 +36,10 @@ export class CollectionPersistent< config: CreatePersistentConfigInterface = {} ) { super(collection.agileInstance(), { - instantiate: false, + loadValue: false, }); config = defineConfig(config, { - instantiate: true, + loadValue: true, storageKeys: [], defaultStorageKey: null as any, }); @@ -50,7 +51,7 @@ export class CollectionPersistent< }); // Load/Store persisted value/s for the first time - if (this.ready && config.instantiate) this.initialLoading(); + if (this.ready && config.loadValue) this.initialLoading(); } /** @@ -84,7 +85,7 @@ export class CollectionPersistent< // Check if Collection is already persisted // (indicated by the persistence of 'true' at '_storageItemKey') - const isPersisted = await this.agileInstance().storages.get( + const isPersisted = await getStorageManager()?.get( _storageItemKey, this.config.defaultStorageKey as any ); @@ -103,6 +104,7 @@ export class CollectionPersistent< // Persist default Group and load its value manually to be 100% sure // that it was loaded completely + defaultGroup.loadedInitialValue = false; defaultGroup.persist(defaultGroupStorageKey, { loadValue: false, defaultStorageKey: this.config.defaultStorageKey || undefined, @@ -112,14 +114,6 @@ export class CollectionPersistent< 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); @@ -155,13 +149,14 @@ export class CollectionPersistent< followCollectionPersistKeyPattern: false, // Because of the dynamic 'storageItemKey', the key is already formatted above }); if (placeholderItem?.persistent?.ready) { - const loadedPersistedValueIntoItem = await placeholderItem.persistent.loadPersistedValue(); // TODO FIRST GROUP REBUILD (by assigning loaded value to Item) + const loadedPersistedValueIntoItem = await placeholderItem.persistent.loadPersistedValue(); // If successfully loaded Item value, assign Item to Collection if (loadedPersistedValueIntoItem) { this.collection().assignItem(placeholderItem, { overwrite: true, // Overwrite to overwrite the existing placeholder Item, if one exists - }); // TODO SECOND GROUP REBUILD (by calling rebuildGroupThatInclude() in the assignItem() method) + rebuildGroups: false, // Not necessary since the Groups that include the to assign Item were already rebuild while assigning the loaded value to the Item via 'loadPersistedValue()' + }); placeholderItem.isPersisted = true; @@ -175,6 +170,7 @@ export class CollectionPersistent< } } + defaultGroup.loadedInitialValue = true; return true; }; const success = await loadValuesIntoCollection(); @@ -207,7 +203,7 @@ export class CollectionPersistent< ); // Set flag in Storage to indicate that the Collection is persisted - this.agileInstance().storages.set(_storageItemKey, true, this.storageKeys); + getStorageManager()?.set(_storageItemKey, true, this.storageKeys); // Persist default Group defaultGroup.persist(defaultGroupStorageKey, { @@ -282,7 +278,7 @@ export class CollectionPersistent< ); // Remove Collection is persisted indicator flag from Storage - this.agileInstance().storages.remove(_storageItemKey, this.storageKeys); + getStorageManager()?.remove(_storageItemKey, this.storageKeys); // Remove default Group from the Storage defaultGroup.persistent?.removePersistedValue(defaultGroupStorageKey); diff --git a/packages/core/src/collection/collection.ts b/packages/core/src/collection/collection.ts new file mode 100644 index 00000000..60535136 --- /dev/null +++ b/packages/core/src/collection/collection.ts @@ -0,0 +1,1711 @@ +import { + Agile, + CollectionPersistent, + ComputedTracker, + copy, + defineConfig, + generateId, + Group, + GroupAddConfigInterface, + GroupConfigInterface, + GroupIngestConfigInterface, + GroupKey, + isFunction, + isValidObject, + Item, + LogCodeManager, + normalizeArray, + PatchOptionConfigInterface, + removeProperties, + Selector, + SelectorConfigInterface, + SelectorKey, + StorageKey, + TrackedChangeMethod, +} from '../internal'; + +export class Collection< + DataType extends Object = DefaultItem, + GroupValueType = Array // To extract the Group Type Value in Integration methods like 'useAgile()' +> { + // Agile Instance the Collection belongs to + public agileInstance: () => Agile; + + public config: CollectionConfigInterface; + private initialConfig: CreateCollectionConfigInterface; + + // Key/Name identifier of the Collection + public _key?: CollectionKey; + // 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; + + // Helper property to check whether an unknown instance is a Collection, + // without importing the Collection itself for using 'instanceof' (Treeshaking support) + public isCollection = true; + + /** + * 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 + * @param agileInstance - Instance of Agile the Collection belongs to. + * @param config - Configuration object + */ + constructor(agileInstance: Agile, config: CollectionConfig = {}) { + this.agileInstance = () => agileInstance; + let _config = typeof config === 'function' ? config(this) : config; + _config = defineConfig(_config, { + primaryKey: 'id', + groups: {}, + selectors: {}, + defaultGroupKey: 'default', + }); + this._key = _config.key; + this.config = { + defaultGroupKey: _config.defaultGroupKey as any, + primaryKey: _config.primaryKey as any, + }; + this.initialConfig = _config; + + this.initGroups(_config.groups as any); + this.initSelectors(_config.selectors as any); + + 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 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, + // (after 'isInstantiated = true') + // the Groups which contain these added Items get 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 + * @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 + */ + public get key(): CollectionKey | undefined { + return this._key; + } + + /** + * Updates the key/name identifier of the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#setkey) + * + * @public + * @param value - New key/name identifier. + */ + public setKey(value: CollectionKey | undefined) { + const oldKey = this._key; + + // Update Collection key + this._key = value; + + // 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 != null && this.persistent?._key === oldKey) + this.persistent?.setKey(value); + + return this; + } + + /** + * 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 + * @param initialItems - Key/Name identifiers of the Items to be clustered by the Group. + * @param config - Configuration object + */ + public Group( + initialItems?: Array, + config: GroupConfigInterface = {} + ): Group { + if (this.isInstantiated) { + const key = config.key ?? generateId(); + LogCodeManager.log('1B:02:00'); + return this.createGroup(key, initialItems); + } + + return new Group(this, initialItems, config); + } + + /** + * 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 + * @param initialKey - Key/Name identifier of the Item to be represented by the Selector. + * @param config - Configuration object + */ + public Selector( + initialKey: ItemKey | null, + config: SelectorConfigInterface = {} + ): Selector { + if (this.isInstantiated) { + const key = config.key ?? generateId(); + LogCodeManager.log('1B:02:01'); + return this.createSelector(key, initialKey); + } + + return new Selector(this, initialKey, config); + } + + /** + * 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 + * @param groups - Entire Groups or Group keys to be set up. + */ + public initGroups(groups: { [key: string]: Group } | string[]): void { + if (!groups) return; + let groupsObject: { [key: string]: Group } = {}; + + // 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, [], { + key: groupKey, + }); + }); + } else groupsObject = groups; + + // Add default Group + groupsObject[this.config.defaultGroupKey] = new Group(this, [], { + key: this.config.defaultGroupKey, + }); + + // 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; + } + + /** + * Sets up the specified Selectors or Selector keys + * and assigns them to the Collection if they are valid. + * + * @internal + * @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 Selector keys/names, create the Selectors based on these keys + if (Array.isArray(selectors)) { + selectors.forEach((selectorKey) => { + selectorsObject[selectorKey] = new Selector( + this, + selectorKey, + { + key: selectorKey, + } + ); + }); + } else selectorsObject = selectors; + + // 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; + } + + /** + * 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 + * @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 | Item | Array>, + groupKeys?: GroupKey | Array, + config: CollectConfigInterface = {} + ): this { + const _data = normalizeArray>(data); + const _groupKeys = normalizeArray(groupKeys); + const defaultGroupKey = this.config.defaultGroupKey; + const primaryKey = this.config.primaryKey; + config = defineConfig(config, { + method: 'push', + background: false, + patch: false, + select: false, + }); + + // Add default groupKey, since all Items are added to the default Group + if (!_groupKeys.includes(defaultGroupKey)) _groupKeys.push(defaultGroupKey); + + // Create not existing Groups + _groupKeys.forEach( + (key) => this.groups[key] == null && this.createGroup(key) + ); + + _data.forEach((data, index) => { + let itemKey; + let success = false; + + // 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, success, index); + }); + + return this; + } + + /** + * 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 + * @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, + changes: DefaultItem | DataType, + config: UpdateConfigInterface = {} + ): Item | undefined { + const item = this.getItem(itemKey, { notExisting: true }); + const primaryKey = this.config.primaryKey; + config = defineConfig(config, { + patch: true, + 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; + } + if (!isValidObject(changes)) { + LogCodeManager.log('1B:03:01', [itemKey, this._key]); + return undefined; + } + + const oldItemKey = item._value[primaryKey]; + const newItemKey = changes[primaryKey] || oldItemKey; + + // 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 data object + if (config.patch) { + // 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 } = + typeof config.patch === 'object' ? config.patch : {}; + patchConfig = { + addNewProperties: true, + ...patchConfig, + }; + + item.patch(changes as any, { + background: config.background, + addNewProperties: patchConfig.addNewProperties, + }); + } + // 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); + } + + item.set(changes as any, { + background: config.background, + }); + } + + return item; + } + + /** + * Creates a new Group and associates it to the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#createGroup) + * + * @public + * @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 + if (group != null) { + if (!group.isPlaceholder) { + LogCodeManager.log('1B:03:02', [groupKey]); + return group; + } + group.set(initialItems, { overwrite: true }); + return group; + } + + // Create new Group + group = new Group(this, initialItems, { key: groupKey }); + this.groups[groupKey] = group; + + return 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 + * @param groupKey - Key/Name identifier of the Group to be checked for existence. + * @param config - Configuration object + */ + public hasGroup( + groupKey: GroupKey | undefined, + config: HasConfigInterface = {} + ): boolean { + return !!this.getGroup(groupKey, config); + } + + /** + * 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 + * @param groupKey - Key/Name identifier of the Group. + * @param config - Configuration object + */ + public getGroup( + groupKey: GroupKey | undefined | null, + config: HasConfigInterface = {} + ): Group | undefined { + config = defineConfig(config, { + notExisting: false, + }); + + // Retrieve Group + const group = groupKey ? this.groups[groupKey] : undefined; + + // Check if retrieved Group exists + if (group == null || (!config.notExisting && !group.exists)) + return undefined; + + ComputedTracker.tracked(group.observers['value']); + return 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 + */ + public getDefaultGroup(): Group | undefined { + return this.getGroup(this.config.defaultGroupKey); + } + + /** + * 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 + * @param groupKey - Key/Name identifier of the Group. + */ + public getGroupWithReference(groupKey: GroupKey): Group { + let group = this.getGroup(groupKey, { notExisting: true }); + + // Create dummy Group to hold reference + if (group == null) { + group = new Group(this, [], { + key: groupKey, + isPlaceholder: true, + }); + this.groups[groupKey] = group; + } + + ComputedTracker.tracked(group.observers['value']); + return 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 + * @param groupKey - Key/Name identifier of the Group to be removed. + */ + public removeGroup(groupKey: GroupKey): this { + if (this.groups[groupKey] != null) delete this.groups[groupKey]; + return this; + } + + /** + * Returns the count of registered Groups in the Collection. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getgroupcount) + * + * @public + */ + 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 | null + ): Selector { + let selector = this.getSelector(selectorKey, { notExisting: true }); + if (!this.isInstantiated) LogCodeManager.log('1B:02:04'); + + // Check if Selector already exists + if (selector != null) { + if (!selector.isPlaceholder) { + LogCodeManager.log('1B:03:03', [selectorKey]); + return selector; + } + selector.select(itemKey, { overwrite: true }); + return selector; + } + + // Create new Selector + selector = new Selector(this, itemKey, { + key: selectorKey, + }); + this.selectors[selectorKey] = selector; + + return selector; + } + + /** + * 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 + * @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); + } + + /** + * 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 + * @param selectorKey - Key/Name identifier of the Selector to be checked for existence. + * @param config - Configuration object + */ + public hasSelector( + selectorKey: SelectorKey | undefined, + config: HasConfigInterface = {} + ): boolean { + return !!this.getSelector(selectorKey, config); + } + + /** + * 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 + * @param selectorKey - Key/Name identifier of the Selector. + * @param config - Configuration object + */ + public getSelector( + selectorKey: SelectorKey | undefined | null, + config: HasConfigInterface = {} + ): Selector | undefined { + config = defineConfig(config, { + notExisting: false, + }); + + // Get Selector + const selector = selectorKey ? this.selectors[selectorKey] : undefined; + + // Check if Selector exists + if (selector == null || (!config.notExisting && !selector.exists)) + return undefined; + + ComputedTracker.tracked(selector.observers['value']); + return selector; + } + + /** + * 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 + * @param selectorKey - Key/Name identifier of the Selector. + */ + public getSelectorWithReference( + selectorKey: SelectorKey + ): Selector { + let selector = this.getSelector(selectorKey, { notExisting: true }); + + // Create dummy Selector to hold reference + if (selector == null) { + selector = new Selector(this, null, { + key: selectorKey, + isPlaceholder: true, + }); + this.selectors[selectorKey] = selector; + } + + ComputedTracker.tracked(selector.observers['value']); + return 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 + * @param selectorKey - Key/Name identifier of the Selector to be removed. + */ + public removeSelector(selectorKey: SelectorKey): this { + if (this.selectors[selectorKey] != null) { + this.selectors[selectorKey].unselect(); + delete this.selectors[selectorKey]; + } + return this; + } + + /** + * 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 + * @param itemKey - Key/Name identifier of the Item. + * @param config - Configuration object + */ + public hasItem( + itemKey: ItemKey | undefined, + config: HasConfigInterface = {} + ): boolean { + return !!this.getItem(itemKey, config); + } + + /** + * 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 + * @param itemKey - Key/Name identifier of the Item. + * @param config - Configuration object + */ + public getItem( + itemKey: ItemKey | undefined | null, + config: HasConfigInterface = {} + ): Item | undefined { + config = defineConfig(config, { + notExisting: false, + }); + + // Get Item + const item = itemKey != null ? this.data[itemKey] : undefined; + + // Check if Item exists + if (item == null || (!config.notExisting && !item.exists)) return undefined; + + ComputedTracker.tracked(item.observers['value']); + return item; + } + + /** + * 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 + * @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 = this.createPlaceholderItem(itemKey, true); + + ComputedTracker.tracked(item.observers['value']); + 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.observers['value']); + return item; + } + + /** + * 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 + * @param itemKey - Key/Name identifier of the Item. + * @param config - Configuration object + */ + public getItemValue( + itemKey: ItemKey | undefined, + config: HasConfigInterface = {} + ): DataType | undefined { + const item = this.getItem(itemKey, config); + if (item == null) return undefined; + return item.value; + } + + /** + * 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 + * @param config - Configuration object + */ + public getAllItems(config: HasConfigInterface = {}): Array> { + config = defineConfig(config, { + notExisting: false, + }); + + const defaultGroup = this.getDefaultGroup(); + let items: Array> = []; + + // 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 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?.getItems() || []; + } + + return items; + } + + /** + * 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 + * @param config - Configuration object + */ + public getAllItemValues(config: HasConfigInterface = {}): Array { + const items = this.getAllItems(config); + return items.map((item) => item.value); + } + + /** + * 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 + * @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 + * @param key - Key/Name identifier of Persistent. + * @param config - Configuration object + */ + public persist( + key?: StorageKey, + config?: CollectionPersistentConfigInterface + ): this; + public persist( + keyOrConfig: StorageKey | CollectionPersistentConfigInterface = {}, + config: CollectionPersistentConfigInterface = {} + ): this { + let _config: CollectionPersistentConfigInterface; + let key: StorageKey | undefined; + + if (isValidObject(keyOrConfig)) { + _config = keyOrConfig as CollectionPersistentConfigInterface; + key = this._key; + } else { + _config = config || {}; + key = keyOrConfig as StorageKey; + } + + _config = defineConfig(_config, { + loadValue: true, + storageKeys: [], + defaultStorageKey: null as any, + }); + + // Check if Collection is already persisted + if (this.persistent != null && this.isPersisted) return this; + + // Create Persistent (-> persist value) + this.persistent = new CollectionPersistent(this, { + loadValue: _config.loadValue, + storageKeys: _config.storageKeys, + key: key, + defaultStorageKey: _config.defaultStorageKey, + }); + + return this; + } + + /** + * 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 + * @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; + if (!isFunction(callback)) { + LogCodeManager.log('00:03:01', ['OnLoad Callback', 'function']); + return this; + } + + // Register specified callback + this.persistent.onLoad = callback; + + // If Collection is already persisted ('isPersisted') fire specified callback immediately + if (this.isPersisted) callback(true); + + return this; + } + + /** + * 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 + */ + public reset(): this { + // Reset data + this.data = {}; + this.size = 0; + + // Reset Groups + for (const key in this.groups) this.getGroup(key)?.reset(); + + // Reset Selectors + for (const key in this.selectors) this.getSelector(key)?.reset(); + + return this; + } + + /** + * Puts `itemKeys/s` into Group/s. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#put) + * + * @public + * @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, + groupKeys: GroupKey | Array, + config: GroupAddConfigInterface = {} + ): this { + const _itemKeys = normalizeArray(itemKeys); + const _groupKeys = normalizeArray(groupKeys); + + // Assign itemKeys to Groups + _groupKeys.forEach((groupKey) => { + this.getGroup(groupKey)?.add(_itemKeys, config); + }); + + return this; + } + + /** + * Moves specified `itemKey/s` from one Group to another Group. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#move) + * + * @public + * @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, + oldGroupKey: GroupKey, + newGroupKey: GroupKey, + config: GroupAddConfigInterface = {} + ): this { + const _itemKeys = normalizeArray(itemKeys); + + // Remove itemKeys from old Group + this.getGroup(oldGroupKey)?.remove( + _itemKeys, + removeProperties(config, ['method', 'overwrite']) + ); + + // Assign itemKeys to new Group + this.getGroup(newGroupKey)?.add(_itemKeys, config); + + return this; + } + + /** + * Updates the key/name identifier of the Item + * and returns a boolean indicating + * whether the Item identifier was updated successfully. + * + * @internal + * @param oldItemKey - Old key/name Item identifier. + * @param newItemKey - New key/name Item identifier. + * @param config - Configuration object + */ + public updateItemKey( + oldItemKey: ItemKey, + newItemKey: ItemKey, + config: UpdateItemKeyConfigInterface = {} + ): boolean { + const item = this.getItem(oldItemKey, { notExisting: true }); + config = defineConfig(config, { + background: false, + }); + + if (item == null || oldItemKey === newItemKey) return false; + + // Check if Item with newItemKey already exists + if (this.hasItem(newItemKey)) { + LogCodeManager.log('1B:03:04', [oldItemKey, newItemKey, this._key]); + return false; + } + + // Update itemKey in data object + delete this.data[oldItemKey]; + this.data[newItemKey] = item; + + // Update key/name of the Item + item.setKey(newItemKey, { + background: config.background, + }); + + // 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 + for (const groupKey in this.groups) { + const group = this.getGroup(groupKey, { notExisting: true }); + if (group == null || !group.has(oldItemKey)) continue; + group.replace(oldItemKey, newItemKey, { background: config.background }); + } + + // 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 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 (but not the Items at this itemKey anymore) + background: config.background, + }); + } + + // Select newItemKey in Selector that has selected the oldItemKey + if (selector.hasSelected(oldItemKey, false)) + selector.select(newItemKey, { + background: config.background, + }); + } + + return true; + } + + /** + * 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 + * @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.groups[groupKey]; + if (group?.has(itemKey)) groupKeys.push(groupKey); + } + return groupKeys; + } + + /** + * 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 + * @param itemKeys - Item/s with identifier/s to be removed. + */ + public remove( + itemKeys: ItemKey | Array + ): { + fromGroups: (groups: Array | ItemKey) => Collection; + everywhere: (config?: RemoveItemsConfigInterface) => Collection; + } { + return { + fromGroups: (groups: Array | ItemKey) => + this.removeFromGroups(itemKeys, groups), + everywhere: (config) => this.removeItems(itemKeys, config || {}), + }; + } + + /** + * Remove Item/s from specified Group/s. + * + * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#removefromgroups) + * + * @public + * @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, + groupKeys: GroupKey | Array + ): this { + const _itemKeys = normalizeArray(itemKeys); + const _groupKeys = normalizeArray(groupKeys); + + _itemKeys.forEach((itemKey) => { + let removedFromGroupsCount = 0; + + // Remove itemKey from the Groups + _groupKeys.forEach((groupKey) => { + const group = this.getGroup(groupKey, { notExisting: true }); + if (!group?.has(itemKey)) return; + group.remove(itemKey); + removedFromGroupsCount++; + }); + + // If the Item was removed from each Group representing the Item, + // remove it completely + if ( + removedFromGroupsCount >= + this.getGroupKeysThatHaveItemKey(itemKey).length + ) + this.removeItems(itemKey); + }); + + return this; + } + + /** + * 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 + * @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, + config: RemoveItemsConfigInterface = {} + ): this { + config = defineConfig(config, { + notExisting: false, + removeSelector: false, + }); + const _itemKeys = normalizeArray(itemKeys); + + _itemKeys.forEach((itemKey) => { + const item = this.getItem(itemKey, { notExisting: config.notExisting }); + if (item == null) return; + const wasPlaceholder = item.isPlaceholder; + + // 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); + } + + // Remove Item from Storage + item.persistent?.removePersistedValue(); + + // Remove Item from Collection + delete this.data[itemKey]; + + // 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 != null && selector.hasSelected(itemKey, false)) { + if (config.removeSelector) { + // Remove Selector + this.removeSelector(selector._key ?? 'unknown'); + } else { + // 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 }); + } + } + } + + if (!wasPlaceholder) this.size--; + }); + + return this; + } + + /** + * 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 + * @param data - Data object + * @param config - Configuration object + */ + 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, primaryKey]); + _data[primaryKey] = generateId(); + } + + const itemKey = _data[primaryKey]; + const item = this.getItem(itemKey, { notExisting: true }); + const wasPlaceholder = item?.isPlaceholder || false; + + // 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 Item was previously a placeholder + // (-> hasn't officially existed in Collection before) + if (wasPlaceholder) this.size++; + + return true; + } + + /** + * 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 + * @param item - Item to be added. + * @param config - Configuration object + */ + public assignItem( + item: Item, + config: AssignItemConfigInterface = {} + ): boolean { + config = defineConfig(config, { + overwrite: false, + background: false, + rebuildGroups: true, + }); + 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, { notExisting: true }) != null) { + if (!config.overwrite) { + this.assignData(item._value); + 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) + if (config.rebuildGroups) + 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, + config: GroupIngestConfigInterface = {} + ): void { + // Rebuild Groups that include itemKey + for (const groupKey of Object.keys(this.groups)) { + const group = this.getGroup(groupKey); + if (group != null && group.has(itemKey)) { + const index = group._preciseItemKeys.findIndex((ik) => itemKey === ik); + + // Update Group output at index + if (index !== -1) { + group.rebuild( + [ + { + key: itemKey, + index: index, + method: TrackedChangeMethod.UPDATE, + }, + ], + config + ); + } + // Add Item to the Group output if it isn't yet represented there to be updated + else { + const indexOfBeforeItemKey = + group.nextStateValue.findIndex((ik) => itemKey === ik) - 1; + + group.rebuild( + [ + { + key: itemKey, + index: + indexOfBeforeItemKey >= 0 + ? group._preciseItemKeys.findIndex( + (ik) => + group.nextStateValue[indexOfBeforeItemKey] === ik + ) + 1 + : 0, + method: TrackedChangeMethod.ADD, + }, + ], + config + ); + } + } + } + } +} + +export type DefaultItem = Record; // same as { [key: string]: any }; +export type CollectionKey = string | number; +export type ItemKey = string | number; + +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; +} + +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; +} + +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'; + /** + * 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; +} + +export interface UpdateConfigInterface { + /** + * 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; +} + +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; +} + +export interface HasConfigInterface { + /** + * Whether Items that do not officially exist, + * such as placeholder Items, can be found + * @default true + */ + notExisting?: boolean; +} + +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; +} + +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; +} + +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 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; + /** + * Whether to rebuild all Groups that include the itemKey of the to assign Item. + * @default true + */ + rebuildGroups?: boolean; +} diff --git a/packages/core/src/collection/group/group.observer.ts b/packages/core/src/collection/group/group.observer.ts index 4c0e860c..3e5d490a 100644 --- a/packages/core/src/collection/group/group.observer.ts +++ b/packages/core/src/collection/group/group.observer.ts @@ -6,10 +6,10 @@ import { equal, generateId, RuntimeJob, - Item, IngestConfigInterface, CreateRuntimeJobConfigInterface, defineConfig, + removeProperties, } from '../../internal'; export class GroupObserver extends Observer { @@ -45,14 +45,14 @@ export class GroupObserver extends Observer { * into the runtime wrapped into a Runtime-Job * where it is executed accordingly. * - * During the execution the runtime applies the rebuilt `nextGroupOutput` to the Group, + * During the execution the runtime applies the `nextGroupOutput` to the Group, * updates its dependents and re-renders the UI-Components it is subscribed to. * * @internal * @param config - Configuration object */ public ingest(config: GroupIngestConfigInterface = {}): void { - this.group().rebuild(config); + this.ingestOutput(this.group().nextGroupOutput, config); } /** @@ -63,23 +63,17 @@ export class GroupObserver extends Observer { * updates its dependents and re-renders the UI-Components it is subscribed to. * * @internal - * @param newGroupItems - New Group Items to be applied to the Group. + * @param newGroupOutput - New Group Output to be applied to the Group. * @param config - Configuration object. */ - public ingestItems( - newGroupItems: Item[], + public ingestOutput( + newGroupOutput: DataType[], config: GroupIngestConfigInterface = {} ): void { const group = this.group(); config = defineConfig(config, { perform: true, - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, force: false, - maxTriesToUpdate: 3, }); // Force overwriting the Group value if it is a placeholder. @@ -89,24 +83,19 @@ export class GroupObserver extends Observer { } // Assign next Group output to Observer - this.nextGroupOutput = copy( - newGroupItems.map((item) => { - return item._value; - }) - ); + this.nextGroupOutput = copy(newGroupOutput); // Check if current Group output and to assign Group output are equal if (equal(group._output, this.nextGroupOutput) && !config.force) return; // Create Runtime-Job const job = new RuntimeJob(this, { - sideEffects: config.sideEffects, - force: config.force, - background: config.background, - key: - config.key ?? - `${this._key != null ? this._key + '_' : ''}${generateId()}_output`, - maxTriesToUpdate: config.maxTriesToUpdate, + ...removeProperties(config, ['perform']), + ...{ + key: + config.key ?? + `${this._key != null ? this._key + '_' : ''}${generateId()}_output`, + }, }); // Pass created Job into the Runtime @@ -130,6 +119,7 @@ export class GroupObserver extends Observer { // Assign new Group output group._output = copy(observer.nextGroupOutput); + group.nextGroupOutput = copy(observer.nextGroupOutput); // Assign new public output to the Observer (output used by the Integrations) job.observer.previousValue = copy(job.observer.value); diff --git a/packages/core/src/collection/group/index.ts b/packages/core/src/collection/group/index.ts index 44aef2c5..c404b7db 100644 --- a/packages/core/src/collection/group/index.ts +++ b/packages/core/src/collection/group/index.ts @@ -1,5 +1,5 @@ import { - State, + EnhancedState, Collection, DefaultItem, ItemKey, @@ -18,26 +18,36 @@ import { GroupObserver, StateObserver, defineConfig, + GroupIngestConfigInterface, } from '../../internal'; export class Group< DataType extends Object = DefaultItem, ValueType = Array // To extract the Group Type Value in Integration methods like 'useAgile()' -> extends State> { +> extends EnhancedState> { // Collection the Group belongs to collection: () => Collection; static rebuildGroupSideEffectKey = 'rebuildGroup'; // Item values represented by the Group - _output: Array = []; + public _output: Array = []; + // Next output of the Group (which can be used for dynamic Group updates) + public nextGroupOutput: Array = []; + // Precise itemKeys of the Group only include itemKeys + // that actually exist in the corresponding Collection + public _preciseItemKeys: Array = []; // Manages dependencies to other States and subscriptions of UI-Components. // It also serves as an interface to the runtime. public observers: GroupObservers = {} as any; // Keeps track of all Item identifiers for Items that couldn't be found in the Collection - notFoundItemKeys: Array = []; + public notFoundItemKeys: Array = []; + + // Whether the initial value was loaded from the corresponding Persistent + // https://github.com/agile-ts/agile/issues/155 + public loadedInitialValue = true; /** * An extension of the State Class that categorizes and preserves the ordering of structured data. @@ -72,7 +82,9 @@ export class Group< // Add side effect to Group // that rebuilds the Group whenever the Group value changes - this.addSideEffect(Group.rebuildGroupSideEffectKey, () => this.rebuild()); + this.addSideEffect(Group.rebuildGroupSideEffectKey, (state, config) => { + this.rebuild(config?.any?.trackedChanges || [], config); + }); // Initial rebuild this.rebuild(); @@ -104,7 +116,7 @@ export class Group< * @param itemKey - Key/Name identifier of the Item. */ public has(itemKey: ItemKey) { - return this.value.findIndex((key) => key === itemKey) !== -1; + return this.value.indexOf(itemKey) !== -1; } /** @@ -130,22 +142,46 @@ export class Group< */ public remove( itemKeys: ItemKey | ItemKey[], - config: StateIngestConfigInterface = {} + config: GroupRemoveConfigInterface = {} ): this { const _itemKeys = normalizeArray(itemKeys); const notExistingItemKeysInCollection: Array = []; const notExistingItemKeys: Array = []; let newGroupValue = copy(this.nextStateValue); + // Need to temporary update the preciseItemKeys + // since in the rebuild one action (trackedChanges) is performed after the other + // which requires a dynamic updated index + const updatedPreciseItemKeys = copy(this._preciseItemKeys); + config = defineConfig(config, { + softRebuild: true, + any: {}, + }); + config.any['trackedChanges'] = []; // TODO might be improved since the 'any' property is very vague // Remove itemKeys from Group _itemKeys.forEach((itemKey) => { + const exists = newGroupValue.includes(itemKey); + // Check if itemKey exists in Group - if (!newGroupValue.includes(itemKey)) { + if (!exists) { notExistingItemKeys.push(itemKey); notExistingItemKeysInCollection.push(itemKey); return; } + // Track changes to soft rebuild the Group when rebuilding the Group in a side effect + if (config.softRebuild) { + const index = updatedPreciseItemKeys.findIndex((ik) => ik === itemKey); + if (index !== -1) { + updatedPreciseItemKeys.splice(index, 1); + config.any['trackedChanges'].push({ + index, + method: TrackedChangeMethod.REMOVE, + key: itemKey, + }); + } + } + // Check if itemKey exists in Collection if (!this.collection().getItem(itemKey)) notExistingItemKeysInCollection.push(itemKey); @@ -162,7 +198,7 @@ export class Group< if (notExistingItemKeysInCollection.length >= _itemKeys.length) config.background = true; - this.set(newGroupValue, config); + this.set(newGroupValue, removeProperties(config, ['softRebuild'])); return this; } @@ -183,27 +219,42 @@ export class Group< const _itemKeys = normalizeArray(itemKeys); const notExistingItemKeysInCollection: Array = []; const existingItemKeys: Array = []; - let newGroupValue = copy(this.nextStateValue); - defineConfig(config, { + const newGroupValue = copy(this.nextStateValue); + // Need to temporary update the preciseItemKeys + // since in the rebuild one action (trackedChanges) is performed after the other + // which requires a dynamic updated index + const updatedPreciseItemKeys = copy(this._preciseItemKeys); + config = defineConfig(config, { method: 'push', - overwrite: false, + softRebuild: true, + any: {}, }); + config.any['trackedChanges'] = []; // TODO might be improved since the 'any' property is very vague // Add itemKeys to Group _itemKeys.forEach((itemKey) => { + const exists = newGroupValue.includes(itemKey); + // Check if itemKey exists in Collection if (!this.collection().getItem(itemKey)) notExistingItemKeysInCollection.push(itemKey); - // 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 { - existingItemKeys.push(itemKey); - return; - } + // Handle existing Item + if (exists) { + existingItemKeys.push(itemKey); + return; + } + + // Track changes to soft rebuild the Group when rebuilding the Group in a side effect + if (config.softRebuild) { + const index = + config.method === 'push' ? updatedPreciseItemKeys.length : 0; + updatedPreciseItemKeys.push(itemKey); + config.any['trackedChanges'].push({ + index, + method: TrackedChangeMethod.ADD, + key: itemKey, + }); } // Add new itemKey to Group @@ -221,7 +272,10 @@ export class Group< ) config.background = true; - this.set(newGroupValue, removeProperties(config, ['method', 'overwrite'])); + this.set( + newGroupValue, + removeProperties(config, ['method', 'softRebuild']) + ); return this; } @@ -339,26 +393,76 @@ export class Group< * [Learn more..](https://agile-ts.org/docs/core/collection/group/methods#rebuild) * * @internal + * @param trackedChanges - Changes that were tracked between two rebuilds. * @param config - Configuration object */ - public rebuild(config: StateIngestConfigInterface = {}): this { - const notFoundItemKeys: Array = []; // Item keys that couldn't be found in the Collection - const groupItems: Array> = []; - + public rebuild( + trackedChanges: TrackedChangeInterface[] = [], + config: GroupIngestConfigInterface = {} + ): this { // Don't rebuild Group if Collection isn't correctly instantiated yet // (because only after a successful instantiation the Collection // contains the Items which are essential for a proper rebuild) if (!this.collection().isInstantiated) return this; - // Fetch Items from Collection - this._value.forEach((itemKey) => { - const item = this.collection().getItem(itemKey); - if (item != null) groupItems.push(item); - else notFoundItemKeys.push(itemKey); - }); + // Item keys that couldn't be found in the Collection + const notFoundItemKeys: Array = []; + + // Soft rebuild the Collection (-> rebuild only parts of the Collection) + if (trackedChanges.length > 0) { + trackedChanges.forEach((change) => { + const item = this.collection().getItem(change.key); + + switch (change.method) { + case TrackedChangeMethod.ADD: + // this._value.splice(change.index, 0, change.key); // Already updated in 'add' method + if (item != null) { + this._preciseItemKeys.splice(change.index, 0, change.key); + this.nextGroupOutput.splice(change.index, 0, copy(item._value)); + } else { + notFoundItemKeys.push(change.key); + } + break; + case TrackedChangeMethod.UPDATE: + if (item != null) { + this.nextGroupOutput[change.index] = copy(item._value); + } else { + notFoundItemKeys.push(change.key); + } + break; + case TrackedChangeMethod.REMOVE: + // this._value.splice(change.index, 1); // Already updated in 'remove' method + this._preciseItemKeys.splice(change.index, 1); + this.nextGroupOutput.splice(change.index, 1); + break; + default: + break; + } + }); + this.observers['output'].ingest(config); + } + // Hard rebuild the whole Collection + else { + const groupItemValues: Array = []; + + // Reset precise itemKeys array to rebuild it from scratch + this._preciseItemKeys = []; + + // Fetch Items from Collection + this._value.forEach((itemKey) => { + const item = this.collection().getItem(itemKey); + if (item != null) { + groupItemValues.push(item._value); + this._preciseItemKeys.push(itemKey); + } else notFoundItemKeys.push(itemKey); + }); + + // Ingest rebuilt Group output into the Runtime + this.observers['output'].ingestOutput(groupItemValues, config); + } // Logging - if (notFoundItemKeys.length > 0) { + if (notFoundItemKeys.length > 0 && this.loadedInitialValue) { LogCodeManager.log( '1C:02:00', [this.collection()._key, this._key], @@ -368,9 +472,6 @@ export class Group< this.notFoundItemKeys = notFoundItemKeys; - // Ingest rebuilt Group output into the Runtime - this.observers['output'].ingestItems(groupItems, config); - return this; } } @@ -395,11 +496,22 @@ export interface GroupAddConfigInterface extends StateIngestConfigInterface { */ 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 + * Whether to soft rebuild the Group. + * -> only rebuild the parts of the Group that have actually changed + * instead of rebuilding the whole Group. + * @default true + */ + softRebuild?: boolean; +} + +export interface GroupRemoveConfigInterface extends StateIngestConfigInterface { + /** + * Whether to soft rebuild the Group. + * -> only rebuild the parts of the Group that have actually changed + * instead of rebuilding the whole Group. + * @default true */ - overwrite?: boolean; + softRebuild?: boolean; } export interface GroupConfigInterface { @@ -425,3 +537,24 @@ export interface GroupPersistConfigInterface */ followCollectionPersistKeyPattern?: boolean; } + +export enum TrackedChangeMethod { + ADD, + REMOVE, + UPDATE, +} + +export interface TrackedChangeInterface { + /** + * TODO + */ + method: TrackedChangeMethod; + /** + * TODO + */ + key: ItemKey; + /** + * TODO + */ + index: number; +} diff --git a/packages/core/src/collection/index.ts b/packages/core/src/collection/index.ts index 83b644fa..b386d611 100644 --- a/packages/core/src/collection/index.ts +++ b/packages/core/src/collection/index.ts @@ -1,1693 +1,40 @@ import { + Collection, + CollectionConfig, + DefaultItem, Agile, - Item, - Group, - GroupKey, - Selector, - SelectorKey, - StorageKey, - GroupConfigInterface, - isValidObject, - normalizeArray, - copy, - CollectionPersistent, - GroupAddConfigInterface, - ComputedTracker, - generateId, - SideEffectConfigInterface, - SelectorConfigInterface, - removeProperties, - isFunction, - LogCodeManager, - PatchOptionConfigInterface, - defineConfig, + shared, } from '../internal'; -export class Collection< - DataType extends Object = DefaultItem, - GroupValueType = Array // To extract the Group Type Value in Integration methods like 'useAgile()' -> { - // Agile Instance the Collection belongs to - public agileInstance: () => Agile; - - public config: CollectionConfigInterface; - private initialConfig: CreateCollectionConfigInterface; - - // Key/Name identifier of the Collection - public _key?: CollectionKey; - // 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 - * @param agileInstance - Instance of Agile the Collection belongs to. - * @param config - Configuration object - */ - constructor(agileInstance: Agile, config: CollectionConfig = {}) { - this.agileInstance = () => agileInstance; - let _config = typeof config === 'function' ? config(this) : config; - _config = defineConfig(_config, { - primaryKey: 'id', - groups: {}, - selectors: {}, - defaultGroupKey: 'default', - }); - this._key = _config.key; - this.config = { - defaultGroupKey: _config.defaultGroupKey as any, - primaryKey: _config.primaryKey as any, - }; - this.initialConfig = _config; - - this.initGroups(_config.groups as any); - this.initSelectors(_config.selectors as any); - - 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 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, - // (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 - * @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 - */ - public get key(): CollectionKey | undefined { - return this._key; - } - - /** - * Updates the key/name identifier of the Collection. - * - * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#setkey) - * - * @public - * @param value - New key/name identifier. - */ - public setKey(value: CollectionKey | undefined) { - const oldKey = this._key; - - // Update Collection key - this._key = value; - - // 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 != null && this.persistent?._key === oldKey) - this.persistent?.setKey(value); - - return this; - } - - /** - * 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 - * @param initialItems - Key/Name identifiers of the Items to be clustered by the Group. - * @param config - Configuration object - */ - public Group( - initialItems?: Array, - config: GroupConfigInterface = {} - ): Group { - if (this.isInstantiated) { - const key = config.key ?? generateId(); - LogCodeManager.log('1B:02:00'); - return this.createGroup(key, initialItems); - } - - return new Group(this, initialItems, config); - } - - /** - * 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 - * @param initialKey - Key/Name identifier of the Item to be represented by the Selector. - * @param config - Configuration object - */ - public Selector( - initialKey: ItemKey | null, - config: SelectorConfigInterface = {} - ): Selector { - if (this.isInstantiated) { - const key = config.key ?? generateId(); - LogCodeManager.log('1B:02:01'); - return this.createSelector(key, initialKey); - } - - return new Selector(this, initialKey, config); - } - - /** - * 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 - * @param groups - Entire Groups or Group keys to be set up. - */ - public initGroups(groups: { [key: string]: Group } | string[]): void { - if (!groups) return; - let groupsObject: { [key: string]: Group } = {}; - - // 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, [], { - key: groupKey, - }); - }); - } else groupsObject = groups; - - // Add default Group - groupsObject[this.config.defaultGroupKey] = new Group(this, [], { - key: this.config.defaultGroupKey, - }); - - // 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; - } - - /** - * Sets up the specified Selectors or Selector keys - * and assigns them to the Collection if they are valid. - * - * @internal - * @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 Selector keys/names, create the Selectors based on these keys - if (Array.isArray(selectors)) { - selectors.forEach((selectorKey) => { - selectorsObject[selectorKey] = new Selector( - this, - selectorKey, - { - key: selectorKey, - } - ); - }); - } else selectorsObject = selectors; - - // 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; - } - - /** - * 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 - * @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 | Item | Array>, - groupKeys?: GroupKey | Array, - config: CollectConfigInterface = {} - ): this { - const _data = normalizeArray>(data); - const _groupKeys = normalizeArray(groupKeys); - const defaultGroupKey = this.config.defaultGroupKey; - const primaryKey = this.config.primaryKey; - config = defineConfig(config, { - method: 'push', - background: false, - patch: false, - select: false, - }); - - // Add default groupKey, since all Items are added to the default Group - if (!_groupKeys.includes(defaultGroupKey)) _groupKeys.push(defaultGroupKey); - - // Create not existing Groups - _groupKeys.forEach( - (key) => this.groups[key] == null && this.createGroup(key) - ); - - _data.forEach((data, index) => { - let itemKey; - let success = false; - - // 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, success, index); - }); - - return this; - } - - /** - * 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 - * @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, - changes: DefaultItem | DataType, - config: UpdateConfigInterface = {} - ): Item | undefined { - const item = this.getItem(itemKey, { notExisting: true }); - const primaryKey = this.config.primaryKey; - config = defineConfig(config, { - patch: true, - 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; - } - if (!isValidObject(changes)) { - LogCodeManager.log('1B:03:01', [itemKey, this._key]); - return undefined; - } - - const oldItemKey = item._value[primaryKey]; - const newItemKey = changes[primaryKey] || oldItemKey; - - // 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 data object - if (config.patch) { - // 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 } = - typeof config.patch === 'object' ? config.patch : {}; - patchConfig = { - addNewProperties: true, - ...patchConfig, - }; - - item.patch(changes as any, { - background: config.background, - addNewProperties: patchConfig.addNewProperties, - }); - } - // 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); - } - - item.set(changes as any, { - background: config.background, - }); - } - - return item; - } - - /** - * Creates a new Group and associates it to the Collection. - * - * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#createGroup) - * - * @public - * @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 - if (group != null) { - if (!group.isPlaceholder) { - LogCodeManager.log('1B:03:02', [groupKey]); - return group; - } - group.set(initialItems, { overwrite: true }); - return group; - } - - // Create new Group - group = new Group(this, initialItems, { key: groupKey }); - this.groups[groupKey] = group; - - return 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 - * @param groupKey - Key/Name identifier of the Group to be checked for existence. - * @param config - Configuration object - */ - public hasGroup( - groupKey: GroupKey | undefined, - config: HasConfigInterface = {} - ): boolean { - return !!this.getGroup(groupKey, config); - } - - /** - * 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 - * @param groupKey - Key/Name identifier of the Group. - * @param config - Configuration object - */ - public getGroup( - groupKey: GroupKey | undefined | null, - config: HasConfigInterface = {} - ): Group | undefined { - config = defineConfig(config, { - notExisting: false, - }); - - // Retrieve Group - const group = groupKey ? this.groups[groupKey] : undefined; - - // Check if retrieved Group exists - if (group == null || (!config.notExisting && !group.exists)) - return undefined; - - ComputedTracker.tracked(group.observers['value']); - return 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 - */ - public getDefaultGroup(): Group | undefined { - return this.getGroup(this.config.defaultGroupKey); - } - - /** - * 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 - * @param groupKey - Key/Name identifier of the Group. - */ - public getGroupWithReference(groupKey: GroupKey): Group { - let group = this.getGroup(groupKey, { notExisting: true }); - - // Create dummy Group to hold reference - if (group == null) { - group = new Group(this, [], { - key: groupKey, - isPlaceholder: true, - }); - this.groups[groupKey] = group; - } - - ComputedTracker.tracked(group.observers['value']); - return 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 - * @param groupKey - Key/Name identifier of the Group to be removed. - */ - public removeGroup(groupKey: GroupKey): this { - if (this.groups[groupKey] != null) delete this.groups[groupKey]; - return this; - } - - /** - * Returns the count of registered Groups in the Collection. - * - * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#getgroupcount) - * - * @public - */ - 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 | null - ): Selector { - let selector = this.getSelector(selectorKey, { notExisting: true }); - if (!this.isInstantiated) LogCodeManager.log('1B:02:04'); - - // Check if Selector already exists - if (selector != null) { - if (!selector.isPlaceholder) { - LogCodeManager.log('1B:03:03', [selectorKey]); - return selector; - } - selector.select(itemKey, { overwrite: true }); - return selector; - } - - // Create new Selector - selector = new Selector(this, itemKey, { - key: selectorKey, - }); - this.selectors[selectorKey] = selector; - - return selector; - } - - /** - * 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 - * @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); - } - - /** - * 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 - * @param selectorKey - Key/Name identifier of the Selector to be checked for existence. - * @param config - Configuration object - */ - public hasSelector( - selectorKey: SelectorKey | undefined, - config: HasConfigInterface = {} - ): boolean { - return !!this.getSelector(selectorKey, config); - } - - /** - * 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 - * @param selectorKey - Key/Name identifier of the Selector. - * @param config - Configuration object - */ - public getSelector( - selectorKey: SelectorKey | undefined | null, - config: HasConfigInterface = {} - ): Selector | undefined { - config = defineConfig(config, { - notExisting: false, - }); - - // Get Selector - const selector = selectorKey ? this.selectors[selectorKey] : undefined; - - // Check if Selector exists - if (selector == null || (!config.notExisting && !selector.exists)) - return undefined; - - ComputedTracker.tracked(selector.observers['value']); - return selector; - } - - /** - * 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 - * @param selectorKey - Key/Name identifier of the Selector. - */ - public getSelectorWithReference( - selectorKey: SelectorKey - ): Selector { - let selector = this.getSelector(selectorKey, { notExisting: true }); - - // Create dummy Selector to hold reference - if (selector == null) { - selector = new Selector(this, null, { - key: selectorKey, - isPlaceholder: true, - }); - this.selectors[selectorKey] = selector; - } - - ComputedTracker.tracked(selector.observers['value']); - return 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 - * @param selectorKey - Key/Name identifier of the Selector to be removed. - */ - public removeSelector(selectorKey: SelectorKey): this { - if (this.selectors[selectorKey] != null) { - this.selectors[selectorKey].unselect(); - delete this.selectors[selectorKey]; - } - return this; - } - - /** - * 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 - * @param itemKey - Key/Name identifier of the Item. - * @param config - Configuration object - */ - public hasItem( - itemKey: ItemKey | undefined, - config: HasConfigInterface = {} - ): boolean { - return !!this.getItem(itemKey, config); - } - - /** - * 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 - * @param itemKey - Key/Name identifier of the Item. - * @param config - Configuration object - */ - public getItem( - itemKey: ItemKey | undefined | null, - config: HasConfigInterface = {} - ): Item | undefined { - config = defineConfig(config, { - notExisting: false, - }); - - // Get Item - const item = itemKey != null ? this.data[itemKey] : undefined; - - // Check if Item exists - if (item == null || (!config.notExisting && !item.exists)) return undefined; - - ComputedTracker.tracked(item.observers['value']); - return item; - } - - /** - * 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 - * @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 = this.createPlaceholderItem(itemKey, true); - - ComputedTracker.tracked(item.observers['value']); - 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.observers['value']); - return item; - } - - /** - * 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 - * @param itemKey - Key/Name identifier of the Item. - * @param config - Configuration object - */ - public getItemValue( - itemKey: ItemKey | undefined, - config: HasConfigInterface = {} - ): DataType | undefined { - const item = this.getItem(itemKey, config); - if (item == null) return undefined; - return item.value; - } - - /** - * 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 - * @param config - Configuration object - */ - public getAllItems(config: HasConfigInterface = {}): Array> { - config = defineConfig(config, { - notExisting: false, - }); - - const defaultGroup = this.getDefaultGroup(); - let items: Array> = []; - - // 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 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?.getItems() || []; - } - - return items; - } - - /** - * 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 - * @param config - Configuration object - */ - public getAllItemValues(config: HasConfigInterface = {}): Array { - const items = this.getAllItems(config); - return items.map((item) => item.value); - } - - /** - * 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 - * @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 - * @param key - Key/Name identifier of Persistent. - * @param config - Configuration object - */ - public persist( - key?: StorageKey, - config?: CollectionPersistentConfigInterface - ): this; - public persist( - keyOrConfig: StorageKey | CollectionPersistentConfigInterface = {}, - config: CollectionPersistentConfigInterface = {} - ): this { - let _config: CollectionPersistentConfigInterface; - let key: StorageKey | undefined; - - if (isValidObject(keyOrConfig)) { - _config = keyOrConfig as CollectionPersistentConfigInterface; - key = this._key; - } else { - _config = config || {}; - key = keyOrConfig as StorageKey; - } - - _config = defineConfig(_config, { - loadValue: true, - storageKeys: [], - defaultStorageKey: null as any, - }); - - // 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, - key: key, - defaultStorageKey: _config.defaultStorageKey, - }); - - return this; - } - - /** - * 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 - * @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; - if (!isFunction(callback)) { - LogCodeManager.log('00:03:01', ['OnLoad Callback', 'function']); - return this; - } - - // Register specified callback - this.persistent.onLoad = callback; - - // If Collection is already persisted ('isPersisted') fire specified callback immediately - if (this.isPersisted) callback(true); - - return this; - } - - /** - * 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 - */ - public reset(): this { - // Reset data - this.data = {}; - this.size = 0; - - // Reset Groups - for (const key in this.groups) this.getGroup(key)?.reset(); - - // Reset Selectors - for (const key in this.selectors) this.getSelector(key)?.reset(); - - return this; - } - - /** - * Puts `itemKeys/s` into Group/s. - * - * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#put) - * - * @public - * @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, - groupKeys: GroupKey | Array, - config: GroupAddConfigInterface = {} - ): this { - const _itemKeys = normalizeArray(itemKeys); - const _groupKeys = normalizeArray(groupKeys); - - // Assign itemKeys to Groups - _groupKeys.forEach((groupKey) => { - this.getGroup(groupKey)?.add(_itemKeys, config); - }); - - return this; - } - - /** - * Moves specified `itemKey/s` from one Group to another Group. - * - * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#move) - * - * @public - * @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, - oldGroupKey: GroupKey, - newGroupKey: GroupKey, - config: GroupAddConfigInterface = {} - ): this { - const _itemKeys = normalizeArray(itemKeys); - - // Remove itemKeys from old Group - this.getGroup(oldGroupKey)?.remove( - _itemKeys, - removeProperties(config, ['method', 'overwrite']) - ); - - // Assign itemKeys to new Group - this.getGroup(newGroupKey)?.add(_itemKeys, config); - - return this; - } - - /** - * Updates the key/name identifier of the Item - * and returns a boolean indicating - * whether the Item identifier was updated successfully. - * - * @internal - * @param oldItemKey - Old key/name Item identifier. - * @param newItemKey - New key/name Item identifier. - * @param config - Configuration object - */ - public updateItemKey( - oldItemKey: ItemKey, - newItemKey: ItemKey, - config: UpdateItemKeyConfigInterface = {} - ): boolean { - const item = this.getItem(oldItemKey, { notExisting: true }); - config = defineConfig(config, { - background: false, - }); - - if (item == null || oldItemKey === newItemKey) return false; - - // Check if Item with newItemKey already exists - if (this.hasItem(newItemKey)) { - LogCodeManager.log('1B:03:04', [oldItemKey, newItemKey, this._key]); - return false; - } - - // Update itemKey in data object - delete this.data[oldItemKey]; - this.data[newItemKey] = item; - - // Update key/name of the Item - item.setKey(newItemKey, { - background: config.background, - }); - - // 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 - for (const groupKey in this.groups) { - const group = this.getGroup(groupKey, { notExisting: true }); - if (group == null || !group.has(oldItemKey)) continue; - group.replace(oldItemKey, newItemKey, { background: config.background }); - } - - // 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 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 (but not the Items at this itemKey anymore) - background: config.background, - }); - } - - // Select newItemKey in Selector that has selected the oldItemKey - if (selector.hasSelected(oldItemKey, false)) - selector.select(newItemKey, { - background: config.background, - }); - } - - return true; - } - - /** - * 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 - * @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.groups[groupKey]; - if (group?.has(itemKey)) groupKeys.push(groupKey); - } - return groupKeys; - } - - /** - * 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 - * @param itemKeys - Item/s with identifier/s to be removed. - */ - public remove( - itemKeys: ItemKey | Array - ): { - fromGroups: (groups: Array | ItemKey) => Collection; - everywhere: (config?: RemoveItemsConfigInterface) => Collection; - } { - return { - fromGroups: (groups: Array | ItemKey) => - this.removeFromGroups(itemKeys, groups), - everywhere: (config) => this.removeItems(itemKeys, config || {}), - }; - } - - /** - * Remove Item/s from specified Group/s. - * - * [Learn more..](https://agile-ts.org/docs/core/collection/methods/#removefromgroups) - * - * @public - * @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, - groupKeys: GroupKey | Array - ): this { - const _itemKeys = normalizeArray(itemKeys); - const _groupKeys = normalizeArray(groupKeys); - - _itemKeys.forEach((itemKey) => { - let removedFromGroupsCount = 0; - - // Remove itemKey from the Groups - _groupKeys.forEach((groupKey) => { - const group = this.getGroup(groupKey, { notExisting: true }); - if (!group?.has(itemKey)) return; - group.remove(itemKey); - removedFromGroupsCount++; - }); - - // If the Item was removed from each Group representing the Item, - // remove it completely - if ( - removedFromGroupsCount >= - this.getGroupKeysThatHaveItemKey(itemKey).length - ) - this.removeItems(itemKey); - }); - - return this; - } - - /** - * 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 - * @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, - config: RemoveItemsConfigInterface = {} - ): this { - config = defineConfig(config, { - notExisting: false, - removeSelector: false, - }); - const _itemKeys = normalizeArray(itemKeys); - - _itemKeys.forEach((itemKey) => { - const item = this.getItem(itemKey, { notExisting: config.notExisting }); - if (item == null) return; - const wasPlaceholder = item.isPlaceholder; - - // 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); - } - - // Remove Item from Storage - item.persistent?.removePersistedValue(); - - // Remove Item from Collection - delete this.data[itemKey]; - - // 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 != null && selector.hasSelected(itemKey, false)) { - if (config.removeSelector) { - // Remove Selector - this.removeSelector(selector._key ?? 'unknown'); - } else { - // 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 }); - } - } - } - - if (!wasPlaceholder) this.size--; - }); - - return this; - } - - /** - * 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 - * @param data - Data object - * @param config - Configuration object - */ - 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, primaryKey]); - _data[primaryKey] = generateId(); - } - - const itemKey = _data[primaryKey]; - const item = this.getItem(itemKey, { notExisting: true }); - const wasPlaceholder = item?.isPlaceholder || false; - - // 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 Item was previously a placeholder - // (-> hasn't officially existed in Collection before) - if (wasPlaceholder) this.size++; - - return true; - } - - /** - * 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 - * @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, { notExisting: true }) != null) { - if (!config.overwrite) { - this.assignData(item._value); - 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, - config: RebuildGroupsThatIncludeItemKeyConfigInterface = {} - ): void { - config = defineConfig(config, { - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - }); - - // Rebuild Groups that include itemKey - for (const groupKey in this.groups) { - const group = this.getGroup(groupKey); - if (group?.has(itemKey)) { - // Not necessary because a sideEffect of ingesting the Group - // into the runtime is to rebuilt itself - // group.rebuild(); - - group?.rebuild({ - background: config?.background, - sideEffects: config?.sideEffects, - storage: false, - }); - } - } - } -} - -export type DefaultItem = Record; // same as { [key: string]: any }; -export type CollectionKey = string | number; -export type ItemKey = string | number; - -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; -} - -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; -} - -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'; - /** - * 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; -} - -export interface UpdateConfigInterface { - /** - * 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; -} - -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; -} - -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; - /** - * Whether to execute the defined side effects. - * @default true - */ - sideEffects?: SideEffectConfigInterface; -} - -export interface HasConfigInterface { - /** - * Whether Items that do not officially exist, - * such as placeholder Items, can be found - * @default true - */ - notExisting?: boolean; -} - -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; -} - -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; -} - -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 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; +export * from './collection'; +// export * from './collection.persistent'; +// export * from './group'; +// export * from './group/group.observer'; +// export * from './item'; +// export * from './selector'; + +/** + * 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 + * @param config - Configuration object + * @param agileInstance - Instance of Agile the Collection belongs to. + */ +export function createCollection( + config?: CollectionConfig, + agileInstance: Agile = shared +): Collection { + return new Collection(agileInstance, config); } diff --git a/packages/core/src/collection/item.ts b/packages/core/src/collection/item.ts index 1be4cebf..44266aee 100644 --- a/packages/core/src/collection/item.ts +++ b/packages/core/src/collection/item.ts @@ -1,5 +1,5 @@ import { - State, + EnhancedState, Collection, StateKey, StateRuntimeJobConfigInterface, @@ -12,7 +12,7 @@ import { defineConfig, } from '../internal'; -export class Item extends State< +export class Item extends EnhancedState< DataType > { // Collection the Group belongs to @@ -166,9 +166,6 @@ export class Item extends State< this.addSideEffect>( Item.updateGroupSideEffectKey, (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 } diff --git a/packages/core/src/collection/selector.ts b/packages/core/src/collection/selector.ts index 937c5458..76795f35 100644 --- a/packages/core/src/collection/selector.ts +++ b/packages/core/src/collection/selector.ts @@ -4,13 +4,13 @@ import { defineConfig, Item, ItemKey, - State, + EnhancedState, StateRuntimeJobConfigInterface, } from '../internal'; export class Selector< DataType extends Object = DefaultItem -> extends State { +> extends EnhancedState { // Collection the Selector belongs to public collection: () => Collection; diff --git a/packages/core/src/computed/computed.tracker.ts b/packages/core/src/computed/computed.tracker.ts index e3254f90..c9c7ecc6 100644 --- a/packages/core/src/computed/computed.tracker.ts +++ b/packages/core/src/computed/computed.tracker.ts @@ -1,4 +1,4 @@ -import { Observer } from '../runtime/observer'; +import { Observer } from '../internal'; export class ComputedTracker { static isTracking = false; diff --git a/packages/core/src/computed/computed.ts b/packages/core/src/computed/computed.ts new file mode 100644 index 00000000..416cb4db --- /dev/null +++ b/packages/core/src/computed/computed.ts @@ -0,0 +1,266 @@ +import { + State, + Agile, + Observer, + StateConfigInterface, + ComputedTracker, + Collection, + StateIngestConfigInterface, + removeProperties, + LogCodeManager, + isAsyncFunction, + extractRelevantObservers, + defineConfig, +} from '../internal'; + +export class Computed extends State< + ComputedValueType +> { + public config: ComputedConfigInterface; + + // Function to compute the Computed Class value + public computeFunction: ComputeFunctionType; + // All dependencies the Computed Class depends on (including hardCoded and automatically detected dependencies) + public deps: Set = new Set(); + // Only hardCoded dependencies the Computed Class depends on + public hardCodedDeps: Array = []; + + // Helper property to check whether an unknown instance is a Computed, + // without importing the Computed itself for using 'instanceof' (Treeshaking support) + public isComputed = true; + + /** + * 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 + * @param agileInstance - Instance of Agile the Computed belongs to. + * @param computeFunction - Function to compute the computed value. + * @param config - Configuration object + */ + constructor( + agileInstance: Agile, + computeFunction: ComputeFunctionType, + config: CreateComputedConfigInterface = {} + ) { + super(agileInstance, null as any, { + key: config.key, + dependents: config.dependents, + }); + config = defineConfig(config, { + computedDeps: [], + autodetect: !isAsyncFunction(computeFunction), + }); + this.agileInstance = () => agileInstance; + this.computeFunction = computeFunction; + this.config = { + autodetect: config.autodetect as any, + }; + + // Extract Observer of passed hardcoded dependency instances + this.hardCodedDeps = extractRelevantObservers( + config.computedDeps as DependableAgileInstancesType[] + ).filter((dep): dep is Observer => dep !== undefined); + this.deps = new Set(this.hardCodedDeps); + + // Make this Observer depend on the specified hard coded dep Observers + this.deps.forEach((observer) => { + observer.addDependent(this.observers['value']); + }); + + // Initial recompute to assign the computed initial value to the Computed + // and autodetect missing dependencies + this.recompute({ autodetect: config.autodetect, overwrite: true }); + } + + /** + * Forces a recomputation of the cached value with the compute function. + * + * [Learn more..](https://agile-ts.org/docs/core/computed/methods/#recompute) + * + * @public + * @param config - Configuration object + */ + public recompute(config: RecomputeConfigInterface = {}): this { + config = defineConfig(config, { + autodetect: false, + }); + this.compute({ autodetect: config.autodetect }).then((result) => { + this.observers['value'].ingestValue( + result, + removeProperties(config, ['autodetect']) + ); + }); + return this; + } + + /** + * 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 + * @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, + deps: Array = [], + config: RecomputeConfigInterface = {} + ): this { + config = defineConfig(config, { + autodetect: this.config.autodetect, + }); + + // Make this Observer no longer depend on the old dep Observers + this.deps.forEach((observer) => { + observer.removeDependent(this.observers['value']); + }); + + // Update dependencies of Computed + this.hardCodedDeps = extractRelevantObservers(deps).filter( + (dep): dep is Observer => dep !== undefined + ); + this.deps = new Set(this.hardCodedDeps); + + // Make this Observer depend on the new hard coded dep Observers + this.deps.forEach((observer) => { + observer.addDependent(this.observers['value']); + }); + + // Update computeFunction + this.computeFunction = computeFunction; + + // Recompute to assign the new computed value to the Computed + // and autodetect missing dependencies + this.recompute(removeProperties(config, ['overwriteDeps'])); + + return this; + } + + /** + * Computes and returns the new value of the Computed Class + * and autodetects used dependencies in the compute function. + * + * @internal + * @param config - Configuration object + */ + public async compute( + config: ComputeConfigInterface = {} + ): Promise { + config = defineConfig(config, { + autodetect: this.config.autodetect, + }); + + // Start auto tracking of Observers on which the computeFunction might depend + if (config.autodetect) ComputedTracker.track(); + + const computedValue = this.computeFunction(); + + // Handle auto tracked Observers + if (config.autodetect) { + const foundDeps = ComputedTracker.getTrackedObservers(); + + // Clean up old dependencies + this.deps.forEach((observer) => { + if ( + !foundDeps.includes(observer) && + !this.hardCodedDeps.includes(observer) + ) { + this.deps.delete(observer); + observer.removeDependent(this.observers['value']); + } + }); + + // Make this Observer depend on the newly found dep Observers + foundDeps.forEach((observer) => { + if (!this.deps.has(observer)) { + this.deps.add(observer); + observer.addDependent(this.observers['value']); + } + }); + } + + return computedValue; + } + + /** + * Not usable in Computed Class. + */ + public persist(): this { + LogCodeManager.log('19:03:00'); + return this; + } +} + +export type ComputeFunctionType = () => + | ComputedValueType + | Promise; + +export interface CreateComputedConfigInterface extends StateConfigInterface { + /** + * Hard-coded dependencies the Computed Class should depend on. + * @default [] + */ + computedDeps?: Array; + /** + * Whether the Computed should automatically detect + * used dependencies in the specified compute method. + * + * Note that the automatic dependency detection does not work + * in an asynchronous compute method! + * + * @default true if the compute method isn't asynchronous, otherwise false + */ + autodetect?: boolean; +} + +export interface ComputedConfigInterface { + /** + * Whether the Computed can automatically detect + * used dependencies in the compute method. + * + * Note that the automatic dependency detection does not work + * in an asynchronous compute method! + * + * @default true if the compute method isn't asynchronous, otherwise false + */ + autodetect: boolean; +} + +export interface ComputeConfigInterface { + /** + * Whether the Computed can automatically detect + * used dependencies in the compute method. + * + * Note that the automatic dependency detection does not work + * in an asynchronous compute method! + * + * @default true + */ + autodetect?: boolean; +} + +export interface RecomputeConfigInterface + extends StateIngestConfigInterface, + ComputeConfigInterface {} + +export type DependableAgileInstancesType = + | State + | Collection //https://stackoverflow.com/questions/66987727/type-classa-id-number-name-string-is-not-assignable-to-type-classar + | Observer; diff --git a/packages/core/src/computed/index.ts b/packages/core/src/computed/index.ts index 4499c73d..185dec62 100644 --- a/packages/core/src/computed/index.ts +++ b/packages/core/src/computed/index.ts @@ -1,262 +1,86 @@ import { - State, - Agile, - Observer, - StateConfigInterface, - ComputedTracker, - Collection, - StateIngestConfigInterface, - removeProperties, - LogCodeManager, - isAsyncFunction, - extractRelevantObservers, + Computed, + ComputeFunctionType, + CreateComputedConfigInterface, + DependableAgileInstancesType, defineConfig, + removeProperties, + CreateAgileSubInstanceInterface, + shared, } from '../internal'; -export class Computed extends State< - ComputedValueType -> { - public config: ComputedConfigInterface; - - // Function to compute the Computed Class value - public computeFunction: ComputeFunctionType; - // All dependencies the Computed Class depends on (including hardCoded and automatically detected dependencies) - public deps: Set = new Set(); - // 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 - * @param agileInstance - Instance of Agile the Computed belongs to. - * @param computeFunction - Function to compute the computed value. - * @param config - Configuration object - */ - constructor( - agileInstance: Agile, - computeFunction: ComputeFunctionType, - config: CreateComputedConfigInterface = {} - ) { - super(agileInstance, null as any, { - key: config.key, - dependents: config.dependents, - }); - config = defineConfig(config, { - computedDeps: [], - autodetect: !isAsyncFunction(computeFunction), +export * from './computed'; +// export * from './computed.tracker'; + +/** + * 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 + * @param computeFunction - Function to compute the computed value. + * @param config - Configuration object + */ +export function createComputed( + computeFunction: ComputeFunctionType, + config?: CreateComputedConfigInterfaceWithAgile +): 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 + * @param computeFunction - Function to compute the computed value. + * @param deps - Hard-coded dependencies on which the Computed Class should depend. + */ +export function createComputed( + computeFunction: ComputeFunctionType, + deps?: Array +): Computed; +export function createComputed( + computeFunction: ComputeFunctionType, + configOrDeps?: + | CreateComputedConfigInterfaceWithAgile + | Array +): Computed { + let _config: CreateComputedConfigInterfaceWithAgile = {}; + + if (Array.isArray(configOrDeps)) { + _config = defineConfig(_config, { + computedDeps: configOrDeps, }); - this.agileInstance = () => agileInstance; - this.computeFunction = computeFunction; - this.config = { - autodetect: config.autodetect as any, - }; - - // Extract Observer of passed hardcoded dependency instances - this.hardCodedDeps = extractRelevantObservers( - config.computedDeps as DependableAgileInstancesType[] - ).filter((dep): dep is Observer => dep !== undefined); - this.deps = new Set(this.hardCodedDeps); - - // Make this Observer depend on the specified hard coded dep Observers - this.deps.forEach((observer) => { - observer.addDependent(this.observers['value']); - }); - - // Initial recompute to assign the computed initial value to the Computed - // and autodetect missing dependencies - this.recompute({ autodetect: config.autodetect, overwrite: true }); + } else { + if (configOrDeps) _config = configOrDeps; } - /** - * Forces a recomputation of the cached value with the compute function. - * - * [Learn more..](https://agile-ts.org/docs/core/computed/methods/#recompute) - * - * @public - * @param config - Configuration object - */ - public recompute(config: RecomputeConfigInterface = {}): this { - config = defineConfig(config, { - autodetect: false, - }); - this.compute({ autodetect: config.autodetect }).then((result) => { - this.observers['value'].ingestValue( - result, - removeProperties(config, ['autodetect']) - ); - }); - return this; - } - - /** - * 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 - * @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, - deps: Array = [], - config: RecomputeConfigInterface = {} - ): this { - config = defineConfig(config, { - autodetect: this.config.autodetect, - }); - - // Make this Observer no longer depend on the old dep Observers - this.deps.forEach((observer) => { - observer.removeDependent(this.observers['value']); - }); - - // Update dependencies of Computed - this.hardCodedDeps = extractRelevantObservers(deps).filter( - (dep): dep is Observer => dep !== undefined - ); - this.deps = new Set(this.hardCodedDeps); - - // Make this Observer depend on the new hard coded dep Observers - this.deps.forEach((observer) => { - observer.addDependent(this.observers['value']); - }); - - // Update computeFunction - this.computeFunction = computeFunction; - - // Recompute to assign the new computed value to the Computed - // and autodetect missing dependencies - this.recompute(removeProperties(config, ['overwriteDeps'])); + _config = defineConfig(_config, { agileInstance: shared }); - return this; - } - - /** - * Computes and returns the new value of the Computed Class - * and autodetects used dependencies in the compute function. - * - * @internal - * @param config - Configuration object - */ - public async compute( - config: ComputeConfigInterface = {} - ): Promise { - config = defineConfig(config, { - autodetect: this.config.autodetect, - }); - - // Start auto tracking of Observers on which the computeFunction might depend - if (config.autodetect) ComputedTracker.track(); - - const computedValue = this.computeFunction(); - - // Handle auto tracked Observers - if (config.autodetect) { - const foundDeps = ComputedTracker.getTrackedObservers(); - - // Clean up old dependencies - this.deps.forEach((observer) => { - if ( - !foundDeps.includes(observer) && - !this.hardCodedDeps.includes(observer) - ) { - this.deps.delete(observer); - observer.removeDependent(this.observers['value']); - } - }); - - // Make this Observer depend on the newly found dep Observers - foundDeps.forEach((observer) => { - if (!this.deps.has(observer)) { - this.deps.add(observer); - observer.addDependent(this.observers['value']); - } - }); - } - - return computedValue; - } - - /** - * Not usable in Computed Class. - */ - public persist(): this { - LogCodeManager.log('19:03:00'); - return this; - } -} - -export type ComputeFunctionType = () => - | ComputedValueType - | Promise; - -export interface CreateComputedConfigInterface extends StateConfigInterface { - /** - * Hard-coded dependencies the Computed Class should depend on. - * @default [] - */ - computedDeps?: Array; - /** - * Whether the Computed should automatically detect - * used dependencies in the specified compute method. - * - * Note that the automatic dependency detection does not work - * in an asynchronous compute method! - * - * @default true if the compute method isn't asynchronous, otherwise false - */ - autodetect?: boolean; + return new Computed( + _config.agileInstance as any, + computeFunction, + removeProperties(_config, ['agileInstance']) + ); } -export interface ComputedConfigInterface { - /** - * Whether the Computed can automatically detect - * used dependencies in the compute method. - * - * Note that the automatic dependency detection does not work - * in an asynchronous compute method! - * - * @default true if the compute method isn't asynchronous, otherwise false - */ - autodetect: boolean; -} - -export interface ComputeConfigInterface { - /** - * Whether the Computed can automatically detect - * used dependencies in the compute method. - * - * Note that the automatic dependency detection does not work - * in an asynchronous compute method! - * - * @default true - */ - autodetect?: boolean; -} - -export interface RecomputeConfigInterface - extends StateIngestConfigInterface, - ComputeConfigInterface {} - -export type DependableAgileInstancesType = - | State - | Collection //https://stackoverflow.com/questions/66987727/type-classa-id-number-name-string-is-not-assignable-to-type-classar - | Observer; +export interface CreateComputedConfigInterfaceWithAgile + extends CreateAgileSubInstanceInterface, + CreateComputedConfigInterface {} diff --git a/packages/core/src/integrations/index.ts b/packages/core/src/integrations/index.ts index c13e081b..196a738b 100644 --- a/packages/core/src/integrations/index.ts +++ b/packages/core/src/integrations/index.ts @@ -1,151 +1,2 @@ -import { Agile, Integration, LogCodeManager, defineConfig } from '../internal'; - -const onRegisterInitialIntegrationCallbacks: (( - integration: Integration -) => void)[] = []; - -export class Integrations { - // Agile Instance the Integrations belongs to - public agileInstance: () => Agile; - - // Registered Integrations - public integrations: Set = new Set(); - - // External added Integrations - // that are to integrate into not yet existing Agile Instances - static initialIntegrations: Integration[] = []; - - /** - * Registers the specified Integration in each existing or not-yet created Agile Instance. - * - * @public - * @param integration - Integration to be registered in each Agile Instance. - */ - static addInitialIntegration(integration: Integration): void { - if (integration instanceof Integration) { - // Executed external registered Integration callbacks - onRegisterInitialIntegrationCallbacks.forEach((callback) => - callback(integration) - ); - - Integrations.initialIntegrations.push(integration); - } - } - - /** - * Fires on each external added Integration. - * - * @public - * @param callback - Callback to be fired when an Integration was externally added. - */ - static onRegisterInitialIntegration( - callback: (integration: Integration) => void - ): void { - onRegisterInitialIntegrationCallbacks.push(callback); - Integrations.initialIntegrations.forEach((integration) => { - callback(integration); - }); - } - - /** - * 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 - * @param agileInstance - Instance of Agile the Integrations belongs to. - * @param config - Configuration object - */ - constructor(agileInstance: Agile, config: IntegrationsConfigInterface = {}) { - config = defineConfig(config, { - autoIntegrate: true, - }); - this.agileInstance = () => agileInstance; - - if (config.autoIntegrate) { - // Setup listener to be notified when an external registered Integration was added - Integrations.onRegisterInitialIntegration((integration) => { - this.integrate(integration); - }); - } - } - - /** - * 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 - if (integration._key == null) { - LogCodeManager.log( - '18:03:00', - [integration._key, this.agileInstance().key], - integration - ); - return false; - } - - // Bind to integrate Integration to AgileTs - if (integration.methods.bind) - integration.ready = await integration.methods.bind(this.agileInstance()); - else integration.ready = true; - - // Integrate Integration - this.integrations.add(integration); - integration.integrated = true; - - LogCodeManager.log( - '18:00:00', - [integration._key, this.agileInstance().key], - integration - ); - - return true; - } - - /** - * 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) => { - if (!integration.ready) { - LogCodeManager.log('18:02:00', [integration._key]); - return; - } - if (integration.methods.updateMethod) - integration.methods.updateMethod(componentInstance, updatedData); - }); - } - - /** - * 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; - } -} - -export interface IntegrationsConfigInterface { - /** - * Whether external added Integrations - * are to integrate automatically into the Integrations Class. - * For example, when the package '@agile-ts/react' was installed, - * whether to automatically integrate the 'reactIntegration'. - * @default true - */ - autoIntegrate?: boolean; -} +export * from './integrations'; +// export * from './integration'; diff --git a/packages/core/src/integrations/integrations.ts b/packages/core/src/integrations/integrations.ts new file mode 100644 index 00000000..c13e081b --- /dev/null +++ b/packages/core/src/integrations/integrations.ts @@ -0,0 +1,151 @@ +import { Agile, Integration, LogCodeManager, defineConfig } from '../internal'; + +const onRegisterInitialIntegrationCallbacks: (( + integration: Integration +) => void)[] = []; + +export class Integrations { + // Agile Instance the Integrations belongs to + public agileInstance: () => Agile; + + // Registered Integrations + public integrations: Set = new Set(); + + // External added Integrations + // that are to integrate into not yet existing Agile Instances + static initialIntegrations: Integration[] = []; + + /** + * Registers the specified Integration in each existing or not-yet created Agile Instance. + * + * @public + * @param integration - Integration to be registered in each Agile Instance. + */ + static addInitialIntegration(integration: Integration): void { + if (integration instanceof Integration) { + // Executed external registered Integration callbacks + onRegisterInitialIntegrationCallbacks.forEach((callback) => + callback(integration) + ); + + Integrations.initialIntegrations.push(integration); + } + } + + /** + * Fires on each external added Integration. + * + * @public + * @param callback - Callback to be fired when an Integration was externally added. + */ + static onRegisterInitialIntegration( + callback: (integration: Integration) => void + ): void { + onRegisterInitialIntegrationCallbacks.push(callback); + Integrations.initialIntegrations.forEach((integration) => { + callback(integration); + }); + } + + /** + * 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 + * @param agileInstance - Instance of Agile the Integrations belongs to. + * @param config - Configuration object + */ + constructor(agileInstance: Agile, config: IntegrationsConfigInterface = {}) { + config = defineConfig(config, { + autoIntegrate: true, + }); + this.agileInstance = () => agileInstance; + + if (config.autoIntegrate) { + // Setup listener to be notified when an external registered Integration was added + Integrations.onRegisterInitialIntegration((integration) => { + this.integrate(integration); + }); + } + } + + /** + * 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 + if (integration._key == null) { + LogCodeManager.log( + '18:03:00', + [integration._key, this.agileInstance().key], + integration + ); + return false; + } + + // Bind to integrate Integration to AgileTs + if (integration.methods.bind) + integration.ready = await integration.methods.bind(this.agileInstance()); + else integration.ready = true; + + // Integrate Integration + this.integrations.add(integration); + integration.integrated = true; + + LogCodeManager.log( + '18:00:00', + [integration._key, this.agileInstance().key], + integration + ); + + return true; + } + + /** + * 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) => { + if (!integration.ready) { + LogCodeManager.log('18:02:00', [integration._key]); + return; + } + if (integration.methods.updateMethod) + integration.methods.updateMethod(componentInstance, updatedData); + }); + } + + /** + * 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; + } +} + +export interface IntegrationsConfigInterface { + /** + * Whether external added Integrations + * are to integrate automatically into the Integrations Class. + * For example, when the package '@agile-ts/react' was installed, + * whether to automatically integrate the 'reactIntegration'. + * @default true + */ + autoIntegrate?: boolean; +} diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index b9356568..f504535e 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,6 +1,7 @@ -// This file exposes Agile functions and types to the outside world -// It also serves as a cyclic dependency workaround -// https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de. +// This file exposes Agile functions and types to the outside world. +// It also serves as a cyclic dependency workaround, +// and allows us to structure the loading order as needed (for example, './agile' need to be loaded before './state') +// https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de // !! All internal Agile modules must be imported from here!! @@ -35,6 +36,7 @@ export * from './storages/persistent'; // State export * from './state'; export * from './state/state.observer'; +export * from './state/state.enhanced'; export * from './state/state.persistent'; export * from './state/state.runtime.job'; diff --git a/packages/core/src/logCodeManager.ts b/packages/core/src/logCodeManager.ts index 413b46b6..5c1afbcf 100644 --- a/packages/core/src/logCodeManager.ts +++ b/packages/core/src/logCodeManager.ts @@ -1,10 +1,4 @@ -// TODO https://stackoverflow.com/questions/68148235/require-module-inside-a-function-doesnt-work -export let loggerPackage: any = null; -try { - loggerPackage = require('@agile-ts/logger'); -} catch (e) { - // empty catch block -} +import { copy } from '@agile-ts/utils'; // The Log Code Manager keeps track // and manages all important Logs of AgileTs. @@ -17,7 +11,7 @@ try { // 00 = General // 10 = Agile // 11 = Storage -// .. +// ... // // --- // 00:|00|:00 second digits are based on the Log Type @@ -31,7 +25,8 @@ const logCodeTypes = { // --- // 00:00:|00| third digits are based on the Log Message (ascending counted) -const logCodeMessages = { +let allowLogging = true; +const niceLogCodeMessages = { // Agile '10:00:00': 'Created new AgileInstance.', '10:02:00': @@ -48,6 +43,8 @@ const logCodeMessages = { "Couldn't find Storage '${0}'. " + "The Storage with the key/name '${0}' doesn't exists!", '11:03:02': "Storage with the key/name '${0}' isn't ready yet!", + '11:02:06': + 'By registering a new Storage Manager the old one will be overwritten!', '11:03:03': 'No Storage found to get a value from! Please specify at least one Storage.', '11:03:04': @@ -75,8 +72,6 @@ const logCodeMessages = { '13:03:00': "Invalid Storage '${0}()' method provided!", // State - '14:02:00': "Incorrect type '${0}' was provided! Requires type of ${1}.", - '14:03:00': "Incorrect type '${0}' was provided! Requires type of ${1}.", '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!", @@ -173,6 +168,21 @@ const logCodeMessages = { '00:03:01': "'${0}' has to be of the type ${1}!", }; +// Note: Not outsource the 'production' env check, +// because then webpack can't treeshake based on the current env +const logCodeMessages: typeof niceLogCodeMessages = + process.env.NODE_ENV !== 'production' ? niceLogCodeMessages : ({} as any); + +/** + * Specifies whether the LogCodeManager is allowed to print any logs. + * + * @internal + * @param logging - Whether the LogCodeManager is allowed to print any logs. + */ +function setAllowLogging(logging: boolean) { + allowLogging = logging; +} + /** * Returns the log message according to the specified log code. * @@ -185,7 +195,8 @@ function getLog>( logCode: T, replacers: any[] = [] ): string { - let result = logCodeMessages[logCode] ?? `'${logCode}' is a unknown logCode!`; + let result = logCodeMessages[logCode]; + if (result == null) return logCode; // Replace '${x}' with the specified replacer instances for (let i = 0; i < replacers.length; i++) { @@ -211,7 +222,7 @@ function log>( ...data: any[] ): void { const logger = LogCodeManager.getLogger(); - if (logger != null && !logger.isActive) return; + if ((logger != null && !logger.isActive) || !allowLogging) return; const logType = logCodeTypes[logCode.substr(3, 2)]; if (typeof logType !== 'string') return; @@ -244,7 +255,7 @@ function logIfTags>( ...data: any[] ): void { const logger = LogCodeManager.getLogger(); - if (logger != null && !logger.isActive) return; + if ((logger != null && !logger.isActive) || !allowLogging) return; const logType = logCodeTypes[logCode.substr(3, 2)]; if (typeof logType !== 'string') return; @@ -258,26 +269,93 @@ function logIfTags>( logger.if.tag(tags)[logType](getLog(logCode, replacers), ...data); } +/** + * Creates an extension of the specified LogCodeManager + * and assigns the provided additional log messages to it. + * + * @param additionalLogs - Log messages to be added to the LogCodeManager. + * @param logCodeManager - LogCodeManager to create an extension from. + */ +export function assignAdditionalLogs< + NewLogCodeMessages, + OldLogCodeMessages = typeof logCodeMessages +>( + additionalLogs: { [key: string]: string }, + logCodeManager: LogCodeManagerInterface +): LogCodeManagerInterface { + const copiedLogCodeManager = copy(logCodeManager); + copiedLogCodeManager.logCodeMessages = { + ...copiedLogCodeManager.logCodeMessages, + ...additionalLogs, + } as any; + return copiedLogCodeManager as any; +} + +let tempLogCodeManager: LogCodeManagerInterface; +if (process.env.NODE_ENV !== 'production') { + let loggerPackage: any = null; + try { + loggerPackage = require('@agile-ts/logger'); + } catch (e) { + // empty catch block + } + + tempLogCodeManager = { + getLog, + log, + logCodeLogTypes: logCodeTypes, + logCodeMessages: logCodeMessages, + getLogger: () => { + return loggerPackage?.getLogger() ?? null; + }, + logIfTags, + setAllowLogging, + }; +} else { + tempLogCodeManager = { + // Log only logCode + getLog: (logCode, replacers) => logCode, + log, + logCodeLogTypes: logCodeTypes, + logCodeMessages: {} as any, + getLogger: () => { + return null; + }, + logIfTags: (tags, logCode, replacers) => { + /* empty because logs with tags can't be that important */ + }, + setAllowLogging, + }; +} + /** * The Log Code Manager keeps track - * and manages all important Logs of AgileTs. + * and manages all important Logs for the '@agile-ts/core' package. * * @internal */ -export const LogCodeManager = { - getLog, - log, - logCodeLogTypes: logCodeTypes, - logCodeMessages: logCodeMessages, - // Not doing 'logger: loggerPackage?.sharedAgileLogger' - // because only by calling a function (now 'getLogger()') the 'sharedLogger' is refetched - getLogger: () => { - return loggerPackage?.sharedAgileLogger ?? null; - }, - logIfTags, -}; +export const LogCodeManager = tempLogCodeManager; export type LogCodesArrayType = { [K in keyof T]: T[K] extends string ? K : never; }[keyof T] & string; + +export interface LogCodeManagerInterface { + getLog: (logCode: LogCodesArrayType, replacers?: any[]) => string; + log: ( + logCode: LogCodesArrayType, + replacers?: any[], + ...data: any[] + ) => void; + logCodeLogTypes: typeof logCodeTypes; + logCodeMessages: T; + getLogger: () => any; + logIfTags: ( + tags: string[], + logCode: LogCodesArrayType, + replacers?: any[], + ...data: any[] + ) => void; + setAllowLogging: (logging: boolean) => void; +} diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index 0dd3419f..de2b0edb 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -1,358 +1,7 @@ -import { - Agile, - SubscriptionContainer, - RuntimeJob, - CallbackSubscriptionContainer, - ComponentSubscriptionContainer, - notEqual, - LogCodeManager, - defineConfig, -} from '../internal'; - -export class Runtime { - // Agile Instance the Runtime belongs to - public agileInstance: () => Agile; - - // Job that is currently being performed - public currentJob: RuntimeJob | null = null; - // Jobs to be performed - public jobQueue: Array = []; - - // 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; - - // Current 'bucket' timeout 'scheduled' for updating the Subscribers (UI-Components) - public bucketTimeout: NodeJS.Timeout | null = null; - - /** - * 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 - * @param agileInstance - Instance of Agile the Runtime belongs to. - */ - constructor(agileInstance: Agile) { - this.agileInstance = () => agileInstance; - } - - /** - * 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: !this.isPerformingJobs, - }); - - // Add specified Job to the queue - this.jobQueue.push(job); - - LogCodeManager.logIfTags(['runtime'], '16:01:00', [job._key], job); - - // Run first Job from the queue - if (config.perform) { - const performJob = this.jobQueue.shift(); - if (performJob) this.perform(performJob); - } - } - - /** - * 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 - * @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 the Observer into runtime, - // since they depend on the Observer and therefore have properly changed too - job.observer.dependents.forEach((observer) => observer.ingest()); - - // Add Job to rerender queue and reset current Job property - if (job.rerender) this.jobsToRerender.push(job); - this.currentJob = null; - - LogCodeManager.logIfTags(['runtime'], '16:01:01', [job._key], job); - - // 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) { - if (this.agileInstance().config.bucket) { - // Check if an bucket timeout is active, if so don't call a new one, - // since if the active timeout is called it will also proceed Jobs - // that were not added before the call - if (this.bucketTimeout == null) { - // https://stackoverflow.com/questions/9083594/call-settimeout-without-delay - this.bucketTimeout = setTimeout(() => { - this.bucketTimeout = null; - this.updateSubscribers(); - }); - } - } else this.updateSubscribers(); - } - } - } - - /** - * 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 - */ - public updateSubscribers(): boolean { - // 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 = []; - - 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 { - // https://medium.com/@bretcameron/how-to-make-your-code-faster-using-javascript-sets-b432457a4a77 - const subscriptionsToUpdate = new Set(); - - // Using for loop for performance optimization - // https://stackoverflow.com/questions/43821759/why-array-foreach-is-slower-than-for-loop-in-javascript - for (let i = 0; i < jobs.length; i++) { - const job = jobs[i]; - job.subscriptionContainersToUpdate.forEach((subscriptionContainer) => { - let updateSubscriptionContainer = true; - - // Handle not ready Subscription Container - if (!subscriptionContainer.ready) { - if ( - !job.config.maxTriesToUpdate || - job.timesTriedToUpdateCount < job.config.maxTriesToUpdate - ) { - job.timesTriedToUpdateCount++; - this.notReadyJobsToRerender.add(job); - LogCodeManager.log( - '16:02:00', - [subscriptionContainer.key], - subscriptionContainer - ); - } else { - LogCodeManager.log( - '16:02:01', - [job.config.maxTriesToUpdate], - subscriptionContainer - ); - } - return; - } - - // 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); - }); - } - - return Array.from(subscriptionsToUpdate); - } - - /** - * 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 { - // Using for loop for performance optimization - // https://stackoverflow.com/questions/43821759/why-array-foreach-is-slower-than-for-loop-in-javascript - for (let i = 0; i < subscriptionsToUpdate.length; i++) { - const subscriptionContainer = subscriptionsToUpdate[i]; - - // Call 'callback function' if Callback based Subscription - if (subscriptionContainer instanceof CallbackSubscriptionContainer) - subscriptionContainer.callback(); - - // Call 'update method' in Integrations if Component based Subscription - if (subscriptionContainer instanceof ComponentSubscriptionContainer) - this.agileInstance().integrations.update( - subscriptionContainer.component, - this.getUpdatedObserverValues(subscriptionContainer) - ); - - subscriptionContainer.updatedSubscribers.clear(); - } - - LogCodeManager.logIfTags( - ['runtime'], - '16:01:02', - [], - subscriptionsToUpdate - ); - } - - /** - * 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 - * @param subscriptionContainer - Subscription Container from which the `updatedSubscribers` are to be mapped into a key map. - */ - public getUpdatedObserverValues( - subscriptionContainer: SubscriptionContainer - ): { [key: string]: any } { - const props: { [key: string]: any } = {}; - for (const observer of subscriptionContainer.updatedSubscribers) { - const key = - subscriptionContainer.subscriberKeysWeakMap.get(observer) ?? - observer.key; - if (key != null) props[key] = observer.value; - } - return props; - } - - /** - * 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 - * @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 handleSelectors( - subscriptionContainer: SubscriptionContainer, - job: RuntimeJob - ): boolean { - 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; - } -} - -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; -} +export * from './runtime'; +// export * from './observer'; +// export * from './runtime.job'; +// export * from './subscription/container/SubscriptionContainer'; +// export * from './subscription/container/CallbackSubscriptionContainer'; +// export * from './subscription/container/ComponentSubscriptionContainer'; +// export * from './subscription/sub.controller'; diff --git a/packages/core/src/runtime/runtime.job.ts b/packages/core/src/runtime/runtime.job.ts index 54748c87..73dbd4b3 100644 --- a/packages/core/src/runtime/runtime.job.ts +++ b/packages/core/src/runtime/runtime.job.ts @@ -40,6 +40,7 @@ export class RuntimeJob { }, force: false, maxTriesToUpdate: 3, + any: {}, }); this.config = { @@ -47,6 +48,7 @@ export class RuntimeJob { force: config.force, sideEffects: config.sideEffects, maxTriesToUpdate: config.maxTriesToUpdate, + any: config.any, }; this.observer = observer; this.rerender = @@ -115,6 +117,10 @@ export interface RuntimeJobConfigInterface { * @default 3 */ maxTriesToUpdate?: number | null; + /** + * Anything unrelated that might be required by a side effect. + */ + any?: any; } export interface SideEffectConfigInterface { diff --git a/packages/core/src/runtime/runtime.ts b/packages/core/src/runtime/runtime.ts new file mode 100644 index 00000000..0dd3419f --- /dev/null +++ b/packages/core/src/runtime/runtime.ts @@ -0,0 +1,358 @@ +import { + Agile, + SubscriptionContainer, + RuntimeJob, + CallbackSubscriptionContainer, + ComponentSubscriptionContainer, + notEqual, + LogCodeManager, + defineConfig, +} from '../internal'; + +export class Runtime { + // Agile Instance the Runtime belongs to + public agileInstance: () => Agile; + + // Job that is currently being performed + public currentJob: RuntimeJob | null = null; + // Jobs to be performed + public jobQueue: Array = []; + + // 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; + + // Current 'bucket' timeout 'scheduled' for updating the Subscribers (UI-Components) + public bucketTimeout: NodeJS.Timeout | null = null; + + /** + * 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 + * @param agileInstance - Instance of Agile the Runtime belongs to. + */ + constructor(agileInstance: Agile) { + this.agileInstance = () => agileInstance; + } + + /** + * 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: !this.isPerformingJobs, + }); + + // Add specified Job to the queue + this.jobQueue.push(job); + + LogCodeManager.logIfTags(['runtime'], '16:01:00', [job._key], job); + + // Run first Job from the queue + if (config.perform) { + const performJob = this.jobQueue.shift(); + if (performJob) this.perform(performJob); + } + } + + /** + * 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 + * @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 the Observer into runtime, + // since they depend on the Observer and therefore have properly changed too + job.observer.dependents.forEach((observer) => observer.ingest()); + + // Add Job to rerender queue and reset current Job property + if (job.rerender) this.jobsToRerender.push(job); + this.currentJob = null; + + LogCodeManager.logIfTags(['runtime'], '16:01:01', [job._key], job); + + // 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) { + if (this.agileInstance().config.bucket) { + // Check if an bucket timeout is active, if so don't call a new one, + // since if the active timeout is called it will also proceed Jobs + // that were not added before the call + if (this.bucketTimeout == null) { + // https://stackoverflow.com/questions/9083594/call-settimeout-without-delay + this.bucketTimeout = setTimeout(() => { + this.bucketTimeout = null; + this.updateSubscribers(); + }); + } + } else this.updateSubscribers(); + } + } + } + + /** + * 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 + */ + public updateSubscribers(): boolean { + // 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 = []; + + 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 { + // https://medium.com/@bretcameron/how-to-make-your-code-faster-using-javascript-sets-b432457a4a77 + const subscriptionsToUpdate = new Set(); + + // Using for loop for performance optimization + // https://stackoverflow.com/questions/43821759/why-array-foreach-is-slower-than-for-loop-in-javascript + for (let i = 0; i < jobs.length; i++) { + const job = jobs[i]; + job.subscriptionContainersToUpdate.forEach((subscriptionContainer) => { + let updateSubscriptionContainer = true; + + // Handle not ready Subscription Container + if (!subscriptionContainer.ready) { + if ( + !job.config.maxTriesToUpdate || + job.timesTriedToUpdateCount < job.config.maxTriesToUpdate + ) { + job.timesTriedToUpdateCount++; + this.notReadyJobsToRerender.add(job); + LogCodeManager.log( + '16:02:00', + [subscriptionContainer.key], + subscriptionContainer + ); + } else { + LogCodeManager.log( + '16:02:01', + [job.config.maxTriesToUpdate], + subscriptionContainer + ); + } + return; + } + + // 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); + }); + } + + return Array.from(subscriptionsToUpdate); + } + + /** + * 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 { + // Using for loop for performance optimization + // https://stackoverflow.com/questions/43821759/why-array-foreach-is-slower-than-for-loop-in-javascript + for (let i = 0; i < subscriptionsToUpdate.length; i++) { + const subscriptionContainer = subscriptionsToUpdate[i]; + + // Call 'callback function' if Callback based Subscription + if (subscriptionContainer instanceof CallbackSubscriptionContainer) + subscriptionContainer.callback(); + + // Call 'update method' in Integrations if Component based Subscription + if (subscriptionContainer instanceof ComponentSubscriptionContainer) + this.agileInstance().integrations.update( + subscriptionContainer.component, + this.getUpdatedObserverValues(subscriptionContainer) + ); + + subscriptionContainer.updatedSubscribers.clear(); + } + + LogCodeManager.logIfTags( + ['runtime'], + '16:01:02', + [], + subscriptionsToUpdate + ); + } + + /** + * 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 + * @param subscriptionContainer - Subscription Container from which the `updatedSubscribers` are to be mapped into a key map. + */ + public getUpdatedObserverValues( + subscriptionContainer: SubscriptionContainer + ): { [key: string]: any } { + const props: { [key: string]: any } = {}; + for (const observer of subscriptionContainer.updatedSubscribers) { + const key = + subscriptionContainer.subscriberKeysWeakMap.get(observer) ?? + observer.key; + if (key != null) props[key] = observer.value; + } + return props; + } + + /** + * 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 + * @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 handleSelectors( + subscriptionContainer: SubscriptionContainer, + job: RuntimeJob + ): boolean { + 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; + } +} + +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/subscription/sub.controller.ts b/packages/core/src/runtime/subscription/sub.controller.ts index 51a76d4b..e83653be 100644 --- a/packages/core/src/runtime/subscription/sub.controller.ts +++ b/packages/core/src/runtime/subscription/sub.controller.ts @@ -331,7 +331,7 @@ export class SubController { } } -interface RegisterSubscriptionConfigInterface +export interface RegisterSubscriptionConfigInterface extends SubscriptionContainerConfigInterface { /** * Whether the Subscription Container shouldn't be ready diff --git a/packages/core/src/shared.ts b/packages/core/src/shared.ts index 5f000c31..a3ec1f87 100644 --- a/packages/core/src/shared.ts +++ b/packages/core/src/shared.ts @@ -1,28 +1,10 @@ -import { - Agile, - Collection, - CollectionConfig, - Computed, - ComputeFunctionType, - CreateComputedConfigInterface, - CreateStorageConfigInterface, - DefaultItem, - defineConfig, - DependableAgileInstancesType, - flatMerge, - removeProperties, - runsOnServer, - State, - StateConfigInterface, - Storage, -} from './internal'; +import { Agile } from './internal'; /** * Shared Agile Instance that is used when no Agile Instance was specified. */ let sharedAgileInstance = new Agile({ key: 'shared', - localStorage: !runsOnServer(), }); export { sharedAgileInstance as shared }; @@ -36,149 +18,6 @@ export function assignSharedAgileInstance(agileInstance: Agile): void { sharedAgileInstance = agileInstance; } -/** - * 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 - * @param config - Configuration object - */ -export function createStorage(config: CreateStorageConfigInterface): Storage { - return new Storage(config); -} - -/** - * 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 - * @param initialValue - Initial value of the State. - * @param config - Configuration object - */ -export function createState( - initialValue: ValueType, - config: CreateStateConfigInterfaceWithAgile = {} -): State { - config = defineConfig(config, { - agileInstance: sharedAgileInstance, - }); - return new State( - config.agileInstance as any, - initialValue, - removeProperties(config, ['agileInstance']) - ); -} - -/** - * 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 - * @param config - Configuration object - * @param agileInstance - Instance of Agile the Collection belongs to. - */ -export function createCollection( - config?: CollectionConfig, - agileInstance: Agile = sharedAgileInstance -): Collection { - return new Collection(agileInstance, config); -} - -/** - * 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 - * @param computeFunction - Function to compute the computed value. - * @param config - Configuration object - */ -export function createComputed( - computeFunction: ComputeFunctionType, - config?: CreateComputedConfigInterfaceWithAgile -): 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 - * @param computeFunction - Function to compute the computed value. - * @param deps - Hard-coded dependencies on which the Computed Class should depend. - */ -export function createComputed( - computeFunction: ComputeFunctionType, - deps?: Array -): Computed; -export function createComputed( - computeFunction: ComputeFunctionType, - configOrDeps?: - | CreateComputedConfigInterface - | Array -): Computed { - let _config: CreateComputedConfigInterfaceWithAgile = {}; - - if (Array.isArray(configOrDeps)) { - _config = defineConfig(_config, { - computedDeps: configOrDeps, - }); - } else { - if (configOrDeps) _config = configOrDeps; - } - - _config = defineConfig(_config, { agileInstance: sharedAgileInstance }); - - return new Computed( - _config.agileInstance as any, - computeFunction, - removeProperties(_config, ['agileInstance']) - ); -} - export interface CreateAgileSubInstanceInterface { /** * Instance of Agile the Instance belongs to. @@ -186,11 +25,3 @@ export interface CreateAgileSubInstanceInterface { */ agileInstance?: Agile; } - -export interface CreateStateConfigInterfaceWithAgile - extends CreateAgileSubInstanceInterface, - StateConfigInterface {} - -export interface CreateComputedConfigInterfaceWithAgile - extends CreateAgileSubInstanceInterface, - CreateComputedConfigInterface {} diff --git a/packages/core/src/state/index.ts b/packages/core/src/state/index.ts index 89bded3c..6a208125 100644 --- a/packages/core/src/state/index.ts +++ b/packages/core/src/state/index.ts @@ -1,860 +1,82 @@ import { - Agile, - StorageKey, - copy, - flatMerge, - isValidObject, - StateObserver, - StatePersistent, - Observer, - equal, - isFunction, - notEqual, - generateId, - PersistentKey, - ComputedTracker, - StateIngestConfigInterface, - removeProperties, - LogCodeManager, + State, + StateConfigInterface, defineConfig, + removeProperties, + CreateAgileSubInstanceInterface, + shared, + EnhancedState, } from '../internal'; -export class State { - // Agile Instance the State belongs to - public agileInstance: () => Agile; - - // Key/Name identifier of the State - public _key?: StateKey; - // 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; - // Current value of the State - public _value: ValueType; - // Previous value of the State - public previousStateValue: ValueType; - // Next value of the State (which can be used for dynamic State updates) - public nextStateValue: ValueType; - - // Manages dependencies to other States and subscriptions of UI-Components. - // It also serves as an interface to the runtime. - public observers: StateObserversInterface = {} as any; - // Registered side effects of changing the State value - public sideEffects: { - [key: string]: SideEffectInterface>; - } = {}; - - // Method for dynamically computing the State value - public computeValueMethod?: ComputeValueMethod; - // Method for dynamically computing the existence of the State - public computeExistsMethod: ComputeExistsMethod; - - // 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 - * @param agileInstance - Instance of Agile the State belongs to. - * @param initialValue - Initial value of the State. - * @param config - Configuration object - */ - constructor( - agileInstance: Agile, - initialValue: ValueType, - config: StateConfigInterface = {} - ) { - config = defineConfig(config, { - dependents: [], - isPlaceholder: false, - }); - this.agileInstance = () => agileInstance; - this._key = config.key; - this.observers['value'] = new StateObserver(this, { - key: config.key, - dependents: config.dependents, - }); - this.initialStateValue = copy(initialValue); - this._value = copy(initialValue); - this.previousStateValue = copy(initialValue); - this.nextStateValue = copy(initialValue); - this.isPlaceholder = true; - this.computeExistsMethod = (v) => { - return v != null; - }; - - // 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 - * @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 - */ - public get value(): ValueType { - ComputedTracker.tracked(this.observers['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 - * @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 - */ - public get key(): StateKey | undefined { - return this._key; - } - - /** - * 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 - this._key = value; - - // Update key of Observers - for (const observerKey in this.observers) - this.observers[observerKey]._key = value; - - // Update key in Persistent (only if oldKey is equal to persistentKey - // because otherwise the persistentKey is detached from the State key - // -> not managed by State anymore) - if (value != null && this.persistent?._key === oldKey) - this.persistent?.setKey(value); - - return this; - } - - /** - * 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 - * @param value - New State value - * @param config - Configuration object - */ - public set( - value: ValueType | ((value: ValueType) => ValueType), - config: StateIngestConfigInterface = {} - ): this { - config = defineConfig(config, { - force: false, - }); - const _value = isFunction(value) - ? (value as any)(copy(this._value)) - : value; - - // Check if value has correct type (Javascript) - if (!this.hasCorrectType(_value)) { - LogCodeManager.log(config.force ? '14:02:00' : '14:03:00', [ - typeof _value, - this.valueType, - ]); - if (!config.force) return this; - } - - // Ingest the State with the new value into the runtime - this.observers['value'].ingestValue(_value, config); - - return this; - } - - /** - * 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 - * @param config - Configuration object - */ - public ingest(config: StateIngestConfigInterface = {}): this { - this.observers['value'].ingest(config); - return this; - } - - /** - * 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 - * @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']; - if (!supportedTypes.includes(type.name)) { - LogCodeManager.log('14:03:01', [type]); - return this; - } - this.valueType = type.name.toLowerCase(); - return this; - } - - /** - * Undoes the latest State value change. - * - * [Learn more..](https://agile-ts.org/docs/core/state/methods/#undo) - * - * @public - * @param config - Configuration object - */ - public undo(config: StateIngestConfigInterface = {}): this { - this.set(this.previousStateValue, config); - return this; - } - - /** - * Resets the State value to its initial value. - * - * [Learn more..](https://agile-ts.org/docs/core/state/methods/#reset) - * - * @public - * @param config - Configuration object - */ - public reset(config: StateIngestConfigInterface = {}): this { - this.set(this.initialStateValue, config); - return this; - } - - /** - * 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 - * @param targetWithChanges - Object to be merged into the current State value. - * @param config - Configuration object - */ - public patch( - targetWithChanges: Object, - config: PatchConfigInterface = {} - ): this { - config = defineConfig(config, { - 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 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 - this.ingest(removeProperties(config, ['addNewProperties'])); - - return this; - } - - /** - * 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 - * @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 - * @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( - keyOrCallback: string | StateWatcherCallback, - callback?: StateWatcherCallback - ): this | string { - const generateKey = isFunction(keyOrCallback); - let _callback: StateWatcherCallback; - let key: string; - - if (generateKey) { - key = generateId(); - _callback = keyOrCallback as StateWatcherCallback; - } else { - key = keyOrCallback as string; - _callback = callback as StateWatcherCallback; - } - - if (!isFunction(_callback)) { - LogCodeManager.log('00:03:01', ['Watcher Callback', 'function']); - return this; - } - this.watchers[key] = _callback; - return generateKey ? key : this; - } - - /** - * 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 - * @param key - Key/Name identifier of the watcher callback to be removed. - */ - public removeWatcher(key: string): this { - delete this.watchers[key]; - return this; - } - - /** - * 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 - * @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'; - this.watch(watcherKey, (value, key) => { - callback(value, key); - this.removeWatcher(watcherKey); - }); - return this; - } - - /** - * 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 - * @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 - * @param key - Key/Name identifier of Persistent. - * @param config - Configuration object - */ - public persist( - key?: PersistentKey, - config?: StatePersistentConfigInterface - ): this; - public persist( - keyOrConfig: PersistentKey | StatePersistentConfigInterface = {}, - config: StatePersistentConfigInterface = {} - ): this { - let _config: StatePersistentConfigInterface; - let key: PersistentKey | undefined; - - if (isValidObject(keyOrConfig)) { - _config = keyOrConfig as StatePersistentConfigInterface; - key = this._key; - } else { - _config = config || {}; - key = keyOrConfig as PersistentKey; - } - - _config = defineConfig(_config, { - loadValue: true, - storageKeys: [], - defaultStorageKey: null as any, - }); - - // 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, - key: key, - defaultStorageKey: _config.defaultStorageKey, - }); - - return this; - } - - /** - * 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 - * @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; - if (!isFunction(callback)) { - LogCodeManager.log('00:03:01', ['OnLoad Callback', 'function']); - return this; - } - - // Register specified callback - this.persistent.onLoad = callback; - - // If State is already persisted ('isPersisted') fire specified callback immediately - if (this.isPersisted) callback(true); - - return this; - } - - /** - * 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 - * @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( - handler: (value: ValueType) => ValueType, - delay?: number - ): this { - if (!isFunction(handler)) { - LogCodeManager.log('00:03:01', ['Interval Callback', 'function']); - return this; - } - if (this.currentInterval) { - LogCodeManager.log('14:03:03', [], this.currentInterval); - return this; - } - this.currentInterval = setInterval(() => { - this.set(handler(this._value)); - }, delay ?? 1000); - return this; - } - - /** - * 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 - */ - public clearInterval(): void { - if (this.currentInterval) { - clearInterval(this.currentInterval as number); - delete this.currentInterval; - } - } - - /** - * 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 - */ - public get exists(): boolean { - return !this.isPlaceholder && this.computeExistsMethod(this.value); - } - - /** - * 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 - * @param method - Method to compute the existence of the State. - */ - public computeExists(method: ComputeExistsMethod): this { - if (!isFunction(method)) { - LogCodeManager.log('00:03:01', ['Compute Exists Method', 'function']); - 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; - } - - /** - * 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 - * @param value - Value to be compared with the current State value. - */ - public is(value: ValueType): boolean { - return equal(value, this.value); - } - - /** - * 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 - * @param value - Value to be compared with the current State value. - */ - public isNot(value: ValueType): boolean { - return notEqual(value, this.value); - } - - /** - * Inverts the current State value. - * - * Some examples are: - * - `'jeff'` -> `'ffej'` - * - `true` -> `false` - * - `[1, 2, 3]` -> `[3, 2, 1]` - * - `10` -> `-10` - * - * @public - */ - public invert(): 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]); - } - return this; - } - - /** - * - * 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 - * @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, - callback: SideEffectFunctionType, - config: AddSideEffectConfigInterface = {} - ): this { - config = defineConfig(config, { - weight: 10, - }); - if (!isFunction(callback)) { - LogCodeManager.log('00:03:01', ['Side Effect Callback', 'function']); - return this; - } - this.sideEffects[key] = { - callback: callback as any, - weight: config.weight as any, - }; - return this; - } - - /** - * 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 - * @param key - Key/Name identifier of the side effect callback to be removed. - */ - public removeSideEffect(key: string): this { - delete this.sideEffects[key]; - return this; - } - - /** - * 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 - * @param key - Key/Name identifier of the side effect callback to be checked for existence. - */ - public hasSideEffect(key: string): boolean { - return !!this.sideEffects[key]; - } - - /** - * Returns a boolean indicating whether the passed value - * is of the before defined State `valueType` or not. - * - * @internal - * @param value - Value to be checked for the correct type. - */ - public hasCorrectType(value: any): boolean { - if (!this.valueType) return true; - const type = typeof value; - return type === this.valueType; - } - - /** - * Returns the persistable value of the State. - * - * @internal - */ - public getPersistableValue(): any { - return this._value; - } +export * from './state'; +// export * from './state.observer'; +// export * from './state.enhanced'; +// export * from './state.persistent'; +// export * from './state.runtime.job'; + +/** + * 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 + * @param initialValue - Initial value of the State. + * @param config - Configuration object + */ +export function createLightState( + initialValue: ValueType, + config: CreateStateConfigInterfaceWithAgile = {} +): State { + config = defineConfig(config, { + agileInstance: shared, + }); + return new State( + config.agileInstance as any, + initialValue, + removeProperties(config, ['agileInstance']) + ); } -export type StateKey = string | number; - -export interface StateObserversInterface { - /** - * Observer responsible for the value of the State. - */ - value: StateObserver; +// TODO 'createState' doesn't get entirely treeshaken away (React project) +/** + * Returns a newly created enhanced State. + * + * An enhanced State manages, like a normal State, 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. + * + * The main difference to a normal State is however + * that an enhanced State provides a wider variety of inbuilt utilities (like a persist, undo, watch functionality) + * but requires a larger bundle size in return. + * + * You can create as many global enhanced States as you need. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#createstate) + * + * @public + * @param initialValue - Initial value of the State. + * @param config - Configuration object + */ +export function createState( + initialValue: ValueType, + config: CreateStateConfigInterfaceWithAgile = {} +): EnhancedState { + config = defineConfig(config, { + agileInstance: shared, + }); + return new EnhancedState( + config.agileInstance as any, + initialValue, + removeProperties(config, ['agileInstance']) + ); } -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; -} - -export interface PatchConfigInterface - extends StateIngestConfigInterface, - PatchOptionConfigInterface {} - -export interface PatchOptionConfigInterface { - /** - * Whether to add new properties to the object during the merge. - * @default true - */ - addNewProperties?: boolean; -} - -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; -} - -export type StateWatcherCallback = (value: T, key: string) => void; -export type ComputeValueMethod = (value: T) => T; -export type ComputeExistsMethod = (value: T) => boolean; - -export type SideEffectFunctionType = ( - instance: Instance, - properties?: { - [key: string]: any; - } -) => void; - -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; -} - -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; -} +export interface CreateStateConfigInterfaceWithAgile + extends CreateAgileSubInstanceInterface, + StateConfigInterface {} diff --git a/packages/core/src/state/state.enhanced.ts b/packages/core/src/state/state.enhanced.ts new file mode 100644 index 00000000..a922d7cc --- /dev/null +++ b/packages/core/src/state/state.enhanced.ts @@ -0,0 +1,544 @@ +import { + Agile, + defineConfig, + equal, + flatMerge, + generateId, + isFunction, + isValidObject, + LogCodeManager, + notEqual, + PersistentKey, + removeProperties, + State, + StateConfigInterface, + StateIngestConfigInterface, + StateKey, + StatePersistent, + StorageKey, +} from '../internal'; + +export class EnhancedState extends State { + // Whether the State is persisted in an external Storage + public isPersisted = false; + // Manages the permanent persistent in external Storages + public persistent: StatePersistent | undefined; + + // Method for dynamically computing the State value + public computeValueMethod?: ComputeValueMethod; + // Method for dynamically computing the existence of the State + public computeExistsMethod: ComputeExistsMethod; + + // When an interval is active, the 'intervalId' to clear the interval is temporary stored here + public currentInterval?: NodeJS.Timer | number; + + /** + * An enhanced State manages, like a normal State, 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. + * + * The main difference to a normal State is however + * that an enhanced State provides a wider variety of inbuilt utilities (like a persist, undo, watch functionality) + * but requires a larger bundle size in return. + * + * You can create as many global enhanced States as you need. + * + * [Learn more..](https://agile-ts.org/docs/core/agile-instance/methods#createstate) + * + * @public + * @param agileInstance - Instance of Agile the State belongs to. + * @param initialValue - Initial value of the State. + * @param config - Configuration object + */ + constructor( + agileInstance: Agile, + initialValue: ValueType, + config: StateConfigInterface = {} + ) { + super(agileInstance, initialValue, config); + this.computeExistsMethod = (v) => { + return v != null; + }; + } + + public setKey(value: StateKey | undefined): this { + const oldKey = this._key; + + // Update State key + super.setKey(value); + + // Update key in Persistent (only if oldKey is equal to persistentKey + // because otherwise the persistentKey is detached from the State key + // -> not managed by State anymore) + if (value != null && this.persistent?._key === oldKey) + this.persistent?.setKey(value); + + return this; + } + + /** + * Undoes the latest State value change. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#undo) + * + * @public + * @param config - Configuration object + */ + public undo(config: StateIngestConfigInterface = {}): this { + this.set(this.previousStateValue, config); + return this; + } + + /** + * Resets the State value to its initial value. + * + * [Learn more..](https://agile-ts.org/docs/core/state/methods/#reset) + * + * @public + * @param config - Configuration object + */ + public reset(config: StateIngestConfigInterface = {}): this { + this.set(this.initialStateValue, config); + return this; + } + + /** + * 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 + * @param targetWithChanges - Object to be merged into the current State value. + * @param config - Configuration object + */ + public patch( + targetWithChanges: Object, + config: PatchConfigInterface = {} + ): this { + config = defineConfig(config, { + 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 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 + this.ingest(removeProperties(config, ['addNewProperties'])); + + return this; + } + + /** + * 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 + * @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 + * @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( + keyOrCallback: string | StateWatcherCallback, + callback?: StateWatcherCallback + ): this | string { + const generateKey = isFunction(keyOrCallback); + let _callback: StateWatcherCallback; + let key: string; + + if (generateKey) { + key = generateId(); + _callback = keyOrCallback as StateWatcherCallback; + } else { + key = keyOrCallback as string; + _callback = callback as StateWatcherCallback; + } + + if (!isFunction(_callback)) { + LogCodeManager.log('00:03:01', ['Watcher Callback', 'function']); + return this; + } + + this.addSideEffect( + key, + (instance) => { + _callback(instance.value, key); + }, + { weight: 0 } + ); + return generateKey ? key : this; + } + + /** + * 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 + * @param key - Key/Name identifier of the watcher callback to be removed. + */ + public removeWatcher(key: string): this { + this.removeSideEffect(key); + return this; + } + + /** + * 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'; + this.watch(watcherKey, (value, key) => { + callback(value, key); + this.removeSideEffect(watcherKey); + }); + return this; + } + + /** + * 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 + * @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( + handler: (value: ValueType) => ValueType, + delay?: number + ): this { + if (!isFunction(handler)) { + LogCodeManager.log('00:03:01', ['Interval Callback', 'function']); + return this; + } + if (this.currentInterval) { + LogCodeManager.log('14:03:03', [], this.currentInterval); + return this; + } + this.currentInterval = setInterval(() => { + this.set(handler(this._value)); + }, delay ?? 1000); + return this; + } + + /** + * 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 + */ + public clearInterval(): void { + if (this.currentInterval) { + clearInterval(this.currentInterval as number); + delete this.currentInterval; + } + } + + /** + * 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 + */ + public get exists(): boolean { + return !this.isPlaceholder && this.computeExistsMethod(this.value); + } + + /** + * 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 + * @param method - Method to compute the existence of the State. + */ + public computeExists(method: ComputeExistsMethod): this { + if (!isFunction(method)) { + LogCodeManager.log('00:03:01', ['Compute Exists Method', 'function']); + 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; + } + + /** + * 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 + * @param value - Value to be compared with the current State value. + */ + public is(value: ValueType): boolean { + return equal(value, this.value); + } + + /** + * 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 + * @param value - Value to be compared with the current State value. + */ + public isNot(value: ValueType): boolean { + return notEqual(value, this.value); + } + + /** + * Inverts the current State value. + * + * Some examples are: + * - `'jeff'` -> `'ffej'` + * - `true` -> `false` + * - `[1, 2, 3]` -> `[3, 2, 1]` + * - `10` -> `-10` + * + * @public + */ + public invert(): 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]); + } + return this; + } + + /** + * 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 + * @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 + * @param key - Key/Name identifier of Persistent. + * @param config - Configuration object + */ + public persist( + key?: PersistentKey, + config?: StatePersistentConfigInterface + ): this; + public persist( + keyOrConfig: PersistentKey | StatePersistentConfigInterface = {}, + config: StatePersistentConfigInterface = {} + ): this { + let _config: StatePersistentConfigInterface; + let key: PersistentKey | undefined; + + if (isValidObject(keyOrConfig)) { + _config = keyOrConfig as StatePersistentConfigInterface; + key = this._key; + } else { + _config = config || {}; + key = keyOrConfig as PersistentKey; + } + + _config = defineConfig(_config, { + loadValue: true, + storageKeys: [], + defaultStorageKey: null as any, + }); + + // Check if State is already persisted + if (this.persistent != null && this.isPersisted) return this; + + // Create Persistent (-> persist value) + this.persistent = new StatePersistent(this, { + loadValue: _config.loadValue, + storageKeys: _config.storageKeys, + key: key, + defaultStorageKey: _config.defaultStorageKey, + }); + + return this; + } + + /** + * 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 + * @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; + if (!isFunction(callback)) { + LogCodeManager.log('00:03:01', ['OnLoad Callback', 'function']); + return this; + } + + // Register specified callback + this.persistent.onLoad = callback; + + // If State is already persisted ('isPersisted') fire specified callback immediately + if (this.isPersisted) callback(true); + + return this; + } + + /** + * Returns the persistable value of the State. + * + * @internal + */ + public getPersistableValue(): any { + return this._value; + } +} + +export interface PatchConfigInterface + extends StateIngestConfigInterface, + PatchOptionConfigInterface {} + +export interface PatchOptionConfigInterface { + /** + * Whether to add new properties to the object during the merge. + * @default true + */ + addNewProperties?: boolean; +} + +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; +} + +export type StateWatcherCallback = (value: T, key: string) => void; +export type ComputeValueMethod = (value: T) => T; +export type ComputeExistsMethod = (value: T) => boolean; diff --git a/packages/core/src/state/state.observer.ts b/packages/core/src/state/state.observer.ts index dc1711b3..332a9f9e 100644 --- a/packages/core/src/state/state.observer.ts +++ b/packages/core/src/state/state.observer.ts @@ -1,7 +1,6 @@ import { Observer, State, - Computed, copy, equal, notEqual, @@ -15,6 +14,7 @@ import { SubscriptionContainer, ObserverKey, defineConfig, + removeProperties, } from '../internal'; export class StateObserver extends Observer { @@ -59,9 +59,9 @@ export class StateObserver extends Observer { * @param config - Configuration object */ public ingest(config: StateIngestConfigInterface = {}): void { - const state = this.state(); + const state = this.state() as any; - if (state instanceof Computed) { + if (state.isComputed) { state.compute().then((result) => { this.ingestValue(result, config); }); @@ -88,15 +88,7 @@ export class StateObserver extends Observer { const state = this.state(); config = defineConfig(config, { perform: true, - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, force: false, - storage: true, - overwrite: false, - maxTriesToUpdate: 3, }); // Force overwriting the State value if it is a placeholder. @@ -106,9 +98,9 @@ export class StateObserver extends Observer { config.overwrite = true; } - // Assign next State value to Observer and compute it if necessary - this.nextStateValue = state.computeValueMethod - ? copy(state.computeValueMethod(newStateValue)) + // Assign next State value to Observer and compute it if necessary (enhanced State) + this.nextStateValue = (state as any).computeValueMethod + ? copy((state as any).computeValueMethod(newStateValue)) : copy(newStateValue); // Check if current State value and to assign State value are equal @@ -116,15 +108,12 @@ export class StateObserver extends Observer { // 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 != null ? this._key + '_' : ''}${generateId()}_value`, - maxTriesToUpdate: config.maxTriesToUpdate, + ...removeProperties(config, ['perform']), + ...{ + key: + config.key ?? + `${this._key != null ? this._key + '_' : ''}${generateId()}_value`, + }, }); // Pass created Job into the Runtime @@ -184,11 +173,6 @@ export class StateObserver extends Observer { public sideEffects(job: StateRuntimeJob) { const state = job.observer.state(); - // Call watcher functions - for (const watcherKey in state.watchers) - if (isFunction(state.watchers[watcherKey])) - state.watchers[watcherKey](state._value, watcherKey); - // Call side effect functions if (job.config?.sideEffects?.enabled) { const sideEffectArray = createArrayFromObject< diff --git a/packages/core/src/state/state.persistent.ts b/packages/core/src/state/state.persistent.ts index d978196c..8d1fa0d9 100644 --- a/packages/core/src/state/state.persistent.ts +++ b/packages/core/src/state/state.persistent.ts @@ -1,14 +1,15 @@ import { CreatePersistentConfigInterface, defineConfig, + EnhancedState, + getStorageManager, Persistent, PersistentKey, - State, } from '../internal'; export class StatePersistent extends Persistent { // State the Persistent belongs to - public state: () => State; + public state: () => EnhancedState; static storeValueSideEffectKey = 'rebuildStateStorageValue'; @@ -20,14 +21,14 @@ export class StatePersistent extends Persistent { * @param config - Configuration object */ constructor( - state: State, + state: EnhancedState, config: CreatePersistentConfigInterface = {} ) { super(state.agileInstance(), { - instantiate: false, + loadValue: false, }); config = defineConfig(config, { - instantiate: true, + loadValue: true, storageKeys: [], defaultStorageKey: null as any, }); @@ -39,7 +40,7 @@ export class StatePersistent extends Persistent { }); // Load/Store persisted value/s for the first time - if (this.ready && config.instantiate) this.initialLoading(); + if (this.ready && config.loadValue) this.initialLoading(); } /** @@ -72,7 +73,7 @@ export class StatePersistent extends Persistent { const _storageItemKey = storageItemKey ?? this._key; // Load State value from the default Storage - const loadedValue = await this.agileInstance().storages.get( + const loadedValue = await getStorageManager()?.get( _storageItemKey, this.config.defaultStorageKey as any ); @@ -150,7 +151,7 @@ export class StatePersistent extends Persistent { if (!this.ready) return false; const _storageItemKey = storageItemKey || this._key; this.state().removeSideEffect(StatePersistent.storeValueSideEffectKey); - this.agileInstance().storages.remove(_storageItemKey, this.storageKeys); + getStorageManager()?.remove(_storageItemKey, this.storageKeys); this.isPersisted = false; return true; } @@ -184,12 +185,12 @@ export class StatePersistent extends Persistent { * @param config - Configuration object */ public rebuildStorageSideEffect( - state: State, + state: EnhancedState, storageItemKey: PersistentKey, config: { [key: string]: any } = {} ) { if (config['storage'] == null || config.storage) { - this.agileInstance().storages.set( + getStorageManager()?.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 16e1628a..2578c7f9 100644 --- a/packages/core/src/state/state.runtime.job.ts +++ b/packages/core/src/state/state.runtime.job.ts @@ -35,6 +35,7 @@ export class StateRuntimeJob extends RuntimeJob { storage: true, overwrite: false, maxTriesToUpdate: 3, + any: {}, }); this.config = { @@ -44,6 +45,7 @@ export class StateRuntimeJob extends RuntimeJob { storage: config.storage, overwrite: config.overwrite, maxTriesToUpdate: config.maxTriesToUpdate, + any: config.any, }; } } diff --git a/packages/core/src/state/state.ts b/packages/core/src/state/state.ts new file mode 100644 index 00000000..c4b5b0ab --- /dev/null +++ b/packages/core/src/state/state.ts @@ -0,0 +1,308 @@ +import { + Agile, + copy, + StateObserver, + Observer, + isFunction, + ComputedTracker, + StateIngestConfigInterface, + LogCodeManager, + defineConfig, +} from '../internal'; + +export class State { + // Agile Instance the State belongs to + public agileInstance: () => Agile; + + // Key/Name identifier of the State + public _key?: StateKey; + // 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; + // Current value of the State + public _value: ValueType; + // Previous value of the State + public previousStateValue: ValueType; + // Next value of the State (which can be used for dynamic State updates) + public nextStateValue: ValueType; + + // Manages dependencies to other States and subscriptions of UI-Components. + // It also serves as an interface to the runtime. + public observers: StateObserversInterface = {} as any; + // Registered side effects of changing the State value + public sideEffects: { + [key: string]: SideEffectInterface>; + } = {}; + + /** + * 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 + * @param agileInstance - Instance of Agile the State belongs to. + * @param initialValue - Initial value of the State. + * @param config - Configuration object + */ + constructor( + agileInstance: Agile, + initialValue: ValueType, + config: StateConfigInterface = {} + ) { + config = defineConfig(config, { + dependents: [], + isPlaceholder: false, + }); + this.agileInstance = () => agileInstance; + this._key = config.key; + this.observers['value'] = new StateObserver(this, { + key: config.key, + dependents: config.dependents, + }); + this.initialStateValue = copy(initialValue); + this._value = copy(initialValue); + this.previousStateValue = copy(initialValue); + this.nextStateValue = copy(initialValue); + this.isPlaceholder = true; + + // 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 + * @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 + */ + public get value(): ValueType { + ComputedTracker.tracked(this.observers['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 + * @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 + */ + public get key(): StateKey | undefined { + return this._key; + } + + /** + * 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 { + // Update State key + this._key = value; + + // Update key of Observers + for (const observerKey in this.observers) + this.observers[observerKey]._key = value; + + return this; + } + + /** + * 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 + * @param value - New State value + * @param config - Configuration object + */ + public set( + value: ValueType | ((value: ValueType) => ValueType), + config: StateIngestConfigInterface = {} + ): this { + config = defineConfig(config, { + force: false, + }); + const _value = isFunction(value) + ? (value as any)(copy(this._value)) + : value; + + // Ingest the State with the new value into the runtime + this.observers['value'].ingestValue(_value, config); + + return this; + } + + /** + * 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 + * @param config - Configuration object + */ + public ingest(config: StateIngestConfigInterface = {}): this { + this.observers['value'].ingest(config); + return this; + } + + /** + * + * 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 + * @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, + callback: SideEffectFunctionType, + config: AddSideEffectConfigInterface = {} + ): this { + config = defineConfig(config, { + weight: 10, + }); + if (!isFunction(callback)) { + LogCodeManager.log('00:03:01', ['Side Effect Callback', 'function']); + return this; + } + this.sideEffects[key] = { + callback: callback as any, + weight: config.weight as any, + }; + return this; + } + + /** + * 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 + * @param key - Key/Name identifier of the side effect callback to be removed. + */ + public removeSideEffect(key: string): this { + delete this.sideEffects[key]; + return this; + } + + /** + * 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 + * @param key - Key/Name identifier of the side effect callback to be checked for existence. + */ + public hasSideEffect(key: string): boolean { + return !!this.sideEffects[key]; + } +} + +export type StateKey = string | number; + +export interface StateObserversInterface { + /** + * Observer responsible for the value of the State. + */ + value: StateObserver; +} + +export interface StateConfigInterface { + /** + * Key/Name identifier of the State. + * @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; +} + +export type SideEffectFunctionType = ( + instance: Instance, + properties?: { + [key: string]: any; + } +) => void; + +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; +} + +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/storages/index.ts b/packages/core/src/storages/index.ts index 498f9bb5..b373d0fc 100644 --- a/packages/core/src/storages/index.ts +++ b/packages/core/src/storages/index.ts @@ -1,333 +1,91 @@ import { - Agile, + CreateStorageConfigInterface, Storage, - Persistent, - StorageKey, - StorageItemKey, - notEqual, - LogCodeManager, + Storages, + shared, + CreateStoragesConfigInterface, + CreateAgileSubInstanceInterface, defineConfig, + removeProperties, + LogCodeManager, + runsOnServer, } from '../internal'; -export class Storages { - // Agile Instance the Storages belongs to - public agileInstance: () => Agile; - - public config: StoragesConfigInterface; - - // 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 - * @param agileInstance - Instance of Agile the Storages belongs to. - * @param config - Configuration object - */ - constructor( - agileInstance: Agile, - config: CreateStoragesConfigInterface = {} - ) { - this.agileInstance = () => agileInstance; - config = defineConfig(config, { - localStorage: false, - defaultStorageKey: null as any, - }); - this.config = { defaultStorageKey: config.defaultStorageKey as any }; - if (config.localStorage) this.instantiateLocalStorage(); - } - - /** - * 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 - */ - public instantiateLocalStorage(): boolean { - if (!Storages.localStorageAvailable()) { - LogCodeManager.log('11:02:00'); - return false; - } - const _localStorage = new Storage({ - key: 'localStorage', - async: false, - methods: { - get: localStorage.getItem.bind(localStorage), - set: localStorage.setItem.bind(localStorage), - remove: localStorage.removeItem.bind(localStorage), - }, - }); - return this.register(_localStorage, { default: true }); - } - - /** - * 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, - config: RegisterConfigInterface = {} - ): boolean { - const hasRegisteredAnyStorage = notEqual(this.storages, {}); - - // Check if Storage already exists - if (Object.prototype.hasOwnProperty.call(this.storages, storage.key)) { - LogCodeManager.log('11:03:00', [storage.key]); - return false; - } - - // 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; - - // Register Storage - this.storages[storage.key] = storage; - if (config.default) this.config.defaultStorageKey = storage.key; +export * from './storages'; +// export * from './storage'; +// export * from './persistent'; + +// Handles the permanent persistence of Agile Classes +let storageManager: Storages | null = null; + +/** + * 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 + * @param config - Configuration object + */ +export function createStorage(config: CreateStorageConfigInterface): Storage { + return new Storage(config); +} - this.persistentInstances.forEach((persistent) => { - // 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; - } +/** + * Returns a newly created Storage Manager. + * + * A Storage Manager manages all external Storages for AgileTs + * and provides an interface to easily store, + * load and remove values from multiple external Storages at once. + * + * @param config - Configuration object + */ +export function createStorageManager( + config: CreateStorageManagerConfigInterfaceWithAgile = {} +): Storages { + config = defineConfig(config, { + agileInstance: shared, + }); + return new Storages( + config.agileInstance as any, + removeProperties(config, ['agileInstance']) + ); +} - // 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(); - } +/** + * Returns the shared Storage Manager + * or creates a new one when no shared Storage Manager exists. + */ +export function getStorageManager(): Storages { + if (storageManager == null) { + const newStorageManager = createStorageManager({ + localStorage: !runsOnServer(), }); - - LogCodeManager.log('13:00:00', [storage.key], storage); - - return true; - } - - /** - * 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]; - if (!storage) { - LogCodeManager.log('11:03:01', [storageKey]); - return undefined; - } - if (!storage.ready) { - LogCodeManager.log('11:03:02', [storageKey]); - return undefined; - } - return storage; - } - - /** - * 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, - storageKey?: StorageKey - ): Promise { - if (!this.hasStorage()) { - LogCodeManager.log('11:03:03'); - return Promise.resolve(undefined); - } - - // Call get method on specified Storage - if (storageKey) { - const storage = this.getStorage(storageKey); - if (storage) return storage.get(storageItemKey); - } - - // Call get method on default Storage - const defaultStorage = this.getStorage(this.config.defaultStorageKey); - return ( - defaultStorage?.get(storageItemKey) || Promise.resolve(undefined) - ); - } - - /** - * 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, - value: any, - storageKeys?: StorageKey[] - ): void { - if (!this.hasStorage()) { - LogCodeManager.log('11:03:04'); - return; - } - - // Call set method on specified Storages - if (storageKeys != null) { - for (const storageKey of storageKeys) - this.getStorage(storageKey)?.set(storageItemKey, value); - return; - } - - // Call set method on default Storage - const defaultStorage = this.getStorage(this.config.defaultStorageKey); - defaultStorage?.set(storageItemKey, value); - } - - /** - * 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, - storageKeys?: StorageKey[] - ): void { - if (!this.hasStorage()) { - LogCodeManager.log('11:03:05'); - return; - } - - // Call remove method on specified Storages - if (storageKeys) { - for (const storageKey of storageKeys) - this.getStorage(storageKey)?.remove(storageItemKey); - return; - } - - // Call remove method on default Storage - const defaultStorage = this.getStorage(this.config.defaultStorageKey); - defaultStorage?.remove(storageItemKey); - } - - /** - * Returns a boolean indicating whether any Storage - * has been registered with the Agile Instance or not. - * - * @public - */ - public hasStorage(): boolean { - return notEqual(this.storages, {}); - } - - /** - * 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 { - localStorage.setItem('_myDummyKey_', 'myDummyValue'); - localStorage.removeItem('_myDummyKey_'); - return true; - } catch (e) { - return false; - } + assignSharedAgileStorageManager(newStorageManager); + return newStorageManager; } + return storageManager; } -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; -} - -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; -} +/** + * Assigns the specified Storage Manager + * as default (shared) Storage Manager for all Agile Instances. + * + * @param instance - Storage Manager to be registered as the default Storage Manager. + */ +export const assignSharedAgileStorageManager = (instance: Storages | null) => { + if (storageManager != null) { + LogCodeManager.log('11:02:06', [], storageManager); + } + storageManager = instance; +}; -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; -} +export interface CreateStorageManagerConfigInterfaceWithAgile + extends CreateAgileSubInstanceInterface, + CreateStoragesConfigInterface {} diff --git a/packages/core/src/storages/persistent.ts b/packages/core/src/storages/persistent.ts index e4134fca..f83aeae9 100644 --- a/packages/core/src/storages/persistent.ts +++ b/packages/core/src/storages/persistent.ts @@ -2,6 +2,7 @@ import { Agile, copy, defineConfig, + getStorageManager, LogCodeManager, StorageKey, } from '../internal'; @@ -45,15 +46,14 @@ export class Persistent { this.agileInstance = () => agileInstance; this._key = Persistent.placeHolderKey; config = defineConfig(config, { - instantiate: true, + loadValue: true, storageKeys: [], defaultStorageKey: null as any, }); - this.agileInstance().storages.persistentInstances.add(this); this.config = { defaultStorageKey: config.defaultStorageKey as any }; // Instantiate Persistent - if (config.instantiate) { + if (config.loadValue) { this.instantiatePersistent({ storageKeys: config.storageKeys, key: config.key, @@ -127,6 +127,12 @@ export class Persistent { this._key = this.formatKey(config.key) ?? Persistent.placeHolderKey; this.assignStorageKeys(config.storageKeys, config.defaultStorageKey); this.validatePersistent(); + + // Register Persistent to Storage Manager + const storageManager = getStorageManager(); + if (this._key !== Persistent.placeHolderKey && storageManager != null) { + storageManager.persistentInstances[this._key] = this; + } } /** @@ -155,7 +161,7 @@ export class Persistent { // Check if the Storages exist at the specified Storage keys this.storageKeys.map((key) => { - if (!this.agileInstance().storages.storages[key]) { + if (!getStorageManager()?.storages[key]) { LogCodeManager.log('12:03:02', [this._key, key]); isValid = false; } @@ -180,7 +186,6 @@ export class Persistent { storageKeys: StorageKey[] = [], defaultStorageKey?: StorageKey ): void { - const storages = this.agileInstance().storages; const _storageKeys = copy(storageKeys); // Assign specified default Storage key to the 'storageKeys' array @@ -191,10 +196,10 @@ export class Persistent { // and specify it as the Persistent's default Storage key // if no valid Storage key was provided if (_storageKeys.length <= 0) { - const defaultStorageKey = storages.config.defaultStorageKey; + const defaultStorageKey = getStorageManager()?.config.defaultStorageKey; if (defaultStorageKey != null) { this.config.defaultStorageKey = defaultStorageKey; - _storageKeys.push(storages.config.defaultStorageKey as any); + _storageKeys.push(getStorageManager()?.config.defaultStorageKey as any); } } else { this.config.defaultStorageKey = defaultStorageKey ?? _storageKeys[0]; @@ -313,11 +318,11 @@ export interface CreatePersistentConfigInterface { */ defaultStorageKey?: StorageKey; /** - * Whether the Persistent should be instantiated immediately + * Whether the Persistent should load/persist the value immediately * or whether this should be done manually. * @default true */ - instantiate?: boolean; + loadValue?: boolean; } export interface PersistentConfigInterface { diff --git a/packages/core/src/storages/storages.ts b/packages/core/src/storages/storages.ts new file mode 100644 index 00000000..99fcfa9b --- /dev/null +++ b/packages/core/src/storages/storages.ts @@ -0,0 +1,336 @@ +import { + Agile, + Storage, + Persistent, + StorageKey, + StorageItemKey, + notEqual, + LogCodeManager, + defineConfig, +} from '../internal'; + +export class Storages { + // Agile Instance the Storages belongs to + public agileInstance: () => Agile; + + public config: StoragesConfigInterface; + + // Registered Storages + public storages: { [key: string]: Storage } = {}; + // Persistent from Instances (for example States) that were persisted + public persistentInstances: { [key: string]: Persistent } = {}; + + /** + * 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 + * @param agileInstance - Instance of Agile the Storages belongs to. + * @param config - Configuration object + */ + constructor( + agileInstance: Agile, + config: CreateStoragesConfigInterface = {} + ) { + this.agileInstance = () => agileInstance; + config = defineConfig(config, { + localStorage: false, + defaultStorageKey: null as any, + }); + this.config = { defaultStorageKey: config.defaultStorageKey as any }; + if (config.localStorage) this.instantiateLocalStorage(); + } + + /** + * 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 + */ + public instantiateLocalStorage(): boolean { + if (!Storages.localStorageAvailable()) { + LogCodeManager.log('11:02:00'); + return false; + } + const _localStorage = new Storage({ + key: 'localStorage', + async: false, + methods: { + get: localStorage.getItem.bind(localStorage), + set: localStorage.setItem.bind(localStorage), + remove: localStorage.removeItem.bind(localStorage), + }, + }); + return this.register(_localStorage, { default: true }); + } + + /** + * 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, + config: RegisterConfigInterface = {} + ): boolean { + const hasRegisteredAnyStorage = notEqual(this.storages, {}); + + // Check if Storage already exists + if (Object.prototype.hasOwnProperty.call(this.storages, storage.key)) { + LogCodeManager.log('11:03:00', [storage.key]); + return false; + } + + // 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; + + // Register Storage + this.storages[storage.key] = storage; + if (config.default) this.config.defaultStorageKey = storage.key; + + for (const persistentKey of Object.keys(this.persistentInstances)) { + const persistent = this.persistentInstances[persistentKey]; + if (persistent == null) continue; + + // 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(); + continue; + } + + // 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(); + } + } + + LogCodeManager.log('13:00:00', [storage.key], storage); + + return true; + } + + /** + * 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]; + if (!storage) { + LogCodeManager.log('11:03:01', [storageKey]); + return undefined; + } + if (!storage.ready) { + LogCodeManager.log('11:03:02', [storageKey]); + return undefined; + } + return storage; + } + + /** + * 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, + storageKey?: StorageKey + ): Promise { + if (!this.hasStorage()) { + LogCodeManager.log('11:03:03'); + return Promise.resolve(undefined); + } + + // Call get method on specified Storage + if (storageKey) { + const storage = this.getStorage(storageKey); + if (storage) return storage.get(storageItemKey); + } + + // Call get method on default Storage + const defaultStorage = this.getStorage(this.config.defaultStorageKey); + return ( + defaultStorage?.get(storageItemKey) || Promise.resolve(undefined) + ); + } + + /** + * 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, + value: any, + storageKeys?: StorageKey[] + ): void { + if (!this.hasStorage()) { + LogCodeManager.log('11:03:04'); + return; + } + + // Call set method on specified Storages + if (storageKeys != null) { + for (const storageKey of storageKeys) + this.getStorage(storageKey)?.set(storageItemKey, value); + return; + } + + // Call set method on default Storage + const defaultStorage = this.getStorage(this.config.defaultStorageKey); + defaultStorage?.set(storageItemKey, value); + } + + /** + * 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, + storageKeys?: StorageKey[] + ): void { + if (!this.hasStorage()) { + LogCodeManager.log('11:03:05'); + return; + } + + // Call remove method on specified Storages + if (storageKeys) { + for (const storageKey of storageKeys) + this.getStorage(storageKey)?.remove(storageItemKey); + return; + } + + // Call remove method on default Storage + const defaultStorage = this.getStorage(this.config.defaultStorageKey); + defaultStorage?.remove(storageItemKey); + } + + /** + * Returns a boolean indicating whether any Storage + * has been registered with the Agile Instance or not. + * + * @public + */ + public hasStorage(): boolean { + return notEqual(this.storages, {}); + } + + /** + * 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 { + localStorage.setItem('_myDummyKey_', 'myDummyValue'); + localStorage.removeItem('_myDummyKey_'); + return true; + } catch (e) { + return false; + } + } +} + +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; +} + +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; +} + +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/utils.ts b/packages/core/src/utils.ts index 8748f28e..3272d416 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,7 +1,6 @@ import { Agile, Observer, - Collection, normalizeArray, isFunction, LogCodeManager, @@ -96,7 +95,7 @@ export function extractObservers( } // If the Instance equals to a Collection - if (instance instanceof Collection) { + if (instance.isCollection) { observers.push( instance.getGroupWithReference(instance.config.defaultGroupKey) .observers as any diff --git a/packages/core/tests/integration/collection.persistent.integration.test.ts b/packages/core/tests/integration/collection.persistent.integration.test.ts index e1d151ee..c9a85992 100644 --- a/packages/core/tests/integration/collection.persistent.integration.test.ts +++ b/packages/core/tests/integration/collection.persistent.integration.test.ts @@ -1,4 +1,11 @@ -import { Agile, Item } from '../../src'; +import { + Agile, + Item, + createStorage, + createCollection, + createStorageManager, + assignSharedAgileStorageManager, +} from '../../src'; import { LogMock } from '../helper/logMock'; describe('Collection Persist Function Tests', () => { @@ -28,9 +35,12 @@ describe('Collection Persist Function Tests', () => { LogMock.mockLogs(); jest.clearAllMocks(); - App = new Agile({ localStorage: false }); - App.registerStorage( - App.createStorage({ + App = new Agile(); + + const storageManager = createStorageManager({ localStorage: false }); + assignSharedAgileStorageManager(storageManager); + storageManager.register( + createStorage({ key: 'testStorage', prefix: 'test', methods: storageMethods, @@ -41,7 +51,7 @@ describe('Collection Persist Function Tests', () => { describe('Collection', () => { it('Can persist Collection', async () => { // Create Collection - const MY_COLLECTION = App.createCollection(); + const MY_COLLECTION = createCollection({}, App); // Test Collecting Item before Persisting MY_COLLECTION.collect({ id: 2, name: 'hans' }); @@ -147,7 +157,7 @@ describe('Collection Persist Function Tests', () => { it('Can load persisted Collection', async () => { // Create Collection - const MY_COLLECTION = App.createCollection(); + const MY_COLLECTION = createCollection({}, App); // Load persisted Value MY_COLLECTION.persist('myCollection'); @@ -201,7 +211,7 @@ describe('Collection Persist Function Tests', () => { it('Can remove persisted Collection', async () => { // Create Collection - const MY_COLLECTION = App.createCollection(); + const MY_COLLECTION = createCollection({}, App); // Load persisted Value MY_COLLECTION.persist('myCollection'); diff --git a/packages/core/tests/unit/agile.test.ts b/packages/core/tests/unit/agile.test.ts index 2d0dc29e..40682716 100644 --- a/packages/core/tests/unit/agile.test.ts +++ b/packages/core/tests/unit/agile.test.ts @@ -1,17 +1,6 @@ -import { - Agile, - State, - Runtime, - SubController, - Integrations, - Storage, - Computed, - Collection, - Storages, -} from '../../src'; +import { Agile, Runtime, SubController, Integrations } from '../../src'; import testIntegration from '../helper/test.integration'; import { LogMock } from '../helper/logMock'; -import * as Shared from '../../src/shared'; // https://github.com/facebook/jest/issues/5023 jest.mock('../../src/runtime', () => { @@ -29,11 +18,6 @@ jest.mock('../../src/runtime/subscription/sub.controller', () => { SubController: jest.fn(), }; }); -jest.mock('../../src/storages', () => { - return { - Storages: jest.fn(), - }; -}); // https://gist.github.com/virgs/d9c50e878fc69832c01f8085f2953f12 // https://medium.com/@masonlgoetz/mock-static-class-methods-in-jest-1ceda967b47f @@ -59,7 +43,6 @@ describe('Agile Tests', () => { const SubControllerMock = SubController as jest.MockedClass< typeof SubController >; - const StoragesMock = Storages as jest.MockedClass; const IntegrationsMock = Integrations as jest.MockedClass< typeof Integrations >; @@ -70,7 +53,6 @@ describe('Agile Tests', () => { // Clear specified mocks RuntimeMock.mockClear(); SubControllerMock.mockClear(); - StoragesMock.mockClear(); IntegrationsMock.mockClear(); // Reset globalThis @@ -97,10 +79,6 @@ describe('Agile Tests', () => { // expect(agile.runtime).toBeInstanceOf(Runtime); // Because 'Runtime' is completely overwritten with a mock (mockImplementation) expect(SubControllerMock).toHaveBeenCalledWith(agile); expect(agile.subController).toBeInstanceOf(SubController); - expect(StoragesMock).toHaveBeenCalledWith(agile, { - localStorage: false, - }); - expect(agile.storages).toBeInstanceOf(Storages); // Check if Agile Instance got bound globally expect(globalThis[Agile.globalKey]).toBeUndefined(); @@ -110,7 +88,6 @@ describe('Agile Tests', () => { const agile = new Agile({ waitForMount: false, bucket: false, - localStorage: true, bindGlobal: true, key: 'jeff', autoIntegrate: false, @@ -129,10 +106,6 @@ describe('Agile Tests', () => { // expect(agile.runtime).toBeInstanceOf(Runtime); // Because 'Runtime' is completely overwritten with a mock (mockImplementation) expect(SubControllerMock).toHaveBeenCalledWith(agile); expect(agile.subController).toBeInstanceOf(SubController); - expect(StoragesMock).toHaveBeenCalledWith(agile, { - localStorage: true, - }); - expect(agile.storages).toBeInstanceOf(Storages); // Check if Agile Instance got bound globally expect(globalThis[Agile.globalKey]).toBe(agile); @@ -167,115 +140,6 @@ describe('Agile Tests', () => { jest.clearAllMocks(); // Because creating the Agile Instance calls some mocks }); - describe('createStorage function tests', () => { - beforeEach(() => { - jest.spyOn(Shared, 'createStorage'); - }); - - it('should call createStorage', () => { - const storageConfig = { - prefix: 'test', - methods: { - get: () => { - /* empty function */ - }, - set: () => { - /* empty function */ - }, - remove: () => { - /* empty function */ - }, - }, - key: 'myTestStorage', - }; - - const response = agile.createStorage(storageConfig); - - expect(response).toBeInstanceOf(Storage); - expect(Shared.createStorage).toHaveBeenCalledWith(storageConfig); - }); - }); - - describe('createState function tests', () => { - beforeEach(() => { - jest.spyOn(Shared, 'createState'); - }); - - it('should call createState with the Agile Instance it was called on', () => { - const response = agile.createState('jeff', { key: 'jeffState' }); - - expect(response).toBeInstanceOf(State); - expect(Shared.createState).toHaveBeenCalledWith('jeff', { - key: 'jeffState', - agileInstance: agile, - }); - }); - }); - - describe('createCollection function tests', () => { - beforeEach(() => { - jest.spyOn(Shared, 'createCollection'); - }); - - it('should call createCollection with the Agile Instance it was called on', () => { - const collectionConfig = { - selectors: ['test', 'test1'], - groups: ['test2', 'test10'], - defaultGroupKey: 'frank', - key: 'myCoolCollection', - }; - - const response = agile.createCollection(collectionConfig); - - expect(response).toBeInstanceOf(Collection); - expect(Shared.createCollection).toHaveBeenCalledWith( - collectionConfig, - agile - ); - }); - }); - - describe('createComputed function tests', () => { - const computedFunction = () => { - // empty - }; - - beforeEach(() => { - jest.spyOn(Shared, 'createComputed'); - }); - - it('should call createComputed with the Agile Instance it was called on (default config)', () => { - const response = agile.createComputed(computedFunction, [ - 'dummyDep' as any, - ]); - - expect(response).toBeInstanceOf(Computed); - expect(Shared.createComputed).toHaveBeenCalledWith(computedFunction, { - computedDeps: ['dummyDep' as any], - agileInstance: agile, - }); - }); - - it('should call createComputed with the Agile Instance it was called on (specific config)', () => { - const computedConfig = { - key: 'jeff', - isPlaceholder: false, - computedDeps: ['dummyDep' as any], - autodetect: true, - }; - - const response = agile.createComputed(computedFunction, computedConfig); - - expect(response).toBeInstanceOf(Computed); - expect(Shared.createComputed).toHaveBeenCalledWith(computedFunction, { - ...computedConfig, - ...{ - agileInstance: agile, - }, - }); - }); - }); - describe('integrate function tests', () => { it('should integrate provided Framework', () => { const returnedAgile = agile.integrate(testIntegration); @@ -287,39 +151,6 @@ describe('Agile Tests', () => { }); }); - describe('registerStorage function tests', () => { - beforeEach(() => { - agile.storages.register = jest.fn(); - }); - - it('should register provided Storage', () => { - const dummyStorage = new Storage({ - prefix: 'test', - methods: { - get: () => { - /* empty function */ - }, - set: () => { - /* empty function */ - }, - remove: () => { - /* empty function */ - }, - }, - key: 'myTestStorage', - }); - - const returnedAgile = agile.registerStorage(dummyStorage, { - default: false, - }); - - expect(returnedAgile).toBe(agile); - expect(agile.storages.register).toHaveBeenCalledWith(dummyStorage, { - default: false, - }); - }); - }); - describe('hasIntegration function tests', () => { it('should check if Agile has any registered Integration', () => { agile.hasIntegration(); @@ -327,17 +158,5 @@ describe('Agile Tests', () => { expect(agile.integrations.hasIntegration).toHaveBeenCalled(); }); }); - - describe('hasStorage function tests', () => { - beforeEach(() => { - agile.storages.hasStorage = jest.fn(); - }); - - it('should check if Agile has any registered Storage', () => { - agile.hasStorage(); - - expect(agile.storages.hasStorage).toHaveBeenCalled(); - }); - }); }); }); diff --git a/packages/core/tests/unit/collection/collection.persistent.test.ts b/packages/core/tests/unit/collection/collection.persistent.test.ts index 838587fd..4c44d3f8 100644 --- a/packages/core/tests/unit/collection/collection.persistent.test.ts +++ b/packages/core/tests/unit/collection/collection.persistent.test.ts @@ -7,8 +7,12 @@ import { StatePersistent, Group, Item, + assignSharedAgileStorageManager, + createStorageManager, + Storages, } from '../../../src'; import { LogMock } from '../../helper/logMock'; +import waitForExpect from 'wait-for-expect'; describe('CollectionPersistent Tests', () => { interface ItemInterface { @@ -18,15 +22,20 @@ describe('CollectionPersistent Tests', () => { let dummyAgile: Agile; let dummyCollection: Collection; + let storageManager: Storages; beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyCollection = new Collection(dummyAgile, { key: 'dummyCollectionKey', }); + // Register Storage Manager + storageManager = createStorageManager(); + assignSharedAgileStorageManager(storageManager); + jest.spyOn(CollectionPersistent.prototype, 'instantiatePersistent'); jest.spyOn(CollectionPersistent.prototype, 'initialLoading'); @@ -52,6 +61,7 @@ describe('CollectionPersistent Tests', () => { }); expect(collectionPersistent.initialLoading).toHaveBeenCalled(); + // Check if Persistent was called with correct parameters expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); expect(collectionPersistent.ready).toBeTruthy(); expect(collectionPersistent.isPersisted).toBeFalsy(); @@ -85,6 +95,7 @@ describe('CollectionPersistent Tests', () => { }); expect(collectionPersistent.initialLoading).toHaveBeenCalled(); + // Check if Persistent was called with correct parameters expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); expect(collectionPersistent.ready).toBeTruthy(); expect(collectionPersistent.isPersisted).toBeFalsy(); @@ -115,6 +126,7 @@ describe('CollectionPersistent Tests', () => { }); expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); + // Check if Persistent was called with correct parameters expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); expect(collectionPersistent.ready).toBeFalsy(); expect(collectionPersistent.isPersisted).toBeFalsy(); @@ -125,7 +137,7 @@ describe('CollectionPersistent Tests', () => { }); }); - it("should create CollectionPersistent and shouldn't call initialLoading if Persistent is ready (config.instantiate = false)", () => { + it("should create CollectionPersistent and shouldn't call initialLoading if Persistent is ready (config.loadValue = false)", () => { // Overwrite instantiatePersistent once to not call it and set ready property jest .spyOn(CollectionPersistent.prototype, 'instantiatePersistent') @@ -135,7 +147,7 @@ describe('CollectionPersistent Tests', () => { }); const collectionPersistent = new CollectionPersistent(dummyCollection, { - instantiate: false, + loadValue: false, }); expect(collectionPersistent).toBeInstanceOf(CollectionPersistent); @@ -147,6 +159,7 @@ describe('CollectionPersistent Tests', () => { }); expect(collectionPersistent.initialLoading).not.toHaveBeenCalled(); + // Check if Persistent was called with correct parameters expect(collectionPersistent._key).toBe(CollectionPersistent.placeHolderKey); expect(collectionPersistent.ready).toBeTruthy(); expect(collectionPersistent.isPersisted).toBeFalsy(); @@ -169,7 +182,7 @@ describe('CollectionPersistent Tests', () => { key: 'collectionPersistentKey', storageKeys: ['dummyStorage'], }); - dummyAgile.registerStorage( + storageManager.register( new Storage({ key: 'dummyStorage', methods: { @@ -226,8 +239,10 @@ describe('CollectionPersistent Tests', () => { it('should call initialLoad in parent and set Collection.isPersisted to true', async () => { await collectionPersistent.initialLoading(); - expect(Persistent.prototype.initialLoading).toHaveBeenCalled(); - expect(dummyCollection.isPersisted).toBeTruthy(); + await waitForExpect(() => { + expect(Persistent.prototype.initialLoading).toHaveBeenCalled(); + expect(dummyCollection.isPersisted).toBeTruthy(); + }); }); }); @@ -266,7 +281,7 @@ describe('CollectionPersistent Tests', () => { ); dummyCollection.assignItem = jest.fn(); - dummyAgile.storages.get = jest.fn(); + storageManager.get = jest.fn(); }); it( @@ -277,7 +292,7 @@ describe('CollectionPersistent Tests', () => { dummyCollection.data = { ['3']: dummyItem3, }; - dummyAgile.storages.get = jest + storageManager.get = jest .fn() .mockReturnValueOnce(Promise.resolve(true)); dummyDefaultGroup._value = ['3']; @@ -285,7 +300,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeTruthy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.config.defaultStorageKey ); @@ -338,7 +353,7 @@ describe('CollectionPersistent Tests', () => { collectionPersistent.ready = true; dummyCollection.data = {}; dummyCollection.size = 0; - dummyAgile.storages.get = jest + storageManager.get = jest .fn() .mockReturnValueOnce(Promise.resolve(true)); placeholderItem1.persist = jest.fn(function () { @@ -373,7 +388,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeTruthy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.config.defaultStorageKey ); @@ -417,9 +432,13 @@ describe('CollectionPersistent Tests', () => { expect( placeholderItem1?.persistent?.loadPersistedValue ).toHaveBeenCalledTimes(1); - expect( - dummyCollection.assignItem - ).toHaveBeenCalledWith(placeholderItem1, { overwrite: true }); + expect(dummyCollection.assignItem).toHaveBeenCalledWith( + placeholderItem1, + { + overwrite: true, + rebuildGroups: false, + } + ); expect(placeholderItem1.isPersisted).toBeTruthy(); // Placeholder Item 2 @@ -442,7 +461,11 @@ describe('CollectionPersistent Tests', () => { placeholderItem2?.persistent?.loadPersistedValue ).not.toHaveBeenCalled(); expect(dummyCollection.assignItem).not.toHaveBeenCalledWith( - placeholderItem2 + placeholderItem2, + { + overwrite: true, + rebuildGroups: false, + } ); // Because Item persistent isn't ready expect(placeholderItem2.isPersisted).toBeFalsy(); @@ -465,9 +488,12 @@ describe('CollectionPersistent Tests', () => { expect( placeholderItem3?.persistent?.loadPersistedValue ).toHaveBeenCalledTimes(1); - expect(dummyCollection.assignItem).not.toHaveBeenCalledWith( - placeholderItem3 - ); // Because Item persistent 'leadPersistedValue()' returned false -> Item properly doesn't exist in Storage + expect( + dummyCollection.assignItem + ).not.toHaveBeenCalledWith(placeholderItem3, { + overwrite: true, + rebuildGroups: false, + }); // Because Item persistent 'leadPersistedValue()' returned false -> Item properly doesn't exist in Storage expect(placeholderItem3.isPersisted).toBeFalsy(); expect(collectionPersistent.setupSideEffects).toHaveBeenCalledWith( @@ -486,7 +512,7 @@ describe('CollectionPersistent Tests', () => { ['3']: dummyItem3, }; dummyCollection.size = 1; - dummyAgile.storages.get = jest + storageManager.get = jest .fn() .mockReturnValueOnce(Promise.resolve(true)); placeholderItem1.persist = jest.fn(function () { @@ -507,7 +533,7 @@ describe('CollectionPersistent Tests', () => { ); expect(response).toBeTruthy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( 'dummyKey', collectionPersistent.config.defaultStorageKey ); @@ -549,7 +575,11 @@ describe('CollectionPersistent Tests', () => { '3' ); // Because Item 3 is already present in the Collection expect(dummyCollection.assignItem).not.toHaveBeenCalledWith( - placeholderItem3 + placeholderItem3, + { + overwrite: true, + rebuildGroups: false, + } ); // Because Item 3 is already present in the Collection // Placeholder Item 1 @@ -568,9 +598,13 @@ describe('CollectionPersistent Tests', () => { expect( placeholderItem1?.persistent?.loadPersistedValue ).toHaveBeenCalledTimes(1); - expect( - dummyCollection.assignItem - ).toHaveBeenCalledWith(placeholderItem1, { overwrite: true }); + expect(dummyCollection.assignItem).toHaveBeenCalledWith( + placeholderItem1, + { + overwrite: true, + rebuildGroups: false, + } + ); expect(placeholderItem1.isPersisted).toBeTruthy(); expect(collectionPersistent.setupSideEffects).toHaveBeenCalledWith( @@ -581,14 +615,14 @@ describe('CollectionPersistent Tests', () => { it("shouldn't load default Group and its Items if Collection flag isn't persisted", async () => { collectionPersistent.ready = true; - dummyAgile.storages.get = jest + storageManager.get = jest .fn() .mockReturnValueOnce(Promise.resolve(undefined)); const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.config.defaultStorageKey ); @@ -612,7 +646,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.get).not.toHaveBeenCalled(); + expect(storageManager.get).not.toHaveBeenCalled(); expect(dummyCollection.getDefaultGroup).not.toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); @@ -629,7 +663,7 @@ describe('CollectionPersistent Tests', () => { it("shouldn't load default Group and its Items if Collection has no defaultGroup", async () => { collectionPersistent.ready = true; - dummyAgile.storages.get = jest + storageManager.get = jest .fn() .mockReturnValueOnce(Promise.resolve(true)); dummyCollection.getDefaultGroup = jest.fn(() => undefined); @@ -637,7 +671,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.loadPersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.config.defaultStorageKey ); @@ -682,7 +716,7 @@ describe('CollectionPersistent Tests', () => { () => dummyDefaultGroup as any ); - dummyAgile.storages.set = jest.fn(); + storageManager.set = jest.fn(); }); it('should persist default Group and its Items (persistentKey)', async () => { @@ -691,7 +725,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.persistValue(); expect(response).toBeTruthy(); - expect(dummyAgile.storages.set).toHaveBeenCalledWith( + expect(storageManager.set).toHaveBeenCalledWith( collectionPersistent._key, true, collectionPersistent.storageKeys @@ -743,7 +777,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.persistValue('dummyKey'); expect(response).toBeTruthy(); - expect(dummyAgile.storages.set).toHaveBeenCalledWith( + expect(storageManager.set).toHaveBeenCalledWith( 'dummyKey', true, collectionPersistent.storageKeys @@ -789,7 +823,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.persistValue('dummyKey'); expect(response).toBeFalsy(); - expect(dummyAgile.storages.set).not.toHaveBeenCalled(); + expect(storageManager.set).not.toHaveBeenCalled(); expect(dummyCollection.getDefaultGroup).not.toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); @@ -808,7 +842,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.persistValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.set).not.toHaveBeenCalled(); + expect(storageManager.set).not.toHaveBeenCalled(); expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); expect(dummyDefaultGroup.persist).not.toHaveBeenCalled(); @@ -919,7 +953,7 @@ describe('CollectionPersistent Tests', () => { if (dummyItem3.persistent) dummyItem3.persistent.removePersistedValue = jest.fn(); - dummyAgile.storages.remove = jest.fn(); + storageManager.remove = jest.fn(); }); it('should remove persisted default Group and its Items from Storage (persistentKey)', async () => { @@ -928,7 +962,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.removePersistedValue(); expect(response).toBeTruthy(); - expect(dummyAgile.storages.remove).toHaveBeenCalledWith( + expect(storageManager.remove).toHaveBeenCalledWith( collectionPersistent._key, collectionPersistent.storageKeys ); @@ -974,7 +1008,7 @@ describe('CollectionPersistent Tests', () => { ); expect(response).toBeTruthy(); - expect(dummyAgile.storages.remove).toHaveBeenCalledWith( + expect(storageManager.remove).toHaveBeenCalledWith( 'dummyKey', collectionPersistent.storageKeys ); @@ -1012,7 +1046,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.removePersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.remove).not.toHaveBeenCalled(); + expect(storageManager.remove).not.toHaveBeenCalled(); expect(dummyCollection.getDefaultGroup).not.toHaveBeenCalled(); expect( @@ -1037,7 +1071,7 @@ describe('CollectionPersistent Tests', () => { const response = await collectionPersistent.removePersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.remove).not.toHaveBeenCalled(); + expect(storageManager.remove).not.toHaveBeenCalled(); expect(dummyCollection.getDefaultGroup).toHaveBeenCalled(); expect( diff --git a/packages/core/tests/unit/collection/collection.test.ts b/packages/core/tests/unit/collection/collection.test.ts index e5b0e686..9dee6d0b 100644 --- a/packages/core/tests/unit/collection/collection.test.ts +++ b/packages/core/tests/unit/collection/collection.test.ts @@ -7,6 +7,7 @@ import { CollectionPersistent, ComputedTracker, StatePersistent, + TrackedChangeMethod, } from '../../../src'; import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../helper/logMock'; @@ -24,7 +25,7 @@ describe('Collection Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); jest.spyOn(Collection.prototype, 'initSelectors'); jest.spyOn(Collection.prototype, 'initGroups'); @@ -55,6 +56,7 @@ describe('Collection Tests', () => { expect(collection.persistent).toBeUndefined(); expect(collection.groups).toStrictEqual({}); expect(collection.selectors).toStrictEqual({}); + expect(collection.isCollection).toBeTruthy(); expect(Collection.prototype.initGroups).toHaveBeenCalledWith({}); expect(Collection.prototype.initSelectors).toHaveBeenCalledWith({}); @@ -93,6 +95,7 @@ describe('Collection Tests', () => { expect(collection.persistent).toBeUndefined(); expect(collection.groups).toStrictEqual({}); expect(collection.selectors).toStrictEqual({}); + expect(collection.isCollection).toBeTruthy(); expect(Collection.prototype.initGroups).toHaveBeenCalledWith([ 'group1', @@ -146,6 +149,7 @@ describe('Collection Tests', () => { expect(collection.persistent).toBeUndefined(); expect(collection.groups).toStrictEqual({}); expect(collection.selectors).toStrictEqual({}); + expect(collection.isCollection).toBeTruthy(); expect(Collection.prototype.initGroups).toHaveBeenCalledWith({ group1: expect.any(Group), @@ -1283,6 +1287,24 @@ describe('Collection Tests', () => { }); }); + describe('select function tests', () => { + beforeEach(() => { + collection.createSelector = jest.fn(); + }); + it( + 'should call createSelector with the specified itemKey ' + + 'as key of the Selector and as selected item key', + () => { + collection.select('test'); + + expect(collection.createSelector).toHaveBeenCalledWith( + 'test', + 'test' + ); + } + ); + }); + describe('hasSelector function tests', () => { let dummySelector: Selector; @@ -1799,19 +1821,19 @@ describe('Collection Tests', () => { }); describe('persist function tests', () => { - it('should create persistent with CollectionKey (default config)', () => { + it('should create Persistent with CollectionKey (default config)', () => { collection.persist(); expect(collection.persistent).toBeInstanceOf(CollectionPersistent); expect(CollectionPersistent).toHaveBeenCalledWith(collection, { - instantiate: true, + loadValue: true, storageKeys: [], key: collection._key, defaultStorageKey: null, }); }); - it('should create persistent with CollectionKey (specific config)', () => { + it('should create Persistent with CollectionKey (specific config)', () => { collection.persist({ storageKeys: ['test1', 'test2'], loadValue: false, @@ -1820,26 +1842,26 @@ describe('Collection Tests', () => { expect(collection.persistent).toBeInstanceOf(CollectionPersistent); expect(CollectionPersistent).toHaveBeenCalledWith(collection, { - instantiate: false, + loadValue: false, storageKeys: ['test1', 'test2'], key: collection._key, defaultStorageKey: 'test1', }); }); - it('should create persistent with passed Key (default config)', () => { + it('should create Persistent with passed Key (default config)', () => { collection.persist('passedKey'); expect(collection.persistent).toBeInstanceOf(CollectionPersistent); expect(CollectionPersistent).toHaveBeenCalledWith(collection, { - instantiate: true, + loadValue: true, storageKeys: [], key: 'passedKey', defaultStorageKey: null, }); }); - it('should create persistent with passed Key (specific config)', () => { + it('should create Persistent with passed Key (specific config)', () => { collection.persist('passedKey', { storageKeys: ['test1', 'test2'], loadValue: false, @@ -1848,14 +1870,14 @@ describe('Collection Tests', () => { expect(collection.persistent).toBeInstanceOf(CollectionPersistent); expect(CollectionPersistent).toHaveBeenCalledWith(collection, { - instantiate: false, + loadValue: false, storageKeys: ['test1', 'test2'], key: 'passedKey', defaultStorageKey: 'test1', }); }); - it("shouldn't overwrite existing persistent", () => { + it("shouldn't overwrite existing Persistent", () => { const dummyPersistent = new CollectionPersistent(collection); collection.persistent = dummyPersistent; collection.isPersisted = true; @@ -2850,7 +2872,7 @@ describe('Collection Tests', () => { collection.assignData = jest.fn(); }); - it('should assign valid Item to Collection (default config)', () => { + it('should assign valid Item to the Collection (default config)', () => { const response = collection.assignItem(toAddDummyItem2); expect(response).toBeTruthy(); @@ -2872,21 +2894,19 @@ describe('Collection Tests', () => { LogMock.hasNotLogged('warn'); }); - it('should assign valid Item to Collection (config.background = true)', () => { + it('should assign valid Item to the Collection (specific config)', () => { const response = collection.assignItem(toAddDummyItem2, { background: true, + rebuildGroups: false, }); 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( + collection.rebuildGroupsThatIncludeItemKey + ).not.toHaveBeenCalled(); expect(collection.assignData).not.toHaveBeenCalled(); expect(toAddDummyItem2.patch).not.toHaveBeenCalled(); @@ -3000,10 +3020,24 @@ describe('Collection Tests', () => { let dummyGroup1: Group; let dummyGroup2: Group; + let dummyItem1: Item; + let dummyItem2: Item; + beforeEach(() => { - dummyGroup1 = new Group(collection, ['dummyItem1', 'dummyItem2'], { - key: 'dummyGroup1', - }); + dummyItem1 = new Item(collection, { id: 'dummyItem1', name: 'Jeff' }); + dummyItem2 = new Item(collection, { id: 'dummyItem2', name: 'Jeff' }); + collection.data = { + dummyItem1: dummyItem1, + dummyItem2: dummyItem2, + }; + + dummyGroup1 = new Group( + collection, + ['dummyItem1', 'missingInCollectionItemKey', 'dummyItem2'], + { + key: 'dummyGroup1', + } + ); dummyGroup2 = new Group(collection, ['dummyItem2'], { key: 'dummyGroup2', }); @@ -3016,43 +3050,89 @@ describe('Collection Tests', () => { dummyGroup2.rebuild = jest.fn(); }); - it('should call ingest on each Group that includes the passed ItemKey (default config)', () => { + it('should update the Item in each Group (output) that includes the specified itemKey (default config)', () => { collection.rebuildGroupsThatIncludeItemKey('dummyItem1'); - expect(dummyGroup1.rebuild).toHaveBeenCalledWith({ - background: false, - sideEffects: { - enabled: true, - exclude: [], - }, - storage: false, - }); + // Group 1 + expect(dummyGroup1.rebuild).toHaveBeenCalledWith( + [ + { + key: 'dummyItem1', + index: 0, + method: TrackedChangeMethod.UPDATE, + }, + ], + {} + ); + + // Group 2 expect(dummyGroup2.rebuild).not.toHaveBeenCalled(); }); - it('should call ingest on each Group that includes the passed ItemKey (specific config)', () => { + it('should update the Item in each Group (output) that includes the specified itemKey (specific config)', () => { collection.rebuildGroupsThatIncludeItemKey('dummyItem2', { + key: 'frank', background: true, - sideEffects: { - enabled: false, - }, + force: true, }); - expect(dummyGroup1.rebuild).toHaveBeenCalledWith({ - background: true, - sideEffects: { - enabled: false, - }, - storage: false, - }); - expect(dummyGroup2.rebuild).toHaveBeenCalledWith({ - background: true, - sideEffects: { - enabled: false, - }, - storage: false, - }); + // Group 1 + expect(dummyGroup1.rebuild).toHaveBeenCalledWith( + [ + { + key: 'dummyItem2', + index: 1, + method: TrackedChangeMethod.UPDATE, + }, + ], + { + key: 'frank', + background: true, + force: true, + } + ); + + // Group 2 + expect(dummyGroup2.rebuild).toHaveBeenCalledWith( + [ + { + key: 'dummyItem2', + index: 0, + method: TrackedChangeMethod.UPDATE, + }, + ], + { + key: 'frank', + background: true, + force: true, + } + ); }); + + it( + 'should update the Item in each Group (output) that includes the specified itemKey ' + + "although the Item doesn't exist in the Group output yet", + () => { + collection.rebuildGroupsThatIncludeItemKey( + 'missingInCollectionItemKey' + ); + + // Group 1 + expect(dummyGroup1.rebuild).toHaveBeenCalledWith( + [ + { + key: 'missingInCollectionItemKey', + index: 1, + method: TrackedChangeMethod.ADD, + }, + ], + {} + ); + + // Group 2 + expect(dummyGroup2.rebuild).not.toHaveBeenCalled(); + } + ); }); }); }); diff --git a/packages/core/tests/unit/collection/group/group.observer.test.ts b/packages/core/tests/unit/collection/group/group.observer.test.ts index a295262e..24b30205 100644 --- a/packages/core/tests/unit/collection/group/group.observer.test.ts +++ b/packages/core/tests/unit/collection/group/group.observer.test.ts @@ -26,7 +26,7 @@ describe('GroupObserver Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyCollection = new Collection(dummyAgile); dummyGroup = new Group(dummyCollection, [], { key: 'dummyGroup', @@ -110,31 +110,41 @@ describe('GroupObserver Tests', () => { describe('ingest function tests', () => { beforeEach(() => { - dummyGroup.rebuild = jest.fn(); + groupObserver.ingestOutput = jest.fn(); }); - it('should rebuild the Group and ingests it into the runtime (default config)', () => { + it('should call ingestOutput with nextGroupOutput (default config)', () => { + groupObserver.group().nextGroupOutput = 'jeff' as any; + groupObserver.ingest(); - expect(dummyGroup.rebuild).toHaveBeenCalledWith({}); + expect(groupObserver.ingestOutput).toHaveBeenCalledWith( + groupObserver.group().nextGroupOutput, + {} + ); }); - it('should rebuild the Group and ingests it into the runtime (specific config)', () => { + it('should call ingestOutput with nextGroupOutput (specific config)', () => { + groupObserver.group().nextGroupOutput = 'jeff' as any; + groupObserver.ingest({ background: true, force: true, maxTriesToUpdate: 5, }); - expect(dummyGroup.rebuild).toHaveBeenCalledWith({ - background: true, - force: true, - maxTriesToUpdate: 5, - }); + expect(groupObserver.ingestOutput).toHaveBeenCalledWith( + groupObserver.group().nextGroupOutput, + { + background: true, + force: true, + maxTriesToUpdate: 5, + } + ); }); }); - describe('ingestItems function tests', () => { + describe('ingestOutput function tests', () => { beforeEach(() => { dummyAgile.runtime.ingest = jest.fn(); }); @@ -155,10 +165,11 @@ describe('GroupObserver Tests', () => { }, force: false, maxTriesToUpdate: 3, + any: {}, }); }); - groupObserver.ingestItems([dummyItem1, dummyItem2]); + groupObserver.ingestOutput([dummyItem1._value, dummyItem2._value]); expect(groupObserver.nextGroupOutput).toStrictEqual([ dummyItem1._value, @@ -187,10 +198,11 @@ describe('GroupObserver Tests', () => { }, force: true, maxTriesToUpdate: 5, + any: {}, }); }); - groupObserver.ingestItems([dummyItem1, dummyItem2], { + groupObserver.ingestOutput([dummyItem1._value, dummyItem2._value], { perform: false, force: true, sideEffects: { @@ -219,7 +231,7 @@ describe('GroupObserver Tests', () => { () => { dummyGroup._output = [dummyItem1._value, dummyItem2._value]; - groupObserver.ingestItems([dummyItem1, dummyItem2]); + groupObserver.ingestOutput([dummyItem1._value, dummyItem2._value]); expect(groupObserver.nextGroupOutput).toStrictEqual([ dummyItem1._value, @@ -246,10 +258,13 @@ describe('GroupObserver Tests', () => { }, force: true, maxTriesToUpdate: 3, + any: {}, }); }); - groupObserver.ingestItems([dummyItem1, dummyItem2], { force: true }); + groupObserver.ingestOutput([dummyItem1._value, dummyItem2._value], { + force: true, + }); expect(groupObserver.nextGroupOutput).toStrictEqual([ dummyItem1._value, @@ -277,11 +292,12 @@ describe('GroupObserver Tests', () => { }, force: true, maxTriesToUpdate: 3, + any: {}, }); }); dummyGroup.isPlaceholder = true; - groupObserver.ingestItems([dummyItem1, dummyItem2]); + groupObserver.ingestOutput([dummyItem1._value, dummyItem2._value]); expect(groupObserver.nextGroupOutput).toStrictEqual([ dummyItem1._value, @@ -312,6 +328,7 @@ describe('GroupObserver Tests', () => { ]; dummyJob.observer.value = [dummyItem1._value]; dummyGroup._output = [dummyItem1._value]; + dummyGroup.nextGroupOutput = [dummyItem1._value]; groupObserver.perform(dummyJob); @@ -319,6 +336,10 @@ describe('GroupObserver Tests', () => { dummyItem1._value, dummyItem2._value, ]); + expect(dummyGroup.nextGroupOutput).toStrictEqual([ + dummyItem1._value, + dummyItem2._value, + ]); expect(groupObserver.value).toStrictEqual([ dummyItem1._value, diff --git a/packages/core/tests/unit/collection/group/group.test.ts b/packages/core/tests/unit/collection/group/group.test.ts index f9d48a74..b544c54c 100644 --- a/packages/core/tests/unit/collection/group/group.test.ts +++ b/packages/core/tests/unit/collection/group/group.test.ts @@ -1,13 +1,14 @@ import { - Group, Agile, Collection, - StateObserver, - ComputedTracker, - Item, - State, CollectionPersistent, + ComputedTracker, + EnhancedState, + Group, GroupObserver, + Item, + StateObserver, + TrackedChangeMethod, } from '../../../../src'; import { LogMock } from '../../../helper/logMock'; @@ -23,7 +24,7 @@ describe('Group Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyCollection = new Collection(dummyAgile, { key: 'dummyCollection', }); @@ -47,11 +48,12 @@ describe('Group Tests', () => { expect(group.collection()).toBe(dummyCollection); expect(group._output).toStrictEqual([]); + expect(group.nextGroupOutput).toStrictEqual([]); expect(group.notFoundItemKeys).toStrictEqual([]); + expect(group.loadedInitialValue).toBeTruthy(); // Check if State was called with correct parameters expect(group._key).toBeUndefined(); - expect(group.valueType).toBeUndefined(); expect(group.isSet).toBeFalsy(); expect(group.isPlaceholder).toBeFalsy(); expect(group.initialStateValue).toStrictEqual([]); @@ -69,7 +71,6 @@ describe('Group Tests', () => { expect(group.computeExistsMethod).toBeInstanceOf(Function); expect(group.isPersisted).toBeFalsy(); expect(group.persistent).toBeUndefined(); - expect(group.watchers).toStrictEqual({}); }); it('should create Group with no initialItems (specific config)', () => { @@ -88,11 +89,12 @@ describe('Group Tests', () => { expect(group.collection()).toBe(dummyCollection); expect(group._output).toStrictEqual([]); + expect(group.nextGroupOutput).toStrictEqual([]); expect(group.notFoundItemKeys).toStrictEqual([]); + expect(group.loadedInitialValue).toBeTruthy(); // Check if State was called with correct parameters expect(group._key).toBe('dummyKey'); - expect(group.valueType).toBeUndefined(); expect(group.isSet).toBeFalsy(); expect(group.isPlaceholder).toBeTruthy(); expect(group.initialStateValue).toStrictEqual([]); @@ -110,7 +112,6 @@ describe('Group Tests', () => { expect(group.computeExistsMethod).toBeInstanceOf(Function); expect(group.isPersisted).toBeFalsy(); expect(group.persistent).toBeUndefined(); - expect(group.watchers).toStrictEqual({}); }); it('should create Group with initialItems (default config)', () => { @@ -126,11 +127,12 @@ describe('Group Tests', () => { expect(group.collection()).toBe(dummyCollection); expect(group._output).toStrictEqual([]); + expect(group.nextGroupOutput).toStrictEqual([]); expect(group.notFoundItemKeys).toStrictEqual([]); + expect(group.loadedInitialValue).toBeTruthy(); // Check if State was called with correct parameters expect(group._key).toBeUndefined(); - expect(group.valueType).toBeUndefined(); expect(group.isSet).toBeFalsy(); expect(group.isPlaceholder).toBeFalsy(); expect(group.initialStateValue).toStrictEqual(['test1', 'test2', 'test3']); @@ -146,7 +148,6 @@ describe('Group Tests', () => { expect(group.computeExistsMethod).toBeInstanceOf(Function); expect(group.isPersisted).toBeFalsy(); expect(group.persistent).toBeUndefined(); - expect(group.watchers).toStrictEqual({}); }); describe('Group Function Tests', () => { @@ -159,14 +160,12 @@ describe('Group Tests', () => { group = new Group(dummyCollection, [], { key: 'groupKey', }); - dummyCollection.collect({ id: 'dummyItem1Key', name: 'coolName' }); - dummyCollection.collect({ id: 'dummyItem2Key', name: 'coolName' }); + dummyCollection.collect({ id: 'dummyItem1Key', name: 'jeff' }); + dummyCollection.collect({ id: 'dummyItem2Key', name: 'frank' }); + dummyCollection.collect({ id: 'dummyItem3Key', name: 'hans' }); dummyItem1 = dummyCollection.getItem('dummyItem1Key') as any; dummyItem2 = dummyCollection.getItem('dummyItem2Key') as any; - dummyItem3 = new Item(dummyCollection, { - id: 'dummyItem3Key', - name: 'coolName', - }); + dummyItem3 = dummyCollection.getItem('dummyItem3Key') as any; }); describe('output get function tests', () => { @@ -230,122 +229,191 @@ describe('Group Tests', () => { describe('remove function tests', () => { beforeEach(() => { - group.nextStateValue = [ + group._value = [ 'dummyItem1Key', 'dummyItem2Key', - 'dummyItem3Key', + 'missingInCollectionItemKey', ]; + group.nextStateValue = group._value; + group._preciseItemKeys = ['dummyItem1Key', 'dummyItem2Key']; + group.set = jest.fn(); }); - it('should remove Item from Group not in background (default config)', () => { + it('should remove Item from Group (default config)', () => { group.remove('dummyItem1Key'); expect(group.set).toHaveBeenCalledWith( - ['dummyItem2Key', 'dummyItem3Key'], - {} + ['dummyItem2Key', 'missingInCollectionItemKey'], + { + any: { + trackedChanges: [ + { + index: 0, + method: TrackedChangeMethod.REMOVE, + key: 'dummyItem1Key', + }, + ], + }, + } ); }); - it('should remove Item from Group in background (config.background = true)', () => { - group.remove('dummyItem1Key', { background: true }); + it('should remove Item from Group (specific config)', () => { + group.remove('dummyItem1Key', { + background: true, + force: true, + storage: false, + softRebuild: false, + }); expect(group.set).toHaveBeenCalledWith( - ['dummyItem2Key', 'dummyItem3Key'], - { background: true } + ['dummyItem2Key', 'missingInCollectionItemKey'], + { + background: true, + force: true, + storage: false, + any: { trackedChanges: [] }, + } ); }); - it("shouldn't remove not existing Item from Group (default config)", () => { + it("shouldn't remove not existing Item from Group", () => { group.remove('notExistingKey'); expect(group.set).not.toHaveBeenCalled(); }); - it("should remove Item from Group that doesn't exist in Collection in background (default config)", () => { - group.remove('dummyItem3Key'); - - expect(group.set).toHaveBeenCalledWith( - ['dummyItem1Key', 'dummyItem2Key'], - { background: true } - ); - }); - - it('should remove Items from Group not in background (default config)', () => { - group.remove(['dummyItem1Key', 'notExistingItemKey', 'dummyItem3Key']); + it('should remove Items from Group', () => { + group.remove([ + 'dummyItem1Key', + 'notExistingItemKey', + 'missingInCollectionItemKey', + 'dummyItem2Key', + ]); - expect(group.set).toHaveBeenCalledWith(['dummyItem2Key'], {}); + expect(group.set).toHaveBeenCalledWith([], { + any: { + trackedChanges: [ + { + index: 0, + method: TrackedChangeMethod.REMOVE, + key: 'dummyItem1Key', + }, + { + index: 0, + method: TrackedChangeMethod.REMOVE, + key: 'dummyItem2Key', + }, + ], + }, + }); }); - it("should remove Items from Group in background if passing not existing Item and Item that doesn't exist in Collection (default config)", () => { - group.remove(['notExistingItemKey', 'dummyItem3Key']); + it("should remove Item/s from Group that doesn't exist in the Collection in background", () => { + group.remove('missingInCollectionItemKey'); expect(group.set).toHaveBeenCalledWith( ['dummyItem1Key', 'dummyItem2Key'], - { background: true } + { + background: true, + any: { trackedChanges: [] }, + } ); }); + + it( + 'should remove Items from Group in background ' + + 'if passing not existing Items to remove ' + + "and Items that doesn't exist in the Collection", + () => { + group.remove(['notExistingItemKey', 'missingInCollectionItemKey']); + + expect(group.set).toHaveBeenCalledWith( + ['dummyItem1Key', 'dummyItem2Key'], + { + background: true, + any: { trackedChanges: [] }, + } + ); + } + ); }); describe('add function tests', () => { beforeEach(() => { - group.nextStateValue = ['placeholder', 'dummyItem1Key', 'placeholder']; + group._value = ['placeholder', 'dummyItem1Key', 'placeholder']; + group.nextStateValue = group._value; + group._preciseItemKeys = ['dummyItem1Key']; + group.set = jest.fn(); }); - it('should add Item to Group at the end not in background (default config)', () => { + it('should add Item at the end of the Group (default config)', () => { group.add('dummyItem2Key'); expect(group.set).toHaveBeenCalledWith( ['placeholder', 'dummyItem1Key', 'placeholder', 'dummyItem2Key'], - {} - ); - }); - - it("should add Item to Group at the beginning not in background (config.method = 'unshift')", () => { - group.add('dummyItem2Key', { method: 'unshift' }); - - expect(group.set).toHaveBeenCalledWith( - ['dummyItem2Key', 'placeholder', 'dummyItem1Key', 'placeholder'], - {} + { + any: { + trackedChanges: [ + { + index: 1, + method: TrackedChangeMethod.ADD, + key: 'dummyItem2Key', + }, + ], + }, + } ); }); - it('should add Item to Group at the end in background (config.background = true)', () => { - group.add('dummyItem2Key', { background: true }); + it('should add Item at the end of the Group (specific config)', () => { + group.add('dummyItem2Key', { + background: true, + force: true, + storage: false, + softRebuild: false, + }); expect(group.set).toHaveBeenCalledWith( ['placeholder', 'dummyItem1Key', 'placeholder', 'dummyItem2Key'], - { background: true } + { + background: true, + force: true, + storage: false, + any: { trackedChanges: [] }, + } ); }); - it("should add Item to Group at the end that doesn't exist in Collection in background (default config)", () => { - group.add('dummyItem3Key'); + it("should add Item at the beginning of the Group (config.method = 'unshift')", () => { + group.add('dummyItem2Key', { method: 'unshift' }); expect(group.set).toHaveBeenCalledWith( - ['placeholder', 'dummyItem1Key', 'placeholder', 'dummyItem3Key'], - { background: true } + ['dummyItem2Key', 'placeholder', 'dummyItem1Key', 'placeholder'], + { + any: { + trackedChanges: [ + { + index: 0, + method: TrackedChangeMethod.ADD, + key: 'dummyItem2Key', + }, + ], + }, + } ); }); - it("shouldn't add existing Item to Group again (default config)", () => { + it("shouldn't add already existing Item to the Group (default config)", () => { group.add('dummyItem1Key'); expect(group.set).not.toHaveBeenCalled(); }); - it('should remove existingItem and add it again at the end to the Group not in background (config.overwrite = true)', () => { - group.add('dummyItem1Key', { overwrite: true }); - - expect(group.set).toHaveBeenCalledWith( - ['placeholder', 'placeholder', 'dummyItem1Key'], - {} - ); - }); - - it('should add Items to Group at the end not in background (default config)', () => { - group.add(['dummyItem1Key', 'dummyItem2Key', 'dummyItem3Key']); + it('should add Items at the end of the Group', () => { + group.add(['dummyItem1Key', 'dummyItem2Key', 'notExistingItemKey']); expect(group.set).toHaveBeenCalledWith( [ @@ -353,20 +421,76 @@ describe('Group Tests', () => { 'dummyItem1Key', 'placeholder', 'dummyItem2Key', - 'dummyItem3Key', + 'notExistingItemKey', ], - {} + { + any: { + trackedChanges: [ + { + index: 1, + method: TrackedChangeMethod.ADD, + key: 'dummyItem2Key', + }, + { + index: 2, + method: TrackedChangeMethod.ADD, + key: 'notExistingItemKey', + }, + ], + }, + } ); }); - it('should add Items toGroup at the end in background if passing existing Item and in Collection not existing Item (default config)', () => { - group.add(['dummyItem1Key', 'dummyItem3Key']); + it("should add Item that doesn't exist in Collection at the end of the Group in background", () => { + group.add('notExistingItemKey'); expect(group.set).toHaveBeenCalledWith( - ['placeholder', 'dummyItem1Key', 'placeholder', 'dummyItem3Key'], - { background: true } + ['placeholder', 'dummyItem1Key', 'placeholder', 'notExistingItemKey'], + { + background: true, + any: { + trackedChanges: [ + { + index: 1, + method: TrackedChangeMethod.ADD, + key: 'notExistingItemKey', + }, + ], + }, + } ); }); + + it( + 'should add Items at the end of the Group in background ' + + 'if passing already added Items ' + + "and Items that doesn't exist in the Collection", + () => { + group.add(['dummyItem1Key', 'notExistingItemKey']); + + expect(group.set).toHaveBeenCalledWith( + [ + 'placeholder', + 'dummyItem1Key', + 'placeholder', + 'notExistingItemKey', + ], + { + background: true, + any: { + trackedChanges: [ + { + index: 1, + method: TrackedChangeMethod.ADD, + key: 'notExistingItemKey', + }, + ], + }, + } + ); + } + ); }); describe('replace function tests', () => { @@ -401,7 +525,7 @@ describe('Group Tests', () => { describe('getItems function tests', () => { beforeEach(() => { - group._value = ['dummyItem1Key', 'dummyItem3Key', 'dummyItem2Key']; + group._value = ['dummyItem1Key', 'notExistingItemKey', 'dummyItem2Key']; }); it('should return all existing Items of the Group', () => { @@ -413,13 +537,13 @@ describe('Group Tests', () => { describe('persist function tests', () => { beforeEach(() => { - jest.spyOn(State.prototype, 'persist'); + jest.spyOn(EnhancedState.prototype, 'persist'); }); it('should persist Group with formatted groupKey (default config)', () => { group.persist(); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getGroupStorageKey( group._key, dummyCollection._key @@ -439,7 +563,7 @@ describe('Group Tests', () => { defaultStorageKey: 'test1', }); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getGroupStorageKey( group._key, dummyCollection._key @@ -455,7 +579,7 @@ describe('Group Tests', () => { it('should persist Group with formatted specified key (default config)', () => { group.persist('dummyKey'); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getGroupStorageKey( 'dummyKey', dummyCollection._key @@ -475,7 +599,7 @@ describe('Group Tests', () => { defaultStorageKey: 'test1', }); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getGroupStorageKey( 'dummyKey', dummyCollection._key @@ -491,74 +615,236 @@ describe('Group Tests', () => { 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, - }); + expect(EnhancedState.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, - }); + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( + 'dummyKey', + { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + } + ); }); }); describe('rebuild function tests', () => { beforeEach(() => { - group._value = ['dummyItem1Key', 'dummyItem3Key', 'dummyItem2Key']; - group.observers['output'].ingestItems = jest.fn(); - }); - - it('should ingest the built Group output and set notFoundItemKeys to the not found Item Keys (default config)', () => { - group.rebuild(); - - expect(group.notFoundItemKeys).toStrictEqual(['dummyItem3Key']); - expect(group._output).toStrictEqual([]); // because of mocking 'ingestValue' - expect(group.observers['output'].ingestItems).toHaveBeenCalledWith( - [dummyItem1, dummyItem2], - {} - ); - - LogMock.hasLoggedCode( - '1C:02:00', - [dummyCollection._key, group._key], - ['dummyItem3Key'] - ); - }); - - it('should ingest the built Group output and set notFoundItemKeys to the not found Item Keys (specific config)', () => { - group.rebuild({ storage: true, overwrite: true, background: false }); - - expect(group.notFoundItemKeys).toStrictEqual(['dummyItem3Key']); - expect(group._output).toStrictEqual([]); // because of mocking 'ingestValue' - expect(group.observers['output'].ingestItems).toHaveBeenCalledWith( - [dummyItem1, dummyItem2], - { storage: true, overwrite: true, background: false } - ); + group._value = [ + 'dummyItem1Key', + 'missingInCollectionItemKey', + 'dummyItem2Key', + 'dummyItem3Key', + ]; + group.observers['output'].ingestOutput = jest.fn(); + group.observers['output'].ingest = jest.fn(); + }); + + it( + 'should hard rebuild the Group if no trackedChanges were specified ' + + 'and set notExistingItemKeys to the not found Item Keys (default config)', + () => { + group.rebuild(); + + expect(group.notFoundItemKeys).toStrictEqual([ + 'missingInCollectionItemKey', + ]); + expect(group.observers['output'].ingestOutput).toHaveBeenCalledWith( + [dummyItem1._value, dummyItem2._value, dummyItem3._value], + {} + ); + expect(group.observers['output'].ingest).not.toHaveBeenCalled(); + + LogMock.hasLoggedCode( + '1C:02:00', + [dummyCollection._key, group._key], + ['missingInCollectionItemKey'] + ); + } + ); + + it( + 'should hard rebuild the Group if no trackedChanges were specified ' + + 'and set notExistingItemKeys to the not found Item Keys (specific config)', + () => { + group.rebuild([], { background: true, force: false, key: 'frank' }); + + expect(group.notFoundItemKeys).toStrictEqual([ + 'missingInCollectionItemKey', + ]); + expect(group.observers['output'].ingestOutput).toHaveBeenCalledWith( + [dummyItem1._value, dummyItem2._value, dummyItem3._value], + { background: true, force: false, key: 'frank' } + ); + + LogMock.hasLoggedCode( + '1C:02:00', + [dummyCollection._key, group._key], + ['missingInCollectionItemKey'] + ); + } + ); + + it( + 'should soft rebuild the Group if trackedChanges were specified ' + + 'and set notExistingItemKeys to the not found itemKeys (ADD)', + () => { + group.nextGroupOutput = [{ id: 'dummyItem1Key', name: 'jeff' }]; + group._preciseItemKeys = ['dummyItem1Key']; + + group.rebuild( + [ + { + index: 1, + method: TrackedChangeMethod.ADD, + key: 'dummyItem3Key', + }, + { + index: 2, + method: TrackedChangeMethod.ADD, + key: 'missingInCollectionItemKey', + }, + ], + { key: 'test', background: true } + ); + + expect(group.notFoundItemKeys).toStrictEqual([ + 'missingInCollectionItemKey', + ]); + expect(group.observers['output'].ingestOutput).not.toHaveBeenCalled(); + expect(group.observers['output'].ingest).toHaveBeenCalledWith({ + key: 'test', + background: true, + }); + expect(group.nextGroupOutput).toStrictEqual([ + { id: 'dummyItem1Key', name: 'jeff' }, + { id: 'dummyItem3Key', name: 'hans' }, + ]); + expect(group._preciseItemKeys).toStrictEqual([ + 'dummyItem1Key', + 'dummyItem3Key', + ]); + + LogMock.hasLoggedCode( + '1C:02:00', + [dummyCollection._key, group._key], + ['missingInCollectionItemKey'] + ); + } + ); + + it('should soft rebuild the Group if trackedChanges were specified (REMOVE)', () => { + group.nextGroupOutput = [ + { id: 'dummyItem1Key', name: 'jeff' }, + { id: 'dummyItem2Key', name: 'frank' }, + { id: 'dummyItem3Key', name: 'hans' }, + ]; + group._preciseItemKeys = [ + 'dummyItem1Key', + 'dummyItem2Key', + 'dummyItem3Key', + ]; - LogMock.hasLoggedCode( - '1C:02:00', - [dummyCollection._key, group._key], - ['dummyItem3Key'] + group.rebuild( + [ + { + index: 1, + method: TrackedChangeMethod.REMOVE, + key: 'dummyItem2Key', + }, + ], + { key: 'test', background: true } ); - }); - - it("shouldn't intest the build Group output if the Collection was not properly instantiated", () => { - dummyCollection.isInstantiated = false; - - group.rebuild(); expect(group.notFoundItemKeys).toStrictEqual([]); - expect(group._output).toStrictEqual([]); - expect(group.observers['output'].ingestItems).not.toHaveBeenCalled(); + expect(group.observers['output'].ingestOutput).not.toHaveBeenCalled(); + expect(group.observers['output'].ingest).toHaveBeenCalledWith({ + key: 'test', + background: true, + }); + expect(group.nextGroupOutput).toStrictEqual([ + { id: 'dummyItem1Key', name: 'jeff' }, + { id: 'dummyItem3Key', name: 'hans' }, + ]); + expect(group._preciseItemKeys).toStrictEqual([ + 'dummyItem1Key', + 'dummyItem3Key', + ]); + LogMock.hasNotLogged('warn'); }); + + it( + 'should soft rebuild the Group if trackedChanges were specified ' + + 'and set notExistingItemKeys to the not found itemKeys (UPDATE)', + () => { + dummyItem1._value = { id: 'dummyItem1Key', name: 'frank' }; + group.nextGroupOutput = [{ id: 'dummyItem1Key', name: 'jeff' }]; + group._preciseItemKeys = ['dummyItem1Key']; + + group.rebuild( + [ + { + index: 0, + method: TrackedChangeMethod.UPDATE, + key: 'dummyItem1Key', + }, + { + index: 1, + method: TrackedChangeMethod.UPDATE, + key: 'missingInCollectionItemKey', + }, + ], + { key: 'test', background: true } + ); + + expect(group.notFoundItemKeys).toStrictEqual([ + 'missingInCollectionItemKey', + ]); + expect(group.observers['output'].ingestOutput).not.toHaveBeenCalled(); + expect(group.observers['output'].ingest).toHaveBeenCalledWith({ + key: 'test', + background: true, + }); + expect(group.nextGroupOutput).toStrictEqual([ + { id: 'dummyItem1Key', name: 'frank' }, + ]); + expect(group._preciseItemKeys).toStrictEqual(['dummyItem1Key']); + + LogMock.hasLoggedCode( + '1C:02:00', + [dummyCollection._key, group._key], + ['missingInCollectionItemKey'] + ); + } + ); + + it( + "shouldn't ingest the build Group output " + + 'if the Collection was not properly instantiated', + () => { + dummyCollection.isInstantiated = false; + + group.rebuild(); + + expect(group.notFoundItemKeys).toStrictEqual([]); + expect(group.observers['output'].ingestOutput).not.toHaveBeenCalled(); + expect(group.observers['output'].ingest).not.toHaveBeenCalled(); + + LogMock.hasNotLogged('warn'); + } + ); }); }); }); diff --git a/packages/core/tests/unit/collection/index.test.ts b/packages/core/tests/unit/collection/index.test.ts new file mode 100644 index 00000000..50aa234a --- /dev/null +++ b/packages/core/tests/unit/collection/index.test.ts @@ -0,0 +1,62 @@ +import { + Agile, + assignSharedAgileInstance, + Collection, + createCollection, +} from '../../../src'; +import { LogMock } from '../../helper/logMock'; + +jest.mock('../../../src/collection/collection'); + +describe('Collection Index', () => { + let sharedAgileInstance: Agile; + + beforeEach(() => { + LogMock.mockLogs(); + + sharedAgileInstance = new Agile(); + assignSharedAgileInstance(sharedAgileInstance); + + jest.clearAllMocks(); + }); + + describe('createCollection function tests', () => { + const CollectionMock = Collection as jest.MockedClass; + + beforeEach(() => { + CollectionMock.mockClear(); + }); + + it('should create Collection with the shared Agile Instance', () => { + const collectionConfig = { + selectors: ['test', 'test1'], + groups: ['test2', 'test10'], + defaultGroupKey: 'frank', + key: 'myCoolCollection', + }; + + const collection = createCollection(collectionConfig); + + expect(collection).toBeInstanceOf(Collection); + expect(CollectionMock).toHaveBeenCalledWith( + sharedAgileInstance, + collectionConfig + ); + }); + + it('should create Collection with a specified Agile Instance', () => { + const agile = new Agile(); + const collectionConfig = { + selectors: ['test', 'test1'], + groups: ['test2', 'test10'], + defaultGroupKey: 'frank', + key: 'myCoolCollection', + }; + + const collection = createCollection(collectionConfig, agile); + + expect(collection).toBeInstanceOf(Collection); + expect(CollectionMock).toHaveBeenCalledWith(agile, collectionConfig); + }); + }); +}); diff --git a/packages/core/tests/unit/collection/item.test.ts b/packages/core/tests/unit/collection/item.test.ts index 374352b8..b5fdeb4f 100644 --- a/packages/core/tests/unit/collection/item.test.ts +++ b/packages/core/tests/unit/collection/item.test.ts @@ -3,7 +3,7 @@ import { Collection, Agile, StateObserver, - State, + EnhancedState, CollectionPersistent, } from '../../../src'; import { LogMock } from '../../helper/logMock'; @@ -20,7 +20,7 @@ describe('Item Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyCollection = new Collection(dummyAgile); jest.spyOn(Item.prototype, 'addRebuildGroupThatIncludeItemKeySideEffect'); @@ -43,7 +43,6 @@ describe('Item Tests', () => { ).toHaveBeenCalledWith('dummyId'); expect(item._key).toBe(dummyData[dummyCollection.config.primaryKey]); - expect(item.valueType).toBeUndefined(); expect(item.isSet).toBeFalsy(); expect(item.isPlaceholder).toBeFalsy(); expect(item.initialStateValue).toStrictEqual(dummyData); @@ -60,7 +59,6 @@ describe('Item Tests', () => { expect(item.computeExistsMethod).toBeInstanceOf(Function); expect(item.isPersisted).toBeFalsy(); expect(item.persistent).toBeUndefined(); - expect(item.watchers).toStrictEqual({}); expect(item.selectedBy.size).toBe(0); }); @@ -82,7 +80,6 @@ describe('Item Tests', () => { // Check if State was called with correct parameters expect(item._key).toBe(dummyData[dummyCollection.config.primaryKey]); - expect(item.valueType).toBeUndefined(); expect(item.isSet).toBeFalsy(); expect(item.isPlaceholder).toBeTruthy(); expect(item.initialStateValue).toStrictEqual(dummyData); @@ -99,7 +96,6 @@ describe('Item Tests', () => { expect(item.computeExistsMethod).toBeInstanceOf(Function); expect(item.isPersisted).toBeFalsy(); expect(item.persistent).toBeUndefined(); - expect(item.watchers).toStrictEqual({}); expect(item.selectedBy.size).toBe(0); }); @@ -119,7 +115,6 @@ describe('Item Tests', () => { // Check if State was called with correct parameters expect(item._key).toBeUndefined(); - expect(item.valueType).toBeUndefined(); expect(item.isSet).toBeFalsy(); expect(item.isPlaceholder).toBeFalsy(); expect(item.initialStateValue).toStrictEqual(dummyData); @@ -136,7 +131,6 @@ describe('Item Tests', () => { expect(item.computeExistsMethod).toBeInstanceOf(Function); expect(item.isPersisted).toBeFalsy(); expect(item.persistent).toBeUndefined(); - expect(item.watchers).toStrictEqual({}); expect(item.selectedBy.size).toBe(0); }); @@ -151,13 +145,13 @@ describe('Item Tests', () => { beforeEach(() => { item.removeSideEffect = jest.fn(); item.patch = jest.fn(); - jest.spyOn(State.prototype, 'setKey'); + jest.spyOn(EnhancedState.prototype, 'setKey'); }); it('should call State setKey, add rebuildGroup sideEffect to Item and patch newItemKey into Item (default config)', () => { item.setKey('myNewKey'); - expect(State.prototype.setKey).toHaveBeenCalledWith('myNewKey'); + expect(EnhancedState.prototype.setKey).toHaveBeenCalledWith('myNewKey'); expect(item.removeSideEffect).toHaveBeenCalledWith( Item.updateGroupSideEffectKey ); @@ -190,7 +184,7 @@ describe('Item Tests', () => { force: true, }); - expect(State.prototype.setKey).toHaveBeenCalledWith('myNewKey'); + expect(EnhancedState.prototype.setKey).toHaveBeenCalledWith('myNewKey'); expect(item.removeSideEffect).toHaveBeenCalledWith( Item.updateGroupSideEffectKey ); @@ -216,13 +210,13 @@ describe('Item Tests', () => { describe('persist function tests', () => { beforeEach(() => { - jest.spyOn(State.prototype, 'persist'); + jest.spyOn(EnhancedState.prototype, 'persist'); }); it('should persist Item with formatted itemKey (default config)', () => { item.persist(); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey( item._key, dummyCollection._key @@ -242,7 +236,7 @@ describe('Item Tests', () => { defaultStorageKey: 'test1', }); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey( item._key, dummyCollection._key @@ -258,7 +252,7 @@ describe('Item Tests', () => { it('should persist Item with formatted specified key (default config)', () => { item.persist('dummyKey'); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey( 'dummyKey', dummyCollection._key @@ -278,7 +272,7 @@ describe('Item Tests', () => { defaultStorageKey: 'test1', }); - expect(State.prototype.persist).toHaveBeenCalledWith( + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( CollectionPersistent.getItemStorageKey( 'dummyKey', dummyCollection._key @@ -294,21 +288,27 @@ describe('Item Tests', () => { 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, - }); + expect(EnhancedState.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, - }); + expect(EnhancedState.prototype.persist).toHaveBeenCalledWith( + 'dummyKey', + { + loadValue: true, + storageKeys: [], + defaultStorageKey: null, + } + ); }); }); @@ -321,9 +321,7 @@ describe('Item Tests', () => { it('should add rebuildGroupThatIncludeItemKey sideEffect to Item', () => { item.addRebuildGroupThatIncludeItemKeySideEffect('itemKey'); - expect( - item.addSideEffect - ).toHaveBeenCalledWith( + expect(item.addSideEffect).toHaveBeenCalledWith( Item.updateGroupSideEffectKey, expect.any(Function), { weight: 100 } diff --git a/packages/core/tests/unit/collection/selector.test.ts b/packages/core/tests/unit/collection/selector.test.ts index 11080f9f..45ef999f 100644 --- a/packages/core/tests/unit/collection/selector.test.ts +++ b/packages/core/tests/unit/collection/selector.test.ts @@ -13,7 +13,7 @@ describe('Selector Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyCollection = new Collection(dummyAgile); jest.spyOn(Selector.prototype, 'select'); @@ -38,7 +38,6 @@ describe('Selector Tests', () => { // Check if State was called with correct parameters expect(selector._key).toBeUndefined(); - expect(selector.valueType).toBeUndefined(); expect(selector.isSet).toBeFalsy(); expect(selector.isPlaceholder).toBeTruthy(); expect(selector.initialStateValue).toBeNull(); @@ -55,7 +54,6 @@ describe('Selector Tests', () => { expect(selector.computeExistsMethod).toBeInstanceOf(Function); expect(selector.isPersisted).toBeFalsy(); expect(selector.persistent).toBeUndefined(); - expect(selector.watchers).toStrictEqual({}); }); it('should create Selector and call initial select (specific config)', () => { @@ -77,7 +75,6 @@ describe('Selector Tests', () => { // Check if State was called with correct parameters expect(selector._key).toBe('dummyKey'); - expect(selector.valueType).toBeUndefined(); expect(selector.isSet).toBeFalsy(); expect(selector.isPlaceholder).toBeTruthy(); expect(selector.initialStateValue).toBeNull(); @@ -94,7 +91,6 @@ describe('Selector Tests', () => { expect(selector.computeExistsMethod).toBeInstanceOf(Function); expect(selector.isPersisted).toBeFalsy(); expect(selector.persistent).toBeUndefined(); - expect(selector.watchers).toStrictEqual({}); }); it("should create Selector and shouldn't call initial select (config.isPlaceholder = true)", () => { @@ -114,7 +110,6 @@ describe('Selector Tests', () => { // Check if State was called with correct parameters expect(selector._key).toBeUndefined(); - expect(selector.valueType).toBeUndefined(); expect(selector.isSet).toBeFalsy(); expect(selector.isPlaceholder).toBeTruthy(); expect(selector.initialStateValue).toBeNull(); @@ -131,7 +126,6 @@ describe('Selector Tests', () => { expect(selector.computeExistsMethod).toBeInstanceOf(Function); expect(selector.isPersisted).toBeFalsy(); expect(selector.persistent).toBeUndefined(); - expect(selector.watchers).toStrictEqual({}); }); it("should create Selector and shouldn't call initial select if specified selector key is null (default config)", () => { @@ -149,7 +143,6 @@ describe('Selector Tests', () => { // Check if State was called with correct parameters expect(selector._key).toBeUndefined(); - expect(selector.valueType).toBeUndefined(); expect(selector.isSet).toBeFalsy(); expect(selector.isPlaceholder).toBeTruthy(); expect(selector.initialStateValue).toBeNull(); @@ -166,7 +159,6 @@ describe('Selector Tests', () => { expect(selector.computeExistsMethod).toBeInstanceOf(Function); expect(selector.isPersisted).toBeFalsy(); expect(selector.persistent).toBeUndefined(); - expect(selector.watchers).toStrictEqual({}); }); describe('Selector Function Tests', () => { @@ -266,17 +258,13 @@ describe('Selector Tests', () => { overwrite: false, storage: true, }); - expect( - selector.addSideEffect - ).toHaveBeenCalledWith( + expect(selector.addSideEffect).toHaveBeenCalledWith( Selector.rebuildItemSideEffectKey, expect.any(Function), { weight: 90 } ); - expect( - dummyItem2.addSideEffect - ).toHaveBeenCalledWith( + expect(dummyItem2.addSideEffect).toHaveBeenCalledWith( Selector.rebuildSelectorSideEffectKey, expect.any(Function), { weight: 100 } @@ -314,17 +302,13 @@ describe('Selector Tests', () => { overwrite: true, storage: true, }); - expect( - selector.addSideEffect - ).toHaveBeenCalledWith( + expect(selector.addSideEffect).toHaveBeenCalledWith( Selector.rebuildItemSideEffectKey, expect.any(Function), { weight: 90 } ); - expect( - dummyItem2.addSideEffect - ).toHaveBeenCalledWith( + expect(dummyItem2.addSideEffect).toHaveBeenCalledWith( Selector.rebuildSelectorSideEffectKey, expect.any(Function), { weight: 100 } @@ -375,17 +359,13 @@ describe('Selector Tests', () => { overwrite: false, storage: true, }); - expect( - selector.addSideEffect - ).toHaveBeenCalledWith( + expect(selector.addSideEffect).toHaveBeenCalledWith( Selector.rebuildItemSideEffectKey, expect.any(Function), { weight: 90 } ); - expect( - dummyItem1.addSideEffect - ).toHaveBeenCalledWith( + expect(dummyItem1.addSideEffect).toHaveBeenCalledWith( Selector.rebuildSelectorSideEffectKey, expect.any(Function), { weight: 100 } @@ -436,17 +416,13 @@ describe('Selector Tests', () => { overwrite: false, storage: true, }); - expect( - selector.addSideEffect - ).toHaveBeenCalledWith( + expect(selector.addSideEffect).toHaveBeenCalledWith( Selector.rebuildItemSideEffectKey, expect.any(Function), { weight: 90 } ); - expect( - dummyItem2.addSideEffect - ).toHaveBeenCalledWith( + expect(dummyItem2.addSideEffect).toHaveBeenCalledWith( Selector.rebuildSelectorSideEffectKey, expect.any(Function), { weight: 100 } @@ -478,17 +454,13 @@ describe('Selector Tests', () => { overwrite: true, storage: true, }); - expect( - selector.addSideEffect - ).toHaveBeenCalledWith( + expect(selector.addSideEffect).toHaveBeenCalledWith( Selector.rebuildItemSideEffectKey, expect.any(Function), { weight: 90 } ); - expect( - dummyItem2.addSideEffect - ).toHaveBeenCalledWith( + expect(dummyItem2.addSideEffect).toHaveBeenCalledWith( Selector.rebuildSelectorSideEffectKey, expect.any(Function), { weight: 100 } @@ -520,17 +492,13 @@ describe('Selector Tests', () => { overwrite: false, storage: true, }); - expect( - selector.addSideEffect - ).toHaveBeenCalledWith( + expect(selector.addSideEffect).toHaveBeenCalledWith( Selector.rebuildItemSideEffectKey, expect.any(Function), { weight: 90 } ); - expect( - dummyItem2.addSideEffect - ).toHaveBeenCalledWith( + expect(dummyItem2.addSideEffect).toHaveBeenCalledWith( Selector.rebuildSelectorSideEffectKey, expect.any(Function), { weight: 100 } diff --git a/packages/core/tests/unit/computed/computed.test.ts b/packages/core/tests/unit/computed/computed.test.ts index ef88decc..59b1d3ed 100644 --- a/packages/core/tests/unit/computed/computed.test.ts +++ b/packages/core/tests/unit/computed/computed.test.ts @@ -16,7 +16,7 @@ describe('Computed Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); jest.spyOn(Computed.prototype, 'recompute'); jest.spyOn(Utils, 'extractRelevantObservers'); @@ -31,6 +31,7 @@ describe('Computed Tests', () => { expect(computed.computeFunction).toBe(computedFunction); expect(computed.config).toStrictEqual({ autodetect: true }); + expect(computed.isComputed).toBeTruthy(); expect(Array.from(computed.deps)).toStrictEqual([]); expect(computed.hardCodedDeps).toStrictEqual([]); expect(Utils.extractRelevantObservers).toHaveBeenCalledWith([]); @@ -42,7 +43,6 @@ describe('Computed Tests', () => { // Check if State was called with correct parameters expect(computed._key).toBeUndefined(); - expect(computed.valueType).toBeUndefined(); expect(computed.isSet).toBeFalsy(); expect(computed.isPlaceholder).toBeFalsy(); expect(computed.initialStateValue).toBe(null); @@ -55,11 +55,6 @@ describe('Computed Tests', () => { ); expect(computed.observers['value']._key).toBeUndefined(); expect(computed.sideEffects).toStrictEqual({}); - expect(computed.computeValueMethod).toBeUndefined(); - expect(computed.computeExistsMethod).toBeInstanceOf(Function); - expect(computed.isPersisted).toBeFalsy(); - expect(computed.persistent).toBeUndefined(); - expect(computed.watchers).toStrictEqual({}); }); it('should create Computed with a not async compute method (specific config)', () => { @@ -82,6 +77,7 @@ describe('Computed Tests', () => { expect(computed.computeFunction).toBe(computedFunction); expect(computed.config).toStrictEqual({ autodetect: false }); + expect(computed.isComputed).toBeTruthy(); expect(Array.from(computed.deps)).toStrictEqual([ dummyObserver2, dummyStateObserver, @@ -111,7 +107,6 @@ describe('Computed Tests', () => { // Check if State was called with correct parameters expect(computed._key).toBe('coolComputed'); - expect(computed.valueType).toBeUndefined(); expect(computed.isSet).toBeFalsy(); expect(computed.isPlaceholder).toBeFalsy(); expect(computed.initialStateValue).toBe(null); @@ -124,11 +119,6 @@ describe('Computed Tests', () => { ]); expect(computed.observers['value']._key).toBe('coolComputed'); expect(computed.sideEffects).toStrictEqual({}); - expect(computed.computeValueMethod).toBeUndefined(); - expect(computed.computeExistsMethod).toBeInstanceOf(Function); - expect(computed.isPersisted).toBeFalsy(); - expect(computed.persistent).toBeUndefined(); - expect(computed.watchers).toStrictEqual({}); }); it('should create Computed with an async compute method (default config)', () => { @@ -138,6 +128,7 @@ describe('Computed Tests', () => { expect(computed.computeFunction).toBe(computedFunction); expect(computed.config).toStrictEqual({ autodetect: false }); + expect(computed.isComputed).toBeTruthy(); expect(Array.from(computed.deps)).toStrictEqual([]); expect(computed.hardCodedDeps).toStrictEqual([]); expect(Utils.extractRelevantObservers).toHaveBeenCalledWith([]); @@ -149,7 +140,6 @@ describe('Computed Tests', () => { // Check if State was called with correct parameters expect(computed._key).toBeUndefined(); - expect(computed.valueType).toBeUndefined(); expect(computed.isSet).toBeFalsy(); expect(computed.isPlaceholder).toBeFalsy(); expect(computed.initialStateValue).toBe(null); @@ -162,11 +152,6 @@ describe('Computed Tests', () => { ); expect(computed.observers['value']._key).toBeUndefined(); expect(computed.sideEffects).toStrictEqual({}); - expect(computed.computeValueMethod).toBeUndefined(); - expect(computed.computeExistsMethod).toBeInstanceOf(Function); - expect(computed.isPersisted).toBeFalsy(); - expect(computed.persistent).toBeUndefined(); - expect(computed.watchers).toStrictEqual({}); }); describe('Computed Function Tests', () => { diff --git a/packages/core/tests/unit/computed/computed.tracker.test.ts b/packages/core/tests/unit/computed/computed.tracker.test.ts index 20e09dc1..97f970ce 100644 --- a/packages/core/tests/unit/computed/computed.tracker.test.ts +++ b/packages/core/tests/unit/computed/computed.tracker.test.ts @@ -7,7 +7,7 @@ describe('ComputedTracker Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); // Reset ComputedTracker (because it works static) ComputedTracker.isTracking = false; diff --git a/packages/core/tests/unit/computed/index.test.ts b/packages/core/tests/unit/computed/index.test.ts new file mode 100644 index 00000000..262ba1c0 --- /dev/null +++ b/packages/core/tests/unit/computed/index.test.ts @@ -0,0 +1,86 @@ +import { + Agile, + assignSharedAgileInstance, + Computed, + createComputed, +} from '../../../src'; +import { LogMock } from '../../helper/logMock'; + +jest.mock('../../../src/computed/computed'); + +describe('Computed Index', () => { + let sharedAgileInstance: Agile; + + beforeEach(() => { + LogMock.mockLogs(); + + sharedAgileInstance = new Agile(); + assignSharedAgileInstance(sharedAgileInstance); + + jest.clearAllMocks(); + }); + + describe('createComputed function tests', () => { + const ComputedMock = Computed as jest.MockedClass; + const computedFunction = () => { + // empty + }; + + beforeEach(() => { + ComputedMock.mockClear(); + }); + + it('should create Computed with the shared Agile Instance (default config)', () => { + const response = createComputed(computedFunction, ['dummyDep' as any]); + + expect(response).toBeInstanceOf(Computed); + expect(ComputedMock).toHaveBeenCalledWith( + sharedAgileInstance, + computedFunction, + { + computedDeps: ['dummyDep' as any], + } + ); + }); + + it('should create Computed with the shared Agile Instance (specific config)', () => { + const computedConfig = { + key: 'jeff', + isPlaceholder: false, + computedDeps: ['dummyDep' as any], + autodetect: true, + }; + + const response = createComputed(computedFunction, computedConfig); + + expect(response).toBeInstanceOf(Computed); + expect(ComputedMock).toHaveBeenCalledWith( + sharedAgileInstance, + computedFunction, + computedConfig + ); + }); + + it('should create Computed with a specified Agile Instance (specific config)', () => { + const agile = new Agile(); + const computedConfig = { + key: 'jeff', + isPlaceholder: false, + computedDeps: ['dummyDep' as any], + autodetect: true, + }; + + const response = createComputed(computedFunction, { + ...computedConfig, + ...{ agileInstance: agile }, + }); + + expect(response).toBeInstanceOf(Computed); + expect(ComputedMock).toHaveBeenCalledWith( + agile, + computedFunction, + computedConfig + ); + }); + }); +}); diff --git a/packages/core/tests/unit/integrations/integrations.test.ts b/packages/core/tests/unit/integrations/integrations.test.ts index a7598d61..845fff56 100644 --- a/packages/core/tests/unit/integrations/integrations.test.ts +++ b/packages/core/tests/unit/integrations/integrations.test.ts @@ -9,7 +9,7 @@ describe('Integrations Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyIntegration1 = new Integration({ key: 'dummyIntegration1', }); diff --git a/packages/core/tests/unit/runtime/observer.test.ts b/packages/core/tests/unit/runtime/observer.test.ts index 5810c138..5acbcf64 100644 --- a/packages/core/tests/unit/runtime/observer.test.ts +++ b/packages/core/tests/unit/runtime/observer.test.ts @@ -103,6 +103,7 @@ describe('Observer Tests', () => { }, force: false, maxTriesToUpdate: 3, + any: {}, }); }); @@ -128,6 +129,7 @@ describe('Observer Tests', () => { }, force: true, maxTriesToUpdate: 3, + any: {}, }); }); diff --git a/packages/core/tests/unit/runtime/runtime.job.test.ts b/packages/core/tests/unit/runtime/runtime.job.test.ts index b01c3765..a93c132c 100644 --- a/packages/core/tests/unit/runtime/runtime.job.test.ts +++ b/packages/core/tests/unit/runtime/runtime.job.test.ts @@ -9,7 +9,7 @@ describe('RuntimeJob Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyIntegration = new Integration({ key: 'myIntegration', }); @@ -36,6 +36,7 @@ describe('RuntimeJob Tests', () => { }, force: false, maxTriesToUpdate: 3, + any: {}, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); @@ -60,6 +61,7 @@ describe('RuntimeJob Tests', () => { }, force: true, maxTriesToUpdate: 10, + any: { jeff: 'frank' }, }); expect(job._key).toBe('dummyJob'); @@ -72,6 +74,7 @@ describe('RuntimeJob Tests', () => { }, force: true, maxTriesToUpdate: 10, + any: { jeff: 'frank' }, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); @@ -97,6 +100,7 @@ describe('RuntimeJob Tests', () => { }, force: false, maxTriesToUpdate: 3, + any: {}, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); @@ -124,6 +128,7 @@ describe('RuntimeJob Tests', () => { }, force: false, maxTriesToUpdate: 3, + any: {}, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); diff --git a/packages/core/tests/unit/runtime/runtime.test.ts b/packages/core/tests/unit/runtime/runtime.test.ts index 5bcc3014..9601a7cc 100644 --- a/packages/core/tests/unit/runtime/runtime.test.ts +++ b/packages/core/tests/unit/runtime/runtime.test.ts @@ -17,7 +17,7 @@ describe('Runtime Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); jest.clearAllMocks(); }); 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 6d73d851..72063f9d 100644 --- a/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts +++ b/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts @@ -14,7 +14,7 @@ describe('SubController Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); jest.clearAllMocks(); }); @@ -215,10 +215,11 @@ describe('SubController Tests', () => { const dummyIntegration = () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); + const callbackSubscriptionContainer = + subController.createCallbackSubscriptionContainer(dummyIntegration, [ + dummyObserver1, + dummyObserver2, + ]); callbackSubscriptionContainer.removeSubscription = jest.fn(); subController.unsubscribe(callbackSubscriptionContainer); @@ -240,10 +241,11 @@ describe('SubController Tests', () => { const dummyIntegration: any = { dummy: 'integration', }; - const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); + const componentSubscriptionContainer = + subController.createComponentSubscriptionContainer(dummyIntegration, [ + dummyObserver1, + dummyObserver2, + ]); componentSubscriptionContainer.removeSubscription = jest.fn(); subController.unsubscribe(componentSubscriptionContainer); @@ -269,15 +271,17 @@ describe('SubController Tests', () => { dummy: 'integration', componentSubscriptionContainers: [], }; - const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); + const componentSubscriptionContainer = + subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2] + ); componentSubscriptionContainer.removeSubscription = jest.fn(); - const componentSubscriptionContainer2 = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); + const componentSubscriptionContainer2 = + subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2] + ); componentSubscriptionContainer2.removeSubscription = jest.fn(); subController.unsubscribe(dummyIntegration); @@ -320,11 +324,12 @@ describe('SubController Tests', () => { dummy: 'integration', }; - const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: false } - ); + const componentSubscriptionContainer = + subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false } + ); expect(componentSubscriptionContainer).toBeInstanceOf( ComponentSubscriptionContainer @@ -362,11 +367,12 @@ describe('SubController Tests', () => { componentSubscriptionContainers: [], }; - const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: false } - ); + const componentSubscriptionContainer = + subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false } + ); expect( dummyIntegration.componentSubscriptionContainers @@ -383,11 +389,12 @@ describe('SubController Tests', () => { dummy: 'integration', }; - const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: false, componentId: 'testID', key: 'dummyKey' } - ); + const componentSubscriptionContainer = + subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false, componentId: 'testID', key: 'dummyKey' } + ); expect(componentSubscriptionContainer).toBeInstanceOf( ComponentSubscriptionContainer @@ -420,11 +427,12 @@ describe('SubController Tests', () => { dummy: 'integration', }; - const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: true } - ); + const componentSubscriptionContainer = + subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: true } + ); expect(componentSubscriptionContainer).toBeInstanceOf( ComponentSubscriptionContainer @@ -453,11 +461,12 @@ describe('SubController Tests', () => { }; subController.mount(dummyIntegration); - const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: true } - ); + const componentSubscriptionContainer = + subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: true } + ); expect(componentSubscriptionContainer).toBeInstanceOf( ComponentSubscriptionContainer @@ -487,10 +496,11 @@ describe('SubController Tests', () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); + const callbackSubscriptionContainer = + subController.createCallbackSubscriptionContainer(dummyIntegration, [ + dummyObserver1, + dummyObserver2, + ]); expect(callbackSubscriptionContainer).toBeInstanceOf( CallbackSubscriptionContainer @@ -520,15 +530,16 @@ describe('SubController Tests', () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { - waitForMount: false, - componentId: 'testID', - key: 'dummyKey', - } - ); + const callbackSubscriptionContainer = + subController.createCallbackSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { + waitForMount: false, + componentId: 'testID', + key: 'dummyKey', + } + ); expect(callbackSubscriptionContainer).toBeInstanceOf( CallbackSubscriptionContainer @@ -557,10 +568,11 @@ describe('SubController Tests', () => { beforeEach(() => { dummyAgile.config.waitForMount = true; - componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); + componentSubscriptionContainer = + subController.createComponentSubscriptionContainer(dummyIntegration, [ + dummyObserver1, + dummyObserver2, + ]); }); it( @@ -585,10 +597,11 @@ describe('SubController Tests', () => { beforeEach(() => { dummyAgile.config.waitForMount = true; - componentSubscriptionContainer = subController.createComponentSubscriptionContainer( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); + componentSubscriptionContainer = + subController.createComponentSubscriptionContainer(dummyIntegration, [ + dummyObserver1, + dummyObserver2, + ]); subController.mount(dummyIntegration); }); diff --git a/packages/core/tests/unit/shared.test.ts b/packages/core/tests/unit/shared.test.ts index fc81e6e8..b8d5e048 100644 --- a/packages/core/tests/unit/shared.test.ts +++ b/packages/core/tests/unit/shared.test.ts @@ -3,27 +3,12 @@ import { Collection, Computed, shared, - State, - Storage, - createStorage, - createState, createCollection, createComputed, assignSharedAgileInstance, } from '../../src'; import { LogMock } from '../helper/logMock'; -jest.mock('../../src/storages/storage'); -jest.mock('../../src/collection'); -jest.mock('../../src/computed'); - -// https://github.com/facebook/jest/issues/5023 -jest.mock('../../src/state', () => { - return { - State: jest.fn(), - }; -}); - describe('Shared Tests', () => { let sharedAgileInstance: Agile; @@ -45,168 +30,4 @@ describe('Shared Tests', () => { expect(shared).toBe(newAgileInstance); }); }); - - describe('createStorage function tests', () => { - const StorageMock = Storage as jest.MockedClass; - - beforeEach(() => { - StorageMock.mockClear(); - }); - - it('should create Storage', () => { - const storageConfig = { - prefix: 'test', - methods: { - get: () => { - /* empty function */ - }, - set: () => { - /* empty function */ - }, - remove: () => { - /* empty function */ - }, - }, - key: 'myTestStorage', - }; - - const storage = createStorage(storageConfig); - - expect(storage).toBeInstanceOf(Storage); - expect(StorageMock).toHaveBeenCalledWith(storageConfig); - }); - }); - - describe('createState function tests', () => { - const StateMock = State as jest.MockedClass; - - it('should create State with the shared Agile Instance', () => { - const state = createState('testValue', { - key: 'myCoolState', - }); - - expect(state).toBeInstanceOf(State); - expect(StateMock).toHaveBeenCalledWith(sharedAgileInstance, 'testValue', { - key: 'myCoolState', - }); - }); - - it('should create State with a specified Agile Instance', () => { - const agile = new Agile(); - - const state = createState('testValue', { - key: 'myCoolState', - agileInstance: agile, - }); - - expect(state).toBeInstanceOf(State); - expect(StateMock).toHaveBeenCalledWith(agile, 'testValue', { - key: 'myCoolState', - }); - }); - }); - - describe('createCollection function tests', () => { - const CollectionMock = Collection as jest.MockedClass; - - beforeEach(() => { - CollectionMock.mockClear(); - }); - - it('should create Collection with the shared Agile Instance', () => { - const collectionConfig = { - selectors: ['test', 'test1'], - groups: ['test2', 'test10'], - defaultGroupKey: 'frank', - key: 'myCoolCollection', - }; - - const collection = createCollection(collectionConfig); - - expect(collection).toBeInstanceOf(Collection); - expect(CollectionMock).toHaveBeenCalledWith( - sharedAgileInstance, - collectionConfig - ); - }); - - it('should create Collection with a specified Agile Instance', () => { - const agile = new Agile(); - const collectionConfig = { - selectors: ['test', 'test1'], - groups: ['test2', 'test10'], - defaultGroupKey: 'frank', - key: 'myCoolCollection', - }; - - const collection = createCollection(collectionConfig, agile); - - expect(collection).toBeInstanceOf(Collection); - expect(CollectionMock).toHaveBeenCalledWith(agile, collectionConfig); - }); - }); - - describe('createComputed function tests', () => { - const ComputedMock = Computed as jest.MockedClass; - const computedFunction = () => { - // empty - }; - - beforeEach(() => { - ComputedMock.mockClear(); - }); - - it('should create Computed with the shared Agile Instance (default config)', () => { - const response = createComputed(computedFunction, ['dummyDep' as any]); - - expect(response).toBeInstanceOf(Computed); - expect(ComputedMock).toHaveBeenCalledWith( - sharedAgileInstance, - computedFunction, - { - computedDeps: ['dummyDep' as any], - } - ); - }); - - it('should create Computed with the shared Agile Instance (specific config)', () => { - const computedConfig = { - key: 'jeff', - isPlaceholder: false, - computedDeps: ['dummyDep' as any], - autodetect: true, - }; - - const response = createComputed(computedFunction, computedConfig); - - expect(response).toBeInstanceOf(Computed); - expect(ComputedMock).toHaveBeenCalledWith( - sharedAgileInstance, - computedFunction, - computedConfig - ); - }); - - it('should create Computed with a specified Agile Instance (specific config)', () => { - const agile = new Agile(); - const computedConfig = { - key: 'jeff', - isPlaceholder: false, - computedDeps: ['dummyDep' as any], - autodetect: true, - }; - - const response = createComputed(computedFunction, { - ...computedConfig, - ...{ agileInstance: agile }, - }); - - expect(response).toBeInstanceOf(Computed); - expect(ComputedMock).toHaveBeenCalledWith( - agile, - computedFunction, - computedConfig - ); - }); - }); }); diff --git a/packages/core/tests/unit/state/index.test.ts b/packages/core/tests/unit/state/index.test.ts new file mode 100644 index 00000000..7899b192 --- /dev/null +++ b/packages/core/tests/unit/state/index.test.ts @@ -0,0 +1,108 @@ +// https://github.com/facebook/jest/issues/5023 +import { + Agile, + assignSharedAgileInstance, + createState, + createLightState, + State, + EnhancedState, +} from '../../../src'; +import { LogMock } from '../../helper/logMock'; + +// https://github.com/facebook/jest/issues/5023 +jest.mock('../../../src/state/state', () => { + return { + State: jest.fn(), + }; +}); +// https://github.com/facebook/jest/issues/5023 +jest.mock('../../../src/state/state.enhanced', () => { + return { + EnhancedState: jest.fn(), + }; +}); + +describe('State Index', () => { + let sharedAgileInstance: Agile; + + beforeEach(() => { + LogMock.mockLogs(); + + sharedAgileInstance = new Agile(); + assignSharedAgileInstance(sharedAgileInstance); + + jest.clearAllMocks(); + }); + + describe('createState function tests', () => { + const EnhancedStateMock = EnhancedState as jest.MockedClass< + typeof EnhancedState + >; + + beforeEach(() => { + EnhancedStateMock.mockClear(); + }); + + it('should create enhanced State with the shared Agile Instance', () => { + const state = createState('testValue', { + key: 'myCoolState', + }); + + // expect(state).toBeInstanceOf(EnhancedState); // Because 'State' is completely overwritten with a mock (mockImplementation) + expect(EnhancedStateMock).toHaveBeenCalledWith( + sharedAgileInstance, + 'testValue', + { + key: 'myCoolState', + } + ); + }); + + it('should create enhanced State with a specified Agile Instance', () => { + const agile = new Agile(); + + const state = createState('testValue', { + key: 'myCoolState', + agileInstance: agile, + }); + + // expect(state).toBeInstanceOf(EnhancedState); // Because 'State' is completely overwritten with a mock (mockImplementation) + expect(EnhancedStateMock).toHaveBeenCalledWith(agile, 'testValue', { + key: 'myCoolState', + }); + }); + }); + + describe('createLightState function tests', () => { + const StateMock = State as jest.MockedClass; + + beforeEach(() => { + StateMock.mockClear(); + }); + + it('should create State with the shared Agile Instance', () => { + const state = createLightState('testValue', { + key: 'myCoolState', + }); + + // expect(state).toBeInstanceOf(State); // Because 'State' is completely overwritten with a mock (mockImplementation) + expect(StateMock).toHaveBeenCalledWith(sharedAgileInstance, 'testValue', { + key: 'myCoolState', + }); + }); + + it('should create State with a specified Agile Instance', () => { + const agile = new Agile(); + + const state = createLightState('testValue', { + key: 'myCoolState', + agileInstance: agile, + }); + + // expect(state).toBeInstanceOf(State); // Because 'State' is completely overwritten with a mock (mockImplementation) + expect(StateMock).toHaveBeenCalledWith(agile, 'testValue', { + key: 'myCoolState', + }); + }); + }); +}); diff --git a/packages/core/tests/unit/state/state.enhanced.test.ts b/packages/core/tests/unit/state/state.enhanced.test.ts new file mode 100644 index 00000000..97afe465 --- /dev/null +++ b/packages/core/tests/unit/state/state.enhanced.test.ts @@ -0,0 +1,811 @@ +import { + State, + Agile, + StateObserver, + Observer, + StatePersistent, + EnhancedState, +} from '../../../src'; +import * as Utils from '@agile-ts/utils'; +import { LogMock } from '../../helper/logMock'; + +jest.mock('../../../src/state/state.persistent'); + +describe('Enhanced State Tests', () => { + let dummyAgile: Agile; + + beforeEach(() => { + LogMock.mockLogs(); + + dummyAgile = new Agile(); + + jest.spyOn(State.prototype, 'set'); + + jest.clearAllMocks(); + }); + + it('should create Enhanced State and should call initial set (default config)', () => { + // Overwrite select once to not call it + jest + .spyOn(EnhancedState.prototype, 'set') + .mockReturnValueOnce(undefined as any); + + const state = new EnhancedState(dummyAgile, 'coolValue'); + + expect(state.isPersisted).toBeFalsy(); + expect(state.persistent).toBeUndefined(); + expect(state.computeValueMethod).toBeUndefined(); + expect(state.computeExistsMethod).toBeInstanceOf(Function); + expect(state.currentInterval).toBeUndefined(); + + // Check if State was called with correct parameters + expect(state._key).toBeUndefined(); + expect(state.isSet).toBeFalsy(); + expect(state.isPlaceholder).toBeTruthy(); + expect(state.initialStateValue).toBe('coolValue'); + expect(state._value).toBe('coolValue'); + expect(state.previousStateValue).toBe('coolValue'); + expect(state.nextStateValue).toBe('coolValue'); + expect(state.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(state.observers['value'].dependents)).toStrictEqual([]); + expect(state.observers['value']._key).toBeUndefined(); + expect(state.sideEffects).toStrictEqual({}); + }); + + it('should create Enhanced State and should call initial set (specific config)', () => { + // Overwrite select once to not call it + jest + .spyOn(EnhancedState.prototype, 'set') + .mockReturnValueOnce(undefined as any); + + const dummyObserver = new Observer(dummyAgile); + + const state = new EnhancedState(dummyAgile, 'coolValue', { + key: 'coolState', + dependents: [dummyObserver], + }); + + expect(state.isPersisted).toBeFalsy(); + expect(state.persistent).toBeUndefined(); + expect(state.computeValueMethod).toBeUndefined(); + expect(state.computeExistsMethod).toBeInstanceOf(Function); + expect(state.currentInterval).toBeUndefined(); + + // Check if State was called with correct parameters + expect(state._key).toBe('coolState'); + expect(state.isSet).toBeFalsy(); + expect(state.isPlaceholder).toBeTruthy(); + expect(state.initialStateValue).toBe('coolValue'); + expect(state._value).toBe('coolValue'); + expect(state.previousStateValue).toBe('coolValue'); + expect(state.nextStateValue).toBe('coolValue'); + expect(state.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(state.observers['value'].dependents)).toStrictEqual([ + dummyObserver, + ]); + expect(state.observers['value']._key).toBe('coolState'); + expect(state.sideEffects).toStrictEqual({}); + }); + + it("should create Enhanced State and shouldn't call initial set (config.isPlaceholder = true)", () => { + // Overwrite select once to not call it + jest + .spyOn(EnhancedState.prototype, 'set') + .mockReturnValueOnce(undefined as any); + + const state = new EnhancedState(dummyAgile, 'coolValue', { + isPlaceholder: true, + }); + + expect(state.isPersisted).toBeFalsy(); + expect(state.persistent).toBeUndefined(); + expect(state.computeValueMethod).toBeUndefined(); + expect(state.computeExistsMethod).toBeInstanceOf(Function); + expect(state.currentInterval).toBeUndefined(); + + // Check if State was called with correct parameters + expect(state._key).toBeUndefined(); + expect(state.isSet).toBeFalsy(); + expect(state.isPlaceholder).toBeTruthy(); + expect(state.initialStateValue).toBe('coolValue'); + expect(state._value).toBe('coolValue'); + expect(state.previousStateValue).toBe('coolValue'); + expect(state.nextStateValue).toBe('coolValue'); + expect(state.observers['value']).toBeInstanceOf(StateObserver); + expect(Array.from(state.observers['value'].dependents)).toStrictEqual([]); + expect(state.observers['value']._key).toBeUndefined(); + expect(state.sideEffects).toStrictEqual({}); + }); + + describe('State Function Tests', () => { + let numberState: EnhancedState; + let objectState: EnhancedState<{ name: string; age: number }>; + let arrayState: EnhancedState; + let booleanState: EnhancedState; + + beforeEach(() => { + numberState = new EnhancedState(dummyAgile, 10, { + key: 'numberStateKey', + }); + objectState = new EnhancedState<{ name: string; age: number }>( + dummyAgile, + { name: 'jeff', age: 10 }, + { + key: 'objectStateKey', + } + ); + arrayState = new EnhancedState(dummyAgile, ['jeff'], { + key: 'arrayStateKey', + }); + booleanState = new EnhancedState(dummyAgile, false, { + key: 'booleanStateKey', + }); + }); + + describe('setKey function tests', () => { + // TODO + }); + + describe('undo function tests', () => { + beforeEach(() => { + numberState.set = jest.fn(); + }); + + it('should assign previousStateValue to currentValue (default config)', () => { + numberState.previousStateValue = 99; + + numberState.undo(); + + expect(numberState.set).toHaveBeenCalledWith( + numberState.previousStateValue, + {} + ); + }); + + it('should assign previousStateValue to currentValue (specific config)', () => { + numberState.previousStateValue = 99; + + numberState.undo({ + force: true, + storage: false, + }); + + expect(numberState.set).toHaveBeenCalledWith( + numberState.previousStateValue, + { + force: true, + storage: false, + } + ); + }); + }); + + describe('reset function tests', () => { + beforeEach(() => { + numberState.set = jest.fn(); + }); + + it('should assign initialStateValue to currentValue (default config)', () => { + numberState.initialStateValue = 99; + + numberState.reset(); + + expect(numberState.set).toHaveBeenCalledWith( + numberState.initialStateValue, + {} + ); + }); + + it('should assign initialStateValue to currentValue (specific config)', () => { + numberState.initialStateValue = 99; + + numberState.reset({ + force: true, + storage: false, + }); + + expect(numberState.set).toHaveBeenCalledWith( + numberState.initialStateValue, + { + force: true, + storage: false, + } + ); + }); + }); + + describe('patch function tests', () => { + beforeEach(() => { + objectState.ingest = jest.fn(); + numberState.ingest = jest.fn(); + arrayState.ingest = jest.fn(); + jest.spyOn(Utils, 'flatMerge'); + }); + + 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 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 specified object value into a object based State (default config)', () => { + objectState.patch({ name: 'frank' }); + + expect(Utils.flatMerge).toHaveBeenCalledWith( + { age: 10, name: 'jeff' }, + { name: 'frank' }, + { addNewProperties: true } + ); + expect(objectState.nextStateValue).toStrictEqual({ + age: 10, + name: 'frank', + }); + expect(objectState.ingest).toHaveBeenCalledWith({}); + }); + + it('should patch specified object value into a object based State (specific config)', () => { + objectState.patch( + { name: 'frank' }, + { + addNewProperties: false, + background: true, + force: true, + overwrite: true, + sideEffects: { + enabled: false, + }, + } + ); + + expect(Utils.flatMerge).toHaveBeenCalledWith( + { age: 10, name: 'jeff' }, + { name: 'frank' }, + { addNewProperties: false } + ); + expect(objectState.nextStateValue).toStrictEqual({ + age: 10, + name: 'frank', + }); + expect(objectState.ingest).toHaveBeenCalledWith({ + background: true, + force: true, + overwrite: true, + sideEffects: { + enabled: false, + }, + }); + }); + + 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', () => { + let dummyCallbackFunction; + + beforeEach(() => { + jest.spyOn(numberState, 'addSideEffect'); + dummyCallbackFunction = jest.fn(); + }); + + it('should add passed watcherFunction to watchers at passed key', () => { + const response = numberState.watch('dummyKey', dummyCallbackFunction); + + expect(response).toBe(numberState); + expect(numberState.addSideEffect).toHaveBeenCalledWith( + 'dummyKey', + expect.any(Function), + { weight: 0 } + ); + + // Test whether registered callback function is called + numberState.sideEffects['dummyKey'].callback(numberState); + expect(dummyCallbackFunction).toHaveBeenCalledWith( + numberState._value, + 'dummyKey' + ); + }); + + it('should add passed watcherFunction to watchers at random key if no key passed and return that generated key', () => { + jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); + + const response = numberState.watch(dummyCallbackFunction); + + expect(response).toBe('randomKey'); + expect(numberState.addSideEffect).toHaveBeenCalledWith( + 'randomKey', + expect.any(Function), + { weight: 0 } + ); + expect(Utils.generateId).toHaveBeenCalled(); + + // Test whether registered callback function is called + numberState.sideEffects['randomKey'].callback(numberState); + expect(dummyCallbackFunction).toHaveBeenCalledWith( + numberState._value, + 'randomKey' + ); + }); + + it("shouldn't add passed invalid watcherFunction to watchers at passed key", () => { + const response = numberState.watch( + 'dummyKey', + 'noFunction hehe' as any + ); + + expect(response).toBe(numberState); + expect(numberState.addSideEffect).not.toHaveBeenCalled(); + LogMock.hasLoggedCode('00:03:01', ['Watcher Callback', 'function']); + }); + }); + + describe('removeWatcher function tests', () => { + beforeEach(() => { + jest.spyOn(numberState, 'removeSideEffect'); + }); + + it('should remove watcher at key from State', () => { + numberState.removeWatcher('dummyKey'); + + expect(numberState.removeSideEffect).toHaveBeenCalledWith('dummyKey'); + }); + }); + + describe('onInaugurated function tests', () => { + let dummyCallbackFunction; + + beforeEach(() => { + jest.spyOn(numberState, 'watch'); + jest.spyOn(numberState, 'removeSideEffect'); + dummyCallbackFunction = jest.fn(); + }); + + it('should add watcher called InauguratedWatcherKey to State', () => { + numberState.onInaugurated(dummyCallbackFunction); + + expect(numberState.watch).toHaveBeenCalledWith( + 'InauguratedWatcherKey', + expect.any(Function) + ); + }); + + it('should remove itself after invoking', () => { + numberState.onInaugurated(dummyCallbackFunction); + + // Call Inaugurated Watcher + numberState.sideEffects['InauguratedWatcherKey'].callback(numberState); + + expect(dummyCallbackFunction).toHaveBeenCalledWith( + numberState.value, + 'InauguratedWatcherKey' + ); + expect(numberState.removeSideEffect).toHaveBeenCalledWith( + 'InauguratedWatcherKey' + ); + }); + }); + + describe('persist function tests', () => { + it('should create Persistent with StateKey (default config)', () => { + numberState.persist(); + + expect(numberState.persistent).toBeInstanceOf(StatePersistent); + expect(StatePersistent).toHaveBeenCalledWith(numberState, { + loadValue: true, + storageKeys: [], + key: numberState._key, + defaultStorageKey: null, + }); + }); + + it('should create Persistent with StateKey (specific config)', () => { + numberState.persist({ + storageKeys: ['test1', 'test2'], + loadValue: false, + defaultStorageKey: 'test1', + }); + + expect(numberState.persistent).toBeInstanceOf(StatePersistent); + expect(StatePersistent).toHaveBeenCalledWith(numberState, { + loadValue: false, + storageKeys: ['test1', 'test2'], + key: numberState._key, + defaultStorageKey: 'test1', + }); + }); + + it('should create Persistent with passed Key (default config)', () => { + numberState.persist('passedKey'); + + expect(numberState.persistent).toBeInstanceOf(StatePersistent); + expect(StatePersistent).toHaveBeenCalledWith(numberState, { + loadValue: true, + storageKeys: [], + key: 'passedKey', + defaultStorageKey: null, + }); + }); + + it('should create Persistent with passed Key (specific config)', () => { + numberState.persist('passedKey', { + storageKeys: ['test1', 'test2'], + loadValue: false, + defaultStorageKey: 'test1', + }); + + expect(numberState.persistent).toBeInstanceOf(StatePersistent); + expect(StatePersistent).toHaveBeenCalledWith(numberState, { + loadValue: false, + storageKeys: ['test1', 'test2'], + key: 'passedKey', + defaultStorageKey: 'test1', + }); + }); + + 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).toBe(dummyPersistent); + // expect(numberState.persistent._key).toBe("newPersistentKey"); // Can not test because of Mocking Persistent + expect(StatePersistent).not.toHaveBeenCalled(); + }); + }); + + describe('onLoad function tests', () => { + const dummyCallbackFunction = jest.fn(); + + it("should set onLoad function if State is persisted and shouldn't call it initially (state.isPersisted = false)", () => { + numberState.persistent = new StatePersistent(numberState); + numberState.isPersisted = false; + + numberState.onLoad(dummyCallbackFunction); + + expect(numberState.persistent.onLoad).toBe(dummyCallbackFunction); + expect(dummyCallbackFunction).not.toHaveBeenCalled(); + LogMock.hasNotLogged('warn'); + }); + + it('should set onLoad function if State is persisted and should call it initially (state.isPersisted = true)', () => { + numberState.persistent = new StatePersistent(numberState); + numberState.isPersisted = true; + + numberState.onLoad(dummyCallbackFunction); + + expect(numberState.persistent.onLoad).toBe(dummyCallbackFunction); + expect(dummyCallbackFunction).toHaveBeenCalledWith(true); + LogMock.hasNotLogged('warn'); + }); + + it("shouldn't set onLoad function if State isn't persisted", () => { + numberState.onLoad(dummyCallbackFunction); + + expect(numberState?.persistent?.onLoad).toBeUndefined(); + expect(dummyCallbackFunction).not.toHaveBeenCalled(); + LogMock.hasNotLogged('warn'); + }); + + it("shouldn't set invalid onLoad callback function", () => { + numberState.persistent = new StatePersistent(numberState); + numberState.isPersisted = false; + + numberState.onLoad(10 as any); + + expect(numberState?.persistent?.onLoad).toBeUndefined(); + LogMock.hasLoggedCode('00:03:01', ['OnLoad Callback', 'function']); + }); + }); + + describe('interval function tests', () => { + const dummyCallbackFunction = jest.fn(); + const dummyCallbackFunction2 = jest.fn(); + + beforeEach(() => { + jest.useFakeTimers(); + numberState.set = jest.fn(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should create an interval (without custom milliseconds)', () => { + dummyCallbackFunction.mockReturnValueOnce(10); + + numberState.interval(dummyCallbackFunction); + + jest.runTimersToTime(1000); // travel 1000s in time -> execute interval + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenLastCalledWith( + expect.any(Function), + 1000 + ); + expect(dummyCallbackFunction).toHaveBeenCalledWith(numberState._value); + expect(numberState.set).toHaveBeenCalledWith(10); + expect(numberState.currentInterval).toEqual({ + id: expect.anything(), + ref: expect.anything(), + unref: expect.anything(), + }); + LogMock.hasNotLogged('warn'); + }); + + it('should create an interval (with custom milliseconds)', () => { + dummyCallbackFunction.mockReturnValueOnce(10); + + numberState.interval(dummyCallbackFunction, 2000); + + jest.runTimersToTime(2000); // travel 2000 in time -> execute interval + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenLastCalledWith( + expect.any(Function), + 2000 + ); + expect(dummyCallbackFunction).toHaveBeenCalledWith(numberState._value); + expect(numberState.set).toHaveBeenCalledWith(10); + expect(numberState.currentInterval).toEqual({ + id: expect.anything(), + ref: expect.anything(), + unref: expect.anything(), + }); + LogMock.hasNotLogged('warn'); + }); + + it("shouldn't be able to create second interval and print warning", () => { + numberState.interval(dummyCallbackFunction, 3000); + const currentInterval = numberState.currentInterval; + numberState.interval(dummyCallbackFunction2); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenLastCalledWith( + expect.any(Function), + 3000 + ); + expect(numberState.currentInterval).toStrictEqual(currentInterval); + LogMock.hasLoggedCode('14:03:03', [], numberState.currentInterval); + }); + + it("shouldn't set invalid interval callback function", () => { + numberState.interval(10 as any); + + expect(setInterval).not.toHaveBeenCalled(); + expect(numberState.currentInterval).toBeUndefined(); + LogMock.hasLoggedCode('00:03:01', ['Interval Callback', 'function']); + }); + }); + + describe('clearInterval function tests', () => { + const dummyCallbackFunction = jest.fn(); + + beforeEach(() => { + jest.useFakeTimers(); + numberState.set = jest.fn(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should clear existing interval', () => { + numberState.interval(dummyCallbackFunction); + const currentInterval = numberState.currentInterval; + + numberState.clearInterval(); + + expect(clearInterval).toHaveBeenCalledTimes(1); + expect(clearInterval).toHaveBeenLastCalledWith(currentInterval); + expect(numberState.currentInterval).toBeUndefined(); + }); + + it("shouldn't clear not existing interval", () => { + numberState.clearInterval(); + + expect(clearInterval).not.toHaveBeenCalled(); + expect(numberState.currentInterval).toBeUndefined(); + }); + }); + + describe('exists get function tests', () => { + it('should return true if State is no placeholder and computeExistsMethod returns true', () => { + numberState.computeExistsMethod = jest.fn().mockReturnValueOnce(true); + numberState.isPlaceholder = false; + + expect(numberState.exists).toBeTruthy(); + expect(numberState.computeExistsMethod).toHaveBeenCalledWith( + numberState.value + ); + }); + + it('should return false if State is no placeholder and computeExistsMethod returns false', () => { + numberState.computeExistsMethod = jest.fn().mockReturnValueOnce(false); + numberState.isPlaceholder = false; + + expect(numberState.exists).toBeFalsy(); + expect(numberState.computeExistsMethod).toHaveBeenCalledWith( + numberState.value + ); + }); + + it('should return false if State is placeholder"', () => { + numberState.computeExistsMethod = jest.fn(() => true); + numberState.isPlaceholder = true; + + expect(numberState.exists).toBeFalsy(); + expect(numberState.computeExistsMethod).not.toHaveBeenCalled(); // since isPlaceholder gets checked first + }); + }); + + describe('computeExists function tests', () => { + it('should assign passed function to computeExistsMethod', () => { + const computeMethod = (value) => value === null; + + numberState.computeExists(computeMethod); + + expect(numberState.computeExistsMethod).toBe(computeMethod); + LogMock.hasNotLogged('warn'); + }); + + it("shouldn't assign passed invalid function to computeExistsMethod", () => { + numberState.computeExists(10 as any); + + expect(numberState.computeExistsMethod).toBeInstanceOf(Function); + LogMock.hasLoggedCode('00:03:01', [ + 'Compute Exists Method', + 'function', + ]); + }); + }); + + describe('is function tests', () => { + beforeEach(() => { + jest.spyOn(Utils, 'equal'); + }); + + it('should return true if passed value is equal to the current StateValue', () => { + const response = numberState.is(10); + + expect(response).toBeTruthy(); + expect(Utils.equal).toHaveBeenCalledWith(10, numberState._value); + }); + + it('should return false if passed value is not equal to the current StateValue', () => { + const response = numberState.is(20); + + expect(response).toBeFalsy(); + expect(Utils.equal).toHaveBeenCalledWith(20, numberState._value); + }); + }); + + describe('isNot function tests', () => { + beforeEach(() => { + jest.spyOn(Utils, 'notEqual'); + }); + + it('should return false if passed value is equal to the current StateValue', () => { + const response = numberState.isNot(10); + + expect(response).toBeFalsy(); + expect(Utils.notEqual).toHaveBeenCalledWith(10, numberState._value); + }); + + it('should return true if passed value is not equal to the current StateValue', () => { + const response = numberState.isNot(20); + + expect(response).toBeTruthy(); + expect(Utils.notEqual).toHaveBeenCalledWith(20, numberState._value); + }); + }); + + describe('invert function tests', () => { + let dummyState: EnhancedState; + + beforeEach(() => { + dummyState = new EnhancedState(dummyAgile, null); + + dummyState.set = jest.fn(); + }); + + it('should invert value of the type boolean', () => { + dummyState.nextStateValue = false; + + dummyState.invert(); + + expect(dummyState.set).toHaveBeenCalledWith(true); + }); + + it('should invert value of the type number', () => { + dummyState.nextStateValue = 10; + + 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']); + }); + }); + + describe('computeValue function tests', () => { + beforeEach(() => { + numberState.set = jest.fn(); + }); + + it('should assign passed function to computeValueMethod and compute State value initially', () => { + const computeMethod = () => 10; + + numberState.computeValue(computeMethod); + + expect(numberState.set).toHaveBeenCalledWith(10); + expect(numberState.computeValueMethod).toBe(computeMethod); + LogMock.hasNotLogged('warn'); + }); + + it("shouldn't assign passed invalid function to computeValueMethod", () => { + numberState.computeValue(10 as any); + + expect(numberState.set).not.toHaveBeenCalled(); + expect(numberState.computeValueMethod).toBeUndefined(); + LogMock.hasLoggedCode('00:03:01', ['Compute Value Method', 'function']); + }); + }); + }); +}); diff --git a/packages/core/tests/unit/state/state.observer.test.ts b/packages/core/tests/unit/state/state.observer.test.ts index 035ec4a5..2c348943 100644 --- a/packages/core/tests/unit/state/state.observer.test.ts +++ b/packages/core/tests/unit/state/state.observer.test.ts @@ -3,10 +3,10 @@ import { Computed, StateRuntimeJob, Observer, - State, StateObserver, - StatePersistent, + EnhancedState, SubscriptionContainer, + State, } from '../../../src'; import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../helper/logMock'; @@ -19,8 +19,10 @@ describe('StateObserver Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); - dummyState = new State(dummyAgile, 'dummyValue', { key: 'dummyState' }); + dummyAgile = new Agile(); + dummyState = new State(dummyAgile, 'dummyValue', { + key: 'dummyState', + }); jest.clearAllMocks(); }); @@ -181,6 +183,7 @@ describe('StateObserver Tests', () => { storage: true, overwrite: false, maxTriesToUpdate: 3, + any: {}, }); }); @@ -212,6 +215,7 @@ describe('StateObserver Tests', () => { storage: true, overwrite: true, maxTriesToUpdate: 5, + any: {}, }); }); @@ -268,6 +272,7 @@ describe('StateObserver Tests', () => { storage: true, overwrite: false, maxTriesToUpdate: 3, + any: {}, }); }); @@ -298,6 +303,7 @@ describe('StateObserver Tests', () => { storage: true, overwrite: true, maxTriesToUpdate: 3, + any: {}, }); }); dummyState.isPlaceholder = true; @@ -317,7 +323,8 @@ describe('StateObserver Tests', () => { 'should ingest the State into the Runtime and compute its new value ' + 'if the State has a set compute function (default config)', () => { - dummyState.computeValueMethod = (value) => `cool value '${value}'`; + (dummyState as EnhancedState).computeValueMethod = (value) => + `cool value '${value}'`; stateObserver.ingestValue('updatedDummyValue'); @@ -341,8 +348,6 @@ describe('StateObserver Tests', () => { dummyJob = new StateRuntimeJob(stateObserver, { key: 'dummyJob', }); - dummyState.persistent = new StatePersistent(dummyState); - dummyState.isPersisted = true; stateObserver.sideEffects = jest.fn(); }); @@ -422,7 +427,6 @@ describe('StateObserver Tests', () => { key: 'dummyJob', }); - dummyState.watchers['dummyWatcher'] = jest.fn(); dummyState.sideEffects['dummySideEffect3'] = { weight: 100, callback: jest.fn(() => { @@ -448,10 +452,6 @@ describe('StateObserver Tests', () => { stateObserver.sideEffects(dummyJob); - expect(dummyState.watchers['dummyWatcher']).toHaveBeenCalledWith( - 'dummyValue', - 'dummyWatcher' - ); expect( dummyState.sideEffects['dummySideEffect'].callback ).toHaveBeenCalledWith(dummyState, dummyJob.config); @@ -479,10 +479,6 @@ describe('StateObserver Tests', () => { stateObserver.sideEffects(dummyJob); - expect(dummyState.watchers['dummyWatcher']).toHaveBeenCalledWith( - 'dummyValue', - 'dummyWatcher' - ); expect( dummyState.sideEffects['dummySideEffect'].callback ).not.toHaveBeenCalled(); @@ -507,10 +503,6 @@ describe('StateObserver Tests', () => { stateObserver.sideEffects(dummyJob); - expect(dummyState.watchers['dummyWatcher']).toHaveBeenCalledWith( - 'dummyValue', - 'dummyWatcher' - ); expect( dummyState.sideEffects['dummySideEffect'].callback ).toHaveBeenCalledWith(dummyState, dummyJob.config); diff --git a/packages/core/tests/unit/state/state.persistent.test.ts b/packages/core/tests/unit/state/state.persistent.test.ts index 65603523..95591c38 100644 --- a/packages/core/tests/unit/state/state.persistent.test.ts +++ b/packages/core/tests/unit/state/state.persistent.test.ts @@ -1,21 +1,30 @@ import { Agile, - State, StatePersistent, Storage, Persistent, + EnhancedState, + Storages, + assignSharedAgileStorageManager, + createStorageManager, } from '../../../src'; import { LogMock } from '../../helper/logMock'; +import waitForExpect from 'wait-for-expect'; describe('StatePersistent Tests', () => { let dummyAgile: Agile; - let dummyState: State; + let dummyState: EnhancedState; + let storageManager: Storages; beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); - dummyState = new State(dummyAgile, 'dummyValue'); + dummyAgile = new Agile(); + dummyState = new EnhancedState(dummyAgile, 'dummyValue'); + + // Register Storage Manager + storageManager = createStorageManager(); + assignSharedAgileStorageManager(storageManager); jest.spyOn(StatePersistent.prototype, 'instantiatePersistent'); jest.spyOn(StatePersistent.prototype, 'initialLoading'); @@ -23,41 +32,44 @@ describe('StatePersistent Tests', () => { jest.clearAllMocks(); }); - it("should create StatePersistent 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 StatePersistent and should call initialLoading if Persistent is ready (default config)', () => { + // Overwrite instantiatePersistent once to not call it jest .spyOn(StatePersistent.prototype, 'instantiatePersistent') .mockImplementationOnce(function () { // @ts-ignore - this.ready = false; + this.ready = true; }); const statePersistent = new StatePersistent(dummyState); expect(statePersistent).toBeInstanceOf(StatePersistent); - expect(statePersistent.state()).toBe(dummyState); + expect(statePersistent.instantiatePersistent).toHaveBeenCalledWith({ key: undefined, storageKeys: [], defaultStorageKey: null, }); - expect(statePersistent.initialLoading).not.toHaveBeenCalled(); + expect(statePersistent.initialLoading).toHaveBeenCalled(); + // Check if Persistent was called with correct parameters expect(statePersistent._key).toBe(StatePersistent.placeHolderKey); - expect(statePersistent.ready).toBeFalsy(); + expect(statePersistent.ready).toBeTruthy(); expect(statePersistent.isPersisted).toBeFalsy(); expect(statePersistent.onLoad).toBeUndefined(); expect(statePersistent.storageKeys).toStrictEqual([]); - expect(statePersistent.config).toStrictEqual({ defaultStorageKey: null }); + expect(statePersistent.config).toStrictEqual({ + defaultStorageKey: null, // gets set in 'instantiatePersistent' which is mocked + }); }); - it("should create StatePersistent and shouldn't call initialLoading if Persistent isn't ready (specific config)", () => { + it('should create StatePersistent and should call initialLoading if Persistent is ready (specific config)', () => { // Overwrite instantiatePersistent once to not call it and set ready property jest .spyOn(StatePersistent.prototype, 'instantiatePersistent') .mockImplementationOnce(function () { // @ts-ignore - this.ready = false; + this.ready = true; }); const statePersistent = new StatePersistent(dummyState, { @@ -72,10 +84,11 @@ describe('StatePersistent Tests', () => { storageKeys: ['test1', 'test2'], defaultStorageKey: 'test2', }); - expect(statePersistent.initialLoading).not.toHaveBeenCalled(); + expect(statePersistent.initialLoading).toHaveBeenCalled(); + // Check if Persistent was called with correct parameters expect(statePersistent._key).toBe(StatePersistent.placeHolderKey); - expect(statePersistent.ready).toBeFalsy(); + expect(statePersistent.ready).toBeTruthy(); expect(statePersistent.isPersisted).toBeFalsy(); expect(statePersistent.onLoad).toBeUndefined(); expect(statePersistent.storageKeys).toStrictEqual([]); @@ -84,21 +97,36 @@ describe('StatePersistent Tests', () => { }); }); - it('should create StatePersistent and should call initialLoading if Persistent is ready (default config)', () => { - // Overwrite instantiatePersistent once to not call it + it("should create StatePersistent and shouldn't call initialLoading if Persistent isn't ready", () => { + // Overwrite instantiatePersistent once to not call it and set ready property jest .spyOn(StatePersistent.prototype, 'instantiatePersistent') .mockImplementationOnce(function () { // @ts-ignore - this.ready = true; + this.ready = false; }); const statePersistent = new StatePersistent(dummyState); - expect(statePersistent.initialLoading).toHaveBeenCalled(); + expect(statePersistent).toBeInstanceOf(StatePersistent); + expect(statePersistent.state()).toBe(dummyState); + expect(statePersistent.instantiatePersistent).toHaveBeenCalledWith({ + key: undefined, + storageKeys: [], + defaultStorageKey: null, + }); + expect(statePersistent.initialLoading).not.toHaveBeenCalled(); + + // Check if Persistent was called with correct parameters + expect(statePersistent._key).toBe(StatePersistent.placeHolderKey); + expect(statePersistent.ready).toBeFalsy(); + expect(statePersistent.isPersisted).toBeFalsy(); + expect(statePersistent.onLoad).toBeUndefined(); + expect(statePersistent.storageKeys).toStrictEqual([]); + expect(statePersistent.config).toStrictEqual({ defaultStorageKey: null }); }); - it("should create StatePersistent and shouldn't call initialLoading if Persistent is ready (config.instantiate = false)", () => { + it("should create StatePersistent and shouldn't call initialLoading if Persistent is ready (config.loadValue = false)", () => { // Overwrite instantiatePersistent once to not call it and set ready property jest .spyOn(StatePersistent.prototype, 'instantiatePersistent') @@ -108,10 +136,25 @@ describe('StatePersistent Tests', () => { }); const statePersistent = new StatePersistent(dummyState, { - instantiate: false, + loadValue: false, }); + expect(statePersistent).toBeInstanceOf(StatePersistent); + expect(statePersistent.state()).toBe(dummyState); + expect(statePersistent.instantiatePersistent).toHaveBeenCalledWith({ + key: undefined, + storageKeys: [], + defaultStorageKey: null, + }); expect(statePersistent.initialLoading).not.toHaveBeenCalled(); + + // Check if Persistent was called with correct parameters + expect(statePersistent._key).toBe(StatePersistent.placeHolderKey); + expect(statePersistent.ready).toBeTruthy(); + expect(statePersistent.isPersisted).toBeFalsy(); + expect(statePersistent.onLoad).toBeUndefined(); + expect(statePersistent.storageKeys).toStrictEqual([]); + expect(statePersistent.config).toStrictEqual({ defaultStorageKey: null }); }); describe('StatePersistent Function Tests', () => { @@ -122,7 +165,7 @@ describe('StatePersistent Tests', () => { key: 'statePersistentKey', storageKeys: ['dummyStorage'], }); - dummyAgile.registerStorage( + storageManager.register( new Storage({ key: 'dummyStorage', methods: { @@ -142,8 +185,10 @@ describe('StatePersistent Tests', () => { it('should initialLoad and set isPersisted in State to true', async () => { await statePersistent.initialLoading(); - expect(Persistent.prototype.initialLoading).toHaveBeenCalled(); - expect(dummyState.isPersisted).toBeTruthy(); + await waitForExpect(() => { + expect(Persistent.prototype.initialLoading).toHaveBeenCalled(); + expect(dummyState.isPersisted).toBeTruthy(); + }); }); }); @@ -158,14 +203,14 @@ describe('StatePersistent Tests', () => { 'and apply it to the State if the loading was successful', async () => { statePersistent.ready = true; - dummyAgile.storages.get = jest.fn(() => + storageManager.get = jest.fn(() => Promise.resolve('dummyValue' as any) ); const response = await statePersistent.loadPersistedValue(); expect(response).toBeTruthy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( statePersistent._key, statePersistent.config.defaultStorageKey ); @@ -184,14 +229,12 @@ describe('StatePersistent Tests', () => { "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) - ); + storageManager.get = jest.fn(() => Promise.resolve(undefined as any)); const response = await statePersistent.loadPersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( statePersistent._key, statePersistent.config.defaultStorageKey ); @@ -205,14 +248,14 @@ describe('StatePersistent Tests', () => { 'and apply it to the State if the loading was successful', async () => { statePersistent.ready = true; - dummyAgile.storages.get = jest.fn(() => + storageManager.get = jest.fn(() => Promise.resolve('dummyValue' as any) ); const response = await statePersistent.loadPersistedValue('coolKey'); expect(response).toBeTruthy(); - expect(dummyAgile.storages.get).toHaveBeenCalledWith( + expect(storageManager.get).toHaveBeenCalledWith( 'coolKey', statePersistent.config.defaultStorageKey ); @@ -231,14 +274,12 @@ describe('StatePersistent Tests', () => { "if Persistent isn't ready yet", async () => { statePersistent.ready = false; - dummyAgile.storages.get = jest.fn(() => - Promise.resolve(undefined as any) - ); + storageManager.get = jest.fn(() => Promise.resolve(undefined as any)); const response = await statePersistent.loadPersistedValue(); expect(response).toBeFalsy(); - expect(dummyAgile.storages.get).not.toHaveBeenCalled(); + expect(storageManager.get).not.toHaveBeenCalled(); expect(dummyState.set).not.toHaveBeenCalled(); expect(statePersistent.setupSideEffects).not.toHaveBeenCalled(); } @@ -364,7 +405,7 @@ describe('StatePersistent Tests', () => { describe('removePersistedValue function tests', () => { beforeEach(() => { dummyState.removeSideEffect = jest.fn(); - dummyAgile.storages.remove = jest.fn(); + storageManager.remove = jest.fn(); statePersistent.isPersisted = true; }); @@ -378,7 +419,7 @@ describe('StatePersistent Tests', () => { expect(dummyState.removeSideEffect).toHaveBeenCalledWith( StatePersistent.storeValueSideEffectKey ); - expect(dummyAgile.storages.remove).toHaveBeenCalledWith( + expect(storageManager.remove).toHaveBeenCalledWith( statePersistent._key, statePersistent.storageKeys ); @@ -394,7 +435,7 @@ describe('StatePersistent Tests', () => { expect(dummyState.removeSideEffect).toHaveBeenCalledWith( StatePersistent.storeValueSideEffectKey ); - expect(dummyAgile.storages.remove).toHaveBeenCalledWith( + expect(storageManager.remove).toHaveBeenCalledWith( 'coolKey', statePersistent.storageKeys ); @@ -408,7 +449,7 @@ describe('StatePersistent Tests', () => { expect(response).toBeFalsy(); expect(dummyState.removeSideEffect).not.toHaveBeenCalled(); - expect(dummyAgile.storages.remove).not.toHaveBeenCalled(); + expect(storageManager.remove).not.toHaveBeenCalled(); expect(statePersistent.isPersisted).toBeTruthy(); }); }); @@ -450,13 +491,13 @@ describe('StatePersistent Tests', () => { describe('rebuildStorageSideEffect function tests', () => { beforeEach(() => { - dummyAgile.storages.set = jest.fn(); + storageManager.set = jest.fn(); }); it('should store current State value in the corresponding Storage (default config)', () => { statePersistent.rebuildStorageSideEffect(dummyState, 'coolKey'); - expect(dummyAgile.storages.set).toHaveBeenCalledWith( + expect(storageManager.set).toHaveBeenCalledWith( 'coolKey', dummyState.getPersistableValue(), statePersistent.storageKeys @@ -468,7 +509,7 @@ describe('StatePersistent Tests', () => { storage: false, }); - expect(dummyAgile.storages.set).not.toHaveBeenCalled(); + expect(storageManager.set).not.toHaveBeenCalled(); }); }); }); 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 85df6e56..63bb50a8 100644 --- a/packages/core/tests/unit/state/state.runtime.job.test.ts +++ b/packages/core/tests/unit/state/state.runtime.job.test.ts @@ -18,7 +18,7 @@ describe('RuntimeJob Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyIntegration = new Integration({ key: 'myIntegration', }); @@ -48,6 +48,7 @@ describe('RuntimeJob Tests', () => { storage: true, overwrite: false, maxTriesToUpdate: 3, + any: {}, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); @@ -70,6 +71,7 @@ describe('RuntimeJob Tests', () => { }, force: true, maxTriesToUpdate: 5, + any: { jeff: 'frank' }, }); expect(job._key).toBe('dummyJob'); @@ -83,6 +85,7 @@ describe('RuntimeJob Tests', () => { storage: true, overwrite: false, maxTriesToUpdate: 5, + any: { jeff: 'frank' }, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); @@ -110,6 +113,7 @@ describe('RuntimeJob Tests', () => { storage: true, overwrite: false, maxTriesToUpdate: 3, + any: {}, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); @@ -139,6 +143,7 @@ describe('RuntimeJob Tests', () => { storage: true, overwrite: false, maxTriesToUpdate: 3, + any: {}, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); diff --git a/packages/core/tests/unit/state/state.test.ts b/packages/core/tests/unit/state/state.test.ts index 78b3afe9..1b46a1aa 100644 --- a/packages/core/tests/unit/state/state.test.ts +++ b/packages/core/tests/unit/state/state.test.ts @@ -3,10 +3,8 @@ import { Agile, StateObserver, Observer, - StatePersistent, ComputedTracker, } from '../../../src'; -import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../helper/logMock'; jest.mock('../../../src/state/state.persistent'); @@ -17,7 +15,7 @@ describe('State Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); jest.spyOn(State.prototype, 'set'); @@ -32,7 +30,6 @@ describe('State Tests', () => { expect(state.set).toHaveBeenCalledWith('coolValue', { overwrite: true }); expect(state._key).toBeUndefined(); - expect(state.valueType).toBeUndefined(); expect(state.isSet).toBeFalsy(); expect(state.isPlaceholder).toBeTruthy(); expect(state.initialStateValue).toBe('coolValue'); @@ -43,11 +40,6 @@ describe('State Tests', () => { expect(Array.from(state.observers['value'].dependents)).toStrictEqual([]); expect(state.observers['value']._key).toBeUndefined(); expect(state.sideEffects).toStrictEqual({}); - expect(state.computeValueMethod).toBeUndefined(); - expect(state.computeExistsMethod).toBeInstanceOf(Function); - expect(state.isPersisted).toBeFalsy(); - expect(state.persistent).toBeUndefined(); - expect(state.watchers).toStrictEqual({}); }); it('should create State and should call initial set (specific config)', () => { @@ -63,7 +55,6 @@ describe('State Tests', () => { expect(state.set).toHaveBeenCalledWith('coolValue', { overwrite: true }); expect(state._key).toBe('coolState'); - expect(state.valueType).toBeUndefined(); expect(state.isSet).toBeFalsy(); expect(state.isPlaceholder).toBeTruthy(); expect(state.initialStateValue).toBe('coolValue'); @@ -76,11 +67,6 @@ describe('State Tests', () => { ]); expect(state.observers['value']._key).toBe('coolState'); expect(state.sideEffects).toStrictEqual({}); - expect(state.computeValueMethod).toBeUndefined(); - expect(state.computeExistsMethod).toBeInstanceOf(Function); - expect(state.isPersisted).toBeFalsy(); - expect(state.persistent).toBeUndefined(); - expect(state.watchers).toStrictEqual({}); }); it("should create State and shouldn't call initial set (config.isPlaceholder = true)", () => { @@ -91,7 +77,6 @@ describe('State Tests', () => { expect(state.set).not.toHaveBeenCalled(); expect(state._key).toBeUndefined(); - expect(state.valueType).toBeUndefined(); expect(state.isSet).toBeFalsy(); expect(state.isPlaceholder).toBeTruthy(); expect(state.initialStateValue).toBe('coolValue'); @@ -102,11 +87,6 @@ describe('State Tests', () => { expect(Array.from(state.observers['value'].dependents)).toStrictEqual([]); expect(state.observers['value']._key).toBeUndefined(); expect(state.sideEffects).toStrictEqual({}); - expect(state.computeValueMethod).toBeUndefined(); - expect(state.computeExistsMethod).toBeInstanceOf(Function); - expect(state.isPersisted).toBeFalsy(); - expect(state.persistent).toBeUndefined(); - expect(state.watchers).toStrictEqual({}); }); describe('State Function Tests', () => { @@ -181,45 +161,31 @@ describe('State Tests', () => { beforeEach(() => { dummyOutputObserver = new StateObserver(numberState, { key: 'oldKey' }); - numberState.persistent = new StatePersistent(numberState); numberState.observers['output'] = dummyOutputObserver; - - numberState.persistent.setKey = jest.fn(); }); it('should update existing Key in all instances', () => { - if (numberState.persistent) - numberState.persistent._key = 'numberStateKey'; - numberState.setKey('newKey'); expect(numberState._key).toBe('newKey'); expect(numberState.observers['value']._key).toBe('newKey'); expect(numberState.observers['output']._key).toBe('newKey'); - expect(numberState.persistent?.setKey).toHaveBeenCalledWith('newKey'); }); it("should update existing Key in all instances except persistent if the StateKey and PersistKey aren't equal", () => { - if (numberState.persistent) numberState.persistent._key = 'randomKey'; - numberState.setKey('newKey'); expect(numberState._key).toBe('newKey'); expect(numberState.observers['value']._key).toBe('newKey'); expect(numberState.observers['output']._key).toBe('newKey'); - expect(numberState.persistent?.setKey).not.toHaveBeenCalled(); }); it('should update existing Key in all instances except persistent if new StateKey is undefined', () => { - if (numberState.persistent) - numberState.persistent._key = 'numberStateKey'; - numberState.setKey(undefined); expect(numberState._key).toBeUndefined(); expect(numberState.observers['value']._key).toBeUndefined(); expect(numberState.observers['output']._key).toBeUndefined(); - expect(numberState.persistent?.setKey).not.toHaveBeenCalled(); }); }); @@ -278,38 +244,15 @@ describe('State Tests', () => { ); }); - it("shouldn't ingestValue if value hasn't correct type (default config)", () => { - numberState.type(Number); - - numberState.set('coolValue' as any); - - LogMock.hasNotLogged('warn'); - LogMock.hasLoggedCode('14:03:00', ['string', 'number']); - expect( - numberState.observers['value'].ingestValue - ).not.toHaveBeenCalled(); - }); - - it("should ingestValue if value hasn't correct type (config.force = true)", () => { - numberState.type(Number); - - numberState.set('coolValue' as any, { force: true }); - - LogMock.hasNotLogged('error'); - LogMock.hasLoggedCode('14:02:00', ['string', 'number']); - expect( - numberState.observers['value'].ingestValue - ).toHaveBeenCalledWith('coolValue', { force: true }); - }); - it("should ingestValue if value hasn't correct type but the type isn't explicit defined (default config)", () => { numberState.set('coolValue' as any); LogMock.hasNotLogged('warn'); LogMock.hasNotLogged('error'); - expect( - numberState.observers['value'].ingestValue - ).toHaveBeenCalledWith('coolValue', { force: false }); + expect(numberState.observers['value'].ingestValue).toHaveBeenCalledWith( + 'coolValue', + { force: false } + ); }); }); @@ -337,678 +280,6 @@ describe('State Tests', () => { }); }); - describe('type function tests', () => { - it('should assign valid Type to State', () => { - numberState.type(Number); - - expect(numberState.valueType).toBe('number'); - }); - - it("shouldn't assign invalid Type to State", () => { - numberState.type('fuckingType'); - - expect(numberState.valueType).toBeUndefined(); - LogMock.hasLoggedCode('14:03:01', ['fuckingType']); - }); - }); - - describe('undo function tests', () => { - beforeEach(() => { - numberState.set = jest.fn(); - }); - - it('should assign previousStateValue to currentValue (default config)', () => { - numberState.previousStateValue = 99; - - numberState.undo(); - - expect(numberState.set).toHaveBeenCalledWith( - numberState.previousStateValue, - {} - ); - }); - - it('should assign previousStateValue to currentValue (specific config)', () => { - numberState.previousStateValue = 99; - - numberState.undo({ - force: true, - storage: false, - }); - - expect(numberState.set).toHaveBeenCalledWith( - numberState.previousStateValue, - { - force: true, - storage: false, - } - ); - }); - }); - - describe('reset function tests', () => { - beforeEach(() => { - numberState.set = jest.fn(); - }); - - it('should assign initialStateValue to currentValue (default config)', () => { - numberState.initialStateValue = 99; - - numberState.reset(); - - expect(numberState.set).toHaveBeenCalledWith( - numberState.initialStateValue, - {} - ); - }); - - it('should assign initialStateValue to currentValue (specific config)', () => { - numberState.initialStateValue = 99; - - numberState.reset({ - force: true, - storage: false, - }); - - expect(numberState.set).toHaveBeenCalledWith( - numberState.initialStateValue, - { - force: true, - storage: false, - } - ); - }); - }); - - describe('patch function tests', () => { - beforeEach(() => { - objectState.ingest = jest.fn(); - numberState.ingest = jest.fn(); - arrayState.ingest = jest.fn(); - jest.spyOn(Utils, 'flatMerge'); - }); - - 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 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 specified object value into a object based State (default config)', () => { - objectState.patch({ name: 'frank' }); - - expect(Utils.flatMerge).toHaveBeenCalledWith( - { age: 10, name: 'jeff' }, - { name: 'frank' }, - { addNewProperties: true } - ); - expect(objectState.nextStateValue).toStrictEqual({ - age: 10, - name: 'frank', - }); - expect(objectState.ingest).toHaveBeenCalledWith({}); - }); - - it('should patch specified object value into a object based State (specific config)', () => { - objectState.patch( - { name: 'frank' }, - { - addNewProperties: false, - background: true, - force: true, - overwrite: true, - sideEffects: { - enabled: false, - }, - } - ); - - expect(Utils.flatMerge).toHaveBeenCalledWith( - { age: 10, name: 'jeff' }, - { name: 'frank' }, - { addNewProperties: false } - ); - expect(objectState.nextStateValue).toStrictEqual({ - age: 10, - name: 'frank', - }); - expect(objectState.ingest).toHaveBeenCalledWith({ - background: true, - force: true, - overwrite: true, - sideEffects: { - enabled: false, - }, - }); - }); - - 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', () => { - const dummyCallbackFunction1 = () => { - /* empty function */ - }; - const dummyCallbackFunction2 = () => { - /* empty function */ - }; - - it('should add passed watcherFunction to watchers at passed key', () => { - const response = numberState.watch('dummyKey', dummyCallbackFunction1); - - expect(response).toBe(numberState); - expect(numberState.watchers).toHaveProperty('dummyKey'); - expect(numberState.watchers['dummyKey']).toBe(dummyCallbackFunction1); - }); - - it('should add passed watcherFunction to watchers at random key if no key passed and return that generated key', () => { - jest.spyOn(Utils, 'generateId').mockReturnValue('randomKey'); - - const response = numberState.watch(dummyCallbackFunction1); - - expect(response).toBe('randomKey'); - expect(numberState.watchers).toHaveProperty('randomKey'); - expect(numberState.watchers['randomKey']).toBe(dummyCallbackFunction1); - expect(Utils.generateId).toHaveBeenCalled(); - }); - - it("shouldn't add passed invalid watcherFunction to watchers at passed key", () => { - const response = numberState.watch( - 'dummyKey', - 'noFunction hehe' as any - ); - - expect(response).toBe(numberState); - expect(numberState.watchers).not.toHaveProperty('dummyKey'); - LogMock.hasLoggedCode('00:03:01', ['Watcher Callback', 'function']); - }); - }); - - describe('removeWatcher function tests', () => { - beforeEach(() => { - numberState.watchers['dummyKey'] = () => { - /* empty function */ - }; - }); - - it('should remove watcher at key from State', () => { - numberState.removeWatcher('dummyKey'); - - expect(numberState.watchers).not.toHaveProperty('dummyKey'); - }); - }); - - describe('onInaugurated function tests', () => { - let dummyCallbackFunction; - - beforeEach(() => { - jest.spyOn(numberState, 'watch'); - dummyCallbackFunction = jest.fn(); - }); - - it('should add watcher called InauguratedWatcherKey to State', () => { - numberState.onInaugurated(dummyCallbackFunction); - - expect(numberState.watch).toHaveBeenCalledWith( - 'InauguratedWatcherKey', - expect.any(Function) - ); - expect(numberState.watchers).toHaveProperty('InauguratedWatcherKey'); - }); - - it('should remove itself after getting called', () => { - numberState.onInaugurated(dummyCallbackFunction); - - // Call Inaugurated Watcher - numberState.watchers['InauguratedWatcherKey'](10, 'testKey'); - - expect(dummyCallbackFunction).toHaveBeenCalledWith(10, 'testKey'); - expect(numberState.watchers).not.toHaveProperty( - 'InauguratedWatcherKey' - ); - }); - }); - - describe('hasWatcher function tests', () => { - beforeEach(() => { - numberState.watchers['dummyKey'] = () => { - /* empty function */ - }; - }); - - it('should return true if Watcher at given Key exists', () => { - expect(numberState.hasWatcher('dummyKey')).toBeTruthy(); - }); - - it("should return false if Watcher at given Key doesn't exists", () => { - expect(numberState.hasWatcher('notExistingDummyKey')).toBeFalsy(); - }); - }); - - describe('persist function tests', () => { - it('should create persistent with StateKey (default config)', () => { - numberState.persist(); - - expect(numberState.persistent).toBeInstanceOf(StatePersistent); - expect(StatePersistent).toHaveBeenCalledWith(numberState, { - instantiate: true, - storageKeys: [], - key: numberState._key, - defaultStorageKey: null, - }); - }); - - it('should create persistent with StateKey (specific config)', () => { - numberState.persist({ - storageKeys: ['test1', 'test2'], - loadValue: false, - defaultStorageKey: 'test1', - }); - - expect(numberState.persistent).toBeInstanceOf(StatePersistent); - expect(StatePersistent).toHaveBeenCalledWith(numberState, { - instantiate: false, - storageKeys: ['test1', 'test2'], - key: numberState._key, - defaultStorageKey: 'test1', - }); - }); - - it('should create persistent with passed Key (default config)', () => { - numberState.persist('passedKey'); - - expect(numberState.persistent).toBeInstanceOf(StatePersistent); - expect(StatePersistent).toHaveBeenCalledWith(numberState, { - instantiate: true, - storageKeys: [], - key: 'passedKey', - defaultStorageKey: null, - }); - }); - - it('should create persistent with passed Key (specific config)', () => { - numberState.persist('passedKey', { - storageKeys: ['test1', 'test2'], - loadValue: false, - defaultStorageKey: 'test1', - }); - - expect(numberState.persistent).toBeInstanceOf(StatePersistent); - expect(StatePersistent).toHaveBeenCalledWith(numberState, { - instantiate: false, - storageKeys: ['test1', 'test2'], - key: 'passedKey', - defaultStorageKey: 'test1', - }); - }); - - 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).toBe(dummyPersistent); - // expect(numberState.persistent._key).toBe("newPersistentKey"); // Can not test because of Mocking Persistent - expect(StatePersistent).not.toHaveBeenCalled(); - }); - }); - - describe('onLoad function tests', () => { - const dummyCallbackFunction = jest.fn(); - - it("should set onLoad function if State is persisted and shouldn't call it initially (state.isPersisted = false)", () => { - numberState.persistent = new StatePersistent(numberState); - numberState.isPersisted = false; - - numberState.onLoad(dummyCallbackFunction); - - expect(numberState.persistent.onLoad).toBe(dummyCallbackFunction); - expect(dummyCallbackFunction).not.toHaveBeenCalled(); - LogMock.hasNotLogged('warn'); - }); - - it('should set onLoad function if State is persisted and should call it initially (state.isPersisted = true)', () => { - numberState.persistent = new StatePersistent(numberState); - numberState.isPersisted = true; - - numberState.onLoad(dummyCallbackFunction); - - expect(numberState.persistent.onLoad).toBe(dummyCallbackFunction); - expect(dummyCallbackFunction).toHaveBeenCalledWith(true); - LogMock.hasNotLogged('warn'); - }); - - it("shouldn't set onLoad function if State isn't persisted", () => { - numberState.onLoad(dummyCallbackFunction); - - expect(numberState?.persistent?.onLoad).toBeUndefined(); - expect(dummyCallbackFunction).not.toHaveBeenCalled(); - LogMock.hasNotLogged('warn'); - }); - - it("shouldn't set invalid onLoad callback function", () => { - numberState.persistent = new StatePersistent(numberState); - numberState.isPersisted = false; - - numberState.onLoad(10 as any); - - expect(numberState?.persistent?.onLoad).toBeUndefined(); - LogMock.hasLoggedCode('00:03:01', ['OnLoad Callback', 'function']); - }); - }); - - describe('interval function tests', () => { - const dummyCallbackFunction = jest.fn(); - const dummyCallbackFunction2 = jest.fn(); - - beforeEach(() => { - jest.useFakeTimers(); - numberState.set = jest.fn(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - it('should create an interval (without custom milliseconds)', () => { - dummyCallbackFunction.mockReturnValueOnce(10); - - numberState.interval(dummyCallbackFunction); - - jest.runTimersToTime(1000); // travel 1000s in time -> execute interval - - expect(setInterval).toHaveBeenCalledTimes(1); - expect(setInterval).toHaveBeenLastCalledWith( - expect.any(Function), - 1000 - ); - expect(dummyCallbackFunction).toHaveBeenCalledWith(numberState._value); - expect(numberState.set).toHaveBeenCalledWith(10); - expect(numberState.currentInterval).toEqual({ - id: expect.anything(), - ref: expect.anything(), - unref: expect.anything(), - }); - LogMock.hasNotLogged('warn'); - }); - - it('should create an interval (with custom milliseconds)', () => { - dummyCallbackFunction.mockReturnValueOnce(10); - - numberState.interval(dummyCallbackFunction, 2000); - - jest.runTimersToTime(2000); // travel 2000 in time -> execute interval - - expect(setInterval).toHaveBeenCalledTimes(1); - expect(setInterval).toHaveBeenLastCalledWith( - expect.any(Function), - 2000 - ); - expect(dummyCallbackFunction).toHaveBeenCalledWith(numberState._value); - expect(numberState.set).toHaveBeenCalledWith(10); - expect(numberState.currentInterval).toEqual({ - id: expect.anything(), - ref: expect.anything(), - unref: expect.anything(), - }); - LogMock.hasNotLogged('warn'); - }); - - it("shouldn't be able to create second interval and print warning", () => { - numberState.interval(dummyCallbackFunction, 3000); - const currentInterval = numberState.currentInterval; - numberState.interval(dummyCallbackFunction2); - - expect(setInterval).toHaveBeenCalledTimes(1); - expect(setInterval).toHaveBeenLastCalledWith( - expect.any(Function), - 3000 - ); - expect(numberState.currentInterval).toStrictEqual(currentInterval); - LogMock.hasLoggedCode('14:03:03', [], numberState.currentInterval); - }); - - it("shouldn't set invalid interval callback function", () => { - numberState.interval(10 as any); - - expect(setInterval).not.toHaveBeenCalled(); - expect(numberState.currentInterval).toBeUndefined(); - LogMock.hasLoggedCode('00:03:01', ['Interval Callback', 'function']); - }); - }); - - describe('clearInterval function tests', () => { - const dummyCallbackFunction = jest.fn(); - - beforeEach(() => { - jest.useFakeTimers(); - numberState.set = jest.fn(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - it('should clear existing interval', () => { - numberState.interval(dummyCallbackFunction); - const currentInterval = numberState.currentInterval; - - numberState.clearInterval(); - - expect(clearInterval).toHaveBeenCalledTimes(1); - expect(clearInterval).toHaveBeenLastCalledWith(currentInterval); - expect(numberState.currentInterval).toBeUndefined(); - }); - - it("shouldn't clear not existing interval", () => { - numberState.clearInterval(); - - expect(clearInterval).not.toHaveBeenCalled(); - expect(numberState.currentInterval).toBeUndefined(); - }); - }); - - describe('exists get function tests', () => { - it('should return true if State is no placeholder and computeExistsMethod returns true', () => { - numberState.computeExistsMethod = jest.fn().mockReturnValueOnce(true); - numberState.isPlaceholder = false; - - expect(numberState.exists).toBeTruthy(); - expect(numberState.computeExistsMethod).toHaveBeenCalledWith( - numberState.value - ); - }); - - it('should return false if State is no placeholder and computeExistsMethod returns false', () => { - numberState.computeExistsMethod = jest.fn().mockReturnValueOnce(false); - numberState.isPlaceholder = false; - - expect(numberState.exists).toBeFalsy(); - expect(numberState.computeExistsMethod).toHaveBeenCalledWith( - numberState.value - ); - }); - - it('should return false if State is placeholder"', () => { - numberState.computeExistsMethod = jest.fn(() => true); - numberState.isPlaceholder = true; - - expect(numberState.exists).toBeFalsy(); - expect(numberState.computeExistsMethod).not.toHaveBeenCalled(); // since isPlaceholder gets checked first - }); - }); - - describe('computeExists function tests', () => { - it('should assign passed function to computeExistsMethod', () => { - const computeMethod = (value) => value === null; - - numberState.computeExists(computeMethod); - - expect(numberState.computeExistsMethod).toBe(computeMethod); - LogMock.hasNotLogged('warn'); - }); - - it("shouldn't assign passed invalid function to computeExistsMethod", () => { - numberState.computeExists(10 as any); - - expect(numberState.computeExistsMethod).toBeInstanceOf(Function); - LogMock.hasLoggedCode('00:03:01', [ - 'Compute Exists Method', - 'function', - ]); - }); - }); - - describe('is function tests', () => { - beforeEach(() => { - jest.spyOn(Utils, 'equal'); - }); - - it('should return true if passed value is equal to the current StateValue', () => { - const response = numberState.is(10); - - expect(response).toBeTruthy(); - expect(Utils.equal).toHaveBeenCalledWith(10, numberState._value); - }); - - it('should return false if passed value is not equal to the current StateValue', () => { - const response = numberState.is(20); - - expect(response).toBeFalsy(); - expect(Utils.equal).toHaveBeenCalledWith(20, numberState._value); - }); - }); - - describe('isNot function tests', () => { - beforeEach(() => { - jest.spyOn(Utils, 'notEqual'); - }); - - it('should return false if passed value is equal to the current StateValue', () => { - const response = numberState.isNot(10); - - expect(response).toBeFalsy(); - expect(Utils.notEqual).toHaveBeenCalledWith(10, numberState._value); - }); - - it('should return true if passed value is not equal to the current StateValue', () => { - const response = numberState.isNot(20); - - expect(response).toBeTruthy(); - expect(Utils.notEqual).toHaveBeenCalledWith(20, numberState._value); - }); - }); - - describe('invert function tests', () => { - let dummyState: State; - - beforeEach(() => { - dummyState = new State(dummyAgile, null); - - dummyState.set = jest.fn(); - }); - - it('should invert value of the type boolean', () => { - dummyState.nextStateValue = false; - - dummyState.invert(); - - expect(dummyState.set).toHaveBeenCalledWith(true); - }); - - it('should invert value of the type number', () => { - dummyState.nextStateValue = 10; - - 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']); - }); - }); - - describe('computeValue function tests', () => { - beforeEach(() => { - numberState.set = jest.fn(); - }); - - it('should assign passed function to computeValueMethod and compute State value initially', () => { - const computeMethod = () => 10; - - numberState.computeValue(computeMethod); - - expect(numberState.set).toHaveBeenCalledWith(10); - expect(numberState.computeValueMethod).toBe(computeMethod); - LogMock.hasNotLogged('warn'); - }); - - it("shouldn't assign passed invalid function to computeValueMethod", () => { - numberState.computeValue(10 as any); - - expect(numberState.set).not.toHaveBeenCalled(); - expect(numberState.computeValueMethod).toBeUndefined(); - LogMock.hasLoggedCode('00:03:01', ['Compute Value Method', 'function']); - }); - }); - describe('addSideEffect function tests', () => { const sideEffectFunction = () => { /* empty function */ @@ -1077,23 +348,5 @@ describe('State Tests', () => { expect(numberState.hasSideEffect('notExistingDummyKey')).toBeFalsy(); }); }); - - describe('hasCorrectType function tests', () => { - it('should return true if State Type matches passed type', () => { - numberState.type(Number); - - expect(numberState.hasCorrectType(10)).toBeTruthy(); - }); - - it("should return false if State Type doesn't matches passed type", () => { - numberState.type(Number); - - expect(numberState.hasCorrectType('stringValue')).toBeFalsy(); - }); - - it('should return true if State has no defined Type', () => { - expect(numberState.hasCorrectType('stringValue')).toBeTruthy(); - }); - }); }); }); diff --git a/packages/core/tests/unit/storages/index.test.ts b/packages/core/tests/unit/storages/index.test.ts new file mode 100644 index 00000000..dde743f3 --- /dev/null +++ b/packages/core/tests/unit/storages/index.test.ts @@ -0,0 +1,165 @@ +import { + Agile, + Storages, + Storage, + assignSharedAgileInstance, +} from '../../../src'; +import * as StorageIndex from '../../../src/storages/index'; +import { LogMock } from '../../helper/logMock'; +jest.mock('../../../src/storages/storages'); +jest.mock('../../../src/storages/storage'); + +describe('Storages Index', () => { + let sharedAgileInstance: Agile; + + beforeEach(() => { + LogMock.mockLogs(); + + sharedAgileInstance = new Agile(); + assignSharedAgileInstance(sharedAgileInstance); + + // Reset Storage Manager + StorageIndex.assignSharedAgileStorageManager(null); + + jest.clearAllMocks(); + }); + + describe('createStorage function tests', () => { + const StorageMock = Storage as jest.MockedClass; + + beforeEach(() => { + StorageMock.mockClear(); + }); + + it('should create Storage', () => { + const storageConfig = { + prefix: 'test', + methods: { + get: () => { + /* empty function */ + }, + set: () => { + /* empty function */ + }, + remove: () => { + /* empty function */ + }, + }, + key: 'myTestStorage', + }; + + const storage = StorageIndex.createStorage(storageConfig); + + expect(storage).toBeInstanceOf(Storage); + expect(StorageMock).toHaveBeenCalledWith(storageConfig); + }); + }); + + describe('createStorageManager function tests', () => { + const StoragesMock = Storages as jest.MockedClass; + + beforeEach(() => { + StoragesMock.mockClear(); + }); + + it('should create Storage Manager (Storages) with the shared Agile Instance', () => { + const storageManager = StorageIndex.createStorageManager({ + localStorage: true, + }); + + expect(storageManager).toBeInstanceOf(Storages); + expect(StoragesMock).toHaveBeenCalledWith(sharedAgileInstance, { + localStorage: true, + }); + }); + + it('should create Storage Manager (Storages) with a specified Agile Instance', () => { + const agile = new Agile(); + + const storageManager = StorageIndex.createStorageManager({ + agileInstance: agile, + localStorage: true, + }); + + expect(storageManager).toBeInstanceOf(Storages); + expect(StoragesMock).toHaveBeenCalledWith(agile, { localStorage: true }); + }); + }); + + describe('getStorageManager function tests', () => { + beforeEach(() => { + StorageIndex.assignSharedAgileStorageManager(null); + + jest.spyOn(StorageIndex, 'assignSharedAgileStorageManager'); + jest.spyOn(StorageIndex, 'createStorageManager'); + }); + + it('should return shared Storage Manager', () => { + const createdStorageManager = new Storages(sharedAgileInstance, { + localStorage: false, + }); + StorageIndex.assignSharedAgileStorageManager(createdStorageManager); + jest.clearAllMocks(); + + const returnedStorageManager = StorageIndex.getStorageManager(); + + expect(returnedStorageManager).toBeInstanceOf(Storages); + expect(returnedStorageManager).toBe(createdStorageManager); + expect(StorageIndex.createStorageManager).not.toHaveBeenCalled(); + expect( + StorageIndex.assignSharedAgileStorageManager + ).not.toHaveBeenCalled(); + }); + + // TODO doesn't work although it should 100% work?! + // it( + // 'should return newly created Storage Manager ' + + // 'if no shared Storage Manager was registered yet', + // () => { + // const createdStorageManager = new Storages(sharedAgileInstance, { + // localStorage: false, + // }); + // jest + // .spyOn(StorageIndex, 'createStorageManager') + // .mockReturnValueOnce(createdStorageManager); + // + // const returnedStorageManager = StorageIndex.getStorageManager(); + // + // expect(returnedStorageManager).toBeInstanceOf(Storages); + // expect(returnedStorageManager).toBe(createdStorageManager); + // expect(StorageIndex.createStorageManager).toHaveBeenCalledWith({ + // localStorage: false, + // }); + // expect( + // StorageIndex.assignSharedAgileStorageManager + // ).toHaveBeenCalledWith(createdStorageManager); + // } + // ); + }); + + describe('assignSharedAgileStorageManager function tests', () => { + it('should assign the specified Storage Manager as shared Storage Manager', () => { + const storageManager = new Storages(sharedAgileInstance); + + StorageIndex.assignSharedAgileStorageManager(storageManager); + + expect(StorageIndex.getStorageManager()).toBe(storageManager); + LogMock.hasNotLoggedCode('11:02:06'); + }); + + it( + 'should assign the specified Storage Manager as shared Storage Manager' + + 'and print warning if a shared Storage Manager is already set', + () => { + const oldStorageManager = new Storages(sharedAgileInstance); + StorageIndex.assignSharedAgileStorageManager(oldStorageManager); + const storageManager = new Storages(sharedAgileInstance); + + StorageIndex.assignSharedAgileStorageManager(storageManager); + + expect(StorageIndex.getStorageManager()).toBe(storageManager); + LogMock.hasLoggedCode('11:02:06', [], oldStorageManager); + } + ); + }); +}); diff --git a/packages/core/tests/unit/storages/persistent.test.ts b/packages/core/tests/unit/storages/persistent.test.ts index 1291b631..5c404777 100644 --- a/packages/core/tests/unit/storages/persistent.test.ts +++ b/packages/core/tests/unit/storages/persistent.test.ts @@ -1,13 +1,26 @@ -import { Agile, Persistent, Storage } from '../../../src'; +import { + Agile, + Persistent, + Storage, + createStorage, + Storages, + assignSharedAgileStorageManager, + createStorageManager, +} from '../../../src'; import { LogMock } from '../../helper/logMock'; describe('Persistent Tests', () => { let dummyAgile: Agile; + let storageManager: Storages; beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); + + // Register Storage Manager + storageManager = createStorageManager(); + assignSharedAgileStorageManager(storageManager); jest.spyOn(Persistent.prototype, 'instantiatePersistent'); @@ -28,9 +41,6 @@ describe('Persistent Tests', () => { key: undefined, defaultStorageKey: null, }); - expect( - dummyAgile.storages.persistentInstances.has(persistent) - ).toBeTruthy(); expect(persistent._key).toBe(Persistent.placeHolderKey); expect(persistent.ready).toBeFalsy(); @@ -58,9 +68,6 @@ describe('Persistent Tests', () => { key: 'persistentKey', defaultStorageKey: 'test1', }); - expect( - dummyAgile.storages.persistentInstances.has(persistent) - ).toBeTruthy(); expect(persistent._key).toBe(Persistent.placeHolderKey); expect(persistent.ready).toBeFalsy(); @@ -76,13 +83,10 @@ describe('Persistent Tests', () => { .spyOn(Persistent.prototype, 'instantiatePersistent') .mockReturnValueOnce(undefined); - const persistent = new Persistent(dummyAgile, { instantiate: false }); + const persistent = new Persistent(dummyAgile, { loadValue: false }); expect(persistent).toBeInstanceOf(Persistent); expect(persistent.instantiatePersistent).not.toHaveBeenCalled(); - expect( - dummyAgile.storages.persistentInstances.has(persistent) - ).toBeTruthy(); expect(persistent._key).toBe(Persistent.placeHolderKey); expect(persistent.ready).toBeFalsy(); @@ -188,25 +192,59 @@ describe('Persistent Tests', () => { }); describe('instantiatePersistent function tests', () => { - it('should call assign key to formatKey and call assignStorageKeys, validatePersistent', () => { + beforeEach(() => { jest.spyOn(persistent, 'formatKey'); jest.spyOn(persistent, 'assignStorageKeys'); jest.spyOn(persistent, 'validatePersistent'); + }); - persistent.instantiatePersistent({ - key: 'persistentKey', - storageKeys: ['myName', 'is', 'jeff'], - defaultStorageKey: 'jeff', - }); + it( + 'should call formatKey, assignStorageKeys, validatePersistent ' + + 'and add Persistent to the shared Storage Manager if Persistent has a valid key', + () => { + persistent.instantiatePersistent({ + key: 'persistentKey', + storageKeys: ['myName', 'is', 'jeff'], + defaultStorageKey: 'jeff', + }); + + expect(persistent._key).toBe('persistentKey'); + expect(persistent.formatKey).toHaveBeenCalledWith('persistentKey'); + expect(persistent.assignStorageKeys).toHaveBeenCalledWith( + ['myName', 'is', 'jeff'], + 'jeff' + ); + expect(persistent.validatePersistent).toHaveBeenCalled(); + expect(storageManager.persistentInstances).toHaveProperty( + 'persistentKey' + ); + expect(storageManager.persistentInstances['persistentKey']).toBe( + persistent + ); + } + ); - expect(persistent._key).toBe('persistentKey'); - expect(persistent.formatKey).toHaveBeenCalledWith('persistentKey'); - expect(persistent.assignStorageKeys).toHaveBeenCalledWith( - ['myName', 'is', 'jeff'], - 'jeff' - ); - expect(persistent.validatePersistent).toHaveBeenCalled(); - }); + it( + 'should call formatKey, assignStorageKeys, validatePersistent ' + + "and shouldn't add Persistent to the shared Storage Manager if Persistent has no valid key", + () => { + persistent.instantiatePersistent({ + storageKeys: ['myName', 'is', 'jeff'], + defaultStorageKey: 'jeff', + }); + + expect(persistent._key).toBe(Persistent.placeHolderKey); + expect(persistent.formatKey).toHaveBeenCalledWith(undefined); + expect(persistent.assignStorageKeys).toHaveBeenCalledWith( + ['myName', 'is', 'jeff'], + 'jeff' + ); + expect(persistent.validatePersistent).toHaveBeenCalled(); + expect(storageManager.persistentInstances).not.toHaveProperty( + 'persistentKey' + ); + } + ); }); describe('validatePersistent function tests', () => { @@ -262,8 +300,8 @@ describe('Persistent Tests', () => { }); it('should return true if set key and set StorageKeys', () => { - dummyAgile.storages.register( - dummyAgile.createStorage({ + storageManager.register( + createStorage({ key: 'test', methods: { get: () => { @@ -337,7 +375,7 @@ describe('Persistent Tests', () => { 'should try to get default StorageKey from Agile if no StorageKey was specified ' + 'and assign it as StorageKey, if it is a valid StorageKey', () => { - dummyAgile.storages.register( + storageManager.register( new Storage({ key: 'storage1', methods: { diff --git a/packages/core/tests/unit/storages/storages.test.ts b/packages/core/tests/unit/storages/storages.test.ts index 139a7e29..9f09d002 100644 --- a/packages/core/tests/unit/storages/storages.test.ts +++ b/packages/core/tests/unit/storages/storages.test.ts @@ -1,4 +1,10 @@ -import { Storages, Agile, Storage, Persistent } from '../../../src'; +import { + Storages, + Agile, + Storage, + Persistent, + assignSharedAgileStorageManager, +} from '../../../src'; import { LogMock } from '../../helper/logMock'; describe('Storages Tests', () => { @@ -7,7 +13,7 @@ describe('Storages Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); jest.spyOn(Storages.prototype, 'instantiateLocalStorage'); @@ -19,7 +25,7 @@ describe('Storages Tests', () => { expect(storages.config).toStrictEqual({ defaultStorageKey: null }); expect(storages.storages).toStrictEqual({}); - expect(storages.persistentInstances.size).toBe(0); + expect(storages.persistentInstances).toStrictEqual({}); expect(storages.instantiateLocalStorage).not.toHaveBeenCalled(); }); @@ -31,7 +37,7 @@ describe('Storages Tests', () => { expect(storages.config).toStrictEqual({ defaultStorageKey: 'jeff' }); expect(storages.storages).toStrictEqual({}); - expect(storages.persistentInstances.size).toBe(0); + expect(storages.persistentInstances).toStrictEqual({}); expect(storages.instantiateLocalStorage).toHaveBeenCalled(); }); @@ -44,7 +50,8 @@ describe('Storages Tests', () => { beforeEach(() => { storages = new Storages(dummyAgile); - dummyAgile.storages = storages; + assignSharedAgileStorageManager(storages); + dummyStorageMethods = { get: jest.fn(), set: jest.fn(), @@ -183,7 +190,7 @@ describe('Storages Tests', () => { expect(response).toBeTruthy(); }); - it('should revalidate and initial load Persistents that have no defined defaultStorage', () => { + it('should revalidate and initial load persistent Instances that have no defined defaultStorage', () => { const dummyPersistent1 = new Persistent(dummyAgile, { key: 'dummyPersistent1', }); diff --git a/packages/core/tests/unit/utils.test.ts b/packages/core/tests/unit/utils.test.ts index 96decb42..46e8aafd 100644 --- a/packages/core/tests/unit/utils.test.ts +++ b/packages/core/tests/unit/utils.test.ts @@ -16,7 +16,7 @@ describe('Utils Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); // @ts-ignore | Reset globalThis globalThis = {}; @@ -118,13 +118,11 @@ describe('Utils Tests', () => { // State with multiple Observer dummyStateWithMultipleObserver = new State(dummyAgile, null); dummyStateValueObserver = new StateObserver(dummyState); - dummyStateWithMultipleObserver.observers[ - 'value' - ] = dummyStateValueObserver; + dummyStateWithMultipleObserver.observers['value'] = + dummyStateValueObserver; dummyStateRandomObserver = new StateObserver(dummyState); - dummyStateWithMultipleObserver.observers[ - 'random' - ] = dummyStateRandomObserver; + dummyStateWithMultipleObserver.observers['random'] = + dummyStateRandomObserver; // Collection dummyCollection = new Collection(dummyAgile); @@ -216,13 +214,11 @@ describe('Utils Tests', () => { // State with multiple Observer dummyStateWithMultipleObserver = new State(dummyAgile, null); dummyStateValueObserver = new StateObserver(dummyState); - dummyStateWithMultipleObserver.observers[ - 'value' - ] = dummyStateValueObserver; + dummyStateWithMultipleObserver.observers['value'] = + dummyStateValueObserver; dummyStateRandomObserver = new StateObserver(dummyState); - dummyStateWithMultipleObserver.observers[ - 'random' - ] = dummyStateRandomObserver; + dummyStateWithMultipleObserver.observers['random'] = + dummyStateRandomObserver; // Collection dummyCollection = new Collection(dummyAgile); diff --git a/packages/core/tsconfig.esm.json b/packages/core/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/core/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/core/tsconfig.production.json b/packages/core/tsconfig.production.json index 7f9c2701..b8ec8c38 100644 --- a/packages/core/tsconfig.production.json +++ b/packages/core/tsconfig.production.json @@ -1,8 +1,9 @@ // Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/cra-template-agile-typescript/package.json b/packages/cra-template-agile-typescript/package.json index fe85bf0c..1933f30f 100644 --- a/packages/cra-template-agile-typescript/package.json +++ b/packages/cra-template-agile-typescript/package.json @@ -14,7 +14,7 @@ "agile" ], "scripts": { - "preview": "npm pack" + "pack": "npm pack" }, "devDependencies": { "@agile-ts/core": "^0.1.3", diff --git a/packages/cra-template-agile/package.json b/packages/cra-template-agile/package.json index 9d64a42c..676cd8e0 100644 --- a/packages/cra-template-agile/package.json +++ b/packages/cra-template-agile/package.json @@ -14,7 +14,7 @@ "agile" ], "scripts": { - "preview": "npm pack" + "pack": "npm pack" }, "devDependencies": { "@agile-ts/core": "^0.1.3", diff --git a/packages/event/package.json b/packages/event/package.json index a1670fd4..20d9bd04 100644 --- a/packages/event/package.json +++ b/packages/event/package.json @@ -12,16 +12,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*" @@ -50,5 +54,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/event/src/event.observer.ts b/packages/event/src/event/event.observer.ts similarity index 97% rename from packages/event/src/event.observer.ts rename to packages/event/src/event/event.observer.ts index 97da3216..1baa1303 100644 --- a/packages/event/src/event.observer.ts +++ b/packages/event/src/event/event.observer.ts @@ -4,7 +4,7 @@ import { ObserverKey, SubscriptionContainer, } from '@agile-ts/core'; -import { Event } from './internal'; +import { Event } from '../internal'; export class EventObserver extends Observer { public event: () => Event; diff --git a/packages/event/src/event.job.ts b/packages/event/src/event/event.runtime.job.ts similarity index 90% rename from packages/event/src/event.job.ts rename to packages/event/src/event/event.runtime.job.ts index f192cadd..dc7547a3 100644 --- a/packages/event/src/event.job.ts +++ b/packages/event/src/event/event.runtime.job.ts @@ -1,4 +1,4 @@ -export class EventJob { +export class EventRuntimeJob { public payload: PayloadType; public creationTimestamp: number; public keys?: string[]; diff --git a/packages/event/src/event.ts b/packages/event/src/event/event.ts similarity index 97% rename from packages/event/src/event.ts rename to packages/event/src/event/event.ts index b54660f9..403ff902 100644 --- a/packages/event/src/event.ts +++ b/packages/event/src/event/event.ts @@ -5,7 +5,7 @@ import { LogCodeManager, Observer, } from '@agile-ts/core'; -import { EventObserver, EventJob } from './internal'; +import { EventObserver, EventRuntimeJob } from '../internal'; import { defineConfig } from '@agile-ts/utils'; export class Event { @@ -21,7 +21,7 @@ export class Event { public observer: EventObserver; public currentTimeout: any; // Timeout that is active right now (delayed Event) - public queue: Array = []; // Queue of delayed Events + public queue: Array = []; // Queue of delayed Events // @ts-ignore public payload: PayloadType; // Holds type of Payload so that it can be read external (never defined) @@ -251,7 +251,7 @@ export class Event { * @param keys - Keys of Callback Functions that get triggered (Note: if not passed all registered Events will be triggered) */ public delayedTrigger(payload: PayloadType, delay: number, keys?: string[]) { - const eventJob = new EventJob(payload, keys); + const eventJob = new EventRuntimeJob(payload, keys); // Execute Event no matter if another event is currently active if (this.config.overlap) { @@ -268,7 +268,7 @@ export class Event { } // Executes EventJob and calls itself again if queue isn't empty to execute the next EventJob - const looper = (eventJob: EventJob) => { + const looper = (eventJob: EventRuntimeJob) => { this.currentTimeout = setTimeout(() => { this.currentTimeout = undefined; this.normalTrigger(eventJob.payload, eventJob.keys); diff --git a/packages/event/src/shared.ts b/packages/event/src/event/index.ts similarity index 67% rename from packages/event/src/shared.ts rename to packages/event/src/event/index.ts index fae03ce1..ebd8344f 100644 --- a/packages/event/src/shared.ts +++ b/packages/event/src/event/index.ts @@ -1,14 +1,14 @@ import { - CreateAgileSubInstanceInterface, - removeProperties, - shared, - defineConfig, -} from '@agile-ts/core'; -import { - Event, CreateEventConfigInterface, DefaultEventPayload, -} from './internal'; + Event, +} from './event'; +import { defineConfig, removeProperties } from '@agile-ts/utils'; +import { CreateAgileSubInstanceInterface, shared } from '@agile-ts/core'; + +export * from './event'; +// export * from './event.observer'; +// export * from './event.job'; export function createEvent( config: CreateEventConfigInterfaceWithAgile = {} diff --git a/packages/event/src/index.ts b/packages/event/src/index.ts index f3396fb7..3cb08d20 100644 --- a/packages/event/src/index.ts +++ b/packages/event/src/index.ts @@ -1,5 +1,4 @@ import { Event } from './internal'; export * from './internal'; -export { useEvent } from './hooks/useEvent'; export default Event; diff --git a/packages/event/src/internal.ts b/packages/event/src/internal.ts index 695b0fbb..758f4fe4 100644 --- a/packages/event/src/internal.ts +++ b/packages/event/src/internal.ts @@ -5,9 +5,6 @@ // !! All internal Agile Editor modules must be imported from here!! // Event -export * from './event.job'; -export * from './event.observer'; +export * from './event/event.runtime.job'; +export * from './event/event.observer'; export * from './event'; - -// Shared -export * from './shared'; diff --git a/packages/event/src/hooks/useEvent.ts b/packages/event/src/react/hooks/useEvent.ts similarity index 95% rename from packages/event/src/hooks/useEvent.ts rename to packages/event/src/react/hooks/useEvent.ts index 5a1d3168..3ef41a70 100644 --- a/packages/event/src/hooks/useEvent.ts +++ b/packages/event/src/react/hooks/useEvent.ts @@ -5,7 +5,7 @@ import { LogCodeManager, SubscriptionContainerKeyType, } from '@agile-ts/core'; -import { Event, EventCallbackFunction } from '../internal'; +import { Event, EventCallbackFunction } from '../../internal'; import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; export function useEvent>( diff --git a/packages/event/src/hooks/useIsomorphicLayoutEffect.ts b/packages/event/src/react/hooks/useIsomorphicLayoutEffect.ts similarity index 100% rename from packages/event/src/hooks/useIsomorphicLayoutEffect.ts rename to packages/event/src/react/hooks/useIsomorphicLayoutEffect.ts diff --git a/packages/event/src/react/index.ts b/packages/event/src/react/index.ts new file mode 100644 index 00000000..1501534e --- /dev/null +++ b/packages/event/src/react/index.ts @@ -0,0 +1 @@ +export { useEvent } from './hooks/useEvent'; diff --git a/packages/event/tests/unit/event.job.test.ts b/packages/event/tests/unit/event/event.job.test.ts similarity index 74% rename from packages/event/tests/unit/event.job.test.ts rename to packages/event/tests/unit/event/event.job.test.ts index 90397943..0e67edda 100644 --- a/packages/event/tests/unit/event.job.test.ts +++ b/packages/event/tests/unit/event/event.job.test.ts @@ -1,5 +1,5 @@ -import { EventJob } from '../../src'; -import { LogMock } from '../../../core/tests/helper/logMock'; +import { EventRuntimeJob } from '../../../src'; +import { LogMock } from '../../../../core/tests/helper/logMock'; describe('EventJob Tests', () => { beforeEach(() => { @@ -8,7 +8,7 @@ describe('EventJob Tests', () => { }); it('should create EventJob (without keys)', () => { - const eventJob = new EventJob('myPayload'); + const eventJob = new EventRuntimeJob('myPayload'); expect(eventJob.payload).toBe('myPayload'); expect(eventJob.creationTimestamp).toBeCloseTo( @@ -19,7 +19,10 @@ describe('EventJob Tests', () => { }); it('should create EventJob (with keys)', () => { - const eventJob = new EventJob('myPayload', ['dummyKey1', 'dummyKey2']); + const eventJob = new EventRuntimeJob('myPayload', [ + 'dummyKey1', + 'dummyKey2', + ]); expect(eventJob.payload).toBe('myPayload'); expect(eventJob.creationTimestamp).toBeCloseTo( diff --git a/packages/event/tests/unit/event.observer.test.ts b/packages/event/tests/unit/event/event.observer.test.ts similarity index 92% rename from packages/event/tests/unit/event.observer.test.ts rename to packages/event/tests/unit/event/event.observer.test.ts index b700a72f..2ee5d487 100644 --- a/packages/event/tests/unit/event.observer.test.ts +++ b/packages/event/tests/unit/event/event.observer.test.ts @@ -1,6 +1,6 @@ -import { EventObserver, Event } from '../../src'; +import { EventObserver, Event } from '../../../src'; import { Agile, Observer, SubscriptionContainer } from '@agile-ts/core'; -import { LogMock } from '../../../core/tests/helper/logMock'; +import { LogMock } from '../../../../core/tests/helper/logMock'; describe('EventObserver Tests', () => { let dummyAgile: Agile; @@ -9,7 +9,7 @@ describe('EventObserver Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); dummyEvent = new Event(dummyAgile); jest.clearAllMocks(); diff --git a/packages/event/tests/unit/event.test.ts b/packages/event/tests/unit/event/event.test.ts similarity index 98% rename from packages/event/tests/unit/event.test.ts rename to packages/event/tests/unit/event/event.test.ts index bd369952..a719ce06 100644 --- a/packages/event/tests/unit/event.test.ts +++ b/packages/event/tests/unit/event/event.test.ts @@ -1,7 +1,7 @@ -import { Event, EventObserver } from '../../src'; +import { Event, EventObserver } from '../../../src'; import { Agile, Observer } from '@agile-ts/core'; import * as Utils from '@agile-ts/utils'; -import { LogMock } from '../../../core/tests/helper/logMock'; +import { LogMock } from '../../../../core/tests/helper/logMock'; describe('Event Tests', () => { let dummyAgile: Agile; @@ -9,7 +9,7 @@ describe('Event Tests', () => { beforeEach(() => { LogMock.mockLogs(); - dummyAgile = new Agile({ localStorage: false }); + dummyAgile = new Agile(); jest.clearAllMocks(); }); diff --git a/packages/event/tests/unit/shared.test.ts b/packages/event/tests/unit/event/index.test.ts similarity index 88% rename from packages/event/tests/unit/shared.test.ts rename to packages/event/tests/unit/event/index.test.ts index 7b5719a0..9d8a6b6a 100644 --- a/packages/event/tests/unit/shared.test.ts +++ b/packages/event/tests/unit/event/index.test.ts @@ -1,8 +1,8 @@ import { Agile, assignSharedAgileInstance } from '@agile-ts/core'; -import { Event, createEvent } from '../../src'; -import { LogMock } from '../../../core/tests/helper/logMock'; +import { Event, createEvent } from '../../../src'; +import { LogMock } from '../../../../core/tests/helper/logMock'; -jest.mock('../../src/event'); +jest.mock('../../../src/event/event'); describe('Shared Tests', () => { let sharedAgileInstance: Agile; diff --git a/packages/event/tsconfig.esm.json b/packages/event/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/event/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/event/tsconfig.json b/packages/event/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/event/tsconfig.json +++ b/packages/event/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/event/tsconfig.production.json b/packages/event/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/event/tsconfig.production.json +++ b/packages/event/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/logger/package.json b/packages/logger/package.json index 6c60caa0..a4ae61f9 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -12,16 +12,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*" @@ -47,5 +51,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 00aa3e3e..a76a7efc 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1,6 +1,9 @@ import { CreateLoggerConfigInterface, Logger } from './logger'; import { defineConfig } from '@agile-ts/utils'; +export * from './logger'; +export default Logger; + const defaultLogConfig = { prefix: 'Agile', active: true, @@ -9,9 +12,6 @@ const defaultLogConfig = { allowedTags: ['runtime', 'storage', 'subscription', 'multieditor'], }; -/** - * Shared Agile Logger. - */ let sharedAgileLogger = new Logger(defaultLogConfig); /** @@ -19,8 +19,7 @@ let sharedAgileLogger = new Logger(defaultLogConfig); * * @param config - Configuration object */ -// https://stackoverflow.com/questions/32558514/javascript-es6-export-const-vs-export-let -function assignSharedAgileLoggerConfig( +export function assignSharedAgileLoggerConfig( config: CreateLoggerConfigInterface = {} ): Logger { config = defineConfig(config, defaultLogConfig); @@ -28,6 +27,9 @@ function assignSharedAgileLoggerConfig( return sharedAgileLogger; } -export { sharedAgileLogger, assignSharedAgileLoggerConfig }; -export * from './logger'; -export default Logger; +/** + * Returns the shared Agile Logger. + */ +export function getLogger(): Logger { + return sharedAgileLogger; +} diff --git a/packages/logger/tsconfig.esm.json b/packages/logger/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/logger/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/logger/tsconfig.json +++ b/packages/logger/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/logger/tsconfig.production.json b/packages/logger/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/logger/tsconfig.production.json +++ b/packages/logger/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/multieditor/package.json b/packages/multieditor/package.json index d887c18c..bfb7dbac 100644 --- a/packages/multieditor/package.json +++ b/packages/multieditor/package.json @@ -16,16 +16,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*" @@ -51,5 +55,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/multieditor/src/item.ts b/packages/multieditor/src/item.ts index b2199b4b..32493150 100644 --- a/packages/multieditor/src/item.ts +++ b/packages/multieditor/src/item.ts @@ -1,11 +1,11 @@ import { - State, StateRuntimeJobConfigInterface, defineConfig, + EnhancedState, } from '@agile-ts/core'; import { MultiEditor, Validator, Status, ItemKey } from './internal'; -export class Item extends State { +export class Item extends EnhancedState { public editor: () => MultiEditor; public isValid = false; diff --git a/packages/multieditor/src/multieditor/index.ts b/packages/multieditor/src/multieditor/index.ts new file mode 100644 index 00000000..5353b95c --- /dev/null +++ b/packages/multieditor/src/multieditor/index.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@agile-ts/utils'; +import { Agile, shared } from '@agile-ts/core'; +import { EditorConfig, MultiEditor } from '../internal'; + +export * from './multieditor'; + +export function createMultieditor< + DataType = any, + SubmitReturnType = void, + OnSubmitConfigType = any +>( + config: EditorConfig, + agileInstance: Agile = shared +): MultiEditor { + config = defineConfig(config, { + agileInstance: shared, + }); + return new MultiEditor( + config, + agileInstance as any + ); +} diff --git a/packages/multieditor/src/multieditor.ts b/packages/multieditor/src/multieditor/multieditor.ts similarity index 99% rename from packages/multieditor/src/multieditor.ts rename to packages/multieditor/src/multieditor/multieditor.ts index a686a6c0..3e000768 100644 --- a/packages/multieditor/src/multieditor.ts +++ b/packages/multieditor/src/multieditor/multieditor.ts @@ -13,7 +13,7 @@ import { StatusType, StatusInterface, ValidationMethodInterface, -} from './internal'; +} from '../internal'; export class MultiEditor< DataType = any, diff --git a/packages/multieditor/tsconfig.esm.json b/packages/multieditor/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/multieditor/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/multieditor/tsconfig.json b/packages/multieditor/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/multieditor/tsconfig.json +++ b/packages/multieditor/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/multieditor/tsconfig.production.json b/packages/multieditor/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/multieditor/tsconfig.production.json +++ b/packages/multieditor/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/proxytree/package.json b/packages/proxytree/package.json index 8550a5b7..557a6367 100644 --- a/packages/proxytree/package.json +++ b/packages/proxytree/package.json @@ -12,16 +12,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*" @@ -41,5 +45,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/proxytree/tsconfig.esm.json b/packages/proxytree/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/proxytree/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/proxytree/tsconfig.json b/packages/proxytree/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/proxytree/tsconfig.json +++ b/packages/proxytree/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/proxytree/tsconfig.production.json b/packages/proxytree/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/proxytree/tsconfig.production.json +++ b/packages/proxytree/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/react/package.json b/packages/react/package.json index eb9a22fe..7c77f662 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@agile-ts/react", - "version": "0.1.2", + "version": "0.2.0-alpha.2", "author": "BennoDev", "license": "MIT", "homepage": "https://agile-ts.org/", @@ -23,16 +23,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*", @@ -75,5 +79,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/react/src/hocs/AgileHOC.ts b/packages/react/src/hocs/AgileHOC.ts index 82055a7d..01b787ed 100644 --- a/packages/react/src/hocs/AgileHOC.ts +++ b/packages/react/src/hocs/AgileHOC.ts @@ -1,17 +1,16 @@ import React, { ComponentClass } from 'react'; -import { +import Agile, { State, - Agile, ComponentSubscriptionContainer, getAgileInstance, Observer, - Collection, isValidObject, flatMerge, extractRelevantObservers, normalizeArray, - LogCodeManager, } from '@agile-ts/core'; +import type { Collection } from '@agile-ts/core'; // Only import Collection and Group type for better Treeshaking +import { LogCodeManager } from '../logCodeManager'; /** * A Higher order Component for binding the most relevant value of multiple Agile Instances @@ -58,10 +57,7 @@ export function AgileHOC( } } if (!agileInstance || !agileInstance.subController) { - LogCodeManager.getLogger()?.error( - 'Failed to subscribe Component with deps', - deps - ); + LogCodeManager.log('32:03:00', [deps]); return reactComponent; } @@ -94,9 +90,8 @@ const createHOC = ( public agileInstance: Agile; public waitForMount: boolean; - public componentSubscriptionContainers: Array< - ComponentSubscriptionContainer - > = []; // Represents all Subscription Container subscribed to this Component (set by subController) + public componentSubscriptionContainers: Array = + []; // Represents all Subscription Container subscribed to this Component (set by subController) public agileProps = {}; // Props of subscribed Agile Instances (are merged into the normal props) constructor(props: any) { @@ -236,9 +231,8 @@ const formatDepsWithIndicator = ( export class AgileReactComponent extends React.Component { // @ts-ignore public agileInstance: Agile; - public componentSubscriptionContainers: Array< - ComponentSubscriptionContainer - > = []; + public componentSubscriptionContainers: Array = + []; public agileProps = {}; constructor(props: any) { diff --git a/packages/react/src/hooks/useAgile.ts b/packages/react/src/hooks/useAgile.ts index 41de3840..15d33408 100644 --- a/packages/react/src/hooks/useAgile.ts +++ b/packages/react/src/hooks/useAgile.ts @@ -1,32 +1,18 @@ -import React from 'react'; import { - Agile, - Collection, - getAgileInstance, Observer, State, - SubscriptionContainerKeyType, - isValidObject, generateId, - ProxyWeakMapType, - ComponentIdType, extractRelevantObservers, - SelectorWeakMapType, - SelectorMethodType, - LogCodeManager, normalizeArray, defineConfig, - Group, } from '@agile-ts/core'; -import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; - -// TODO https://stackoverflow.com/questions/68148235/require-module-inside-a-function-doesnt-work -let proxyPackage: any = null; -try { - proxyPackage = require('@agile-ts/proxytree'); -} catch (e) { - // empty catch block -} +import type { Collection, Group } from '@agile-ts/core'; // Only import Collection and Group type for better Treeshaking +import { + BaseAgileHookConfigInterface, + getReturnValue, + SubscribableAgileInstancesType, + useBaseAgile, +} from './useBaseAgile'; /** * A React Hook for binding the most relevant value of multiple Agile Instances @@ -69,7 +55,6 @@ export function useAgile< ): AgileOutputHookArrayType | AgileOutputHookType { config = defineConfig(config, { key: generateId(), - proxyBased: false, agileInstance: null as any, componentId: undefined, observerType: undefined, @@ -79,180 +64,26 @@ export function useAgile< normalizeArray(deps), config.observerType ); - const proxyTreeWeakMap = new WeakMap(); - - // Builds return value, - // depending on whether the deps were provided in array shape or not - const getReturnValue = ( - depsArray: (Observer | undefined)[] - ): AgileOutputHookArrayType | AgileOutputHookType => { - const handleReturn = ( - dep: Observer | undefined - ): AgileOutputHookType => { - if (dep == null) return undefined as any; - const value = dep.value; - - // If proxyBased and the value is of the type object. - // Wrap a Proxy around the object to track the accessed properties. - if (config.proxyBased && isValidObject(value, true)) { - if (proxyPackage != null) { - const { ProxyTree } = proxyPackage; - const proxyTree = new ProxyTree(value); - proxyTreeWeakMap.set(dep, proxyTree); - return proxyTree.proxy; - } else { - console.error( - 'In order to use the Agile proxy functionality, ' + - `the installation of an additional package called '@agile-ts/proxytree' is required!` - ); - } - } - // If specified selector function and the value is of type object. - // Return the selected value. - // (Destroys the type of the useAgile hook, - // however the type is adjusted in the useSelector hook) - if (config.selector && isValidObject(value, true)) { - return config.selector(value); - } - - return value; - }; - - // Handle single dep return value - if (depsArray.length === 1 && !Array.isArray(deps)) { - return handleReturn(depsArray[0]); - } - - // Handle deps array return value - return depsArray.map((dep) => { - return handleReturn(dep); - }) as AgileOutputHookArrayType; + const handleReturn = (dep: Observer | undefined) => { + return dep != null ? dep.value : undefined; }; - // Trigger State, used to force Component to rerender - const [, forceRender] = React.useReducer((s) => s + 1, 0); - - useIsomorphicLayoutEffect(() => { - let agileInstance = config.agileInstance; - - // https://github.com/microsoft/TypeScript/issues/20812 - const observers: Observer[] = depsArray.filter( - (dep): dep is Observer => dep !== undefined - ); - - // Try to extract Agile Instance from the specified Instance/s - if (!agileInstance) agileInstance = getAgileInstance(observers[0]); - if (!agileInstance || !agileInstance.subController) { - LogCodeManager.getLogger()?.error( - 'Failed to subscribe Component with deps because of missing valid Agile Instance.', - deps - ); - return; - } - - // TODO Proxy doesn't work as expected when 'selecting' a not yet existing property. - // For example you select the 'user.data.name' property, but the 'user' object is undefined. - // -> No correct Proxy Path could be created on the Component mount, since the to select property doesn't exist - // -> Selector was created based on the not complete Proxy Path - // -> Component re-renders to often - // - // Build Proxy Path WeakMap based on the Proxy Tree WeakMap - // by extracting the routes from the Proxy Tree. - // Building the Path WeakMap in the 'useIsomorphicLayoutEffect' - // because the 'useIsomorphicLayoutEffect' is called after the rerender. - // -> All used paths in the UI-Component were successfully tracked. - let proxyWeakMap: ProxyWeakMapType | undefined = undefined; - if (config.proxyBased && proxyPackage != null) { - proxyWeakMap = new WeakMap(); - for (const observer of observers) { - const proxyTree = proxyTreeWeakMap.get(observer); - if (proxyTree != null) { - proxyWeakMap.set(observer, { - paths: proxyTree.getUsedRoutes() as any, - }); - } - } - } - - // Build Selector WeakMap based on the specified selector method - let selectorWeakMap: SelectorWeakMapType | undefined = undefined; - if (config.selector != null) { - selectorWeakMap = new WeakMap(); - for (const observer of observers) { - selectorWeakMap.set(observer, { methods: [config.selector] }); - } - } - - // Create Callback based Subscription - const subscriptionContainer = agileInstance.subController.subscribe( - () => { - forceRender(); - }, - observers, - { - key: config.key, - proxyWeakMap, - waitForMount: false, - componentId: config.componentId, - selectorWeakMap, - } - ); - - // Unsubscribe Callback based Subscription on unmount - return () => { - agileInstance?.subController.unsubscribe(subscriptionContainer); - }; - }, config.deps); + useBaseAgile( + depsArray, + () => ({ + key: config.key, + waitForMount: false, + componentId: config.componentId, + }), + config.deps || [], + config.agileInstance + ); - return getReturnValue(depsArray); + return getReturnValue(depsArray, handleReturn, Array.isArray(deps)); } -export type SubscribableAgileInstancesType = - | State - | Collection //https://stackoverflow.com/questions/66987727/type-classa-id-number-name-string-is-not-assignable-to-type-classar - | Observer - | undefined; - -export interface AgileHookConfigInterface { - /** - * Key/Name identifier of the Subscription Container to be created. - * @default undefined - */ - key?: SubscriptionContainerKeyType; - /** - * Instance of Agile the Subscription Container belongs to. - * @default `undefined` if no Agile Instance could be extracted from the provided Instances. - */ - agileInstance?: Agile; - /** - * Whether to wrap a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) - * around the bound Agile Instance value object, - * to automatically constrain the way the selected Agile Instance - * is compared to determine whether the Component needs to be re-rendered - * based on the object's used properties. - * - * Requires an additional package called `@agile-ts/proxytree`! - * - * @default false - */ - proxyBased?: boolean; - /** - * Equality comparison function - * that allows you to customize the way the selected Agile Instance - * is compared to determine whether the Component needs to be re-rendered. - * - * * Note that setting this property can destroy the useAgile type. - * -> should only be used internal! - * - * @default undefined - */ - selector?: SelectorMethodType; - /** - * Key/Name identifier of the UI-Component the Subscription Container is bound to. - * @default undefined - */ - componentId?: ComponentIdType; +export interface AgileHookConfigInterface extends BaseAgileHookConfigInterface { /** * What type of Observer to be bound to the UI-Component. * @@ -262,15 +93,6 @@ export interface AgileHookConfigInterface { * @default undefined */ observerType?: string; - /** - * Dependencies that determine, in addition to unmounting and remounting the React-Component, - * when the specified Agile Sub Instances should be re-subscribed to the React-Component. - * - * [Github issue](https://github.com/agile-ts/agile/issues/170) - * - * @default [] - */ - deps?: any[]; } // Array Type diff --git a/packages/react/src/hooks/useBaseAgile.ts b/packages/react/src/hooks/useBaseAgile.ts new file mode 100644 index 00000000..b566b756 --- /dev/null +++ b/packages/react/src/hooks/useBaseAgile.ts @@ -0,0 +1,125 @@ +import React from 'react'; +import Agile, { + Collection, + ComponentIdType, + getAgileInstance, + Observer, + State, + SubscriptionContainerKeyType, + RegisterSubscriptionConfigInterface, +} from '@agile-ts/core'; +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; +import { LogCodeManager } from '../logCodeManager'; + +/** + * An internal used React Hook + * to create a Callback based Subscription Container + * based on the specified depsArray + * and thus bind these dependencies to a Functional React Component. + * + * @internal + * @param depsArray - Observers to be bound to the Functional Component. + * @param getSubContainerConfig - Method to get the Subscription Container configuration object. + * @param deps - Dependencies that determine, in addition to unmounting and remounting the React-Component, + * when the specified Agile Sub Instances should be re-subscribed to the React-Component. + * @param agileInstance - Agile Instance the to create Subscription Container belongs to. + */ +export const useBaseAgile = ( + depsArray: (Observer | undefined)[], + getSubContainerConfig: ( + observers: Observer[] + ) => RegisterSubscriptionConfigInterface, + deps: any[], + agileInstance?: Agile +) => { + // Trigger State, used to force Component to rerender + const [, forceRender] = React.useReducer((s) => s + 1, 0); + + useIsomorphicLayoutEffect(() => { + // https://github.com/microsoft/TypeScript/issues/20812 + const observers = depsArray.filter( + (dep): dep is Observer => dep !== undefined + ); + + const subContainerConfig = getSubContainerConfig(observers); + + // Try to extract Agile Instance from the specified Instance/s + if (agileInstance == null) agileInstance = getAgileInstance(observers[0]); + if (agileInstance == null || agileInstance.subController == null) { + LogCodeManager.log('30:03:00', deps); + return; + } + + // Create Callback based Subscription + const subscriptionContainer = agileInstance.subController.subscribe( + () => { + forceRender(); + }, + observers, + subContainerConfig + ); + + // Unsubscribe Callback based Subscription on unmount + return () => { + agileInstance?.subController.unsubscribe(subscriptionContainer); + }; + }, deps); +}; + +/** + * Builds return value for Agile Instance 'binding' Hooks, + * depending on whether the dependencies were provided in array shape or not. + * + * @internal + * @param depsArray - Dependencies to extract the return value from. + * @param handleReturn - Method to handle the return value. + * @param wasProvidedAsArray - Whether the specified depsArray was provided as array in the Hook. + */ +export const getReturnValue = ( + depsArray: (Observer | undefined)[], + handleReturn: (dep: Observer | undefined) => any, + wasProvidedAsArray: boolean +): any => { + // Handle single dep return value + if (depsArray.length === 1 && !wasProvidedAsArray) { + return handleReturn(depsArray[0]); + } + + // Handle deps array return value + return depsArray.map((dep) => { + return handleReturn(dep); + }); +}; + +export type SubscribableAgileInstancesType = + | State + | Collection //https://stackoverflow.com/questions/66987727/type-classa-id-number-name-string-is-not-assignable-to-type-classar + | Observer + | undefined; + +export interface BaseAgileHookConfigInterface { + /** + * Key/Name identifier of the Subscription Container to be created. + * @default undefined + */ + key?: SubscriptionContainerKeyType; + /** + * Instance of Agile the Subscription Container belongs to. + * @default `undefined` if no Agile Instance could be extracted from the provided Instances. + */ + agileInstance?: Agile; + /** + * Key/Name identifier of the UI-Component the Subscription Container is bound to. + * @default undefined + */ + componentId?: ComponentIdType; + /** + * Dependencies that determine, in addition to unmounting and remounting the React-Component, + * when the specified Agile Sub Instances should be re-subscribed to the React-Component. + * + * [Github issue](https://github.com/agile-ts/agile/issues/170) + * + * @default [] + */ + deps?: any[]; +} diff --git a/packages/react/src/hooks/useProxy.ts b/packages/react/src/hooks/useProxy.ts index e565e3a7..02b100ef 100644 --- a/packages/react/src/hooks/useProxy.ts +++ b/packages/react/src/hooks/useProxy.ts @@ -1,17 +1,72 @@ import { - AgileHookConfigInterface, + defineConfig, + extractRelevantObservers, + Observer, + ProxyWeakMapType, +} from '@agile-ts/core'; +import { generateId, isValidObject, normalizeArray } from '@agile-ts/utils'; +import { + getReturnValue, SubscribableAgileInstancesType, - useAgile, + useBaseAgile, +} from './useBaseAgile'; +import { + AgileHookConfigInterface, AgileOutputHookArrayType, AgileOutputHookType, } from './useAgile'; -import { defineConfig } from '@agile-ts/core'; +import { LogCodeManager } from '../logCodeManager'; +// TODO https://stackoverflow.com/questions/68148235/require-module-inside-a-function-doesnt-work +let proxyPackage: any = null; +try { + proxyPackage = require('@agile-ts/proxytree'); +} catch (e) { + // empty catch block +} + +/** + * A React Hook for binding the most relevant value of multiple Agile Instances + * (like the Collection's output or the State's value) + * to a React Functional Component. + * + * This binding ensures that the Component re-renders + * whenever the most relevant Observer of an Agile Instance mutates. + * + * In addition the the default 'useAgile' Hook, + * the useProxy Hooks wraps a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + * around the to bind Agile Instance value objects, + * to automatically constraint the way the selected Agile Instances + * are compared to determine whether the React Component needs to be re-rendered + * based on the object's used properties. + * + * @public + * @param deps - Agile Sub Instances to be bound to the Functional Component. + * @param config - Configuration object + */ export function useProxy>( deps: X | [], config?: AgileHookConfigInterface ): AgileOutputHookArrayType; - +/** + * A React Hook for binding the most relevant Agile Instance value + * (like the Collection's output or the State's value) + * to a React Functional Component. + * + * This binding ensures that the Component re-renders + * whenever the most relevant Observer of the Agile Instance mutates. + * + * In addition the the default 'useAgile' Hook, + * the useProxy Hooks wraps a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + * around the to bind Agile Instance value objects, + * to automatically constraint the way the selected Agile Instances + * are compared to determine whether the React Component needs to be re-rendered + * based on the object's used properties. + * + * @public + * @param dep - Agile Sub Instance to be bound to the Functional Component. + * @param config - Configuration object + */ export function useProxy( dep: X, config?: AgileHookConfigInterface @@ -24,10 +79,72 @@ export function useProxy< deps: X | Y, config: AgileHookConfigInterface = {} ): AgileOutputHookArrayType | AgileOutputHookType { - return useAgile( - deps as any, - defineConfig(config, { - proxyBased: true, - }) + config = defineConfig(config, { + key: generateId(), + agileInstance: null as any, + componentId: undefined, + deps: [], + }); + const depsArray = extractRelevantObservers(normalizeArray(deps)); + const proxyTreeWeakMap = new WeakMap(); + + const handleReturn = (dep: Observer | undefined) => { + if (dep == null) return undefined as any; + const value = dep.value; + + // If proxyBased and the value is of the type object. + // Wrap a Proxy around the object to track the accessed properties. + if (isValidObject(value, true)) { + if (proxyPackage != null) { + const { ProxyTree } = proxyPackage; + const proxyTree = new ProxyTree(value); + proxyTreeWeakMap.set(dep, proxyTree); + return proxyTree.proxy; + } else { + LogCodeManager.log('31:03:00'); + } + } + + return value; + }; + + useBaseAgile( + depsArray, + (observers) => { + // TODO Proxy doesn't work as expected when 'selecting' a not yet existing property. + // For example you select the 'user.data.name' property, but the 'user' object is undefined. + // -> No correct Proxy Path could be created on the Component mount, since the to select property doesn't exist + // -> Selector was created based on the not complete Proxy Path + // -> Component re-renders to often + // + // Build Proxy Path WeakMap based on the Proxy Tree WeakMap + // by extracting the routes from the Proxy Tree. + // Building the Path WeakMap in the 'useIsomorphicLayoutEffect' + // because the 'useIsomorphicLayoutEffect' is called after the rerender. + // -> All used paths in the UI-Component were successfully tracked. + let proxyWeakMap: ProxyWeakMapType | undefined = undefined; + if (proxyPackage != null) { + proxyWeakMap = new WeakMap(); + for (const observer of observers) { + const proxyTree = proxyTreeWeakMap.get(observer); + if (proxyTree != null) { + proxyWeakMap.set(observer, { + paths: proxyTree.getUsedRoutes() as any, + }); + } + } + } + + return { + key: config.key, + waitForMount: false, + componentId: config.componentId, + proxyWeakMap, + }; + }, + config.deps || [], + config.agileInstance ); + + return getReturnValue(depsArray, handleReturn, Array.isArray(deps)); } diff --git a/packages/react/src/hooks/useSelector.ts b/packages/react/src/hooks/useSelector.ts index 1d694065..043df0c2 100644 --- a/packages/react/src/hooks/useSelector.ts +++ b/packages/react/src/hooks/useSelector.ts @@ -1,25 +1,62 @@ import { - AgileHookConfigInterface, + SelectorMethodType, + defineConfig, + Observer, + SelectorWeakMapType, + extractRelevantObservers, +} from '@agile-ts/core'; +import { generateId, isValidObject } from '@agile-ts/utils'; +import { + BaseAgileHookConfigInterface, + getReturnValue, SubscribableAgileInstancesType, - useAgile, -} from './useAgile'; -import { SelectorMethodType, defineConfig } from '@agile-ts/core'; + useBaseAgile, +} from './useBaseAgile'; import { AgileValueHookType } from './useValue'; +/** + * A React Hook for binding a selected value of an Agile Instance + * (like the Collection's output or the State's value) + * to a React Functional Component. + * + * This binding ensures that the Component re-renders + * whenever the selected value of an Agile Instance mutates. + * + * @public + * @param dep - Agile Sub Instance to be bound to the Functional Component. + * @param selectorMethod - Equality comparison function. + * that allows you to customize the way the selected Agile Instance + * is compared to determine whether the Component needs to be re-rendered. + * @param config - Configuration object + */ export function useSelector< ReturnType, X extends SubscribableAgileInstancesType, ValueType extends AgileValueHookType >( dep: X, - selector: SelectorMethodType, - config?: AgileHookConfigInterface + selectorMethod: SelectorMethodType, + config?: BaseAgileHookConfigInterface ): ReturnType; - +/** + * A React Hook for binding a selected value of an Agile Instance + * (like the Collection's output or the State's value) + * to a React Functional Component. + * + * This binding ensures that the Component re-renders + * whenever the selected value of an Agile Instance mutates. + * + * @public + * @param dep - Agile Sub Instance to be bound to the Functional Component. + * @param selectorMethod - Equality comparison function. + * that allows you to customize the way the selected Agile Instance + * is compared to determine whether the Component needs to be re-rendered. + * @param config - Configuration object + */ export function useSelector( dep: SubscribableAgileInstancesType, - selector: SelectorMethodType, - config?: AgileHookConfigInterface + selectorMethod: SelectorMethodType, + config?: BaseAgileHookConfigInterface ): ReturnType; export function useSelector< @@ -28,13 +65,52 @@ export function useSelector< ReturnType = any >( dep: X, - selector: SelectorMethodType, - config: AgileHookConfigInterface = {} + selectorMethod: SelectorMethodType, + config: BaseAgileHookConfigInterface = {} ): ReturnType { - return useAgile( - dep as any, - defineConfig(config, { - selector: selector, - }) - ) as any; + config = defineConfig(config, { + key: generateId(), + agileInstance: null as any, + componentId: undefined, + deps: [], + }); + const depsArray = extractRelevantObservers([dep]); + + const handleReturn = (dep: Observer | undefined): any => { + if (dep == null) return undefined as any; + const value = dep.value; + + // If specified selector function and the value is of type object. + // Return the selected value. + // (Destroys the type of the useAgile hook, + // however the type can be adjusted in the useSelector hook) + if (isValidObject(value, true)) { + return selectorMethod(value); + } + + return value; + }; + + useBaseAgile( + depsArray, + (observers) => { + // Build Selector WeakMap based on the specified selector method + let selectorWeakMap: SelectorWeakMapType | undefined = undefined; + selectorWeakMap = new WeakMap(); + for (const observer of observers) { + selectorWeakMap.set(observer, { methods: [selectorMethod] }); + } + + return { + key: config.key, + waitForMount: false, + componentId: config.componentId, + selectorWeakMap, + }; + }, + config.deps || [], + config.agileInstance + ); + + return getReturnValue(depsArray, handleReturn, false); } diff --git a/packages/react/src/hooks/useValue.ts b/packages/react/src/hooks/useValue.ts index d1e50ad6..46670532 100644 --- a/packages/react/src/hooks/useValue.ts +++ b/packages/react/src/hooks/useValue.ts @@ -5,20 +5,20 @@ import { State, defineConfig, } from '@agile-ts/core'; +import { useAgile } from './useAgile'; import { - AgileHookConfigInterface, + BaseAgileHookConfigInterface, SubscribableAgileInstancesType, - useAgile, -} from './useAgile'; +} from './useBaseAgile'; export function useValue>( deps: X | [], - config?: AgileHookConfigInterface + config?: BaseAgileHookConfigInterface ): AgileValueHookArrayType; export function useValue( dep: X, - config?: AgileHookConfigInterface + config?: BaseAgileHookConfigInterface ): AgileValueHookType; export function useValue< @@ -26,7 +26,7 @@ export function useValue< Y extends SubscribableAgileInstancesType >( deps: X | Y, - config: AgileHookConfigInterface = {} + config: BaseAgileHookConfigInterface = {} ): AgileValueHookArrayType | AgileValueHookType { return useAgile( deps as any, diff --git a/packages/react/src/hooks/useWatcher.ts b/packages/react/src/hooks/useWatcher.ts index 39fb3238..e0cb08b8 100644 --- a/packages/react/src/hooks/useWatcher.ts +++ b/packages/react/src/hooks/useWatcher.ts @@ -1,8 +1,8 @@ import React from 'react'; -import { StateWatcherCallback, State } from '@agile-ts/core'; +import { StateWatcherCallback, EnhancedState } from '@agile-ts/core'; export function useWatcher( - state: State, + state: EnhancedState, callback: StateWatcherCallback ): void { React.useEffect(() => { diff --git a/packages/react/src/logCodeManager.ts b/packages/react/src/logCodeManager.ts new file mode 100644 index 00000000..b3601ba2 --- /dev/null +++ b/packages/react/src/logCodeManager.ts @@ -0,0 +1,27 @@ +import { + LogCodeManager as CoreLogCodeManager, + assignAdditionalLogs, +} from '@agile-ts/core'; + +const additionalLogs = { + '30:03:00': + 'Failed to subscribe Component with deps because of missing valid Agile Instance.', + '31:03:00': + "In order to use the Agile proxy functionality, the installation of an additional package called '@agile-ts/proxytree' is required!", + '32:03:00': 'Failed to subscribe Component with deps', +}; + +/** + * The Log Code Manager keeps track + * and manages all important Logs for the '@agile-ts/react' package. + * + * @internal + */ +export const LogCodeManager = + typeof process === 'object' && process.env.NODE_ENV !== 'production' + ? assignAdditionalLogs< + typeof CoreLogCodeManager.logCodeMessages & typeof additionalLogs + >(additionalLogs, CoreLogCodeManager) + : assignAdditionalLogs< + typeof CoreLogCodeManager.logCodeMessages & typeof additionalLogs + >({}, CoreLogCodeManager); diff --git a/packages/react/tests/old/useAgile.spec.ts b/packages/react/tests/old/useAgile.spec.ts index 6a8c9b4e..58f2254c 100644 --- a/packages/react/tests/old/useAgile.spec.ts +++ b/packages/react/tests/old/useAgile.spec.ts @@ -2,51 +2,58 @@ // THIS ARE ONLY TYPE TESTS // NOTE: Has to be out commented because React Hooks in not React Components are not possible! -/* -import { Agile, Collection } from '@agile-ts/core'; -import { useAgile } from './hooks/useAgile'; - -const App = new Agile(); - -const MY_NUMBER_STATE = App.createState(1); -const MY_STRING_STATE = App.createState('test'); -const MY_STRING_COMPUTED = App.createComputed( - () => MY_STRING_STATE.value + ' ' + MY_NUMBER_STATE.value -); -const MY_COLLECTION = App.createCollection<{ id: string; name: 'Test' }>(); -const MY_SELECTOR = MY_COLLECTION.getSelector(1); - -const [ - myStringState, - mySelector, - myNumberState, - myStringComputed, - myCollection, - myGroup, - myNumberState2, -] = useAgile([ - MY_STRING_STATE, - MY_SELECTOR, - MY_NUMBER_STATE, - MY_STRING_COMPUTED, - MY_COLLECTION as any, - MY_COLLECTION.getGroup('test'), - MY_NUMBER_STATE, -]); - -const myStringState2 = useAgile(MY_STRING_STATE); -const [myGroup2, myStringState3, myCollection2] = useAgile([ - MY_COLLECTION.getGroup('test'), - MY_STRING_STATE, - MY_COLLECTION as any, -]); - -const myState10 = useAgile(MY_NUMBER_STATE); -const myCollection10 = useAgile(MY_COLLECTION); -const myCollection11 = useAgile( - new Collection<{ id: number; name: string }>(App) -); +// Be aware that in the test folder other ts rules count than in the src folder +// THIS ARE ONLY TYPE TESTS +// NOTE: Has to be out commented because React Hooks in not React Components are not possible! -const myGroupValue = useValue(MY_COLLECTION.getGroup('test')); -const myGroupAgile = useAgile(MY_COLLECTION.getGroup('test')); - */ +// import { +// Collection, +// createCollection, +// createComputed, +// createState, +// shared, +// } from '@agile-ts/core'; +// import { useAgile } from './useAgile'; +// import { useValue } from './useValue'; +// +// const MY_NUMBER_STATE = createState(1); +// const MY_STRING_STATE = createState('test'); +// const MY_STRING_COMPUTED = createComputed( +// () => MY_STRING_STATE.value + ' ' + MY_NUMBER_STATE.value +// ); +// const MY_COLLECTION = createCollection<{ id: string; name: 'Test' }>(); +// const MY_SELECTOR = MY_COLLECTION.getSelector(1); +// +// const [ +// myStringState, +// mySelector, +// myNumberState, +// myStringComputed, +// myCollection, +// myGroup, +// myNumberState2, +// ] = useAgile([ +// MY_STRING_STATE, +// MY_SELECTOR, +// MY_NUMBER_STATE, +// MY_STRING_COMPUTED, +// MY_COLLECTION as any, +// MY_COLLECTION.getGroup('test'), +// MY_NUMBER_STATE, +// ]); +// +// const myStringState2 = useAgile(MY_STRING_STATE); +// const [myGroup2, myStringState3, myCollection2] = useAgile([ +// MY_COLLECTION.getGroup('test'), +// MY_STRING_STATE, +// MY_COLLECTION as any, +// ]); +// +// const myState10 = useAgile(MY_NUMBER_STATE); +// const myCollection10 = useAgile(MY_COLLECTION); +// const myCollection11 = useAgile( +// new Collection<{ id: number; name: string }>(shared) +// ); +// +// const myGroupValue = useValue(MY_COLLECTION.getGroup('test')); +// const myGroupAgile = useAgile(MY_COLLECTION.getGroup('test')); diff --git a/packages/react/tsconfig.esm.json b/packages/react/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/react/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/react/tsconfig.production.json b/packages/react/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/react/tsconfig.production.json +++ b/packages/react/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/tsconfig.default.json b/packages/tsconfig.default.json index fb331abd..d6f8f4a3 100644 --- a/packages/tsconfig.default.json +++ b/packages/tsconfig.default.json @@ -10,7 +10,7 @@ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ - "declaration": true, /* Generates corresponding '.d.ts' file. */ + "declaration": true, /* Generates corresponding '.d.ts' file. */ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./core/dist/index.js", /* Concatenate and emit output to single file. */ diff --git a/packages/utils/package.json b/packages/utils/package.json index d97926ce..28bb15bc 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -11,16 +11,20 @@ "agile-ts" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*" @@ -40,5 +44,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 6161059c..ffef55ed 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,20 +1,17 @@ -//========================================================================================================= -// Copy -//========================================================================================================= /** - * @internal - * Creates a fresh copy of an Array/Object + * Creates a fresh (deep) copy of the specified value. * https://www.samanthaming.com/tidbits/70-3-ways-to-clone-objects/ - * @param value - Array/Object that gets copied + * + * @public + * @param value - Value to be copied. */ export function copy(value: T): T { // Extra checking 'value == null' because 'typeof null === object' if (value == null || typeof value !== 'object') return value; // Ignore everything that is no object or array but has the type of an object (e.g. classes) - const valConstructorName = Object.getPrototypeOf( - value - ).constructor.name.toLowerCase(); + const valConstructorName = + Object.getPrototypeOf(value).constructor.name.toLowerCase(); if (valConstructorName !== 'object' && valConstructorName !== 'array') return value; @@ -27,15 +24,13 @@ export function copy(value: T): T { return newObject as T; } -//========================================================================================================= -// Is Valid Object -//========================================================================================================= /** - * @internal - * Checks if passed value is a valid Object + * Checks whether the specified value is a valid object. * https://stackoverflow.com/questions/12996871/why-does-typeof-array-with-objects-return-object-and-not-array - * @param value - Value that is tested for its correctness - * @param considerArray - Whether Arrays should be considered as object + * + * @public + * @param value - Value + * @param considerArray - Whether to considered an array as an object. */ export function isValidObject(value: any, considerArray = false): boolean { function isHTMLElement(obj: any) { @@ -59,12 +54,10 @@ export function isValidObject(value: any, considerArray = false): boolean { ); } -//========================================================================================================= -// Includes Array -//========================================================================================================= /** - * @internal - * Check if array1 contains all elements of array2 + * Checks whether 'array1' contains all elements of 'array2'. + * + * @public * @param array1 - Array 1 * @param array2 - Array 2 */ @@ -75,13 +68,11 @@ export function includesArray( return array2.every((element) => array1.includes(element)); } -//========================================================================================================= -// Normalize Array -//========================================================================================================= /** - * @internal - * Transforms Item/s to an Item Array - * @param items - Item/s that gets transformed to an Array + * Transforms Item/s into an array of Items. + * + * @public + * @param items - Item/s to be transformed into an array of Items. * @param config - Config */ export function normalizeArray( @@ -95,25 +86,21 @@ export function normalizeArray( return Array.isArray(items) ? items : [items as DataType]; } -//========================================================================================================= -// Is Function -//========================================================================================================= /** - * @internal - * Checks if value is a function - * @param value - Value that gets tested if its a function + * Checks whether the specified function is a function. + * + * @public + * @param value - Value to be checked */ export function isFunction(value: any): boolean { return typeof value === 'function'; } -//========================================================================================================= -// Is Async Function -//========================================================================================================= /** - * @internal - * Checks if value is an async function - * @param value - Value that gets tested if its an async function + * Checks whether the specified function is an async function. + * + * @public + * @param value - Value to be checked. */ export function isAsyncFunction(value: any): boolean { const valueString = value.toString(); @@ -124,13 +111,11 @@ export function isAsyncFunction(value: any): boolean { ); } -//========================================================================================================= -// Is Json String -//========================================================================================================= /** - * @internal - * Checks if value is valid JsonString - * @param value - Value that gets checked + * Checks whether the specified value is a valid JSON string + * + * @public + * @param value - Value to be checked. */ export function isJsonString(value: any): boolean { if (typeof value !== 'string') return false; @@ -142,15 +127,13 @@ export function isJsonString(value: any): boolean { return true; } -//========================================================================================================= -// Define Config -//========================================================================================================= /** - * @internal - * Merges default values/properties into config object - * @param config - Config object that receives default values - * @param defaults - Default values object that gets merged into config object - * @param overwriteUndefinedProperties - If undefined Properties in config gets overwritten by the default value + * Merges the default values object ('defaults') into the configuration object ('config'). + * + * @public + * @param config - Configuration object to merge the default values in. + * @param defaults - Default values object to be merged into the configuration object. + * @param overwriteUndefinedProperties - Whether to overwrite 'undefined' set properties with default values. */ export function defineConfig( config: ConfigInterface, @@ -174,24 +157,22 @@ export function defineConfig( return shallowCopiedConfig; } -//========================================================================================================= -// Flat Merge -//========================================================================================================= -/** - * @internal - * @param addNewProperties - Adds new properties to source Object - */ export interface FlatMergeConfigInterface { + /** + * + * Whether to add new properties (properties that doesn't exist in the source object yet) to the source object. + * @default true + */ addNewProperties?: boolean; } /** - * @internal - * Merges items into object, be aware that the merge will only happen at the top level of the object. - * Initially it adds new properties of the changes object into the source object. + * Merges the 'changes' object into the 'source' object at top level. + * + * @public * @param source - Source object - * @param changes - Changes that get merged into the source object - * @param config - Config + * @param changes - Changes object to be merged into the source object + * @param config - Configuration object */ export function flatMerge( source: DataType, @@ -219,14 +200,12 @@ export function flatMerge( return _source; } -//========================================================================================================= -// Equals -//========================================================================================================= /** - * @internal - * Check if two values are equal - * @param value1 - First Value - * @param value2 - Second Value + * Checks whether the two specified values are equivalent. + * + * @public + * @param value1 - First value. + * @param value2 - Second value. */ export function equal(value1: any, value2: any): boolean { return ( @@ -239,46 +218,44 @@ export function equal(value1: any, value2: any): boolean { ); } -//========================================================================================================= -// Not Equals -//========================================================================================================= /** - * @internal - * Checks if two values aren't equal - * @param value1 - First Value - * @param value2 - Second Value + * Checks whether the two specified values are NOT equivalent. + * + * @public + * @param value1 - First value. + * @param value2 - Second value. */ export function notEqual(value1: any, value2: any): boolean { return !equal(value1, value2); } -//========================================================================================================= -// Generate Id -//========================================================================================================= /** - * @internal - * Generates random Id - * @param length - Length of generated Id + * Generates a randomized id based on alphabetic and numeric characters. + * + * @public + * @param length - Length of the to generate id (default = 5). + * @param characters - Characters to generate the id from (default = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'). */ -export function generateId(length?: number): string { - const characters = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +export function generateId( + length = 5, + characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +): string { const charactersLength = characters.length; let result = ''; - if (!length) length = 5; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } -//========================================================================================================= -// Create Array From Object -//========================================================================================================= /** - * @internal - * Transforms Object to Array - * @param object - Object that gets transformed + * Transforms the specified object into an array. + * + * Example: + * {"1": 'jeff', 2: 'frank'} -> [{key: "1", instance: 'jeff'}, {key: 2, instance: 'frank'}] + * + * @public + * @param object - Object to be transformed to an array. */ export function createArrayFromObject

(object: { [key: string]: P; @@ -293,13 +270,11 @@ export function createArrayFromObject

(object: { return array; } -//========================================================================================================= -// Clone -//========================================================================================================= /** - * @internal - * Clones a Class - * @param instance - Instance of Class you want to clone + * Clones the specified class. + * + * @public + * @param instance - Class to be cloned. */ export function clone(instance: T): T { // Clone Class @@ -312,14 +287,12 @@ export function clone(instance: T): T { return objectClone; } -//========================================================================================================= -// Remove Properties -//========================================================================================================= /** - * @internal - * Removes properties from Object - * @param object - Object from which the properties get removed - * @param properties - Properties that get removed from the object + * Removes specified properties from the defined object. + * + * @public + * @param object - Object to remove the specified properties from. + * @param properties - Property keys to be removed from the specified object. */ export function removeProperties( object: T, diff --git a/packages/utils/tsconfig.esm.json b/packages/utils/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/utils/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/utils/tsconfig.production.json b/packages/utils/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/utils/tsconfig.production.json +++ b/packages/utils/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/packages/vue/package.json b/packages/vue/package.json index 6221bcdc..83f30d4c 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -14,16 +14,20 @@ "reactive" ], "main": "dist/index.js", + "module": "dist/esm/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", - "prepare": "tsc && tsc -p ./tsconfig.production.json", + "build": "yarn run build:esm && yarn run build:cjs", + "prepare": "yarn run build", + "build:esm": "tsc -p ./tsconfig.esm.json", + "build:cjs": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.production.json", "dev:publish": "yalc publish", "dev:push": "yalc push", "watch:push": "tsc-watch --onSuccess \"yarn run dev:push\"", "watch": "tsc -w", "release": "yarn run prepare", - "preview": "npm pack", + "release:manual": "yarn run prepare && yarn run release && npm publish", + "pack": "npm pack", "test": "jest", "test:coverage": "jest --coverage", "lint": "eslint src/**/*" @@ -60,5 +64,6 @@ "LICENSE", "README.md", "CHANGELOG.md" - ] + ], + "sideEffects": false } diff --git a/packages/vue/src/bindAgileInstances.ts b/packages/vue/src/bindAgileInstances.ts index 73d5508e..52ffdcff 100644 --- a/packages/vue/src/bindAgileInstances.ts +++ b/packages/vue/src/bindAgileInstances.ts @@ -1,12 +1,12 @@ import Vue from 'vue'; import { Agile, - Collection, extractRelevantObservers, Observer, State, } from '@agile-ts/core'; import { isValidObject, normalizeArray } from '@agile-ts/utils'; +import type { Collection } from '@agile-ts/core'; // Only import Collection and Group type for better Treeshaking export function bindAgileInstances( deps: DepsType, diff --git a/packages/vue/tsconfig.esm.json b/packages/vue/tsconfig.esm.json new file mode 100644 index 00000000..a00a08bf --- /dev/null +++ b/packages/vue/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2015", + "outDir": "dist/esm", + "declaration": false, // already generated via 'tsconfig.json' in the root dist folder + "removeComments": true + } +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 600cd205..791f587c 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../tsconfig.default.json", "compilerOptions": { + "module": "commonjs", "rootDir": "src", "outDir": "dist" }, "include": [ "./src/**/*" // Only include what is in src (-> dist, tests, .. will be excluded) ] -} \ No newline at end of file +} diff --git a/packages/vue/tsconfig.production.json b/packages/vue/tsconfig.production.json index 4b5c4d12..b8ec8c38 100644 --- a/packages/vue/tsconfig.production.json +++ b/packages/vue/tsconfig.production.json @@ -1,7 +1,9 @@ +// Use File: Overwrites already generated js files with new js files that have no comments +// Not doing in main 'tsconfig.json' because then the typescript declarations would have no comments too { "extends": "./tsconfig.json", "compilerOptions": { - "declaration": false, + "declaration": false, // '.d.ts' files should have been generated before "removeComments": true } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index f275cf02..5219edd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,8 +2,15 @@ # yarn lockfile v1 +"@agile-ts/core@^0.1.2": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@agile-ts/core/-/core-0.1.3.tgz#d96dd4a20d65adce9aaba1c494b31e4e0dd1bb60" + integrity sha512-sHw9PMbqww0dwqLEZih9hIpZjMAmZB4yea7bkbqblNc1CRDKfCGeYGnNcg8GOqXfNfq5SywMGWo5KhhFFyx+ag== + dependencies: + "@agile-ts/utils" "^0.0.7" + "@agile-ts/core@file:packages/core": - version "0.1.2" + version "0.2.0-alpha.4" dependencies: "@agile-ts/utils" "^0.0.7" @@ -15,6 +22,11 @@ "@agile-ts/proxytree@file:packages/proxytree": version "0.0.5" +"@agile-ts/react@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@agile-ts/react/-/react-0.1.2.tgz#d07f6b935d9322cd60d2e9e3871da554b04460af" + integrity sha512-W4u2+X6KCeXPdkjit/NsMJG5nBsa7dNFaEzyfTsp5Cqbs99zLqY6dO8LUIYyhRt/+HBvEW9o64i/6Kqd59WM1Q== + "@akryum/winattr@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@akryum/winattr/-/winattr-3.0.0.tgz#c345d49f8415583897e345729c12b3503927dd11"