Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update code to match internal Embrace repo #11

Merged
merged 2 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ module.exports = {
]
},
parserOptions: {
project: './tsconfig.json',
project: './tsconfig.eslint.json',
tsconfigRootDir: __dirname
},
rules: {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:

strategy:
matrix:
node-version: [12.x]
node-version: [16.x, 18.x, 20.x]

steps:
- uses: actions/checkout@v4
Expand Down
38 changes: 19 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
* <Gird>
* <Grid>
* <Header>
* <Body>
* </Grid>
Expand All @@ -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<typeof Body> = 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(() => <header className="header">Welcome</header>)

// Produce a component by composing `mainGrid` with `Header` and `Body`
Expand Down Expand Up @@ -69,15 +69,15 @@ 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 }) => (
<header className="header">
<F.span>{state.pipe(Rx.map(({ user }) => `Hello, ${user}`))}</F.span>
<button onClick={notify('onClick')}>Button</button>
</header>
))

// `CustomHeader` logic (Flow). Contains initial value and the actions handler
// `CustomHeader` logic (Flow). Contains actions handler and initial value
const customHeaderFlow: Flow.For<typeof CustomHeader> = flow(
Rx.map(() => ({ user: 'username' })),
Rx.startWith({ user: 'anonymous' })
Expand All @@ -103,25 +103,25 @@ ReactDOM.render(<App />, 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.

## WHAT?

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.

Expand All @@ -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:

Expand All @@ -157,7 +157,7 @@ interface UIPart<State, Action, Slots> {
}
```

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.

Expand Down Expand Up @@ -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
Expand All @@ -250,7 +250,7 @@ Embrace uses raw UIPart to build few higher-level abstractions to help build the
```ts
interface Grid<State, Action, Slots> extends UIPart<State, Action, Slots> {}
```
- **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<State, Action, Children extends Record<string, UIAny>> {
readonly grid: UI.Grid<State, Action, keyof Children>
Expand Down Expand Up @@ -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 }) => (
<F.div>
'Welcome'
{slots.nav}
Expand All @@ -316,7 +316,7 @@ const buttonNav = UI.Node.make<never, 'navClick'>(({ notify }) => (
const body = UI.Node.make<string, never>(({ state }) => <F.div>{state}</F.div>)

// 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, {
Expand Down Expand Up @@ -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<typeof NewApp> = Flow.composeKnot<typeof NewApp>({
// 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
Expand Down Expand Up @@ -517,7 +517,7 @@ const shiftPress: Observable<boolean> = 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<typeof App> = Flow.composeKnot<typeof App>({
// 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
Expand Down Expand Up @@ -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<typeof App> = Flow.composeKnot<typeof App>({
// 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
Expand Down
15 changes: 8 additions & 7 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ module.exports = {
moduleFileExtensions: ['js', 'ts', 'tsx'],
testMatch: ['**/*.spec.ts?(x)'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: './tsconfig.json',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this now be tsconfig.eslint.json, so that it does not exclude test files? I don't know. It passes either way.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the tests are passing, so let's keep the base one. It will be confusing to use eslint config in the jest setup

isolatedModules: true
}
]
},
preset: 'ts-jest',
globals: {
'ts-jest': {
tsconfig: './tsconfig.json',
isolatedModules: true
}
}
testEnvironment: 'jsdom'
}
39 changes: 20 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand All @@ -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"
Expand Down
30 changes: 16 additions & 14 deletions tests/base.spec.tsx → src/__tests__/base.spec.tsx
Original file line number Diff line number Diff line change
@@ -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() })

Expand Down Expand Up @@ -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<any, any>(x => x + 'new')), // TODO: <any, any> to prevent TS2589
UI.patch('comp2')(() => UI.Node.empty)
)

Expand Down Expand Up @@ -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<any, any>(x => x + 'new')) // TODO: <any, any> to prevent TS2589
)

const linit = { comp1: init, comp2: init }
Expand Down
Loading
Loading