From 75c9b190c842ad3cb711b03e5ae57ea852118e10 Mon Sep 17 00:00:00 2001 From: lorenzofox3 Date: Mon, 18 Mar 2024 17:33:25 +0100 Subject: [PATCH] feat(controller): add reactive props --- .../cart/cart-product-item.component.js | 2 +- .../products/edit/edit-product.page.js | 2 +- .../list/product-list-item.component.js | 2 +- apps/restaurant-cashier/utils/components.js | 43 +------ packages/controllers/readme.md | 69 ++++++++++ packages/controllers/src/controller.js | 21 +--- packages/controllers/src/index.d.ts | 16 +++ packages/controllers/src/index.js | 3 +- packages/controllers/src/props.js | 37 ++++++ .../test/{index.js => controller.js} | 0 packages/controllers/test/props.js | 119 ++++++++++++++++++ packages/controllers/test/test-suite.html | 3 +- 12 files changed, 253 insertions(+), 64 deletions(-) create mode 100644 packages/controllers/src/props.js rename packages/controllers/test/{index.js => controller.js} (100%) create mode 100644 packages/controllers/test/props.js diff --git a/apps/restaurant-cashier/cart/cart-product-item.component.js b/apps/restaurant-cashier/cart/cart-product-item.component.js index 2725890..2d03a39 100644 --- a/apps/restaurant-cashier/cart/cart-product-item.component.js +++ b/apps/restaurant-cashier/cart/cart-product-item.component.js @@ -5,7 +5,7 @@ import { withView } from '@cofn/view'; const compositionPipeline = compose([reactiveProps(['product']), withView]); export const CartProductItem = compositionPipeline(({ html, $host }) => { - return ({ product }) => { + return ({ properties: { product } }) => { if (product.image?.url) { $host.style.setProperty('background-image', `url(${product.image.url})`); } diff --git a/apps/restaurant-cashier/products/edit/edit-product.page.js b/apps/restaurant-cashier/products/edit/edit-product.page.js index fbbc544..9f66ce6 100644 --- a/apps/restaurant-cashier/products/edit/edit-product.page.js +++ b/apps/restaurant-cashier/products/edit/edit-product.page.js @@ -29,7 +29,7 @@ export const loadPage = async ({ define, state }) => { const wrapComponent = compose([reactiveProps(['product']), withView]); const EditProductForm = wrapComponent(({ html, router, $host }) => { - return ({ product }) => html` + return ({ properties: { product } }) => html`

Edit product #${product.sku.toUpperCase()}

diff --git a/apps/restaurant-cashier/products/list/product-list-item.component.js b/apps/restaurant-cashier/products/list/product-list-item.component.js index cb8a9c8..5df7ae6 100644 --- a/apps/restaurant-cashier/products/list/product-list-item.component.js +++ b/apps/restaurant-cashier/products/list/product-list-item.component.js @@ -18,7 +18,7 @@ export const ProductListItem = compositionPipeline(({ html, $host }) => { ); }; - return ({ product = {} }) => + return ({ properties: { product } }) => html`

${product.title}

diff --git a/apps/restaurant-cashier/utils/components.js b/apps/restaurant-cashier/utils/components.js index f95b7c7..461d138 100644 --- a/apps/restaurant-cashier/utils/components.js +++ b/apps/restaurant-cashier/utils/components.js @@ -1,41 +1,2 @@ -// todo draw that somewhere else (a prop utils ? or part of the framework) -export const reactiveProps = (props) => (comp) => - function* ({ $host, ...rest }) { - let pendingUpdate = false; - const properties = {}; - const { render } = $host; - - $host.render = (update = {}) => - render({ - ...properties, - ...update, - }); - - Object.defineProperties( - $host, - Object.fromEntries( - props.map((propName) => { - properties[propName] = $host[propName]; - return [ - propName, - { - enumerable: true, - get() { - return properties[propName]; - }, - set(value) { - properties[propName] = value; - pendingUpdate = true; - window.queueMicrotask(() => { - pendingUpdate = false; - $host.render(); - }); - }, - }, - ]; - }), - ), - ); - - yield* comp({ $host, ...rest }); - }; +import { withProps } from '@cofn/controllers'; +export const reactiveProps = withProps; diff --git a/packages/controllers/readme.md b/packages/controllers/readme.md index b11bd0a..ab77550 100644 --- a/packages/controllers/readme.md +++ b/packages/controllers/readme.md @@ -1,8 +1,77 @@ # Controllers +A set of higher order function to add update logic to a coroutine component + ## Installation you can install the library with a package manager (like npm): ``npm install @cofn/controllers`` Or import it directly from a CDN + +## Reactive props + +Defines a list of properties to watch. The namespace ``properties`` is injected into the rendering generator + +```js +import {define} from '@cofn/core'; +import {withProps} from '@cofn/controllers' + +const withName = withProps(['name']); + +define('my-comp', withNameAndAge(function *({$root}){ + while(true) { + const { properties } = yield; + $root.textContent = properties.name; + } +})); + +// + +myCompEl.name = 'Bob'; // > render + +// ... + +myCompEl.name = 'Woot'; // > render + +``` + +## Controller API + +Defines a controller passed to the rendering generator. Takes as input a factory function which returns the controller. + +The regular component dependencies are injected into the controller factory and a meta object ``state``. +Whenever a property is set on this meta object, the component renders. The namespace ``state`` is injected into the rendering generator. + +```js +import {define} from '@cofn/core'; +import {withController} from '@cofn/controller'; + +const withCountController = withController(({state, $host}) => { + const step = $host.hasAttribute('step') ? Number($host.getAttribute('step')) : 1; + state.count = 0; + + return { + increment(){ + state = state + step; + }, + decrement(){ + state = state - step; + } + }; +}); + +define('count-me',withCountController(function *({$root, controller}){ + $root.replaceChildren(template.content.cloneNode(true)); + const [decrementEl, incrementEl] = $host.querySelectorAll('button'); + const countEl = $host.querySelector('span'); + + decrementEl.addEventListener('click', controller.decrement); + incrementEl.addEventListener('click', controller.increment); + + while(true) { + const { $scope } = yield; + countEl.textContent = $scope.count; + } +})); +``` diff --git a/packages/controllers/src/controller.js b/packages/controllers/src/controller.js index 1f73a33..fb5f041 100644 --- a/packages/controllers/src/controller.js +++ b/packages/controllers/src/controller.js @@ -1,8 +1,7 @@ export const withController = (controllerFn) => (view) => function* (deps) { - const state = {}; + const state = deps.state || {}; const { $host } = deps; - let instantiated = false; const ctrl = { getState() { @@ -15,13 +14,12 @@ export const withController = (controllerFn) => (view) => set(obj, prop, value) { obj[prop] = value; // no need to render if the view is not connected - if ($host.isConnected && instantiated) { + if ($host.isConnected) { $host.render(); } return true; }, }), - attributes: getAttributes(deps.$host), // to get initial state if required }), }; @@ -34,21 +32,8 @@ export const withController = (controllerFn) => (view) => }); // inject controller in the view - const componentInstance = view({ + yield* view({ ...deps, controller: ctrl, }); - - instantiated = true; - - try { - yield* componentInstance; - } finally { - componentInstance.return(); - } }; - -const getAttributes = (el) => - Object.fromEntries( - el.getAttributeNames().map((name) => [name, el.getAttribute(name)]), - ); diff --git a/packages/controllers/src/index.d.ts b/packages/controllers/src/index.d.ts index 00d068f..f10989c 100644 --- a/packages/controllers/src/index.d.ts +++ b/packages/controllers/src/index.d.ts @@ -20,3 +20,19 @@ export declare function withController< { state: State } >, ) => ComponentRoutine; + +export declare function withProps>( + props: (keyof Properties)[], +): ( + view: ComponentRoutine< + Dependencies, + { + properties: Properties; + } + >, +) => ComponentRoutine< + Dependencies, + { + properties: Properties; + } +>; diff --git a/packages/controllers/src/index.js b/packages/controllers/src/index.js index d3abc73..4b5f3d2 100644 --- a/packages/controllers/src/index.js +++ b/packages/controllers/src/index.js @@ -1 +1,2 @@ -export * from './controller' \ No newline at end of file +export * from './controller'; +export * from './props.js'; diff --git a/packages/controllers/src/props.js b/packages/controllers/src/props.js new file mode 100644 index 0000000..45d5388 --- /dev/null +++ b/packages/controllers/src/props.js @@ -0,0 +1,37 @@ +export const withProps = (props) => (gen) => + function* ({ $host, ...rest }) { + const properties = {} || rest.properties; + const { render } = $host; + + $host.render = (update = {}) => + render({ + properties: { + ...properties, + }, + ...update, + }); + + Object.defineProperties( + $host, + Object.fromEntries( + props.map((propName) => { + properties[propName] = $host[propName]; + return [ + propName, + { + enumerable: true, + get() { + return properties[propName]; + }, + set(value) { + properties[propName] = value; + $host.render(); + }, + }, + ]; + }), + ), + ); + + yield* gen({ $host, ...rest }); + }; diff --git a/packages/controllers/test/index.js b/packages/controllers/test/controller.js similarity index 100% rename from packages/controllers/test/index.js rename to packages/controllers/test/controller.js diff --git a/packages/controllers/test/props.js b/packages/controllers/test/props.js new file mode 100644 index 0000000..f6c4e43 --- /dev/null +++ b/packages/controllers/test/props.js @@ -0,0 +1,119 @@ +import { test } from '@cofn/test-lib/client'; +import { withProps } from '../src/index.js'; +import { define } from '@cofn/core'; +import { nextTick } from './utils.js'; + +const debug = document.getElementById('debug'); +const withTestProps = withProps(['test', 'other']); + +define( + 'test-props-controller', + withTestProps(function* ({ $host }) { + let loopCount = 0; + Object.defineProperty($host, 'count', { + get() { + return loopCount; + }, + }); + try { + while (true) { + const { properties } = yield; + loopCount += 1; + $host.textContent = JSON.stringify(properties); + } + } finally { + $host.teardown = true; + } + }), +); + +const withEl = (specFn) => + async function zora_spec_fn(assert) { + const el = document.createElement('test-props-controller'); + debug.appendChild(el); + try { + await specFn({ ...assert, el }); + } catch (err) { + console.log(err); + throw err; + } + }; + +test( + 'component is rendered with initial set properties', + withEl(async ({ eq }) => { + const el = document.createElement('test-props-controller'); + el.test = 'foo'; + await nextTick(); + eq(el.textContent, JSON.stringify({ test: 'foo' })); + }), +); + +test( + 'component is updated when a property is set', + withEl(async ({ eq, el }) => { + el.test = 'foo'; + el.other = 'blah'; + await nextTick(); + eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); + el.test = 42; + await nextTick(); + eq(el.textContent, JSON.stringify({ test: 42, other: 'blah' })); + }), +); + +test( + 'component is updated when a property is set', + withEl(async ({ eq, el }) => { + el.test = 'foo'; + el.other = 'blah'; + await nextTick(); + eq(el.count, 1); + eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); + el.test = 42; + await nextTick(); + eq(el.count, 2); + eq(el.textContent, JSON.stringify({ test: 42, other: 'blah' })); + }), +); + +test( + 'component is updated once when several properties are set', + withEl(async ({ eq, el }) => { + el.test = 'foo'; + el.other = 'blah'; + await nextTick(); + eq(el.count, 1); + eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); + el.test = 42; + el.other = 'updated'; + await nextTick(); + eq(el.count, 2); + eq(el.textContent, JSON.stringify({ test: 42, other: 'updated' })); + }), +); + +test( + 'component is not updated when a property is set but the property is not in the reactive property list', + withEl(async ({ eq, el }) => { + el.test = 'foo'; + el.other = 'blah'; + await nextTick(); + eq(el.count, 1); + eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); + el.whatever = 42; + await nextTick(); + eq(el.count, 1); + eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); + }), +); + +test( + 'tears down of the component is called', + withEl(async ({ ok, notOk, el }) => { + notOk(el.teardown); + el.remove(); + await nextTick(); + ok(el.teardown); + }), +); diff --git a/packages/controllers/test/test-suite.html b/packages/controllers/test/test-suite.html index e7f2bd8..77e3a4e 100644 --- a/packages/controllers/test/test-suite.html +++ b/packages/controllers/test/test-suite.html @@ -17,7 +17,8 @@

Test reporting