From ce8de801876b44d8a750dee6709e9778df80fa1f Mon Sep 17 00:00:00 2001 From: Gabe Berke-Williams Date: Tue, 28 Nov 2023 11:40:21 -0800 Subject: [PATCH] Update code to match internal Embrace repo * Update to version 0.1.51 (the latest internal version) * Move `tests/` to `src/__tests__`/ to match internal code * Do not build tests when doing `yarn build` * This meant excluding the spec files from `tsconfig.json`, so this commit also introduces `tsconfig.eslint.json` so that ESLint will still find and lint those test files. * Update `jest.config.js` for new version of ts-jest * Update package versions --- .eslintrc.js | 2 +- README.md | 38 +-- jest.config.js | 15 +- package.json | 39 +-- {tests => src/__tests__}/base.spec.tsx | 30 ++- src/__tests__/compose_list.spec.tsx | 241 ++++++++++++++++++ src/__tests__/compose_option.spec.tsx | 140 ++++++++++ {tests => src/__tests__}/flow.spec.tsx | 4 +- {tests => src/__tests__}/ui_animated.spec.tsx | 22 +- {tests => src/__tests__}/ui_union.spec.tsx | 4 +- {tests => src/__tests__}/utils.ts | 0 src/flow.ts | 156 +++++++++++- src/internal/animated.ts | 4 +- src/internal/foldable.ts | 2 + src/internal/index.ts | 126 +++++---- src/internal/observable.ts | 21 ++ src/internal/rx_map.ts | 2 +- src/internal/rx_prioritize.ts | 29 +++ src/internal/rx_split_by.ts | 2 +- src/ui.ts | 84 +++--- tsconfig.eslint.json | 5 + tsconfig.json | 13 +- 22 files changed, 782 insertions(+), 197 deletions(-) rename {tests => src/__tests__}/base.spec.tsx (98%) create mode 100644 src/__tests__/compose_list.spec.tsx create mode 100644 src/__tests__/compose_option.spec.tsx rename {tests => src/__tests__}/flow.spec.tsx (99%) rename {tests => src/__tests__}/ui_animated.spec.tsx (92%) rename {tests => src/__tests__}/ui_union.spec.tsx (98%) rename {tests => src/__tests__}/utils.ts (100%) create mode 100644 src/internal/observable.ts create mode 100644 src/internal/rx_prioritize.ts create mode 100644 tsconfig.eslint.json diff --git a/.eslintrc.js b/.eslintrc.js index 14d2e42..206ac26 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -208,7 +208,7 @@ module.exports = { ] }, parserOptions: { - project: './tsconfig.json', + project: './tsconfig.eslint.json', tsconfigRootDir: __dirname }, rules: { diff --git a/README.md b/README.md index 42c6d26..ca11e23 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ import { Flow, UI } from '@grammarly/embrace' /** * Page layout that defines slots for header and body components. * Should be composed with `Header` and `Body` upon mount (see `UI.Knot.make` below) - * + * *
* * @@ -40,7 +40,7 @@ const Body = UI.Node.make<{ readonly content: string }, never>(({ state }) => ( // Flow with an initial value of the `Body` state export const bodyFlow: Flow.For = Rx.startWith({ content: 'Hello, World!' }) -// Header component. Neither consumes state or emits events +// Header component. Neither consumes state nor emits events const Header = UI.Node.make(() =>
Welcome
) // Produce a component by composing `mainGrid` with `Header` and `Body` @@ -69,7 +69,7 @@ import { F } from '@grammarly/focal' import { Flow, UI } from '@grammarly/embrace' import { bodyFlow, Main } from './' -// A different version of the `Header` component. Consume state, can emit `onClick` event +// A different version of the `Header` component. Consumes state, can emit `onClick` event const CustomHeader = UI.Node.make<{ readonly user: string }, 'onClick'>(({ state, notify }) => (
{state.pipe(Rx.map(({ user }) => `Hello, ${user}`))} @@ -77,7 +77,7 @@ const CustomHeader = UI.Node.make<{ readonly user: string }, 'onClick'>(({ state
)) -// `CustomHeader` logic (Flow). Contains initial value and the actions handler +// `CustomHeader` logic (Flow). Contains actions handler and initial value const customHeaderFlow: Flow.For = flow( Rx.map(() => ({ user: 'username' })), Rx.startWith({ user: 'anonymous' }) @@ -103,15 +103,15 @@ ReactDOM.render(, document.getElementById('root')) ## Project Status -Embrace is an experimental prototype. Breaking changes may occur up to 1.0.0 is released. +Embrace is an experimental prototype. Breaking changes may occur up to 1.0.0 release. ## WHY? -With the applications growing with features and code-base, more and more developers are looking for ways to re-use the code between them. +With applications growing with features and code-base, more and more developers are looking for ways to re-use the code between them. The popular solution is to extract shared code to the components library. While this approach does solve the scaling problem, it also adds challenges: -- **It is hard to customize existing shared components**: components tend to stick with the design of the original app in which they were originally created. As a result, it is very hard to customize and re-use them in other apps. In most cases, customization requires a partial or total redesign of the component. +- **It is hard to customize existing shared components**: components tend to stick with the design of the original app in which they were initially created. As a result, it is very hard to customize and re-use them in other apps. In most cases, customization requires a partial or total component redesign. - **No easy way to detect breaking changes**: each React component could potentially have an unlimited amount of state management logic that is not visible through its props types. Heavy usage of such React APIs as `context` only makes things worse. As a result, a change to a platform component could break some of its (many) clients, especially if a client had customized a library's component behavior. - **Adding new experiments increases the complexity of the UI code**: every running experiment adds one or more branches into an already complex UI logic. As the number of experiments grows, the complexity increases in a non-linear way. @@ -119,9 +119,9 @@ While this approach does solve the scaling problem, it also adds challenges: Main ideas behind Embrace: -- **Use static typing to manage breaking changes**: it should not be possible to change the component behavior without changing its type. I.e., both the state component uses and actions it can trigger must be part of its strongly-typed interface. Furthermore, using a new version of the library's component with breaking change should result in a compilation error. +- **Use static typing to manage breaking changes**: it should not be possible to change the component behavior without changing its type. I.e., both the state component uses and actions it can trigger must be part of its strongly-typed interface. Furthermore, using a new version of the library's component with a breaking change should result in a compilation error. - **Enforce UI components not to have state management logic**: there should be a clear separation of UI and App State logic. UI components should only be allowed to trigger strongly-typed actions, i.e., an "intent" that must be handled by some other state management module. Not having such clear separation makes it hard to re-use components, as different apps usually have different means to manage their state. -- **Allow centralized UI customizations**: it should be possible to collocate all UI changes (driven by an experiment or library-using app customization) in one place. I.e., the complexity of a UI code should grow linearly (or logarithmic in the worst-case scenario) with the number of customizations. +- **Allow centralized UI customizations**: it should be possible to collocate all UI changes (driven by an experiment or library-using app customization) in one place. I.e., the complexity of a UI code should grow linearly (or logarithmically in the worst-case scenario) with the number of customizations. - **Minimize time spent on experiments cleanup**: centralized UI customization means that it should be possible to enumerate all active experiments or other UI customizations. Therefore, removing an experiment should be as easy as deleting an item from the list. - **Minimize time spent on code reviews**: with all the constraints above, the new UI building approach should minimize the number of possible ways to implement a new feature, making it easier to accept changes from contributors. @@ -131,12 +131,12 @@ The Embrace library should implement all of the requirements above. The idea beh ![embrace flow](img/embrace_flow.jpg) -UI Tree accepts state changes as they happen, triggering re-render of corresponding React components, and emits actions as they are triggered (by the user or some other UI event). +UI Tree accepts state changes as they happen, triggering re-render of corresponding React components, and emits actions as they are triggered by the user or some other UI event. As a result, we get a "classic" UI cycle where data flows one way (from "machine" to "UI"), and actions flow the opposite way. The innovation is in the implementation: - it forces immutability and type safety on the UI Tree - it makes the UI Tree representation truly declarative (see `Patching UI Tree` section below) -- it does not rely on React's VDOM and instead triggers a re-render of affected components using [wormholes pattern](https://static1.squarespace.com/static/53e04d59e4b0c0da377d20b1/t/53e5a285e4b0cc1fd4cab024/1407558277521/Winograd-Cort-Wormholes.pdf) implemented using RxJS observables and Focal React. +- it does not rely on React's VDOM and instead triggers a re-render of affected components using [wormholes pattern](https://www.danwc.com/data/Winograd-Cort-Wormholes.pdf) implemented using RxJS observables and Focal React. As of now, Embrace _does not_ dictate how the state management part is implemented. It only provides a set of useful types and helper functions to compose a larger UI Tree from smaller parts in a type-safe way: @@ -157,7 +157,7 @@ interface UIPart { } ``` -UIPart accepts an Observable of `State`, notifies of new `Actions` and can also have `Slot`s, which is a placeholder for another UIPart. +UIPart accepts an Observable of `State`, notifies of new `Actions` and can also have `Slot`s, which are placeholders for another UIPart. UIPart cannot modify app's state directly (e.g., by having a reference to a view model or some other shared state), it can only trigger an `Action`, which may (or may not) result in a new `State` passed to UIPart. @@ -240,7 +240,7 @@ const element = UI.mount( ### Composing UI Parts together -Embrace uses raw UIPart to build few higher-level abstractions to help build the UI Tree (in fact, UIPart is currently not exported from Embrace, which means you will not use UIPart but instead primitives described below). +Embrace uses raw UIPart to build a few higher-level abstractions to help build the UI Tree (in fact, UIPart is currently not exported from Embrace, which means you will not use UIPart but instead primitives described below). - **Node**: the most basic part of UI. Renders UI according to passed State and emits Actions as side-effects from interactions with UI. Node cannot have slots: ```ts @@ -250,7 +250,7 @@ Embrace uses raw UIPart to build few higher-level abstractions to help build the ```ts interface Grid extends UIPart {} ``` -- **Knot**: a composition of a `Grid` with other `UIParts`, can be used to fill a `Grid`'s Slots with specific UIParts +- **Knot**: a composition of a `Grid` with other `UIParts`, can be used to fill `Grid`'s Slots with specific UIParts ```ts interface Knot> { readonly grid: UI.Grid @@ -300,7 +300,7 @@ const appGrid = UI.Grid.make<'header' | 'body'>(({ slots }) => ( )) // Header is a Grid that has nav slot -const header = UI.Grid.make<'nav'>(({ slots }) => ( +const headerGrid = UI.Grid.make<'nav'>(({ slots }) => ( 'Welcome' {slots.nav} @@ -316,7 +316,7 @@ const buttonNav = UI.Node.make(({ notify }) => ( const body = UI.Node.make(({ state }) => {state}) // header with a nav button -const header = UI.Knot.make(header, { nav: buttonNav }) +const header = UI.Knot.make(headerGrid, { nav: buttonNav }) // default app users header with a button nav and a string body const app = UI.Knot.make(appGrid, { @@ -477,7 +477,7 @@ const NewApp = pipe( // It is not possible to mutate a part of the actions stream, instead we will create a new one from the smaller parts const flow: Flow.For = Flow.composeKnot({ - // footer and root part did not changed, re-use their flow + // footer and root part did not change, re-use their flow root: ContentFlow, footer: FooterFlow, // re-implement header flow @@ -517,7 +517,7 @@ const shiftPress: Observable = fromEvent(window.document, 'keydown').pi // It is not possible to mutate a part of the actions stream, instead we will create a new one from the smaller parts const flow: Flow.For = Flow.composeKnot({ - // footer and root part did not changed, re-use their flow + // footer and root part did not change, re-use their flow root: ContentFlow, footer: FooterFlow, // re-implement header flow @@ -581,7 +581,7 @@ declare const App: UI.Knot< // It is not possible to mutate a part of the actions stream, instead we will create a new one from the smaller parts const flow: Flow.For = Flow.composeKnot({ - // header and footer did not changed, re-use their flow + // header and footer did not change, re-use their flow header: HeaderFlow, footer: FooterFlow, // re-implement root flow diff --git a/jest.config.js b/jest.config.js index 5eafb20..c4ced4a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,13 +2,14 @@ module.exports = { moduleFileExtensions: ['js', 'ts', 'tsx'], testMatch: ['**/*.spec.ts?(x)'], transform: { - '^.+\\.tsx?$': 'ts-jest' + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.json', + isolatedModules: true + } + ] }, preset: 'ts-jest', - globals: { - 'ts-jest': { - tsconfig: './tsconfig.json', - isolatedModules: true - } - } + testEnvironment: 'jsdom' } diff --git a/package.json b/package.json index 1cb2335..c5f7ad6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@grammarly/embrace", - "version": "0.1.0", + "version": "0.1.51", "description": "Typesafe, declarative, and composable UI engine on top of React and Focal", "main": "cjs/index.js", "module": "esm/index.js", @@ -27,7 +27,7 @@ "test": "jest" }, "dependencies": { - "@grammarly/focal": "0.8.5", + "@grammarly/focal": "^0.8.5", "fp-ts": "2.9.5", "ts-toolbelt": "6.9.9" }, @@ -38,36 +38,37 @@ "devDependencies": { "@grammarly/tslint-config": "0.6.0", "@types/enzyme": "3.1.15", - "@types/enzyme-adapter-react-16": "1.0.3", - "@types/jest": "26.0.20", + "@types/enzyme-adapter-react-16": "1.0.6", + "@types/jest": "29.2.3", "@types/react": "16.8.2", - "@typescript-eslint/eslint-plugin": "4.8.1", - "@typescript-eslint/eslint-plugin-tslint": "4.8.1", - "@typescript-eslint/parser": "4.8.1", + "@typescript-eslint/eslint-plugin": "6.9.1", + "@typescript-eslint/eslint-plugin-tslint": "6.9.1", + "@typescript-eslint/parser": "6.9.1", "enzyme": "3.8.0", - "enzyme-adapter-react-16": "1.7.1", + "enzyme-adapter-react-16": "1.15.6", "enzyme-to-json": "3.3.3", "eslint": "^7.19.0", "eslint-config-prettier": "6.7.0", "eslint-import-resolver-typescript": "1.1.1", "eslint-nibble": "5.1.0", - "eslint-plugin-fp-ts": "0.2.1", - "eslint-plugin-functional": "3.2.1", - "eslint-plugin-import": "^2.19.1", - "eslint-plugin-import-helpers": "1.0.2", - "eslint-plugin-jest": "24.1.3", + "eslint-plugin-fp-ts": "0.3.0", + "eslint-plugin-functional": "4.2.0", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-import-helpers": "1.2.1", + "eslint-plugin-jest": "27.1.3", "eslint-plugin-react-hooks": "4.2.0", - "eslint-plugin-sonarjs": "^0.5.0", - "eslint-plugin-todo-plz": "1.1.0", - "jest": "26.6.3", + "eslint-plugin-sonarjs": "^0.11.0", + "eslint-plugin-todo-plz": "1.2.1", + "jest": "29.3.1", + "jest-environment-jsdom": "29.3.1", "react": "16.11.0", "react-dom": "16.11.0", "rxjs": "6.5.3", - "ts-jest": "26.4.4", - "tslib": "1.11.1", + "ts-jest": "29.1.1", + "tslib": "^2.3.0", "tslint": "5.18.0", "tslint-sonarts": "^1.9.0", - "typescript": "4.1.2" + "typescript": "5.2.2" }, "resolutions": { "@types/node": "18.11.9" diff --git a/tests/base.spec.tsx b/src/__tests__/base.spec.tsx similarity index 98% rename from tests/base.spec.tsx rename to src/__tests__/base.spec.tsx index eda41cd..1b1c419 100644 --- a/tests/base.spec.tsx +++ b/src/__tests__/base.spec.tsx @@ -1,15 +1,17 @@ -import * as React from 'react' -import * as Enzyme from 'enzyme' -import * as Adapter from 'enzyme-adapter-react-16' -import * as A from 'fp-ts/lib/Array' -import * as R from 'fp-ts/lib/Record' -import { Endomorphism, flow, identity } from 'fp-ts/lib/function' -import { pipe } from 'fp-ts/lib/pipeable' -import { of as rxOf } from 'rxjs' -import * as Rx from 'rxjs/operators' -import { F } from '@grammarly/focal' -import { Flow, UI } from '../src/index' -import { getMapFoldableWithIndex } from './utils' +import * as Enzyme from "enzyme"; +import * as Adapter from "enzyme-adapter-react-16"; +import * as A from "fp-ts/lib/Array"; +import { Endomorphism, flow, identity } from "fp-ts/lib/function"; +import { pipe } from "fp-ts/lib/pipeable"; +import * as R from "fp-ts/lib/Record"; +import * as React from "react"; +import { of as rxOf } from "rxjs"; +import * as Rx from "rxjs/operators"; + +import { F } from "@grammarly/focal"; + +import { Flow, UI } from "../index"; +import { getMapFoldableWithIndex } from "./utils"; Enzyme.configure({ adapter: new Adapter() }) @@ -712,7 +714,7 @@ describe('UI Tree', () => { it('from list of knot to list of knot singleton', () => { const patchedComp = pipe( UI.List.make(R.record, comp12), - UI.patch('comp1')(UI.mapAction(x => x + 'new')), + UI.patch('comp1')(UI.mapAction(x => x + 'new')), // TODO: to prevent TS2589 UI.patch('comp2')(() => UI.Node.empty) ) @@ -758,7 +760,7 @@ describe('UI Tree', () => { it('from knot of list to knot of list', () => { const patchedComp = pipe( UI.List.make(R.record, comp12), - UI.patch('comp1')(UI.mapAction(x => x + 'new')) + UI.patch('comp1')(UI.mapAction(x => x + 'new')) // TODO: to prevent TS2589 ) const linit = { comp1: init, comp2: init } diff --git a/src/__tests__/compose_list.spec.tsx b/src/__tests__/compose_list.spec.tsx new file mode 100644 index 0000000..978c6fc --- /dev/null +++ b/src/__tests__/compose_list.spec.tsx @@ -0,0 +1,241 @@ +import * as React from 'react' +import * as Enzyme from 'enzyme' +import * as Adapter from 'enzyme-adapter-react-16' +import { eqString } from 'fp-ts/lib/Eq' +import * as O from 'fp-ts/lib/Option' +import * as RA from 'fp-ts/lib/ReadonlyArray' +import * as R from 'fp-ts/lib/ReadonlyRecord' +import * as RS from 'fp-ts/lib/ReadonlySet' +import { Endomorphism, flow } from 'fp-ts/lib/function' +import { pipe } from 'fp-ts/lib/pipeable' +import * as Rx from 'rxjs/operators' +import { Atom, F } from '@grammarly/focal' +import { Flow, UI } from '../index' + +describe('Flow.composeList', () => { + Enzyme.configure({ adapter: new Adapter() }) + + interface ItemAction { + readonly id: string + readonly kind: 'copy' | 'remove' + } + + it('readonlyRecord - can update collection structure', () => { + interface ItemState { + readonly id: string + readonly label: string + readonly removable: boolean + } + + const item = UI.Node.make(({ state, notify }) => ( + + {state.pipe( + Rx.map(({ id, label, removable }) => ( +
+ {label} +
+ )) + )} +
+ )) + + interface ItemData { + readonly id: string + readonly removable: boolean + } + + const list = UI.List.make(R.readonlyRecord, item) + + // These counters are used to ensure that the total number of action "seen" by the item flows + // equal the number of actions "seen" by the collection provider function. + let itemFlowActionsCount = 0 + let collectionActionsCount = 0 + + const createItemFlow = ({ id, removable }: ItemData): Flow.For => + flow( + Rx.tap(() => itemFlowActionsCount++), + Rx.startWith(null), + Rx.mapTo({ + id, + removable, + label: `item ${{ a: 'A!', b: 'B!', 'a-copy': 'A Copy!' }[id]}` + }) + ) + + const startState = { + a: { id: 'a', removable: false }, // will be copied on click + b: { id: 'b', removable: true } // will be removed on click + } + + const listFlow = Flow.composeList( + R.readonlyRecord, + flow( + Rx.tap(() => collectionActionsCount++), + Rx.scan( + (acc, a) => + // copy item and mark original and copy as removable + a.action.kind === 'copy' + ? pipe( + acc, + R.updateAt(a.action.id, { id: a.action.id, removable: true }), + O.getOrElse(() => acc), + R.insertAt(`${a.action.id}-copy`, { + id: `${a.action.id}-copy`, + removable: true + }) + ) + : pipe(acc, R.deleteAt(a.key)), + startState + ), + Rx.startWith(startState) + ), + createItemFlow + ) + + const res =
{UI.mount(list, listFlow)}
+ + const w = Enzyme.mount(res) + expect(w.html()).toBe( + '
item A!
item B!
' + ) + + w.find('[data-name="a"]') + .hostNodes() + .forEach(el => el.simulate('click')) + + expect(w.html()).toBe( + '
item A!
item A Copy!
item B!
' + ) + expect(itemFlowActionsCount).toBe(1) + expect(collectionActionsCount).toBe(1) + + w.find('[data-name="a"]') + .hostNodes() + .forEach(el => el.simulate('click')) + + expect(w.html()).toBe( + '
item A Copy!
item B!
' + ) + expect(itemFlowActionsCount).toBe(2) + expect(collectionActionsCount).toBe(2) + + w.find('[data-name="b"]') + .hostNodes() + .forEach(el => el.simulate('click')) + + expect(w.html()).toBe('
item A Copy!
') + expect(itemFlowActionsCount).toBe(3) + expect(collectionActionsCount).toBe(3) + + w.find('[data-name="a-copy"]') + .hostNodes() + .forEach(el => el.simulate('click')) + + expect(w.html()).toBe('
') + expect(itemFlowActionsCount).toBe(4) + expect(collectionActionsCount).toBe(4) + }) + + it('readonlyArray - can use external state', () => { + interface ItemState { + readonly id: string + readonly label: string + readonly counter: number + readonly removable: boolean + } + + const item = UI.Node.make(({ state, notify }) => ( + + {state.pipe( + Rx.map(({ id, label, counter, removable }) => ( +
+ {label}:{counter} +
+ )) + )} +
+ )) + + const list = UI.List.make(RA.readonlyArray, item) + + interface State { + readonly actionsCounter: number + readonly itemsToShow: ReadonlyArray + readonly removableItems: ReadonlySet + } + + const state = Atom.create({ + actionsCounter: 0, + itemsToShow: ['a', 'b'], + removableItems: new Set(['b']) + }) + + function reducer(action: ItemAction): Endomorphism { + return state => ({ + actionsCounter: state.actionsCounter + 1, + removableItems: + action.kind === 'copy' + ? pipe( + // Make the copied item and its copy removable on next click + state.removableItems, + RS.insert(eqString)(action.id), + RS.insert(eqString)(`${action.id}-copy`) + ) + : state.removableItems, + itemsToShow: + action.kind === 'copy' + ? pipe( + state.itemsToShow, + RA.chain(id => (id === action.id ? [id, `${id}-copy`] : [id])) + ) + : state.itemsToShow.filter(item => item !== action.id) + }) + } + + // This example shows a case where all actions are handled by the item flows + // by updating a central state atom. + const listFlow = Flow.composeList( + RA.readonlyArray, + () => state.view('itemsToShow'), + id => + Flow.fromSideEffect( + a => state.modify(reducer(a)), + state.view(s => ({ + id, + label: `${id.toUpperCase()}`, + counter: s.actionsCounter, + removable: s.removableItems.has(id) + })) + ) + ) + + const res =
{UI.mount(list, listFlow)}
+ + const w = Enzyme.mount(res) + expect(w.html()).toBe('
A:0
B:0
') + + w.find('[data-name="a"]') + .hostNodes() + .forEach(el => el.simulate('click')) + expect(w.html()).toBe( + '
A:1
A-COPY:1
B:1
' + ) + + w.find('[data-name="a"]') + .hostNodes() + .forEach(el => el.simulate('click')) + expect(w.html()).toBe( + '
A-COPY:2
B:2
' + ) + + w.find('[data-name="b"]') + .hostNodes() + .forEach(el => el.simulate('click')) + expect(w.html()).toBe('
A-COPY:3
') + + w.find('[data-name="a-copy"]') + .hostNodes() + .forEach(el => el.simulate('click')) + expect(w.html()).toBe('
') + }) +}) diff --git a/src/__tests__/compose_option.spec.tsx b/src/__tests__/compose_option.spec.tsx new file mode 100644 index 0000000..6b627d2 --- /dev/null +++ b/src/__tests__/compose_option.spec.tsx @@ -0,0 +1,140 @@ +import * as Enzyme from "enzyme"; +import * as Adapter from "enzyme-adapter-react-16"; +import { flow } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import * as React from "react"; +import * as Rx from "rxjs/operators"; + +import { F } from "@grammarly/focal"; + +import { Flow, UI } from "../index"; +import { assertNever } from "./utils"; + +describe('Flow.composeOption', () => { + Enzyme.configure({ adapter: new Adapter() }) + + const comp1 = UI.Node.make(({ state, notify }) => ( + + {state} + + )) + + const comp2 = UI.Node.make(({ state, notify }) => ( + + {state} + + )) + + it('re-use child flow in option', () => { + const comp1Option = UI.Union.asOption(comp1) + + const comp1Flow: Flow.For = flow( + Rx.mapTo('first component'), + Rx.startWith('first component') + ) + + const comp1OptionFlow = Flow.composeOption( + comp1Flow, + flow( + Rx.map(() => O.none), + Rx.startWith({ _tag: 'Some' }) + ) + ) + + const res = UI.mount(comp1Option, comp1OptionFlow) + + const w = Enzyme.mount(res) + expect(w.html()).toBe('
first component
') + + w.find('[name="first"]') + .hostNodes() + .forEach(el => el.simulate('click')) + + expect(w.html()).toBeNull() + }) + + it('re-use nested children flows in option of knot', () => { + const grid = UI.Grid.make<'comp1' | 'comp2', string>(({ slots, state }) => ( + + {slots.comp1} + {state} + {slots.comp2} + + )) + + const knot = UI.Knot.make(grid, { + comp1, + comp2 + }) + + const knotOption = UI.Union.asOption(knot) + + let totalActionsCount = 0 + let comp1ActionsCount = 0 + let comp2ActionsCount = 0 + + const comp1Flow: Flow.For = flow( + Rx.tap(() => comp1ActionsCount++), + Rx.mapTo('first'), + Rx.startWith('first') + ) + const comp2Flow: Flow.For = flow( + Rx.tap(() => comp2ActionsCount++), + Rx.mapTo(0), + Rx.startWith(0) + ) + + const knotFlow = Flow.composeKnot({ + comp1: comp1Flow, + comp2: comp2Flow, + root: flow(Rx.mapTo('foo'), Rx.startWith('foo')) + }) + + const knotOptionFlow = Flow.composeOption( + knotFlow, + flow( + Rx.tap(() => totalActionsCount++), + Rx.map(action => { + switch (action.key) { + case 'comp1': + return { _tag: 'Some' as const } + case 'comp2': + return { _tag: 'None' as const } + default: + assertNever(action) + } + }), + Rx.startWith({ _tag: 'Some' as const }) + ) + ) + + const res = UI.mount(knotOption, knotOptionFlow) + + const w = Enzyme.mount(res) + expect(w.html()).toBe( + '
first
foo
0
' + ) + + w.find('[name="first"]') + .hostNodes() + .forEach(el => el.simulate('click')) + + expect(w.html()).toBe( + '
first
foo
0
' + ) + + expect(totalActionsCount).toBe(1) + expect(comp1ActionsCount).toBe(1) + expect(comp2ActionsCount).toBe(0) + + w.find('[name="second"]') + .hostNodes() + .forEach(el => el.simulate('click')) + + expect(w.html()).toBeNull() + + expect(totalActionsCount).toBe(2) + expect(comp1ActionsCount).toBe(1) + expect(comp2ActionsCount).toBe(1) + }) +}) diff --git a/tests/flow.spec.tsx b/src/__tests__/flow.spec.tsx similarity index 99% rename from tests/flow.spec.tsx rename to src/__tests__/flow.spec.tsx index 79c48f2..6f85a9c 100644 --- a/tests/flow.spec.tsx +++ b/src/__tests__/flow.spec.tsx @@ -11,8 +11,8 @@ import { TestMessage } from 'rxjs/internal/testing/TestMessage' import * as Rx from 'rxjs/operators' import { TestScheduler } from 'rxjs/testing' import { Atom, F } from '@grammarly/focal' -import { Flow, UI } from '../src/index' -import { AnimationActions, AnimationState } from '../src/internal/animated' +import { Flow, UI } from '../index' +import { AnimationActions, AnimationState } from '../internal/animated' import { assertNever } from './utils' describe('Flow', () => { diff --git a/tests/ui_animated.spec.tsx b/src/__tests__/ui_animated.spec.tsx similarity index 92% rename from tests/ui_animated.spec.tsx rename to src/__tests__/ui_animated.spec.tsx index 444bda7..6646e25 100644 --- a/tests/ui_animated.spec.tsx +++ b/src/__tests__/ui_animated.spec.tsx @@ -1,13 +1,15 @@ -import * as React from 'react' -import * as Enzyme from 'enzyme' -import * as Adapter from 'enzyme-adapter-react-16' -import * as O from 'fp-ts/lib/Option' -import * as TH from 'fp-ts/lib/These' -import { flow } from 'fp-ts/lib/function' -import * as Rx from 'rxjs/operators' -import { F } from '@grammarly/focal' -import { Flow, UI } from '../src/index' -import { assertNever } from './utils' +import * as Enzyme from "enzyme"; +import * as Adapter from "enzyme-adapter-react-16"; +import { flow } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import * as TH from "fp-ts/lib/These"; +import * as React from "react"; +import * as Rx from "rxjs/operators"; + +import { F } from "@grammarly/focal"; + +import { Flow, UI } from "../index"; +import { assertNever } from "./utils"; Enzyme.configure({ adapter: new Adapter() }) diff --git a/tests/ui_union.spec.tsx b/src/__tests__/ui_union.spec.tsx similarity index 98% rename from tests/ui_union.spec.tsx rename to src/__tests__/ui_union.spec.tsx index 1ec4c4a..0c8ed05 100644 --- a/tests/ui_union.spec.tsx +++ b/src/__tests__/ui_union.spec.tsx @@ -7,14 +7,14 @@ import { pipe } from 'fp-ts/lib/pipeable' import { NEVER, Subject } from 'rxjs' import * as Rx from 'rxjs/operators' import { F } from '@grammarly/focal' -import { UI } from '../src/ui' +import { UI } from '../ui' import { assertNever } from './utils' Enzyme.configure({ adapter: new Adapter() }) describe('union of', () => { let renderCount = 0 // to ensure that we do not do unnecessary react rerenders - jest.spyOn(console, 'error').mockImplementation() + jest.spyOn(window.console, 'error').mockImplementation() beforeEach(() => (renderCount = 0)) diff --git a/tests/utils.ts b/src/__tests__/utils.ts similarity index 100% rename from tests/utils.ts rename to src/__tests__/utils.ts diff --git a/src/flow.ts b/src/flow.ts index 00d723b..bc93c1a 100644 --- a/src/flow.ts +++ b/src/flow.ts @@ -1,13 +1,18 @@ +import { Kind, URIS } from 'fp-ts/lib/HKT' import { IO } from 'fp-ts/lib/IO' + import * as O from 'fp-ts/lib/Option' import * as R from 'fp-ts/lib/Reader' import * as Record from 'fp-ts/lib/Record' import * as T from 'fp-ts/lib/These' -import { flow, pipe } from 'fp-ts/lib/function' +import { TraversableWithIndex1 } from 'fp-ts/lib/TraversableWithIndex' +import { flow, identity, pipe } from 'fp-ts/lib/function' import { combineLatest, EMPTY, merge, NEVER, Observable, of } from 'rxjs' import * as Rx from 'rxjs/operators' -import { UIAny } from './internal' +import { UIAny, UIListAny } from './internal' import { AnimationActions, AnimationState } from './internal/animated' +import * as Obs from './internal/observable' +import { prioritize } from './internal/rx_prioritize' import { IsNever } from './internal/utils' import { UI } from './ui' @@ -218,21 +223,144 @@ export namespace Flow { ) { return R.asks(actions => pipe( - kindSelector(actions), - Rx.switchMap(keyRecord => { - const [[tag, key]] = Object.entries(keyRecord) as [UnionTag, UnionKeys][] - return pipe( - actions, - Rx.filter(a => a.key === key), - Rx.map(a => a.action), - flowComposition[key], - Rx.map(state => ({ ...state, [tag]: key })) + actions, + // we mirror the `actions` observable into `actionsPrioritized` and `actionsDeprioritized` observables + // with the guarantee that they are subscribed in order. This is necessary to deliver actions to + // the item flows before they are delivered to `kindSelector`, which is important when an + // action triggers a kind update and causes `switchMap` to unsubscribe + // from the item flow before the item flow gets a chance to handle the action. + prioritize((actionsPrioritized, actionsDeprioritized) => + pipe( + kindSelector(actionsDeprioritized), + Rx.switchMap(keyRecord => { + const [[tag, key]] = Object.entries(keyRecord) as [UnionTag, UnionKeys][] + return pipe( + actionsPrioritized, + Rx.filter(a => a.key === key), + Rx.map(a => a.action), + flowComposition[key], + Rx.map(state => ({ ...state, [tag]: key })) + ) + }) ) - }) + ) ) ) as Flow.For } + type SomeFlow = Node extends UI.Union.Option + ? Flow.For + : never + + type OptionKindSelector = Node extends UI.Union.Option + ? R.Reader>, Observable>> + : never + + /** + * Create a Flow for composition of the components (@see UI.Union.Node) from its value flow. + * + * example: + * ```ts + * declare const comp: UI.Node<{ text: string; } + * declare const compFlow: Flow.For + * declare const kindSelector: (actions: Observable>) => Observable<{_tag: 'Some' | 'Node'}> + * + * const optionFlow = Flow.composeOption(compFlow, kindSelector) + * ``` + */ + export function composeOption>( + flow: SomeFlow, + kindSelector: OptionKindSelector + ): Flow.For { + return composeUnion( + ({ + None: () => of(null as never), + Some: Flow.composeKnot({ + value: flow + } as KnotFlowComposition) + } as unknown) as UnionFlowComposition, + R.asks(actions => + pipe( + actions, + Rx.filter(a => a.key === 'Some'), + Rx.map(a => a.action.action), + kindSelector + ) + ) as UnionKindSelector + ) + } + + type ListItemFlowProvider = List extends UI.List + ? (data: T) => Flow, UI.ComposedState> + : never + + type ListCollectionProvider = List extends UI.List + ? F extends URIS + ? R.Reader>, Observable>> + : never + : never + + type ListTraversableState = List extends UI.List + ? F extends URIS + ? TraversableWithIndex1 + : never + : never + + /** + * Create a Flow for composition of the components (@see UI.List) from its children flow. + * + * example: + * ```ts + * import * as A from 'fp-ts/lib/Array' + * + * declare const item: UI.Node<{ text: 'string' }, 'click'> + * type FlowInput = string + * declare const createItemFlow: (data: FlowInput) => Flow.For + * declare const list = UI.List.make(A.array, item) + * declare const collectionProvider: (actions: Observable>) => Observable + * + * const listFlow = Flow.composeList(A.array, collectionProvider, createItemFlow) + * ``` + */ + export function composeList( + // A traversable instance for the state collection + F: ListTraversableState, + // Given a stream of actions, we return a stream that provides the Foldable structure of the state. + // Leaf values and keys are passed to the `itemFlowProvider` during traversal to get the final state. + collectionProvider: ListCollectionProvider, + // Given a cell key and data, return a flow for that cell + itemFlowProvider: ListItemFlowProvider + ): Flow.For { + return R.asks(actions => + pipe( + actions, + // we mirror the `actions` observable into `actionsPrioritized` and `actionsDeprioritized` observables + // with the guarantee that they are subscribed in order. This is necessary to deliver actions to + // the item flows before they are delivered to `collectionProvider`, which is important when an + // action triggers a collection update via `collectionProvider` and causes `switchMap` to unsubscribe + // from the item flow, not giving the item flow a chance to handle the action. + prioritize((actionsPrioritized, actionsDeprioritized) => + pipe( + collectionProvider(actionsDeprioritized), + Rx.switchMap(collection => + // Traverse the collection, transform each cell into a flow by feeding `itemFlowProvider` with the cell data. + // Then combine the resulting state using a combineLatest strategy to get the final list state. + F.traverseWithIndex(Obs.ApplicativeCombine)(collection, (key, data) => + itemFlowProvider(data as T)( + pipe( + actionsPrioritized, + Rx.filter(a => a.key === key), + Rx.map(a => a.action) + ) + ) + ) + ) + ) + ) + ) + ) as Flow.For + } + export type AnimationDecisionFor = Tree extends UI.Animated< infer In, infer Out, @@ -306,13 +434,13 @@ export namespace Flow { O.fromNullable(init[prevKey]), O.chain(av => av.root), O.map(getEventsByKey(animation, prevKey)), - O.getOrElse(() => EMPTY), + O.fold(() => EMPTY, identity), Rx.mapTo(Record.deleteAt(prevKey)) ) const nextTransitionEvents = pipe( init[nextKey].root, O.map(getEventsByKey(animation, nextKey)), - O.getOrElse(() => EMPTY), + O.fold(() => EMPTY, identity), Rx.mapTo((state: AnimationState) => { const nextState = { ...state } nextState[nextKey] = { ...nextState[nextKey], root: O.none } diff --git a/src/internal/animated.ts b/src/internal/animated.ts index 8235d54..26a2af0 100644 --- a/src/internal/animated.ts +++ b/src/internal/animated.ts @@ -47,12 +47,12 @@ export namespace AnimationActions { return { animation: pipe( actions, - Rx.filter((a: AnimationFromComposition) => a.action.key === 'root'), + Rx.filter((a): a is AnimationFromComposition => a.action.key === 'root'), Rx.map(a => ({ key: a.key, action: a.action.action } as AnimationFromIteration)) ), action: pipe( actions, - Rx.filter((a: ChildrenFromComposition) => a.action.key === 'children'), + Rx.filter((a): a is ChildrenFromComposition => a.action.key === 'children'), Rx.map(a => ({ key: a.key, action: a.action.action } as ChildrenFromIteration)) ) } diff --git a/src/internal/foldable.ts b/src/internal/foldable.ts index 2950ec6..9306186 100644 --- a/src/internal/foldable.ts +++ b/src/internal/foldable.ts @@ -1,3 +1,5 @@ +// Must not be empty for swc. See https://github.com/swc-project/swc/issues/7822 + import { FoldableWithIndex, FoldableWithIndex1, diff --git a/src/internal/index.ts b/src/internal/index.ts index f90a130..33c8317 100644 --- a/src/internal/index.ts +++ b/src/internal/index.ts @@ -1,7 +1,7 @@ import { I } from 'ts-toolbelt' import { UI } from '../ui' import { KindAny } from './foldable' -import { DropNever, EmptyWhenNever, Id, IsNever, Merge, RecordWithSingleKey } from './utils' +import { DropNever, Id, IsNever, Merge, RecordWithSingleKey } from './utils' /** * Recursive traverse through @param Part until the end of the @param Path @@ -51,80 +51,69 @@ export type Recompose = Part extends unknown export type ROOT = 'root' export const ROOT: ROOT = 'root' -export type _ComposedState< - _Node extends UIAny, - I extends I.Iteration = I.IterationOf<'8'> -> = _Node extends infer Node // defer type evaluation, helps with compilation performance - ? { - readonly node: Node extends UI.Node - ? S // for UINode just return state - : never +export type _ComposedState> = { + readonly node: Node extends UI.Node + ? S // for UINode just return state + : never - readonly list: Node extends UI.List // if we got UIList - return wrapped into HKT by list type and continue iteration - ? KindAny>, K> - : never + readonly list: Node extends UI.List // if we got UIList - return wrapped into HKT by list type and continue iteration + ? KindAny>, K> + : never - readonly composite: Node extends UI.Composite - ? _ComposedState> - : never + readonly composite: Node extends UI.Composite + ? _ComposedState> + : never - readonly knot: Node extends UI.Knot - ? DropNever< - { - readonly [K in keyof Children]: _ComposedState> // iterate over each children, and - } & { readonly [ROOT]: S } // append own UIKnot state in `ROOT` namespace. - > // and ignore all children which state are never - : never + readonly knot: Node extends UI.Knot + ? DropNever< + { + readonly [K in keyof Children]: _ComposedState> // iterate over each children, and + } & { readonly [ROOT]: S } // append own UIKnot state in `ROOT` namespace. + > // and ignore all children which state are never + : never - readonly union: Node extends UI.Union - ? { - readonly [K in keyof Members]: Id< - { readonly [P in Tag]: K } & // skip state merge operation if member state are never - EmptyWhenNever<_ComposedState>> - > - }[keyof Members] - : never + readonly union: Node extends UI.Union + ? { + readonly [K in keyof Members]: _ComposedState> extends infer R + ? [R] extends [never] + ? // skip state merge operation if member state are never + { readonly [P in Tag]: K } + : Id<{ readonly [P in Tag]: K } & R> + : never + }[keyof Members] + : never - readonly unknown: unknown - }[I.Pos extends 0 ? 'unknown' : UIMatcher] - : never + readonly unknown: unknown +}[I.Pos extends 0 ? 'unknown' : UIMatcher] -export type _ComposedAction< - _Node extends UIAny, - I extends I.Iteration = I.IterationOf<'7'> -> = _Node extends infer Node // defer type evaluation, helps with compilation performance - ? { - readonly node: Node extends UI.Node - ? I // for UINode just return action - : never +export type _ComposedAction> = { + readonly node: Node extends UI.Node + ? I // for UINode just return action + : never - readonly list: Node extends UI.List // if we got UIList - return wrapped into namespace by list key and continue iteration - ? KeyedAction>> // dummy mapped type required to workaround typescript limitations on recursive types - : never + readonly list: Node extends UI.List // if we got UIList - return wrapped into namespace by list key and continue iteration + ? KeyedAction>> // dummy mapped type required to workaround typescript limitations on recursive types + : never - readonly composite: Node extends UI.Composite - ? _ComposedAction> - : never + readonly composite: Node extends UI.Composite + ? _ComposedAction> + : never - readonly knot: Node extends UI.Knot - ? - | KeyedAction // Append own UIKnot action in `root` namespace, and - | { - readonly [K in keyof Children]: KeyedAction< - K, - _ComposedAction> - > - }[keyof Children] // form action union from iteration over each children. - : never + readonly knot: Node extends UI.Knot + ? + | KeyedAction // Append own UIKnot action in `root` namespace, and + | { + readonly [K in keyof Children]: KeyedAction>> + }[keyof Children] // form action union from iteration over each children. + : never - readonly union: Node extends UI.Union - ? { - readonly [K in keyof Members]: KeyedAction>> - }[keyof Members] - : never - readonly unknown: unknown - }[I.Pos extends 0 ? 'unknown' : UIMatcher] - : never + readonly union: Node extends UI.Union + ? { + readonly [K in keyof Members]: KeyedAction>> + }[keyof Members] + : never + readonly unknown: unknown +}[I.Pos extends 0 ? 'unknown' : UIMatcher] type _Decompose> = { readonly node: Part extends UI.Node ? [State, Action] : never @@ -237,7 +226,7 @@ type _ChangeDescendant< }[I.Pos extends Path['length'] ? 'node' : UIMatcher] : never -export type GridWithSingleSlotWithoutStateAndActions = Node extends UI.Grid< +export type GridWithSingleSlotWithoutStateAndActions = Node extends UI.Grid< infer S, infer A, infer Slot @@ -255,12 +244,13 @@ export type GridWithSingleSlotWithoutStateAndActions = Node * Any renderable UI Tree element which has own `State` & `Action` */ export type UIAnyWithOwnSA = UINodeAny | UIKnotAny | UIListAny -export type UIAny = UIAnyWithOwnSA | UIUnionAny | UICompositeAny +export type UIAny = UIAnyWithOwnSA | UIUnionAny | UICompositeAny | UIGroupAny export type UINodeAny = UI.Node -export type UIKnotAny = UI.Knot +export type UIKnotAny = UI.Knot> export type UICompositeAny = UI.Composite export type UIListAny = UI.List export type UIUnionAny = UI.Union +export type UIGroupAny = UI.Group> /** * Transforms provided @param Part into string literal. diff --git a/src/internal/observable.ts b/src/internal/observable.ts new file mode 100644 index 0000000..49768c4 --- /dev/null +++ b/src/internal/observable.ts @@ -0,0 +1,21 @@ +import { Applicative1 } from 'fp-ts/lib/Applicative' +import { combineLatest, Observable, of as rxOf } from 'rxjs' +import { map } from 'rxjs/operators' + +declare module 'fp-ts/lib/HKT' { + interface URItoKind { + readonly Observable: Observable + } +} + +export const URI = 'Observable' +export type URI = typeof URI + +export const of: (a: A) => Observable = a => rxOf(a) + +export const ApplicativeCombine: Applicative1 = { + URI, + map: (fa, f) => fa.pipe(map(f)), + ap: (fab, fa) => combineLatest([fab, fa]).pipe(map(([fab, fa]) => fab(fa))), + of +} diff --git a/src/internal/rx_map.ts b/src/internal/rx_map.ts index eea6940..c0d5421 100644 --- a/src/internal/rx_map.ts +++ b/src/internal/rx_map.ts @@ -78,7 +78,7 @@ function getEqByValue(valueEq: Eq.Eq): Eq.Eq> { const entries = a.keys() let e: IteratorResult - while (!(e = entries.next()).done) { + while (!((e = entries.next()).done ?? false)) { if (!b.has(e.value) || !valueEq.equals(a.get(e.value)!, b.get(e.value)!)) { return false } diff --git a/src/internal/rx_prioritize.ts b/src/internal/rx_prioritize.ts new file mode 100644 index 0000000..f6a2fd3 --- /dev/null +++ b/src/internal/rx_prioritize.ts @@ -0,0 +1,29 @@ +import { Observable, OperatorFunction, Subject, Subscription } from 'rxjs' +import { publish } from 'rxjs/operators' + +/** + * Allows to control the order in which subscriptions are made. + * + * Taken from @see https://github.com/cartant/rxjs-etc/blob/main/source/operators/prioritize.ts + * + * @license Use of this source code is governed by an MIT-style license that + * can be found in the LICENSE file at https://github.com/cartant/rxjs-etc + */ +export function prioritize( + selector: (...prioritizedList: Observable[]) => Observable +): OperatorFunction { + return source => + new Observable(observer => { + const published = publish()(source) + const orderedSubjects: Subject[] = [] + const subscription = new Subscription() + for (let i = 0; i < selector.length; ++i) { + const subject = new Subject() + orderedSubjects.push(subject) + subscription.add(published.subscribe(subject)) + } + subscription.add(selector(...orderedSubjects).subscribe(observer)) + subscription.add(published.connect()) + return subscription + }) +} diff --git a/src/internal/rx_split_by.ts b/src/internal/rx_split_by.ts index f2ffba7..0d9a7cb 100644 --- a/src/internal/rx_split_by.ts +++ b/src/internal/rx_split_by.ts @@ -24,7 +24,7 @@ import { RefCountSubscription } from 'rxjs/internal/operators/groupBy' * state, * splitBy('kind') * ) - * // $ShoudBeEqualTo + * // $ShouldBeEqualTo * Observable | GroupedObservable<'three', Three>> **/ export function splitBy( diff --git a/src/ui.ts b/src/ui.ts index 113212f..dc01086 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -312,9 +312,7 @@ export namespace UI { State, Action, Slots extends PropertyKey, - Children extends { - readonly [K in Slots]: UIAny - } + Children extends Record >( grid: UI.Grid, children: Exact @@ -482,7 +480,11 @@ export namespace UI { s.state, Rx.map( flow( - O.map(name => (inTransition as any)[name] || (outTransition as any)[name]), + O.map(name => + Boolean((inTransition as any)[name]) + ? (inTransition as any)[name] + : (outTransition as any)[name] + ), O.toUndefined ) ), @@ -821,7 +823,7 @@ export namespace UI { children: null as never, notify: props.notify, // state will be undefined in cases when children has state equal to never - state: props.state || EMPTY + state: props.state ?? EMPTY }) } ) @@ -835,11 +837,11 @@ export namespace UI { } else if (isKnot(uiNode)) { return props => pipe( - props.state, + props.state as Observable, fromFoldable(Record.record), Rx.map(data => { const children = pipe( - uiNode.children as Record, + uiNode.children, Record.mapWithIndex((key, part) => // Here squash will be called only once, during first state emit, so it does not make sense to cache it makeNamespacedNode( @@ -847,7 +849,7 @@ export namespace UI { squash(part) )({ children: null as never, - notify: props.notify, + notify: props.notify as (i: KeyedAction) => void, // state will be undefined in cases when children has state equal to never state: data.get(key) || EMPTY }) @@ -857,7 +859,7 @@ export namespace UI { return uiNode.grid({ children, // state will be undefined in cases when grid has state equal to never - state: data.get(ROOT) || EMPTY, + state: (data.get(ROOT) || EMPTY) as Observable, notify: action => props.notify({ key: ROOT, action } as ComposedAction) }) }), @@ -866,9 +868,9 @@ export namespace UI { } else if (isList(uiNode)) { return props => pipe( - props.state, + props.state as Observable>, fromFoldable(uiNode.foldable), - Rx.scan((cache, data: Map>) => { + Rx.scan((cache, data) => { const children = new Map() data.forEach((state, key) => { const cached = cache.get(key) @@ -876,8 +878,15 @@ export namespace UI { children.set(key, cached) } else { // TODO: Should we add a cache for that squash??? - const kc = makeNamespacedNode(key, squash(uiNode.of)) - children.set(key, kc({ children: null as never, notify: props.notify, state })) + const keyedComponent = makeNamespacedNode(key, squash(uiNode.of)) + children.set( + key, + keyedComponent({ + children: null as never, + notify: props.notify as (i: KeyedAction) => void, + state + }) + ) } }) @@ -893,7 +902,7 @@ export namespace UI { } else if (isUnion(uiNode)) { return props => pipe( - props.state, + props.state as Observable<{ readonly [key: string]: any }>, splitBy(uiNode.tag), Rx.map(state => // TODO: Should we add a cache for that squash??? @@ -902,7 +911,7 @@ export namespace UI { squash(uiNode.members[state.key]) )({ children: null as never, - notify: props.notify, + notify: props.notify as (i: KeyedAction) => void, state }) ), @@ -919,7 +928,7 @@ export namespace UI { * * see examples in {@link mapAction} & {@link contramapState}. */ - export function promap( + export function promap( uiNode: Node, contramapState: (v: State) => Decompose[0], mapAction: (v: Decompose[1]) => Action @@ -974,9 +983,11 @@ export namespace UI { * > * ``` */ - export type ComposedState = Node extends unknown - ? _ComposedState // hide type complexity to make it more readable in usage places - : never + export type ComposedState = UIAny extends Node + ? // hide type complexity to make it more readable in usage places + // TODO: return unknown for infinite type UIAny + any + : _ComposedState /** * @returns computed Actions of provided UI parts composition. @@ -1007,12 +1018,15 @@ export namespace UI { * } * ``` */ - export type ComposedAction = Node extends unknown - ? _ComposedAction // hide type complexity to make it more readable in usage places - : never + export type ComposedAction = UIAny extends Node + ? // hide type complexity to make it more readable in usage places + // TODO: return unknown for infinite type UIAny + any + : _ComposedAction } interface ReactElement extends React.ReactElement {} + interface UIPart extends R.Reader< MountProps, @@ -1053,41 +1067,41 @@ function makeNamespacedNode( ) } -function changeDescendant( - parent: RootNode, +function changeDescendant( + parent: Node, path: P, - f: (current: FocusDescendant) => NewNode -): ChangeDescendant { + f: (current: FocusDescendant) => NewNode +): ChangeDescendant { if (isComposite(parent) && path.length > 0) { return { grid: parent.grid, - child: changeDescendant(parent.child, path, f) - } as ChangeDescendant + child: changeDescendant(parent.child as Node, path, f) + } as ChangeDescendant } else if (isKnot(parent) && path.length > 0) { const [head, ...tail] = path return { grid: parent.grid, children: { ...parent.children, - [head]: changeDescendant(parent.children[head], tail as P, f) + [head]: changeDescendant(parent.children[head] as Node, tail as P, f) } - } as ChangeDescendant + } as ChangeDescendant } else if (isList(parent) && path.length > 0) { return { foldable: parent.foldable, - of: changeDescendant(parent.of, path, f) - } as ChangeDescendant + of: changeDescendant(parent.of as Node, path, f) + } as ChangeDescendant } else if (isUnion(parent) && path.length > 0) { const [head, ...tail] = path return { tag: parent.tag, members: { ...parent.members, - [head]: changeDescendant(parent.members[head], tail as P, f) + [head]: changeDescendant(parent.members[head] as Node, tail as P, f) } - } as ChangeDescendant + } as ChangeDescendant } else { - return f(parent as any) as ChangeDescendant + return f(parent as any) as ChangeDescendant } } diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..1d66470 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + // Do not exclude any files + "exclude": [] +} diff --git a/tsconfig.json b/tsconfig.json index 96a7ea8..4784dde 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,22 +2,31 @@ "compilerOptions": { "declaration": true, "declarationMap": true, + "isolatedModules": true, + "target": "es5", + "module": "es2015", + "importHelpers": true, "jsx": "react", - "lib": ["es6", "es2017", "dom", "scripthost"], + "lib": ["es2017.object", "ES2016.Array.Include", "es6", "es2015", "dom", "scripthost"], "moduleResolution": "node", "sourceMap": true, "strict": true, "strictFunctionTypes": false, "allowSyntheticDefaultImports": false, "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, "noImplicitReturns": true, "noUncheckedIndexedAccess": false, "noUnusedLocals": true, "noUnusedParameters": true, "allowUnreachableCode": false, "noEmitOnError": true, + "strictPropertyInitialization": false, + "strictNullChecks": true, "skipLibCheck": true, "composite": true, "incremental": true - } + }, + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] }