From bda242a538ceb55fc3bfce3cd54928613e58d9ba Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Tue, 20 Aug 2019 14:32:51 +0200 Subject: [PATCH 1/9] Rename loop to component and handle two return values --- src/component.ts | 42 ++++++++++++++++++++++++++++++++---------- test/component.spec.ts | 19 +++++++++++-------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/component.ts b/src/component.ts index 84da745..edea2d1 100644 --- a/src/component.ts +++ b/src/component.ts @@ -84,6 +84,9 @@ export abstract class Component implements Monad { ); } } + result(o: R): Result { + return { output: o, child: this }; + } view(): Component { return view(this); } @@ -253,9 +256,17 @@ const placeholderProxyHandler = { } }; -class LoopComponent extends Component { +type Result = { output: R; child: Child }; + +function isLoopResult(r: any): r is Result { + return typeof r === "object" && "child" in r; +} + +class LoopComponent extends Component { constructor( - private f: (o: O) => Child | Now>, + private f: ( + o: L + ) => Child | Now> | Result | Now>, private placeholderNames?: string[] ) { super(); @@ -272,28 +283,39 @@ class LoopComponent extends Component { } } const res = this.f(placeholderObject); - const child = Now.is(res) ? runNow(res) : res; - const { output } = toComponent(child).run(parent, destroyed); + const result = Now.is(res) ? runNow | Result>(res) : res; + const { output, child } = isLoopResult(result) + ? result + : { output: {} as O, child: result }; + const { output: looped } = toComponent(child).run(parent, destroyed); const needed = Object.keys(placeholderObject); for (const name of needed) { if (name === "destroyed") { continue; } - if (output[name] === undefined) { + if (looped[name] === undefined) { throw new Error(`The property ${name} is missing.`); } - placeholderObject[name].replaceWith(output[name]); + placeholderObject[name].replaceWith(looped[name]); } return { available: output, output: {} }; } } -export function loop( - f: (o: O) => Child | Now>, +export function component( + f: (l: L) => Child | Now>, + placeholderNames?: string[] +): Component<{}, {}>; +export function component( + f: (l: L) => Result | Now>, + placeholderNames?: string[] +): Component; +export function component( + f: (l: L) => Child | Now> | Result | Now>, placeholderNames?: string[] ): Component { - const f2 = isGeneratorFunction(f) ? fgo(f) : f; - return new LoopComponent(f2, placeholderNames); + const f2 = isGeneratorFunction(f) ? fgo(f) : f; + return new LoopComponent(f2, placeholderNames); } class MergeComponent< diff --git a/test/component.spec.ts b/test/component.spec.ts index f7970aa..785ebdd 100644 --- a/test/component.spec.ts +++ b/test/component.spec.ts @@ -15,7 +15,7 @@ import { view, emptyComponent, elements, - loop, + component, testComponent, list, runComponent, @@ -221,23 +221,26 @@ describe("component specs", () => { }); }); - describe("loop", () => { + describe("component loop", () => { type Looped = { name: H.Behavior; destroyed: H.Future }; it("passed selected output as argument", () => { let b: H.Behavior | undefined = undefined; - const comp = loop<{ foo: H.Behavior }>((input) => { + const comp = component< + { foo: H.Behavior }, + { bar: H.Behavior } + >((input) => { b = input.foo; return Component.of({ foo: H.Behavior.of(2) - }); + }).result({ bar: H.Behavior.of(3) }); }); const { available, output } = testComponent(comp); assert.deepEqual(Object.keys(output), []); - assert.deepEqual(Object.keys(available), ["foo"]); + assert.deepEqual(Object.keys(available), ["bar"]); expect(H.at(b!)).to.equal(2); }); it("works with selected fgo and looped behavior", () => { - const comp = loop( + const comp = component( fgo(function*({ name }: Looped): IterableIterator> { yield div(name); ({ name } = yield input({ props: { value: "Foo" } }).output({ @@ -252,7 +255,7 @@ describe("component specs", () => { }); it("can be told to destroy", () => { let toplevel = false; - const comp = loop( + const comp = component( fgo(function*({ name, destroyed @@ -273,7 +276,7 @@ describe("component specs", () => { expect(toplevel).to.equal(true); }); it("throws helpful error is a reactive is missing", () => { - const c = loop((props: { foo: H.Behavior }) => { + const c = component((props: { foo: H.Behavior }) => { // Access something that isn't there (props as any).bar; return div([dynamic(props.foo)]).output((_) => ({ From 044819597ea3702827f1376aa1e46b17266c5914 Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Tue, 20 Aug 2019 14:36:38 +0200 Subject: [PATCH 2/9] Refactor TodoMVC to use component function --- examples/todo/src/Item.ts | 272 ++++++++++++------------------- examples/todo/src/TodoApp.ts | 277 ++++++++++++++------------------ examples/todo/src/TodoFooter.ts | 69 +++----- examples/todo/src/TodoInput.ts | 64 +++----- examples/todo/src/index.ts | 7 +- 5 files changed, 279 insertions(+), 410 deletions(-) diff --git a/examples/todo/src/Item.ts b/examples/todo/src/Item.ts index 458616d..d6e0b1d 100644 --- a/examples/todo/src/Item.ts +++ b/examples/todo/src/Item.ts @@ -1,186 +1,126 @@ import { combine } from "@funkia/jabz"; -import { - Behavior, - changes, - filter, - keepWhen, - performStream, - sample, - snapshot, - stepper, - Stream, - lift, - toggle -} from "@funkia/hareactive"; - -import { modelView, elements, fgo, Component } from "../../../src"; +import * as H from "@funkia/hareactive"; +import { elements, fgo, component } from "../../../src"; const { div, li, input, label, button, checkbox } = elements; import { setItemIO, itemBehavior, removeItemIO } from "./localstorage"; const enter = 13; const esc = 27; -const isKey = (keyCode: number) => (ev: { keyCode: number }) => - ev.keyCode === keyCode; -export const itemIdToPersistKey = (id: number) => `todoItem:${id}`; -export const itemOutputToId = ({ id }: Output) => id; - -export type Item = { - taskName: Behavior; - isComplete: Behavior; -}; -export type PersistedItem = { - taskName: string; - isComplete: boolean; -}; - -export type Input = { +export type Props = { name: string; id: number; - toggleAll: Stream; - currentFilter: Behavior; + toggleAll: H.Stream; + currentFilter: H.Behavior; }; type FromView = { - toggleTodo: Stream; - taskName: Behavior; - startEditing: Stream; - nameBlur: Stream; - deleteClicked: Stream; - nameKeyup: Stream; - newNameInput: Stream; + toggleTodo: H.Stream; + taskName: H.Behavior; + startEditing: H.Stream; + nameBlur: H.Stream; + deleteClicked: H.Stream; + cancel: H.Stream; + enter: H.Stream; + newNameInput: H.Stream; }; export type Output = { - taskName: Behavior; - isComplete: Behavior; - newName: Behavior; - isEditing: Behavior; - focusInput: Stream; - hidden: Behavior; - destroyItemId: Stream; - completed: Behavior; + destroyItemId: H.Stream; + completed: H.Behavior; id: number; }; -const itemModel = fgo(function*( - { - toggleTodo, - startEditing, - nameBlur, - deleteClicked, - nameKeyup, - newNameInput, - taskName - }: FromView, - { toggleAll, name: initialName, id, currentFilter }: Input -): any { - const enterPress = filter(isKey(enter), nameKeyup); - const enterNotPressed = yield toggle(true, startEditing, enterPress); - const cancel = filter(isKey(esc), nameKeyup); - const notCancelled = yield toggle(true, startEditing, cancel); - const stopEditing = combine( - enterPress, - keepWhen(nameBlur, enterNotPressed), - cancel - ); - const isEditing = yield toggle(false, startEditing, stopEditing); - const newName = yield stepper( - initialName, - combine( - newNameInput.map((ev) => ev.target.value), - snapshot(taskName, cancel) - ) +export default (props: Props) => + component( + fgo(function*(on) { + const enterNotPressed = yield H.toggle(true, on.startEditing, on.enter); + const notCancelled = yield H.toggle(true, on.startEditing, on.cancel); + const stopEditing = combine( + on.enter, + H.keepWhen(on.nameBlur, enterNotPressed), + on.cancel + ); + const editing = yield H.toggle(false, on.startEditing, stopEditing); + const newName = yield H.stepper( + props.name, + combine( + on.newNameInput.map((ev) => ev.target.value), + H.snapshot(on.taskName, on.cancel) + ) + ); + const nameChange = H.snapshot( + newName, + H.keepWhen(stopEditing, notCancelled) + ); + + // Restore potentially persisted todo item + const persistKey = "todoItem:" + props.id; + const savedItem = yield H.sample(itemBehavior(persistKey)); + const initial = + savedItem === null + ? { taskName: props.name, completed: false } + : savedItem; + + // Initialize task to restored values + const taskName: H.Behavior = yield H.stepper( + initial.taskName, + nameChange + ); + const completed: H.Behavior = yield H.stepper( + initial.completed, + combine(on.toggleTodo, props.toggleAll) + ); + + // Persist todo item + const item = H.lift( + (taskName, completed) => ({ taskName, completed }), + taskName, + completed + ); + yield H.performStream( + H.changes(item).map((i) => setItemIO(persistKey, i)) + ); + + const destroyItem = combine( + on.deleteClicked, + nameChange.filter((s) => s === "") + ); + const destroyItemId = destroyItem.mapTo(props.id); + + // Remove persist todo item + yield H.performStream(destroyItem.mapTo(removeItemIO(persistKey))); + + const hidden = H.lift( + (complete, filter) => + (filter === "completed" && !complete) || + (filter === "active" && complete), + completed, + props.currentFilter + ); + + return li({ class: ["todo", { completed, editing, hidden }] }, [ + div({ class: "view" }, [ + checkbox({ + class: "toggle", + props: { checked: completed } + }).output({ toggleTodo: "checkedChange" }), + label(taskName).output({ startEditing: "dblclick" }), + button({ class: "destroy" }).output({ deleteClicked: "click" }) + ]), + input({ + class: "edit", + value: taskName, + actions: { focus: on.startEditing } + }).output((o) => ({ + newNameInput: o.input, + nameBlur: o.blur, + enter: o.keyup.filter((ev) => ev.keyCode === enter), + cancel: o.keyup.filter((ev) => ev.keyCode === esc) + })) + ]) + .output(() => ({ taskName })) + .result({ destroyItemId, completed, id: props.id }); + }) ); - const nameChange = snapshot(newName, keepWhen(stopEditing, notCancelled)); - - // Restore potentially persisted todo item - const persistKey = itemIdToPersistKey(id); - const savedItem = yield sample(itemBehavior(persistKey)); - const initial = - savedItem === null - ? { taskName: initialName, isComplete: false } - : savedItem; - - // Initialize task to restored values - const taskName_: Behavior = yield stepper( - initial.taskName, - nameChange - ); - const isComplete: Behavior = yield stepper( - initial.isComplete, - combine(toggleTodo, toggleAll) - ); - - // Persist todo item - const item = lift( - (taskName, isComplete) => ({ taskName, isComplete }), - taskName_, - isComplete - ); - yield performStream( - changes(item).map((i: PersistedItem) => setItemIO(persistKey, i)) - ); - - const destroyItem = combine( - deleteClicked, - nameChange.filter((s) => s === "") - ); - const destroyItemId = destroyItem.mapTo(id); - - // Remove persist todo item - yield performStream(destroyItem.mapTo(removeItemIO(persistKey))); - - const hidden = lift( - (complete, filter) => - (filter === "completed" && !complete) || - (filter === "active" && complete), - isComplete, - currentFilter - ); - - return { - taskName: taskName_, - isComplete, - isEditing, - newName, - focusInput: startEditing, - id, - destroyItemId, - completed: isComplete, - hidden - }; -}); - -function itemView( - { taskName, isComplete, isEditing, focusInput, hidden }: Output, - _: Input -): Component { - return li( - { - class: ["todo", { completed: isComplete, editing: isEditing, hidden }] - }, - [ - div({ class: "view" }, [ - checkbox({ - class: "toggle", - props: { checked: isComplete } - }).output({ toggleTodo: "checkedChange" }), - label(taskName).output({ startEditing: "dblclick" }), - button({ class: "destroy" }).output({ deleteClicked: "click" }) - ]), - input({ - class: "edit", - props: { value: taskName }, - actions: { focus: focusInput } - }).output({ - newNameInput: "input", - nameKeyup: "keyup", - nameBlur: "blur" - }) - ] - ).output((o) => ({ taskName, ...o })); -} - -export default modelView(itemModel, itemView); diff --git a/examples/todo/src/TodoApp.ts b/examples/todo/src/TodoApp.ts index 5b30a01..029f9e6 100644 --- a/examples/todo/src/TodoApp.ts +++ b/examples/todo/src/TodoApp.ts @@ -1,194 +1,159 @@ import { fgo, sequence, IO, combine } from "@funkia/jabz"; -import { - Behavior, - sample, - snapshot, - Stream, - performStream, - changes, - lift, - snapshotWith, - accumCombine, - moment, - shiftCurrent, - empty -} from "@funkia/hareactive"; -import { modelView, elements, list, output } from "../../../src"; +import * as H from "@funkia/hareactive"; +import { elements, list, component } from "../../../src"; const { h1, p, header, footer, section, checkbox, ul, label } = elements; -import { Router, routePath } from "@funkia/rudolph"; +import { locationHashB } from "@funkia/rudolph"; import todoInput, { Out as InputOut } from "./TodoInput"; import item, { Output as ItemOut, - Input as ItemParams, + Props as ItemParams, itemIdToPersistKey } from "./Item"; -import todoFooter, { Params as FooterParams } from "./TodoFooter"; +import todoFooter from "./TodoFooter"; import { setItemIO, itemBehavior, removeItemIO } from "./localstorage"; const isEmpty = (array: any[]) => array.length === 0; const includes = (a: A, list: A[]) => list.indexOf(a) !== -1; type FromView = { - toggleAll: Stream; - itemOutputs: Behavior; - clearCompleted: Stream<{}>; + toggleAll: H.Stream; + itemOutputs: H.Behavior; + clearCompleted: H.Stream<{}>; } & InputOut; -type ToView = { - toggleAll: Stream; - todoNames: Behavior; - itemOutputs: Behavior; - areAllCompleted: Behavior; -} & FooterParams; - // A behavior representing the current value of the localStorage property const todoListStorage = itemBehavior("todoList"); -function getCompletedIds(outputs: Behavior): Behavior { - return moment((at) => { +const getCompletedIds = (outputs: H.Behavior) => + H.moment((at) => { return at(outputs) .filter((o) => at(o.completed)) .map((o) => o.id); }); -} type ListModel = { - prependItemS: Stream; - removeKeyListS: Stream; + prependItemS: H.Stream; + removeKeyListS: H.Stream; itemToKey: (a: A) => B; initial: A[]; }; // This model handles the modification of the list of Todos -function listModel({ - prependItemS, - removeKeyListS, - itemToKey, - initial -}: ListModel) { - return accumCombine( +function listModel(props: ListModel) { + return H.accumCombine( [ - [prependItemS, (item, list) => [item].concat(list)], + [props.prependItemS, (item, list) => [item].concat(list)], [ - removeKeyListS, - (keys, list) => list.filter((item) => !includes(itemToKey(item), keys)) + props.removeKeyListS, + (keys, list) => + list.filter((item) => !includes(props.itemToKey(item), keys)) ] ], - initial - ); -} - -function* model({ addItem, toggleAll, clearCompleted, itemOutputs }: FromView) { - const nextId = itemOutputs.map( - (outs) => outs.reduce((maxId, { id }) => Math.max(maxId, id), 0) + 1 - ); - - const newTodoS = snapshotWith((name, id) => ({ name, id }), nextId, addItem); - const deleteS = shiftCurrent( - itemOutputs.map((list) => - list.length > 0 ? combine(...list.map((o) => o.destroyItemId)) : empty - ) - ); - const completedIds = getCompletedIds(itemOutputs); - - const savedTodoName: ItemParams[] = yield sample(todoListStorage); - const restoredTodoName = savedTodoName === null ? [] : savedTodoName; - - const clearCompletedIdS = snapshot(completedIds, clearCompleted); - const removeListS = combine(deleteS.map((a) => [a]), clearCompletedIdS); - const todoNames = yield listModel<{ id: number; name: string }, number>({ - prependItemS: newTodoS, - removeKeyListS: removeListS, - itemToKey: ({ id }) => id, - initial: restoredTodoName - }); - - yield performStream( - clearCompletedIdS.map((ids) => - sequence(IO, ids.map((id) => removeItemIO(itemIdToPersistKey(id)))) - ) - ); - yield performStream(changes(todoNames).map((n) => setItemIO("todoList", n))); - - const areAllCompleted = lift( - (currentIds, currentOuts) => currentIds.length === currentOuts.length, - completedIds, - itemOutputs + props.initial ); - const areAnyCompleted = completedIds.map(isEmpty).map((b) => !b); - - return { - itemOutputs, - todoNames, - clearAll: clearCompleted, - areAnyCompleted, - toggleAll, - areAllCompleted - }; } -function view( - { - itemOutputs, - todoNames, - areAnyCompleted, - toggleAll, - areAllCompleted - }: ToView, - router: Router -) { - const currentFilter = routePath( - { - "/active": () => "active", - "/completed": () => "completed", - "*": () => "" - }, - router - ); - return [ - section({ class: "todoapp" }, [ - header({ class: "header" }, [ - h1("todos"), - todoInput.output({ addItem: "addItem" }) - ]), - section( - { - class: ["main", { hidden: todoNames.map(isEmpty) }] - }, - [ - checkbox({ - class: "toggle-all", - attrs: { id: "toggle-all" }, - props: { checked: areAllCompleted } - }).output({ toggleAll: "checkedChange" }), - label({ attrs: { for: "toggle-all" } }, "Mark all as complete"), - ul( - { class: "todo-list" }, - list( - (n) => - item({ toggleAll, currentFilter, ...n }).output({ - completed: "completed", - destroyItemId: "destroyItemId", - id: "id" - }), - todoNames, - (o) => o.id - ).output((o) => ({ itemOutputs: o })) - ) - ] - ), - output( - { clearCompleted: "clearCompleted" }, - todoFooter({ todosB: itemOutputs, areAnyCompleted, currentFilter }) +export const app = component( + fgo(function*(on) { + const nextId = on.itemOutputs.map( + (outs) => outs.reduce((maxId, { id }) => Math.max(maxId, id), 0) + 1 + ); + + const newTodoS = H.snapshotWith( + (name, id) => ({ name, id }), + nextId, + on.addItem + ); + const deleteS = H.shiftCurrent( + on.itemOutputs.map((list) => + list.length > 0 ? combine(...list.map((o) => o.destroyItemId)) : H.empty ) - ]), - footer({ class: "info" }, [ - p("Double-click to edit a todo"), - p("Written with Turbine"), - p("Part of TodoMVC") - ]) - ]; -} - -export const app = modelView(fgo(model), view); + ); + const completedIds = getCompletedIds(on.itemOutputs); + + const savedTodoName: ItemParams[] = yield H.sample(todoListStorage); + const restoredTodoName = savedTodoName === null ? [] : savedTodoName; + + const clearCompletedIdS = H.snapshot(completedIds, on.clearCompleted); + const removeListS = combine(deleteS.map((a) => [a]), clearCompletedIdS); + const todoNames = yield listModel<{ id: number; name: string }, number>({ + prependItemS: newTodoS, + removeKeyListS: removeListS, + itemToKey: ({ id }) => id, + initial: restoredTodoName + }); + + yield H.performStream( + clearCompletedIdS.map((ids) => + sequence(IO, ids.map((id) => removeItemIO(itemIdToPersistKey(id)))) + ) + ); + yield H.performStream( + H.changes(todoNames).map((n) => setItemIO("todoList", n)) + ); + + const areAllCompleted = H.lift( + (a, b) => a.length === b.length, + completedIds, + on.itemOutputs + ); + const areAnyCompleted = completedIds.map(isEmpty).map((b) => !b); + + // Strip the leading `/` from the hash location + const currentFilter = locationHashB.map((s) => s.slice(1)); + const hidden = todoNames.map(isEmpty); + + const itemsLeft = H.moment( + (at) => at(on.itemOutputs).filter((t) => !at(t.completed)).length + ); + + return [ + section({ class: "todoapp" }, [ + header({ class: "header" }, [ + h1("todos"), + todoInput.output({ addItem: "addItem" }) + ]), + section( + { + class: ["main", { hidden }] + }, + [ + checkbox({ + class: "toggle-all", + attrs: { id: "toggle-all" }, + props: { checked: areAllCompleted } + }).output({ toggleAll: "checkedChange" }), + label({ attrs: { for: "toggle-all" } }, "Mark all as complete"), + ul( + { class: "todo-list" }, + list( + (n) => + item({ toggleAll: on.toggleAll, currentFilter, ...n }).output( + { + completed: "completed", + destroyItemId: "destroyItemId", + id: "id" + } + ), + todoNames, + (o) => o.id + ).output((o) => ({ itemOutputs: o })) + ) + ] + ), + todoFooter({ + itemsLeft, + areAnyCompleted, + currentFilter, + hidden + }).output({ clearCompleted: "clearCompleted" }) + ]), + footer({ class: "info" }, [ + p("Double-click to edit a todo"), + p("Written with Turbine"), + p("Part of TodoMVC") + ]) + ]; + }) +); diff --git a/examples/todo/src/TodoFooter.ts b/examples/todo/src/TodoFooter.ts index 876d1c4..cd1efbf 100644 --- a/examples/todo/src/TodoFooter.ts +++ b/examples/todo/src/TodoFooter.ts @@ -1,67 +1,48 @@ -import { Behavior, Stream, moment } from "@funkia/hareactive"; +import * as H from "@funkia/hareactive"; import { elements, view } from "../../../src"; const { span, button, ul, li, a, footer, strong } = elements; -import { Output as ItemOut } from "./Item"; - -export type Params = { - currentFilter: Behavior; - todosB: Behavior; - areAnyCompleted: Behavior; -}; - -export type Out = { - clearCompleted: Stream; +export type Props = { + currentFilter: H.Behavior; + itemsLeft: H.Behavior; + areAnyCompleted: H.Behavior; + hidden: H.Behavior; }; -const isEmpty = (list: any[]) => list.length === 0; -const formatRemainer = (value: number) => ` item${value === 1 ? "" : "s"} left`; - const filterItem = ( name: string, path: string, - currentFilter: Behavior + currentFilter: H.Behavior ) => view( li( a( { href: `#/${path}`, - class: { - selected: currentFilter.map((s) => s === path) - } + class: { selected: currentFilter.map((s) => s === path) } }, name ).output({ click: "click" }) ) ); -const todoFooter = ({ currentFilter, todosB, areAnyCompleted }: Params) => { - const hidden = todosB.map(isEmpty); - const itemsLeft = moment( - (at) => at(todosB).filter((t) => !at(t.completed)).length +const todoFooter = (props: Props) => + view( + footer({ class: ["footer", { hidden: props.hidden }] }, [ + span({ class: "todo-count" }, [ + strong(props.itemsLeft), + props.itemsLeft.map((n) => ` item${n === 1 ? "" : "s"} left`) + ]), + ul({ class: "filters" }, [ + filterItem("All", "", props.currentFilter), + filterItem("Active", "active", props.currentFilter), + filterItem("Completed", "completed", props.currentFilter) + ]), + button( + { class: ["clear-completed", { hidden: props.areAnyCompleted }] }, + "Clear completed" + ).output({ clearCompleted: "click" }) + ]) ); - return footer({ class: ["footer", { hidden }] }, [ - span({ class: "todo-count" }, [ - strong(itemsLeft), - itemsLeft.map(formatRemainer) - ]), - ul({ class: "filters" }, [ - filterItem("All", "", currentFilter), - filterItem("Active", "active", currentFilter), - filterItem("Completed", "completed", currentFilter) - ]), - button( - { - style: { - visibility: areAnyCompleted.map((b) => (b ? "visible" : "hidden")) - }, - class: "clear-completed" - }, - "Clear completed" - ).output({ clearCompleted: "click" }) - ]); -}; - export default todoFooter; diff --git a/examples/todo/src/TodoInput.ts b/examples/todo/src/TodoInput.ts index 9fed0fb..12c8d90 100644 --- a/examples/todo/src/TodoInput.ts +++ b/examples/todo/src/TodoInput.ts @@ -1,52 +1,40 @@ -import { - Stream, - snapshot, - changes, - combine, - Behavior, - stepper -} from "@funkia/hareactive"; +import * as H from "@funkia/hareactive"; -import { elements, modelView, fgo } from "../../../src"; +import { elements, fgo, component } from "../../../src"; const { input } = elements; const KEYCODE_ENTER = 13; -const isEnterKey = (ev: any) => ev.keyCode === KEYCODE_ENTER; -const isValidValue = (value: string) => value !== ""; type FromView = { - enterPressed: Stream; - value: Behavior; + enterPressed: H.Stream; + value: H.Behavior; }; export type Out = { - addItem: Stream; - clearedValue: Behavior; + addItem: H.Stream; }; -function* model({ enterPressed, value }: FromView) { - const clearedValue: Behavior = yield stepper( - "", - combine(enterPressed.mapTo(""), changes(value)) - ); - - const addItem = snapshot(clearedValue, enterPressed).filter(isValidValue); - - return { addItem, clearedValue }; -} - -const view = ({ clearedValue }: { clearedValue: Behavior }) => - input({ - class: "new-todo", - props: { value: clearedValue }, - attrs: { +export default component( + fgo(function*(on) { + const clearedValue: H.Behavior = yield H.stepper( + "", + H.combine(on.enterPressed.mapTo(""), H.changes(on.value)) + ); + const addItem = H.snapshot(clearedValue, on.enterPressed).filter( + (title) => title !== "" + ); + + return input({ + class: "new-todo", + value: clearedValue, autofocus: "true", autocomplete: "off", placeholder: "What needs to be done?" - } - }).output((o) => ({ - value: o.value, - enterPressed: o.keyup.filter(isEnterKey) - })); - -export default modelView(fgo(model), view)(); + }) + .output((o) => ({ + value: o.value, + enterPressed: o.keyup.filter((ev) => ev.keyCode === KEYCODE_ENTER) + })) + .result({ addItem }); + }) +); diff --git a/examples/todo/src/index.ts b/examples/todo/src/index.ts index 74d994e..ad1199b 100644 --- a/examples/todo/src/index.ts +++ b/examples/todo/src/index.ts @@ -1,10 +1,5 @@ import "todomvc-app-css/index.css"; import { runComponent } from "../../../src"; import { app } from "./TodoApp"; -import { createRouter } from "@funkia/rudolph"; -const router = createRouter({ - useHash: true -}); - -runComponent("#mount", app(router)); +runComponent("#mount", app); From e177e47caae9b9ece2581680420cd872d1a3412a Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Wed, 21 Aug 2019 08:41:32 +0200 Subject: [PATCH 3/9] Properties and attributes can be set with stream --- src/dom-builder.ts | 21 +++++++++++++++------ src/utils.ts | 9 +++++++-- test/component.spec.ts | 4 ++-- test/dom-builder.spec.ts | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/dom-builder.ts b/src/dom-builder.ts index ad4fb1e..4b59237 100644 --- a/src/dom-builder.ts +++ b/src/dom-builder.ts @@ -1,4 +1,10 @@ -import { Behavior, Future, isBehavior, Stream } from "@funkia/hareactive"; +import { + Behavior, + Future, + isBehavior, + Stream, + isStream +} from "@funkia/hareactive"; import { behaviorFromEvent, streamFromEvent, @@ -90,16 +96,17 @@ export type ClassDescription = export interface ClassDescriptionArray extends Array {} export type Attributes = { - [name: string]: (Showable | boolean) | Behavior; + [name: string]: + | (Showable | boolean) + | Stream + | Behavior; }; type _InitialProperties = { streams?: StreamDescriptions; behaviors?: BehaviorDescriptions; style?: Style; - props?: { - [name: string]: Showable | Behavior; - }; + props?: Attributes; attrs?: Attributes; actionDefinitions?: ActionDefinitions; actions?: Actions; @@ -166,7 +173,7 @@ const styleSetter = (element: HTMLElement) => (key: string, value: string) => (element.style[key] = value); function handleObject( - object: { [key: string]: A | Behavior } | undefined, + object: { [key: string]: A | Behavior | Stream } | undefined, element: HTMLElement, createSetter: (element: HTMLElement) => (key: string, value: A) => void ): void { @@ -176,6 +183,8 @@ function handleObject( const value = object[key]; if (isBehavior(value)) { render((newValue) => setter(key, newValue), value); + } else if (isStream(value)) { + value.subscribe((newValue) => setter(key, newValue)); } else { setter(key, value); } diff --git a/src/utils.ts b/src/utils.ts index 7685ba9..d1bc419 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { isBehavior } from "@funkia/hareactive"; +import { isBehavior, isStream } from "@funkia/hareactive"; function arrayConcat(arr1: A[], arr2: A[]): A[] { const result = []; @@ -12,7 +12,12 @@ function arrayConcat(arr1: A[], arr2: A[]): A[] { } function isObject(item: any): item is Object { - return typeof item === "object" && !Array.isArray(item) && !isBehavior(item); + return ( + typeof item === "object" && + !Array.isArray(item) && + !isBehavior(item) && + !isStream(item) + ); } export function mergeObj(a: A, b: B): A & B { diff --git a/test/component.spec.ts b/test/component.spec.ts index 785ebdd..4123e90 100644 --- a/test/component.spec.ts +++ b/test/component.spec.ts @@ -34,7 +34,7 @@ describe("component specs", () => { let result: number | undefined = undefined; const c = performComponent(() => (result = 12)); assert.strictEqual(result, undefined); - const { output, available } = testComponent(c); + const { output } = testComponent(c); assert.strictEqual(result, 12); assert.strictEqual(output, 12); }); @@ -98,7 +98,7 @@ describe("component specs", () => { newFoo: "foo", newBar: "bar" }); - const { dom, available, output } = testComponent(comp); + const { available, output } = testComponent(comp); expect(output.newFoo).to.equal(1); expect(output.newBar).to.equal(2); expect((available as any).newFoo).to.be.undefined; diff --git a/test/dom-builder.spec.ts b/test/dom-builder.spec.ts index 7425cbf..a9d3111 100644 --- a/test/dom-builder.spec.ts +++ b/test/dom-builder.spec.ts @@ -308,6 +308,14 @@ describe("dom-builder", () => { push("/bar", hrefB); expect(aElm).to.have.attribute("href", "/bar"); }); + it("sets attributes from streams", () => { + const hrefS = sinkStream(); + const { dom } = testComponent(element("a", { attrs: { href: hrefS } })()); + const aElm = dom.firstChild; + expect(aElm).to.not.have.attribute("href"); + push("/bar", hrefS); + expect(aElm).to.have.attribute("href", "/bar"); + }); it("sets boolean attributes correctly", () => { const { dom } = testComponent( element("a", { attrs: { contenteditable: true } })() @@ -355,6 +363,30 @@ describe("dom-builder", () => { push("there", htmlB); expect(aElm.innerHTML).to.equal("there"); }); + it("sets properties from stream", () => { + const value = sinkStream(); + const { dom } = testComponent(element("input", { props: { value } })()); + const inputElm = dom.firstChild! as HTMLInputElement; + assert.strictEqual(inputElm.value, ""); + push("bar", value); + assert.strictEqual(inputElm.value, "bar"); + }); + it("sets properties from stream", () => { + const value = sinkStream(); + const { dom } = testComponent(element("input", { props: { value } })()); + const inputElm = dom.firstChild! as HTMLInputElement; + assert.strictEqual(inputElm.value, ""); + push("bar", value); + assert.strictEqual(inputElm.value, "bar"); + }); + it("sets input value from stream", () => { + const value = sinkStream(); + const { dom } = testComponent(element("input", { value })()); + const inputElm = dom.firstChild! as HTMLInputElement; + assert.strictEqual(inputElm.value, ""); + push("bar", value); + assert.strictEqual(inputElm.value, "bar"); + }); it("sets input value", () => { const b = sinkBehavior("foo"); const { dom } = testComponent(E.input({ value: b })); From db67f289d60a0526b957f3ab3fbff6296b824194 Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Wed, 21 Aug 2019 08:50:31 +0200 Subject: [PATCH 4/9] Refactor todo item --- examples/todo/src/Item.ts | 50 +++++++++++++++------------------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/examples/todo/src/Item.ts b/examples/todo/src/Item.ts index d6e0b1d..5be6d13 100644 --- a/examples/todo/src/Item.ts +++ b/examples/todo/src/Item.ts @@ -7,6 +7,7 @@ import { setItemIO, itemBehavior, removeItemIO } from "./localstorage"; const enter = 13; const esc = 27; +export const itemIdToPersistKey = (id: number) => `todoItem:${id}`; export type Props = { name: string; @@ -19,11 +20,10 @@ type FromView = { toggleTodo: H.Stream; taskName: H.Behavior; startEditing: H.Stream; - nameBlur: H.Stream; deleteClicked: H.Stream; - cancel: H.Stream; - enter: H.Stream; - newNameInput: H.Stream; + stopEditing: H.Stream; + newName: H.Behavior; + editing: H.Behavior; }; export type Output = { @@ -35,34 +35,20 @@ export type Output = { export default (props: Props) => component( fgo(function*(on) { - const enterNotPressed = yield H.toggle(true, on.startEditing, on.enter); - const notCancelled = yield H.toggle(true, on.startEditing, on.cancel); - const stopEditing = combine( - on.enter, - H.keepWhen(on.nameBlur, enterNotPressed), - on.cancel - ); - const editing = yield H.toggle(false, on.startEditing, stopEditing); - const newName = yield H.stepper( - props.name, - combine( - on.newNameInput.map((ev) => ev.target.value), - H.snapshot(on.taskName, on.cancel) - ) - ); - const nameChange = H.snapshot( - newName, - H.keepWhen(stopEditing, notCancelled) - ); - // Restore potentially persisted todo item - const persistKey = "todoItem:" + props.id; + const persistKey = itemIdToPersistKey(props.id); const savedItem = yield H.sample(itemBehavior(persistKey)); const initial = savedItem === null ? { taskName: props.name, completed: false } : savedItem; + const editing = yield H.toggle(false, on.startEditing, on.stopEditing); + const nameChange = H.snapshot( + on.newName, + on.stopEditing.filter((b) => b) + ); + // Initialize task to restored values const taskName: H.Behavior = yield H.stepper( initial.taskName, @@ -111,16 +97,18 @@ export default (props: Props) => ]), input({ class: "edit", - value: taskName, + value: H.snapshot(on.taskName, on.startEditing), actions: { focus: on.startEditing } }).output((o) => ({ - newNameInput: o.input, - nameBlur: o.blur, - enter: o.keyup.filter((ev) => ev.keyCode === enter), - cancel: o.keyup.filter((ev) => ev.keyCode === esc) + newName: o.value, + stopEditing: H.combine( + o.keyup.filter((ev) => ev.keyCode === enter).mapTo(true), + H.keepWhen(o.blur, editing).mapTo(true), + o.keyup.filter((ev) => ev.keyCode === esc).mapTo(false) + ) })) ]) - .output(() => ({ taskName })) + .output(() => ({ taskName, editing })) .result({ destroyItemId, completed, id: props.id }); }) ); From 588a0983312eaba9976264b4c5d3b6db9ab466df Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Wed, 21 Aug 2019 11:28:32 +0200 Subject: [PATCH 5/9] Email validator uses component function --- examples/simple/index.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/examples/simple/index.ts b/examples/simple/index.ts index 05f8e5b..eee4b70 100644 --- a/examples/simple/index.ts +++ b/examples/simple/index.ts @@ -1,20 +1,18 @@ -import { map, Now, Behavior } from "@funkia/hareactive"; -import { elements, modelView, runComponent } from "../../src"; +import { elements, runComponent, component } from "../../src"; +import { Behavior } from "@funkia/hareactive"; const { span, input, div } = elements; const isValidEmail = (s: string) => /.+@.+\..+/i.test(s); -const model = ({ email }: { email: Behavior }) => { - const isValid = email.map(isValidEmail); - return Now.of({ isValid }); -}; +type On = { email: Behavior }; -const view = ({ isValid }: { isValid: Behavior }) => [ - span("Please enter an email address: "), - input().output({ email: "value" }), - div(["The address is ", map((b) => (b ? "valid" : "invalid"), isValid)]) -]; +const app = component((on) => { + const isValid = on.email.map(isValidEmail); + return [ + span("Please enter an email address: "), + input().output({ email: "value" }), + div(["The address is ", isValid.map((b) => (b ? "valid" : "invalid"))]) + ]; +}); -const app = modelView(model, view); - -runComponent("#mount", app()); +runComponent("#mount", app); From 9616676a74a465cb611d42b74cb610122abe8659 Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Wed, 21 Aug 2019 13:39:02 +0200 Subject: [PATCH 6/9] When output is merged streams are combined, error thrown on other collisions --- src/component.ts | 27 ++++++++++++++++----------- src/utils.ts | 27 +++++++++++++++++++-------- test/component.spec.ts | 27 +++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/component.ts b/src/component.ts index edea2d1..69b98f6 100644 --- a/src/component.ts +++ b/src/component.ts @@ -71,14 +71,17 @@ export abstract class Component implements Monad { output(handler: any): Component { if (typeof handler === "function") { return new HandleOutput( - (a, o) => ({ available: a, output: mergeObj(o, handler(a)) }), + (a, o) => ({ + available: a, + output: mergeObj(mergeObj({}, handler(a)), o) + }), this ); } else { return new HandleOutput( (a, o) => ({ available: a, - output: mergeObj(o, copyRemaps(handler, a)) + output: mergeObj(mergeObj({}, o), copyRemaps(handler, a)) }), this ); @@ -323,15 +326,17 @@ class MergeComponent< O extends object, B, P extends object -> extends Component { +> extends Component<{}, O & P> { constructor(private c1: Component, private c2: Component) { super(); } - run(parent: DomApi, destroyed: Future): Out { - const { output: o1 } = this.c1.run(parent, destroyed); - const { output: o2 } = this.c2.run(parent, destroyed); - const output = Object.assign({}, o1, o2); - return { available: output, output }; + run(parent: DomApi, destroyed: Future): Out<{}, O & P> { + const res1 = this.c1.run(parent, destroyed); + const res2 = this.c2.run(parent, destroyed); + return { + available: {}, + output: mergeObj(mergeObj({}, res2.output), res1.output) + }; } } @@ -341,7 +346,7 @@ class MergeComponent< export function merge( c1: Component, c2: Component -): Component { +): Component<{}, O & P> { return new MergeComponent(c1, c2); } @@ -561,11 +566,11 @@ class ListComponent extends Component { } } run(parent: DomApi, destroyed: Future): Out { - const output: Record = {}; + let output: Record = {}; for (let i = 0; i < this.components.length; ++i) { const component = this.components[i]; const res = component.run(parent, destroyed); - Object.assign(output, res.output); + mergeObj(output, res.output); } return { available: output, output }; } diff --git a/src/utils.ts b/src/utils.ts index d1bc419..6e52945 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,15 +20,26 @@ function isObject(item: any): item is Object { ); } -export function mergeObj(a: A, b: B): A & B { - const c: { [key: string]: any } = {}; - for (const key of Object.keys(a) as (keyof A & string)[]) { - c[key] = a[key]; - } - for (const key of Object.keys(b) as (keyof B & string)[]) { - c[key] = b[key]; +export function mergeObj< + A extends Record, + B extends Record +>(a: A, b: B): A & B { + for (const key of Object.keys(b) as string[]) { + const valueA: any = a[key]; + const valueB: any = b[key]; + if (valueA !== undefined) { + if (isStream(valueA) && isStream(valueB)) { + (a as any)[key] = valueA.combine(valueB); + } else { + throw new Error( + `Components was merged with colliding output on key ${key}` + ); + } + } else { + (a as any)[key] = valueB; + } } - return c; + return a; } export type Merge = { [K in keyof T]: T[K] }; diff --git a/test/component.spec.ts b/test/component.spec.ts index 4123e90..00eec32 100644 --- a/test/component.spec.ts +++ b/test/component.spec.ts @@ -121,11 +121,34 @@ describe("component specs", () => { const b2 = button().output({ click2: "click" }); const m = merge(b1, b2); const { output, available } = testComponent(m); - expect(available).to.have.property("click1"); - expect(available).to.have.property("click2"); + assert.deepEqual(available, {}); expect(output).to.have.property("click1"); expect(output).to.have.property("click2"); }); + it("merges colliding streams", () => { + const sink1 = H.sinkStream(); + const sink2 = H.sinkStream(); + const m = merge( + Component.of({ click: sink1 }), + Component.of({ click: sink2 }) + ); + const { output } = testComponent(m); + expect(output).to.have.property("click"); + const result: number[] = []; + output.click.subscribe((n) => result.push(n)); + sink1.push(0); + sink2.push(1); + assert.deepEqual(result, [0, 1]); + }); + it("throws on all other collisions", () => { + assert.throws(() => { + const m = merge( + Component.of({ click: H.Behavior.of(0) }), + Component.of({ click: H.empty }) + ); + testComponent(m); + }, "colliding"); + }); }); describe("empty component", () => { it("creates no dom", () => { From 91c07a73fd6a0618b8d94c5a1b53ab8df19b2271 Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Wed, 21 Aug 2019 15:09:38 +0200 Subject: [PATCH 7/9] Better handling of placeholders in component function --- src/component.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/component.ts b/src/component.ts index 69b98f6..9f4db12 100644 --- a/src/component.ts +++ b/src/component.ts @@ -270,20 +270,22 @@ class LoopComponent extends Component { private f: ( o: L ) => Child | Now> | Result | Now>, - private placeholderNames?: string[] + private placeholderNames?: (keyof L)[] ) { super(); } run(parent: DomApi, destroyed: Future): Out { let placeholderObject: any = { destroyed }; - if (supportsProxy) { + if (this.placeholderNames !== undefined) { + for (const name of this.placeholderNames) { + placeholderObject[name] = placeholder(); + } + } else if (supportsProxy) { placeholderObject = new Proxy(placeholderObject, placeholderProxyHandler); } else { - if (this.placeholderNames !== undefined) { - for (const name of this.placeholderNames) { - placeholderObject[name] = placeholder(); - } - } + throw new Error( + "component called with no list of names and proxies are not supported." + ); } const res = this.f(placeholderObject); const result = Now.is(res) ? runNow | Result>(res) : res; @@ -307,15 +309,15 @@ class LoopComponent extends Component { export function component( f: (l: L) => Child | Now>, - placeholderNames?: string[] + placeholderNames?: (keyof L)[] ): Component<{}, {}>; export function component( f: (l: L) => Result | Now>, - placeholderNames?: string[] + placeholderNames?: (keyof L)[] ): Component; export function component( f: (l: L) => Child | Now> | Result | Now>, - placeholderNames?: string[] + placeholderNames?: (keyof L)[] ): Component { const f2 = isGeneratorFunction(f) ? fgo(f) : f; return new LoopComponent(f2, placeholderNames); From a30126f97e8bf5f803db25f0e7e681d7420f3b35 Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Thu, 22 Aug 2019 08:31:31 +0200 Subject: [PATCH 8/9] Refactor counters example to use component function --- examples/counters/src/index.ts | 56 +++++------ examples/counters/src/version2.ts | 55 +++++------ examples/counters/src/version3.ts | 108 +++++++++------------ examples/counters/src/version4.ts | 154 +++++++++++++----------------- 4 files changed, 155 insertions(+), 218 deletions(-) diff --git a/examples/counters/src/index.ts b/examples/counters/src/index.ts index 3aebebf..e1d4070 100644 --- a/examples/counters/src/index.ts +++ b/examples/counters/src/index.ts @@ -1,32 +1,26 @@ -import { Behavior, combine, stepper, Stream } from "@funkia/hareactive"; +import { Behavior, stepper, Stream } from "@funkia/hareactive"; import { go } from "@funkia/jabz"; -import { elements, fgo, modelView, runComponent } from "../../../src"; +import { elements, fgo, runComponent, view, component } from "../../../src"; import { main1 } from "./version1"; import { main2 } from "./version2"; import { main3 } from "./version3"; -import { counterList as main4 } from "./version4"; +import { main4 } from "./version4"; const { button, div } = elements; -const numberToApp = { - "1": main1, - "2": main2, - "3": main3, - "4": main4 -}; +const numberToApp = { "1": main1, "2": main2, "3": main3, "4": main4 }; type AppId = keyof (typeof numberToApp); -function selectorButton(n: AppId, selected: Behavior) { - return button( - { - class: ["btn btn-default", { active: selected.map((m) => n === m) }] - }, - `Version ${n}` - ).map(({ click }) => ({ - select: click.mapTo(n) - })); -} +const selectorButton = (n: AppId, selected: Behavior) => + view( + button( + { + class: ["btn btn-default", { active: selected.map((m) => n === m) }] + }, + `Version ${n}` + ).output((o) => ({ selectVersion: o.click.mapTo(n).log(n) })) + ); type FromView = { selectVersion: Stream; @@ -36,26 +30,20 @@ type FromModel = { selected: Behavior; }; -const versionSelector = modelView( +const versionSelector = component( fgo(function*({ selectVersion }) { const selected = yield stepper("1", selectVersion); - return { selected }; - }), - ({ selected }) => - div({ class: "btn-group" }, [ - selectorButton("1", selected).output({ select1: "select" }), - selectorButton("2", selected).output({ select2: "select" }), - selectorButton("3", selected).output({ select3: "select" }), - selectorButton("4", selected).output({ select4: "select" }) - ]) - .map((o) => ({ - selectVersion: combine(o.select1, o.select2, o.select3, o.select4) - })) - .output({ selectVersion: "selectVersion" }) + return div({ class: "btn-group" }, [ + selectorButton("1", selected).output({ selectVersion: "selectVersion" }), + selectorButton("2", selected).output({ selectVersion: "selectVersion" }), + selectorButton("3", selected).output({ selectVersion: "selectVersion" }), + selectorButton("4", selected).output({ selectVersion: "selectVersion" }) + ]).result({ selected }); + }) ); const main = go(function*() { - const { selected } = yield versionSelector(); + const { selected } = yield versionSelector.output({ selected: "selected" }); const currentApp = selected.map((n: AppId) => numberToApp[n]); yield div(currentApp); return {}; diff --git a/examples/counters/src/version2.ts b/examples/counters/src/version2.ts index ec00d36..43fcd5d 100644 --- a/examples/counters/src/version2.ts +++ b/examples/counters/src/version2.ts @@ -1,41 +1,32 @@ import { Behavior, accum, Stream, combine } from "@funkia/hareactive"; -import { elements, modelView, fgo } from "../../../src"; +import { elements, fgo, component } from "../../../src"; const { div, button } = elements; -type CounterModelInput = { +type On = { incrementClick: Stream; decrementClick: Stream; }; -type CounterViewInput = { - count: Behavior; -}; - -const counterModel = fgo(function*({ - incrementClick, - decrementClick -}: CounterModelInput) { - const increment = incrementClick.mapTo(1); - const decrement = decrementClick.mapTo(-1); - const changes = combine(increment, decrement); - const count = yield accum((n, m) => n + m, 0, changes); - return { count }; -}); - -const counterView = ({ count }: CounterViewInput) => - div([ - "Counter ", - count, - " ", - button({ class: "btn btn-default" }, " + ").output({ - incrementClick: "click" - }), - " ", - button({ class: "btn btn-default" }, " - ").output({ - decrementClick: "click" - }) - ]); +const counter = component( + fgo(function*({ incrementClick, decrementClick }) { + const increment = incrementClick.mapTo(1); + const decrement = decrementClick.mapTo(-1); + const changes = combine(increment, decrement); + const count = yield accum((n, m) => n + m, 0, changes); -const counter = modelView(counterModel, counterView); + return div([ + "Counter ", + count, + " ", + button({ class: "btn btn-default" }, " + ").output({ + incrementClick: "click" + }), + " ", + button({ class: "btn btn-default" }, " - ").output({ + decrementClick: "click" + }) + ]); + }) +); -export const main2 = counter(); +export const main2 = counter; diff --git a/examples/counters/src/version3.ts b/examples/counters/src/version3.ts index 1baad82..2f2c182 100644 --- a/examples/counters/src/version3.ts +++ b/examples/counters/src/version3.ts @@ -2,13 +2,12 @@ import { Behavior, combine, map, - Now, accum, scan, Stream } from "@funkia/hareactive"; -import { elements, fgo, list, ModelReturn, modelView } from "../../../src"; +import { elements, fgo, list, component } from "../../../src"; const { br, div, button, h1, ul } = elements; const add = (n: number, m: number) => n + m; @@ -19,76 +18,57 @@ type CounterModelInput = { decrementClick: Stream; }; -type CounterViewInput = { - count: Behavior; -}; - type CounterOutput = { count: Behavior; }; -const counterModel = fgo(function*({ - incrementClick, - decrementClick -}: CounterModelInput): ModelReturn { - const increment = incrementClick.mapTo(1); - const decrement = decrementClick.mapTo(-1); - const count = yield accum(add, 0, combine(increment, decrement)); - return { count }; -}); +const counter = () => + component( + fgo(function*(on) { + const increment = on.incrementClick.mapTo(1); + const decrement = on.decrementClick.mapTo(-1); + const count = yield accum(add, 0, combine(increment, decrement)); -const counterView = ({ count }: CounterViewInput) => - div([ - "Counter ", - count, - " ", - button({ class: "btn btn-default" }, " + ").output({ - incrementClick: "click" - }), - " ", - button({ class: "btn btn-default" }, " - ").output({ - decrementClick: "click" + return div([ + "Counter ", + count, + " ", + button({ class: "btn btn-default" }, " + ").output({ + incrementClick: "click" + }), + " ", + button({ class: "btn btn-default" }, " - ").output({ + decrementClick: "click" + }) + ]).result({ count }); }) - ]); - -const counter = modelView(counterModel, counterView); - -type ViewInput = { - counterIds: Behavior; - sum: Behavior; -}; + ); -type ModelInput = { +type ListOn = { addCounter: Stream; - listOut: Behavior; }; -const counterListModel = fgo(function*({ - addCounter, - listOut -}: ModelInput): Iterator> { - const nextId: Stream = yield scan(add, 2, addCounter.mapTo(1)); - const appendCounterFn = map( - (id) => (ids: number[]) => ids.concat([id]), - nextId - ); - const counterIds = yield accum<(a: number[]) => number[], number[]>( - apply, - [0], - appendCounterFn - ); - return { counterIds }; -}); - -const counterListView = ({ sum, counterIds }: ViewInput) => [ - h1("Counters"), - button({ class: "btn btn-primary" }, "Add counter").output({ - addCounter: "click" - }), - br, - ul(list(counter, counterIds).output((o) => ({ listOut: o }))) -]; - -const counterList = modelView(counterListModel, counterListView); +const counterList = component( + fgo(function*({ addCounter }) { + const nextId: Stream = yield scan(add, 2, addCounter.mapTo(1)); + const appendCounterFn = map( + (id) => (ids: number[]) => ids.concat([id]), + nextId + ); + const counterIds = yield accum<(a: number[]) => number[], number[]>( + apply, + [0], + appendCounterFn + ); + return [ + h1("Counters"), + button({ class: "btn btn-primary" }, "Add counter").output({ + addCounter: "click" + }), + br, + ul(list(counter, counterIds)) + ]; + }) +); -export const main3 = counterList(); +export const main3 = counterList; diff --git a/examples/counters/src/version4.ts b/examples/counters/src/version4.ts index 7cb374c..b9fcba9 100644 --- a/examples/counters/src/version4.ts +++ b/examples/counters/src/version4.ts @@ -1,6 +1,4 @@ -import { foldr, lift, flatten } from "@funkia/jabz"; import { - Now, Behavior, scan, Stream, @@ -8,10 +6,11 @@ import { map, accum, shiftCurrent, - empty + empty, + moment } from "@funkia/hareactive"; -import { modelView, list, elements, fgo } from "../../../src"; +import { list, elements, fgo, component } from "../../../src"; const { ul, li, p, br, button, h1 } = elements; const add = (n: number, m: number) => n + m; @@ -21,10 +20,6 @@ const apply = (f: (a: A) => A, a: A) => f(a); type Id = number; -type CounterModelOut = { - count: Behavior; -}; - type CounterModelInput = { incrementClick: Stream; decrementClick: Stream; @@ -36,93 +31,76 @@ type CounterOutput = { deleteS: Stream; }; -const counterModel = fgo(function*( - { incrementClick, decrementClick, deleteClick }: CounterModelInput, - id: Id -) { - const increment = incrementClick.mapTo(1); - const decrement = decrementClick.mapTo(-1); - const deleteS = deleteClick.mapTo(id); - const count = yield accum(add, 0, combine(increment, decrement)); - return { count, deleteS }; -}); +const counter = (id: Id) => + component( + fgo(function*({ incrementClick, decrementClick, deleteClick }) { + const increment = incrementClick.mapTo(1); + const decrement = decrementClick.mapTo(-1); + const deleteS = deleteClick.mapTo(id); + const count = yield accum(add, 0, combine(increment, decrement)); -function counterView({ count }: CounterModelOut) { - return li([ - "Counter ", - count, - " ", - button({ class: "btn btn-default" }, " + ").output({ - incrementClick: "click" - }), - " ", - button({ class: "btn btn-default" }, " - ").output({ - decrementClick: "click" - }), - " ", - button({ class: "btn btn-default" }, "x").output({ - deleteClick: "click" + return li([ + "Counter ", + count, + " ", + button({ class: "btn btn-default" }, " + ").output({ + incrementClick: "click" + }), + " ", + button({ class: "btn btn-default" }, " - ").output({ + decrementClick: "click" + }), + " ", + button({ class: "btn btn-default" }, "x").output({ + deleteClick: "click" + }) + ]).result({ count, deleteS }); }) - ]); -} - -const counter = modelView(counterModel, counterView); - -type ToView = { - counterIds: Behavior; - sum: Behavior; -}; + ); type ToModel = { addCounter: Stream; listOut: Behavior; }; -const mainModel = fgo(function*({ - addCounter, - listOut -}: ToModel): Iterator> { - const removeIdB = listOut.map((l) => - l.length > 0 ? combine(...l.map((o) => o.deleteS)) : >empty - ); - const sum = >( - flatten( - map( - (list) => - foldr( - ({ count }, sum) => lift(add, count, sum), - Behavior.of(0), - list - ), - listOut +const counterList = component( + fgo(function*({ addCounter, listOut }) { + const removeIdB = listOut.map((l) => + l.length > 0 ? combine(...l.map((o) => o.deleteS)) : >empty + ); + const sum = moment((at) => + at(listOut) + .map((t) => at(t.count)) + .reduce(add, 0) + ); + const removeCounterIdFn = shiftCurrent(removeIdB).map( + (id) => (arr: number[]) => arr.filter((i) => i !== id) + ); + const nextId: Stream = yield scan(add, 2, addCounter.mapTo(1)); + const appendCounterFn = map( + (id) => (ids: Id[]) => ids.concat([id]), + nextId + ); + const modifications = combine(appendCounterFn, removeCounterIdFn); + const counterIds: Behavior = yield accum( + apply, + [0, 1, 2], + modifications + ); + return [ + h1("Counters"), + p(["Sum ", sum]), + button({ class: "btn btn-primary" }, "Add counter").output({ + addCounter: "click" + }), + br, + ul( + list((n) => counter(n).output((o) => o), counterIds).output((o) => ({ + listOut: o + })) ) - ) - ); - const removeCounterIdFn = shiftCurrent(removeIdB).map( - (id) => (arr: number[]) => arr.filter((i) => i !== id) - ); - const nextId: Stream = yield scan(add, 2, addCounter.mapTo(1)); - const appendCounterFn = map((id) => (ids: Id[]) => ids.concat([id]), nextId); - const modifications = combine(appendCounterFn, removeCounterIdFn); - const counterIds = yield accum(apply, [0, 1, 2], modifications); - return { counterIds, sum }; -}); - -const counterListView = ({ sum, counterIds }: ToView) => [ - h1("Counters"), - p(["Sum ", sum]), - button({ class: "btn btn-primary" }, "Add counter").output({ - addCounter: "click" - }), - br, - ul( - list((n) => counter(n).output((o) => o), counterIds).output((o) => ({ - listOut: o - })) - ) -]; + ]; + }) +); -export const counterList = modelView( - mainModel, - counterListView -)(); +export const main4 = counterList; From b9079354fb004fa8078a051b1cabafc4f637a37f Mon Sep 17 00:00:00 2001 From: Simon Friis Vindum Date: Sat, 24 Aug 2019 07:17:59 +0200 Subject: [PATCH 9/9] Rename properties in result type --- src/component.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/component.ts b/src/component.ts index 9f4db12..a42eca4 100644 --- a/src/component.ts +++ b/src/component.ts @@ -88,7 +88,7 @@ export abstract class Component implements Monad { } } result(o: R): Result { - return { output: o, child: this }; + return { available: o, child: this }; } view(): Component { return view(this); @@ -259,7 +259,7 @@ const placeholderProxyHandler = { } }; -type Result = { output: R; child: Child }; +type Result = { available: R; child: Child }; function isLoopResult(r: any): r is Result { return typeof r === "object" && "child" in r; @@ -289,9 +289,9 @@ class LoopComponent extends Component { } const res = this.f(placeholderObject); const result = Now.is(res) ? runNow | Result>(res) : res; - const { output, child } = isLoopResult(result) + const { available, child } = isLoopResult(result) ? result - : { output: {} as O, child: result }; + : { available: {} as O, child: result }; const { output: looped } = toComponent(child).run(parent, destroyed); const needed = Object.keys(placeholderObject); for (const name of needed) { @@ -303,7 +303,7 @@ class LoopComponent extends Component { } placeholderObject[name].replaceWith(looped[name]); } - return { available: output, output: {} }; + return { available, output: {} }; } }