typescript(monadic): feat: working event raising
Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
parent
5a1e511a6d
commit
17f813ea0c
|
@ -1,8 +1,5 @@
|
|||
import {
|
||||
runUi,
|
||||
makeComponent,
|
||||
Dispatcher,
|
||||
ComponentSpec,
|
||||
withAction,
|
||||
mkChild,
|
||||
buildSpec,
|
||||
|
@ -10,12 +7,16 @@ import {
|
|||
emptySpec,
|
||||
withTemplate,
|
||||
withChild,
|
||||
withOutput,
|
||||
} from '../../src';
|
||||
import { render, html, TemplateResult } from 'lit-html';
|
||||
import { pipe } from 'fp-ts/lib/pipeable';
|
||||
import { constant } from 'fp-ts/es6/function';
|
||||
import * as O from 'fp-ts/es6/Option';
|
||||
|
||||
const baseSpec = pipe(emptySpec, withTemplate<TemplateResult>());
|
||||
|
||||
// Todo component
|
||||
type TodoState = {
|
||||
name: string;
|
||||
done: boolean;
|
||||
|
@ -23,16 +24,20 @@ type TodoState = {
|
|||
|
||||
type TodoAction = 'complete';
|
||||
|
||||
type TodoOutput = 'delete';
|
||||
|
||||
const todo = pipe(
|
||||
baseSpec,
|
||||
withAction<TodoAction>(),
|
||||
withState<TodoState>(),
|
||||
withOutput<TodoOutput>(),
|
||||
buildSpec(
|
||||
({ name, done }, { dispatch }) => {
|
||||
({ name, done }, { dispatch, raise }) => {
|
||||
return html`
|
||||
<div>
|
||||
Name: ${name} Completed: ${done}
|
||||
<button @click=${() => dispatch('complete')}>Complete todo</button>
|
||||
<button @click=${() => raise('delete')}>Delete todo</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
@ -50,32 +55,65 @@ const todo = pipe(
|
|||
)
|
||||
);
|
||||
|
||||
type TodoListState = {
|
||||
todos: TodoState[];
|
||||
inputValue: string;
|
||||
};
|
||||
|
||||
type TodoListAction =
|
||||
// Todo list input component
|
||||
type TodoListInputAction =
|
||||
| {
|
||||
label: 'setInput';
|
||||
value: string;
|
||||
}
|
||||
| 'create';
|
||||
|
||||
type TodoListInputOutput = 'createTodo';
|
||||
|
||||
const todoListInput = pipe(
|
||||
baseSpec,
|
||||
withAction<TodoListInputAction>(),
|
||||
withState<string>(),
|
||||
withOutput<TodoListInputOutput>(),
|
||||
buildSpec(
|
||||
(value, { dispatch }) => {
|
||||
return html`
|
||||
<input
|
||||
value=${value}
|
||||
@input=${(event: InputEvent) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
dispatch({ label: 'setInput', value: target.value });
|
||||
}}
|
||||
/>
|
||||
<button @click=${() => dispatch('create')}>Add todo</button>
|
||||
`;
|
||||
},
|
||||
(action, state, { raise }) => {
|
||||
if (action === 'create') {
|
||||
raise('createTodo');
|
||||
return state;
|
||||
}
|
||||
|
||||
return action.value;
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
// Todo list component
|
||||
type TodoListState = {
|
||||
todos: TodoState[];
|
||||
inputValue: string;
|
||||
};
|
||||
|
||||
type TodoListAction = 'createTodo' | { label: 'deleteTodo'; index: number };
|
||||
|
||||
const todoList = pipe(
|
||||
emptySpec,
|
||||
baseSpec,
|
||||
withAction<TodoListAction>(),
|
||||
withState<TodoListState>(),
|
||||
withChild<'todo', number, TodoState, null>(),
|
||||
withChild<'input', null, string, TodoListInputOutput>(),
|
||||
buildSpec(
|
||||
(state, { dispatch, child }) => {
|
||||
(state, { child }) => {
|
||||
return html`
|
||||
<div>
|
||||
<input
|
||||
value="${state.inputValue}"
|
||||
@input=${({ value }) => dispatch({ label: 'setInput', value })}
|
||||
/>
|
||||
<button @click=${() => dispatch('create')}>Add todo</button>
|
||||
${child('input', 'input', null)}
|
||||
<div>
|
||||
${state.todos.map((_, index) =>
|
||||
child('todo', String(index), index)
|
||||
|
@ -84,8 +122,8 @@ const todoList = pipe(
|
|||
</div>
|
||||
`;
|
||||
},
|
||||
(action: TodoListAction, state: TodoListState) => {
|
||||
if (action === 'create') {
|
||||
(action, state) => {
|
||||
if (action === 'createTodo') {
|
||||
return {
|
||||
inputValue: '',
|
||||
todos: [
|
||||
|
@ -96,18 +134,16 @@ const todoList = pipe(
|
|||
},
|
||||
],
|
||||
};
|
||||
} else if (action.label === 'setInput') {
|
||||
} else if (action.label === 'deleteTodo') {
|
||||
return {
|
||||
...state,
|
||||
inputValue: action.value,
|
||||
todos: state.todos.filter((_, index) => index !== action.index),
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
{
|
||||
todo: mkChild(
|
||||
(index: number) => ({
|
||||
index => ({
|
||||
get: ({ todos }) => todos[index],
|
||||
set: (state, newTodoState) => ({
|
||||
...state,
|
||||
|
@ -116,15 +152,40 @@ const todoList = pipe(
|
|||
),
|
||||
}),
|
||||
}),
|
||||
todo
|
||||
todo,
|
||||
(index, output) =>
|
||||
output === 'delete'
|
||||
? O.some({ label: 'deleteTodo', index } as TodoListAction)
|
||||
: O.none
|
||||
),
|
||||
input: mkChild(
|
||||
constant({
|
||||
get: ({ inputValue }) => inputValue,
|
||||
set: (state, newInputValue) => ({
|
||||
...state,
|
||||
inputValue: newInputValue,
|
||||
}),
|
||||
}),
|
||||
todoListInput,
|
||||
constant(O.some('createTodo' as TodoListAction))
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
const main = async () => {
|
||||
const output = runUi({
|
||||
parent: document.getElementById('app'),
|
||||
render,
|
||||
initialState: { todos: [], inputValue: '' },
|
||||
component: todoList,
|
||||
});
|
||||
|
||||
runUi({
|
||||
parent: document.getElementById('app'),
|
||||
render,
|
||||
initialState: { todos: [], inputValue: '' },
|
||||
component: todoList,
|
||||
for await (const event of output) {
|
||||
console.log('Received an event from the todo list component!');
|
||||
}
|
||||
};
|
||||
|
||||
main().catch(err => {
|
||||
console.log('An error ocurred while running the root component');
|
||||
throw err;
|
||||
});
|
||||
|
|
|
@ -6,9 +6,12 @@ import {
|
|||
Child,
|
||||
} from './Component';
|
||||
import { GenericLens } from './helpers';
|
||||
import { constant, identity } from 'fp-ts/es6/function';
|
||||
import { constant, identity, flow } from 'fp-ts/es6/function';
|
||||
import * as O from 'fp-ts/es6/Option';
|
||||
|
||||
// This is here since all the builders are pretty much this
|
||||
const alwaysIdentity = constant(identity);
|
||||
|
||||
type ComponentBuilder = <
|
||||
T,
|
||||
S,
|
||||
|
@ -47,6 +50,9 @@ export const mkChild: ChildBuilder = (
|
|||
handleOutput = constant(O.none)
|
||||
) => ({ lens, component, handleOutput });
|
||||
|
||||
/**
|
||||
* Structure which keeps track of the different types needed to build a component.
|
||||
*/
|
||||
export type ComponentSpec<
|
||||
T,
|
||||
S,
|
||||
|
@ -56,16 +62,29 @@ export type ComponentSpec<
|
|||
C extends ChildrenConfigs<N>
|
||||
> = [T, S, A, O, N, C];
|
||||
|
||||
export const withChild = constant(identity) as <
|
||||
/**
|
||||
* Add a child to a spec (type only, the implementation is given when building the spec)
|
||||
*/
|
||||
export const withChild = alwaysIdentity as <
|
||||
N1 extends keyof any,
|
||||
I,
|
||||
S,
|
||||
O
|
||||
>() => <T, PS, A, PO, N0 extends string, C extends ChildrenConfigs<N0>>(
|
||||
>() => <
|
||||
T,
|
||||
PS,
|
||||
A,
|
||||
PO,
|
||||
N0 extends Exclude<string, N1>,
|
||||
C extends ChildrenConfigs<N0>
|
||||
>(
|
||||
spec: ComponentSpec<T, PS, A, PO, N0, C>
|
||||
) => ComponentSpec<T, PS, A, PO, N1 & N0, C & Record<N1, Child<I, S, O>>>;
|
||||
|
||||
export const withAction = constant(identity) as <A1>() => <
|
||||
/**
|
||||
* Specify the action type of a spec
|
||||
*/
|
||||
export const withAction = alwaysIdentity as <A1>() => <
|
||||
T,
|
||||
S,
|
||||
A0,
|
||||
|
@ -76,7 +95,24 @@ export const withAction = constant(identity) as <A1>() => <
|
|||
spec: ComponentSpec<T, S, A0, O, N, C>
|
||||
) => ComponentSpec<T, S, A1, O, N, C>;
|
||||
|
||||
export const withState = constant(identity) as <S1>() => <
|
||||
/**
|
||||
* Specify the output type of a spec
|
||||
*/
|
||||
export const withOutput = alwaysIdentity as <O1>() => <
|
||||
T,
|
||||
S,
|
||||
A,
|
||||
O0,
|
||||
N extends string,
|
||||
C extends ChildrenConfigs<N>
|
||||
>(
|
||||
spec: ComponentSpec<T, S, A, O0, N, C>
|
||||
) => ComponentSpec<T, S, A, O1, N, C>;
|
||||
|
||||
/**
|
||||
* Specify the type of state in a spec
|
||||
*/
|
||||
export const withState = alwaysIdentity as <S1>() => <
|
||||
T,
|
||||
S0,
|
||||
A,
|
||||
|
@ -87,7 +123,10 @@ export const withState = constant(identity) as <S1>() => <
|
|||
spec: ComponentSpec<T, S0, A, O, N, C>
|
||||
) => ComponentSpec<T, S1, A, O, N, C>;
|
||||
|
||||
export const withTemplate = constant(identity) as <T1>() => <
|
||||
/**
|
||||
* Specify what a component renders to
|
||||
*/
|
||||
export const withTemplate = alwaysIdentity as <T1>() => <
|
||||
T0,
|
||||
S,
|
||||
A,
|
||||
|
@ -98,7 +137,10 @@ export const withTemplate = constant(identity) as <T1>() => <
|
|||
spec: ComponentSpec<T0, S, A, O, N, C>
|
||||
) => ComponentSpec<T1, S, A, O, N, C>;
|
||||
|
||||
export const buildSpec = (constant(makeComponent) as any) as <
|
||||
/**
|
||||
* Takes implementations of the components watching the spec and creates the component
|
||||
*/
|
||||
export const buildSpec = flow(makeComponent, constant) as <
|
||||
T,
|
||||
S,
|
||||
A,
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
import { mapRecord } from './Record';
|
||||
import { values, GenericLens } from './helpers';
|
||||
import O from 'fp-ts/es6/Option';
|
||||
import { constant } from 'fp-ts/es6/function';
|
||||
import { values, GenericLens, areEqual } from './helpers';
|
||||
import * as O from 'fp-ts/es6/Option';
|
||||
import { constant, flow } from 'fp-ts/es6/function';
|
||||
|
||||
/**
|
||||
* Helper type for dispatcher functions
|
||||
*/
|
||||
export type Dispatcher<A> = (action: A) => void;
|
||||
|
||||
export type Communicate<
|
||||
T,
|
||||
A,
|
||||
O,
|
||||
N extends string,
|
||||
C extends ChildrenConfigs<N>
|
||||
> = {
|
||||
export type Communicate<A, O> = {
|
||||
dispatch: Dispatcher<A>;
|
||||
child: <K extends N>(key: K, name: string, input: C[K]['input']) => T;
|
||||
raise: (event: O) => void;
|
||||
};
|
||||
|
||||
export type CanAddChildren<
|
||||
T,
|
||||
N extends string,
|
||||
C extends ChildrenConfigs<N>
|
||||
> = {
|
||||
child: <K extends N>(key: K, name: string, input: C[K]['input']) => T;
|
||||
};
|
||||
export type RenderFunction<
|
||||
T,
|
||||
S,
|
||||
|
@ -27,7 +27,7 @@ export type RenderFunction<
|
|||
O,
|
||||
N extends string,
|
||||
C extends ChildrenConfigs<N>
|
||||
> = (state: S, communicate: Communicate<T, A, O, N, C>) => T;
|
||||
> = (state: S, communicate: Communicate<A, O> & CanAddChildren<T, N, C>) => T;
|
||||
|
||||
export type ComponentConfig<
|
||||
T,
|
||||
|
@ -38,7 +38,7 @@ export type ComponentConfig<
|
|||
C extends ChildrenConfigs<N>
|
||||
> = {
|
||||
render: RenderFunction<T, S, A, O, N, C>;
|
||||
handleAction: (action: A, state: S) => S;
|
||||
handleAction: (action: A, state: S, communication: Communicate<A, O>) => S;
|
||||
children: ChildrenTemplates<T, S, A, N, C>;
|
||||
};
|
||||
|
||||
|
@ -102,51 +102,98 @@ export class Component<
|
|||
public constructor(
|
||||
protected state: S,
|
||||
private config: ComponentConfig<T, S, A, O, N, C>,
|
||||
private raise: Communicate<A, O>['raise'],
|
||||
private pushDownwards: (state: S) => void
|
||||
) {
|
||||
this.childrenMap = mapRecord(this.config.children, constant({}));
|
||||
}
|
||||
|
||||
protected pushStateUpwards(state: S) {
|
||||
/**
|
||||
* Do something only if the state changed.
|
||||
*
|
||||
* @param state The new state.
|
||||
* @param then The thing you want to do with the state
|
||||
*/
|
||||
private setStateAndThen = (then: (state: S) => unknown) => (state: S) => {
|
||||
if (areEqual(state, this.state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = state;
|
||||
|
||||
then(state);
|
||||
};
|
||||
|
||||
/**
|
||||
* Propagate a new state upstream the tree
|
||||
*
|
||||
* @param state The new state
|
||||
*/
|
||||
protected pushStateUpwards = this.setStateAndThen(state => {
|
||||
for (const { component, lens } of this.children()) {
|
||||
component.pushStateUpwards(lens.set(state, component.state));
|
||||
component.pushStateUpwards(lens.get(state));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Function to get a child or create it if it doesn't exist
|
||||
*/
|
||||
private getChild: CanAddChildren<T, N, C> = {
|
||||
child: (key, name, input) => {
|
||||
const hasName = Reflect.has(this.childrenMap[key], name);
|
||||
|
||||
if (!hasName) {
|
||||
const config = this.config.children[key];
|
||||
const lens = config.lens(input);
|
||||
const raise = flow(config.handleOutput, O.map(this.dispatch));
|
||||
const child = this.childrenMap[key] as Record<string, any>;
|
||||
|
||||
child[name] = {
|
||||
lens,
|
||||
component: new Component(
|
||||
lens.get(this.state),
|
||||
this.config.children[key].component,
|
||||
event => raise(input, event),
|
||||
childState => {
|
||||
const newState = lens.set(this.state, childState);
|
||||
|
||||
this.setStateAndThen(this.pushDownwards)(newState);
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return this.childrenMap[key][name].component.getTemplate();
|
||||
},
|
||||
};
|
||||
|
||||
private getCommunication(): Communicate<A, O> {
|
||||
return {
|
||||
dispatch: value => this.dispatch(value),
|
||||
raise: this.raise,
|
||||
};
|
||||
}
|
||||
|
||||
public getTemplate(): T {
|
||||
return this.config.render(this.state, {
|
||||
dispatch: value => this.dispatch(value),
|
||||
raise: _ => undefined,
|
||||
child: (key, name, input) => {
|
||||
const hasName = Reflect.has(this.childrenMap[key], name);
|
||||
|
||||
if (!hasName) {
|
||||
const lens = this.config.children[key].lens(input);
|
||||
const child = this.childrenMap[key] as Record<string, any>;
|
||||
|
||||
child[name] = {
|
||||
lens,
|
||||
component: new Component(
|
||||
lens.get(this.state),
|
||||
this.config.children[key].component,
|
||||
state => this.pushDownwards(lens.set(this.state, state))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return this.childrenMap[key][name].component.getTemplate();
|
||||
},
|
||||
...this.getCommunication(),
|
||||
...this.getChild,
|
||||
});
|
||||
}
|
||||
|
||||
public dispatch(action: A) {
|
||||
const newState = this.config.handleAction(action, this.state);
|
||||
/**
|
||||
* Dispatch an arbitrary action on the component.
|
||||
*/
|
||||
public dispatch = (action: A) => {
|
||||
const newState = this.config.handleAction(
|
||||
action,
|
||||
this.state,
|
||||
this.getCommunication()
|
||||
);
|
||||
|
||||
this.pushStateUpwards(newState);
|
||||
this.pushDownwards(newState);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a list of all the children of the component
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { ComponentConfig, Component } from './Component';
|
||||
import { IterableEmitter } from './iterableEmitter';
|
||||
|
||||
export type EnvConfig<T, S, A, O> = {
|
||||
render: (template: T, parent: HTMLElement) => void;
|
||||
|
@ -7,14 +8,29 @@ export type EnvConfig<T, S, A, O> = {
|
|||
initialState: S;
|
||||
};
|
||||
|
||||
export const runUi = <T, S, A, O>(config: EnvConfig<T, S, A, O>) => {
|
||||
// Not an arrow function cause it's a generator
|
||||
/**
|
||||
* Run a component and render it to the dom
|
||||
*/
|
||||
export async function* runUi<T, S, A, O>(config: EnvConfig<T, S, A, O>) {
|
||||
const reRender = () => config.render(component.getTemplate(), config.parent);
|
||||
|
||||
const component = new Component(config.initialState, config.component, _ => {
|
||||
reRender();
|
||||
});
|
||||
const outputEmitter = new IterableEmitter<null | O>(null);
|
||||
|
||||
const component = new Component(
|
||||
config.initialState,
|
||||
config.component,
|
||||
outputEmitter.next,
|
||||
_ => {
|
||||
reRender();
|
||||
}
|
||||
);
|
||||
|
||||
reRender();
|
||||
|
||||
return component;
|
||||
};
|
||||
for await (const event of outputEmitter) {
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,3 +12,9 @@ export type GenericLens<T, U> = {
|
|||
get: (v: T) => U;
|
||||
set: (v: T, n: U) => T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Some basic equality to not re-render with the same state
|
||||
*/
|
||||
export const areEqual = <T>(a: T, b: T) =>
|
||||
a === b || JSON.stringify(a) === JSON.stringify(b);
|
||||
|
|
|
@ -11,4 +11,5 @@ export {
|
|||
buildSpec,
|
||||
withTemplate,
|
||||
withChild,
|
||||
withOutput,
|
||||
} from './Builder';
|
||||
|
|
Loading…
Reference in a new issue