From 19bde440ce5b4c760583c953de44edda2f736cff Mon Sep 17 00:00:00 2001 From: Matei Adriel Date: Tue, 12 May 2020 15:55:32 +0300 Subject: [PATCH] typescript(monadic): docs: small tutorial Signed-off-by: prescientmoon --- typescript/monadic/.prettierrc | 5 + typescript/monadic/README.md | 356 +++++++++++++++++++++++-- typescript/monadic/demos/basic/main.ts | 1 + 3 files changed, 347 insertions(+), 15 deletions(-) create mode 100644 typescript/monadic/.prettierrc diff --git a/typescript/monadic/.prettierrc b/typescript/monadic/.prettierrc new file mode 100644 index 0000000..528e360 --- /dev/null +++ b/typescript/monadic/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": false, + "trailingComma": "none", + "singleQuote": true +} diff --git a/typescript/monadic/README.md b/typescript/monadic/README.md index 7555d82..a0480c0 100644 --- a/typescript/monadic/README.md +++ b/typescript/monadic/README.md @@ -1,27 +1,353 @@ -# TSDX Bootstrap +# Monadic -This project was bootstrapped with [TSDX](https://github.com/jaredpalmer/tsdx). +Random functional-ish web framework thing I made for fun. -## Local Development +# Small tutorial -Below is a list of commands you will probably find useful. +## Templates -### `npm start` or `yarn start` +This library lets you render to any kind of template. For this tutorial we'll be using lit-html. -Runs the project in development/watch mode. Your project will be rebuilt upon changes. TSDX has a special logger for you convenience. Error messages are pretty printed and formatted for compatibility VS Code's Problems tab. +Components are created using specs. Let's create a base spec which says "render this component to a lit-html template result": - +```ts +import { withTemplate } from '../../src'; +import { TemplateResult } from 'lit-html'; +import { pipe } from 'fp-ts/lib/pipeable'; -Your library will be rebuilt if you make edits. +// Pipe is a helper from fp-ts which is the equivalent of the pipe operator proposal as a function +const baseSpec = pipe(emptySpec, withTemplate()); +``` -### `npm run build` or `yarn build` +## Components -Bundles the package to the `dist` folder. -The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module). +The most important thing a component has is state. To specify the the type of a component' state use the withState builder: - +```ts +type State = { count: number }; -### `npm test` or `yarn test` +const counterComponent = pipe(baseSpec, withState()); +``` -Runs the test watcher (Jest) in an interactive mode. -By default, runs tests related to files changed since the last commit. +State is modified using actions. First we need a type for the action. For our counter component our only possible actions are increasing and decreasing the state: + +```ts +type Action = 'increase' | 'decrease'; + +const counterComponent = pipe( + baseSpec, + withState(), + withAction() +); +``` + +## Building specs + +To build a spec you need to use the `buildSpec` function. BuildSpec takes 3 arguments: + +- The render function: takes some sate and generates a lit-html template +- The handleAction function: takes some state and an action and returns some new state +- Children: we'll cover this later. For now we'll just pass {} to it + +Let's create a small render function for our counter component: + +```ts +const counterComponent = pipe( + baseSpec, + withState(), + withAction(), + buildSpec( + ({ count }) => { + return html` +
+ Count: ${count} +
+ `; + }, + (action, state) => { + // just return the state for now + return state; + }, + {} + ) +); +``` + +The render function also takes a second argument exposing some functions to the component. For now let's take a look at dispatch. Dispatch takes an action and calls your handleAction function replacing the component state with the return of that function. + +Let's implement the basic actions for our component + +```ts +const counterComponent = pipe( + ...buildSpec( + ({ count }, { dispatch }) => { + return html` +
+ Count: ${count} + + +
+ `; + }, + (action, { count }) => { + if (action === 'increase') { + return { count: count + 1 }; + } + + return { count: count - 1 }; + }, + {} + ) +); +``` + +## Running a component + +To run a component simply use the runUi function: + +```ts +runUi({ + parent: document.getElementById('app') // you can use any dom element here, + render, // this is a function which takes the parent and a template and renders it. In this case we use the render function from lit-html + initialState: { count: 0 }, // this is the state your component starts out with + component: counterComponent, // the component to render +}); +``` + +Now if you run your app with something like parcel you should see a working counter! + +## Children + +In my library all the state is always in sync. To do that we need to explain monadic how to sync it. This is what the last argument of buildSpec if for. Let's define a simple counters component which keeps a list of counters: + +```ts +type CounterListState = { + counters: CounterState[]; // We renamed our previous State type to CounterState to prevent confusion +}; + +const counterList = pipe( + baseSpec, + withState(), + buildSpec( + ({ counters }) => { + return html` +
+ Counters go here +
+ `; + }, + (_, state) => { + // We have no actions for now so we can just return the state + return state; + }, + {} + ) +); +``` + +Using components inside another components: + +1. You need to explain typescript what your components looks like. To do this you need to use the withChild state builder which takes 4 arguments: + - The name of the component + - The input your state syncing function takes. In our case this will be just the index of the counter. + - The state your child component has + - The output your components raises. We'll talk about this one later + +Let's add our counter component to the spec of our counterList component: + +```ts +const counterList = pipe( + baseSpec, + withState(), + withChild<"counter", number, CounterState, null>() + ... +``` + +## Syncing states + +To sync a parent and a child state we need 2 functions: + +- Get: takes the parent state and returns the child state +- Set: takes a parent and a child state and merges them + +Let's see this in action by passing an actual child to the 3rd argument of buildSpec: + +```ts +buildSpec( + ...,{ + // This has to match the name you passed to withChild + counter: mkChild( + // We told withChild our component takes an input of type number being the index of the counter + index => ({ + // Our + get: ({ counters }) => counters[index], + set: (state, newCounterState) => { + ...state, + // This function just returns an array with an index modified. Try implementing it yourself :D + counters: setIndex(state.counters, index, newCounterState) + } + }), + counterComponent // This is the actual component we created earlier + // There's also an optional 3rd parameter for outputs but we'll cover that later + ) + } +); +``` + +Now we can include the component in our render function: + +```ts +({ counters }, { child }) => { + return html` +
+ This is a counter list with ${counters.length} counters inside it! +
+ ${counters.map((_, index) => + child( + // This is the name of the counter. It has to match what you passed to withChild + 'counter', + // This is similar to key in react. It's used to keep track of the state of each counter. + // Using the index is usually bad practice + // You should probably use something like an id for it if you can + String(index), + // This is the input we pass to our sync functions. It has to match what we gave to withChild + index + ) + )} +
+
+ `; +}; +``` + +Now run your component with some initial state and you should see a counter element for each counter in the state! + +> Exercise: add an input element and a button allowing the creation of new counters + +## Output + +Outputs are like events. Let's say we want counters to have a "delete" button. To do that we need to allow the counter component to send messages to the counterList component. Let's start by defining the type of our messages: + +```ts +type CounterOutput = 'delete'; +``` + +Then let's edit the counter component to emit the output on click. For this the `raise` function is used: + +```ts +const counterComponent = pipe( + ..., + withOutput(), + buildSpec( + ({ count }, { dispatch, raise }) => { + return html` +
+ + Count: ${count} + + +
+ `; + }, + ... + ) +); +``` + +And now we need to update the `withChild` builder from the list component to reflect the changes: + +```ts +... +withChild<"counter", number, CounterState, CounterOutput>() +... +``` + +Now we need to actually handle the outputs. You don't always want to do something with an output, so monadic allows you do discard any output you want. To handle the output -> action relation `mkChild` accepts an extra argument which takes an output and returns: + +```ts +Option +``` + +where Option is the Option type from fp-ts. + +Let's create a new action in our list component which deletes a counter based on it's index: + +```ts +type CounterAction = { + label: 'delete'; + index: number; +}; + +const counterList = pipe( + ..., + withAction(), + buildSpec( + ..., + (action, state) => { + if (action.label === 'delete') { + return { + ...state, + counters: state.counters.filter((_, index) => index !== action.index), + }; + } + } + ) +); +``` + +And now let's define the function which handles the outputs: + +```ts +buildSpec( + ...,{ + counter: mkChild( + ..., + (index, output) => output === "delete" ? O.some({ label: "delete", index }) : O.none + ) + } +); +``` + +And voila! Now you should be able to delete any counter! + +## Listening to outputs on root: + +Our counter list doesn't have any outputs currently, but let's assume it had the following set of outputs: + +```ts +type CounterListOutputs = + | { label: 'foo'; value: number } + | { label: 'bar'; value: string }; +``` + +We can listen to outputs on our root component with a simple for loop: + +```ts +const main = async () => { + // runUi returns an async iterator + const output = runUi(...); + + for await (const event of output) { + if (event.label === "foo") { + console.log(`Received a foo event with a number payload of ${event.value}`) + } + else if (event.label === "bar") { + console.log(`Received a bar event with a string payload of ${event.value}`) + } + } +}; + +main().catch(err => { + // Handle errors +}); +``` + +## Closing notes: + +You access `dispatch` and `raise` inside your action handler as well: + +```ts +(action, state, { dispatch, raise }) => { + ... +} +``` diff --git a/typescript/monadic/demos/basic/main.ts b/typescript/monadic/demos/basic/main.ts index bb54e0f..9de1cc7 100644 --- a/typescript/monadic/demos/basic/main.ts +++ b/typescript/monadic/demos/basic/main.ts @@ -172,6 +172,7 @@ const todoList = pipe( } ) ); + const main = async () => { const output = runUi({ parent: document.getElementById('app'),