Skip to content

Commit

Permalink
feat(controller): add reactive props
Browse files Browse the repository at this point in the history
  • Loading branch information
lorenzofox3 committed Mar 18, 2024
1 parent bc4b77a commit 75c9b19
Show file tree
Hide file tree
Showing 12 changed files with 253 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/restaurant-cashier/products/edit/edit-product.page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<h1 tabindex="-1">Edit product #${product.sku.toUpperCase()}</h1>
<div class="surface content-grid transition-card-expand boxed">
<form autocomplete="off" novalidate @submit="${handleSubmit}" class="product-form grid-narrow">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const ProductListItem = compositionPipeline(({ html, $host }) => {
);
};

return ({ product = {} }) =>
return ({ properties: { product } }) =>
html`<article class="product-card">
<header>
<h2 class="text-ellipsis">${product.title}</h2>
Expand Down
43 changes: 2 additions & 41 deletions apps/restaurant-cashier/utils/components.js
Original file line number Diff line number Diff line change
@@ -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;
69 changes: 69 additions & 0 deletions packages/controllers/readme.md
Original file line number Diff line number Diff line change
@@ -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;
}
}));

// <my-comp></my-comp>

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;
}
}));
```
21 changes: 3 additions & 18 deletions packages/controllers/src/controller.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
}),
};

Expand All @@ -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)]),
);
16 changes: 16 additions & 0 deletions packages/controllers/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,19 @@ export declare function withController<
{ state: State }
>,
) => ComponentRoutine<Dependencies, { state: State }>;

export declare function withProps<Properties extends Record<string, any>>(
props: (keyof Properties)[],
): <Dependencies>(
view: ComponentRoutine<
Dependencies,
{
properties: Properties;
}
>,
) => ComponentRoutine<
Dependencies,
{
properties: Properties;
}
>;
3 changes: 2 additions & 1 deletion packages/controllers/src/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './controller'
export * from './controller';
export * from './props.js';
37 changes: 37 additions & 0 deletions packages/controllers/src/props.js
Original file line number Diff line number Diff line change
@@ -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 });
};
File renamed without changes.
119 changes: 119 additions & 0 deletions packages/controllers/test/props.js
Original file line number Diff line number Diff line change
@@ -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);
}),
);
3 changes: 2 additions & 1 deletion packages/controllers/test/test-suite.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ <h1>Test reporting</h1>
</body>
<script type='module'>
import { report, createHTMLReporter, createSocketSink } from '@cofn/test-lib/client';
import './index.js';
import './controller.js';
import './props.js';

const [st1, st2] = report().tee();
st1.pipeTo(createSocketSink(import.meta.hot));
Expand Down

0 comments on commit 75c9b19

Please sign in to comment.