Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 44 additions & 24 deletions typescript/packages/common-ui/src/hyperscript/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import {
Signal,
WriteableSignal,
effect,
isSignal,
} from "@commontools/common-frp/signal";
import { Stream, WriteableStream } from "@commontools/common-frp/stream";
import {
Stream,
WriteableStream,
isStream,
} from "@commontools/common-frp/stream";
import {
isBinding,
VNode,
Expand Down Expand Up @@ -81,6 +86,13 @@ const readEvent = (event: Event) => {
}
};

const modifyPropsForSchemaValidation = (props: object) =>
Object.fromEntries(
Object.entries(props).filter(
([_, value]) => !isSignal(value) && !isStream(value) && !isBinding(value)
)
);

/** Render a VNode tree, binding reactive data sources. */
const renderVNode = (vnode: VNode, context: RenderContext): Node => {
// Make sure we have a view for this tag. If we don't it is not whitelisted.
Expand All @@ -91,10 +103,10 @@ const renderVNode = (vnode: VNode, context: RenderContext): Node => {
}

// Validate props against the view's schema.
if (!view.props.validate(vnode.props)) {
if (!view.props.validate(modifyPropsForSchemaValidation(vnode.props))) {
throw new TypeError(`Invalid props for tag: ${vnode.tag}.
Props: ${JSON.stringify(vnode.props)}
Schema: ${JSON.stringify(view.props.schema)}`);
Props: ${JSON.stringify(vnode.props)}, ${JSON.stringify(modifyPropsForSchemaValidation(vnode.props), undefined, 2)}
Schema: ${JSON.stringify(view.props.schema, undefined, 2)}`);
}

// Create the element
Expand All @@ -103,29 +115,34 @@ const renderVNode = (vnode: VNode, context: RenderContext): Node => {
// Bind each prop to a reactive value (if any) and collect cancels
const cancels: Array<Cancel> = [];

for (const [key, value] of Object.entries(vnode.props)) {
for (const [prop, value] of Object.entries(vnode.props)) {
const [key, detail] = prop.split("#", 2);
// Don't bind properties that aren't whitelisted in the schema.
if (!Object.hasOwn(view.props.schema.properties, key)) {
continue;
}

if (isEventKey(key) && isBinding(value)) {
const bound = context[value.name];
if (isSendable(bound)) {
const { send } = bound;
const event = readEventNameFromEventKey(key);
const cancel = listen(element, event, (event: Event) => {
send(readEvent(event));
});
cancels.push(cancel);
}
} else if (isBinding(value)) {
const boundValue = context[value.name];
if (boundValue != null) {
const cancel = effect([boundValue], (value) => {
setProp(element, key, value);
});
cancels.push(cancel);
if (isBinding(value) || isSignal(value) || isStream(value)) {
const bound =
isSignal(value) || isStream(value) ? value : context[value.name];
if (isEventKey(key)) {
if (isSendable(bound)) {
const { send } = bound;
const event = readEventNameFromEventKey(key);
const cancel = listen(element, event, (event: Event) => {
let vdomEvent = readEvent(event);
if (detail) vdomEvent = vdomEvent.detail[detail];
send(vdomEvent);
});
cancels.push(cancel);
}
} else {
if (bound) {
const cancel = effect([bound], (value) => {
setProp(element, key, value);
});
cancels.push(cancel);
}
}
} else {
setProp(element, key, value);
Expand All @@ -138,13 +155,16 @@ const renderVNode = (vnode: VNode, context: RenderContext): Node => {
element[__cancel__] = cancel;

if (isRepeatBinding(vnode.children)) {
const { name, template } = vnode.children;
const scopedContext = context[name];
const { name, template, signal } = vnode.children;
const scopedContext = signal ? signal : context[name];
const cancel = renderDynamicChildren(element, template, scopedContext);
cancels.push(cancel);
} else if (isBinding(vnode.children)) {
const { name } = vnode.children;
renderText(element, context[name]);
} else if (isSignal(vnode.children)) {
const signal = vnode.children;
renderText(element, signal);
} else {
renderStaticChildren(element, vnode.children, context);
}
Expand Down
89 changes: 39 additions & 50 deletions typescript/packages/common-ui/src/hyperscript/view.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
import * as Schema from '../shared/schema.js';
import { isSignal, Signal } from "@commontools/common-frp/signal";
import * as Schema from "../shared/schema.js";
import {
AnyJSONObjectSchema,
JSONSchemaRecord,
bindable
} from './schema-helpers.js';
import {deepFreeze} from '../shared/deep-freeze.js';
bindable,
} from "./schema-helpers.js";
import { deepFreeze } from "../shared/deep-freeze.js";

export type Binding = {
"@type": "binding";
name: string;
}
};

/** Is value a binding to a reactive value? */
export const isBinding = (value: any): value is Binding => {
return (
value &&
value["@type"] === "binding" &&
typeof value.name === "string"
value && value["@type"] === "binding" && typeof value.name === "string"
);
}
};

/** Create a template binding */
export const binding = (name: string): Binding => ({
"@type": "binding",
name
name,
});

/** A repeat binding repeats items in a dynamic list using a template */
export type RepeatBinding = {
"@type": "repeat";
name: string;
signal?: Signal<any>;
template: VNode;
}
};

/** Is value a binding to a reactive value? */
export const isRepeatBinding = (value: any): value is RepeatBinding => {
Expand All @@ -41,16 +41,17 @@ export const isRepeatBinding = (value: any): value is RepeatBinding => {
typeof value.name === "string" &&
isVNode(value.template)
);
}
};

/** Create a template binding */
export const repeat = (
name: string,
name: string | Signal<any>,
template: VNode
): RepeatBinding => ({
"@type": "repeat",
name,
template
name: typeof name === "string" ? name : "signal",
template,
...(isSignal(name) ? { signal: name } : {}),
});

export type Value = string | number | boolean | null | object;
Expand All @@ -59,7 +60,7 @@ export type ReactiveValue = Binding | Value;

export type Props = {
[key: string]: ReactiveValue;
}
};

export type Tag = string;

Expand All @@ -69,7 +70,7 @@ export type VNode = {
tag: Tag;
props: Props;
children: Children;
}
};

// NOTE: don't freeze this object, since the validator will want to mutate it.
export const VNodeSchema = {
Expand All @@ -89,32 +90,25 @@ export const VNodeSchema = {
{ type: "null" },
{ type: "object" },
{ type: "array" },
{ type: "signal" }
]
}
{ type: "signal" },
],
},
},
children: {
type: "array",
items: {
oneOf: [
{ type: "string" },
{ $ref: "#" }
]
}
}
oneOf: [{ type: "string" }, { $ref: "#" }],
},
},
},
required: ["tag", "props", "children"]
}
required: ["tag", "props", "children"],
};

/** Internal helper for creating VNodes */
const vnode = (
tag: string,
props: Props = {},
children: Children
): VNode => ({
const vnode = (tag: string, props: Props = {}, children: Children): VNode => ({
tag,
props,
children
children,
});

/** Is object a VNode? */
Expand All @@ -125,23 +119,20 @@ export const isVNode = (value: any): value is VNode => {
typeof value.props === "object" &&
value.children != null
);
}
};

export type Factory = {
(): VNode
(): VNode;

(props: Props): VNode
(props: Props): VNode;

(
props: Props,
children: Children
): VNode
(props: Props, children: Children): VNode;
};

export type PropsDescription = {
schema: AnyJSONObjectSchema;
validate: (data: any) => boolean;
}
};

export type View = Factory & {
tag: Tag;
Expand All @@ -164,16 +155,16 @@ export const view = (
type: "object",
properties: Object.fromEntries(
Object.entries(propertySchema).map(([key, value]) => {
return [key, bindable(value)]
return [key, bindable(value)];
})
)
),
};

// Compile props validator for fast validation at runtime.
const validate = Schema.compile({
...schema,
// Allow additional properties when validating props.
additionalProperties: true
additionalProperties: true,
});

/**
Expand All @@ -183,13 +174,11 @@ export const view = (
* @param children - child nodes
* @returns VNode
*/
const create = (
props: Props = {},
children: Children = []
) => vnode(tag, props, children);
const create = (props: Props = {}, children: Children = []) =>
vnode(tag, props, children);

create.tag = tag;
create.props = {validate, schema};
create.props = { validate, schema };

return deepFreeze(create);
};
7 changes: 7 additions & 0 deletions typescript/packages/lookslike-high-level/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Gem } from "./recipe.js";
const { state } = signal;

import { todoList, todoTask } from "./recipes/todo-list.js";
import { localSearch } from "./recipes/local-search.js";

import "./recipes/todo-list-as-task.js"; // Necessary, so that suggestions are indexed.

export const dataGems = state<Gem[]>([]);
Expand Down Expand Up @@ -45,4 +47,9 @@ export const recipes: RecipeManifest[] = [
recipe: todoList,
inputs: { title: "", items: [] },
},
{
name: "Find places",
recipe: localSearch,
inputs: { query: "", location: "" },
},
];
Loading