refactor: rewrite webhook form template with jsx (#4721)

Big one

- added `new Variable(name, initialValue)` to defined unique variable
- added `expression\`${variable}\`` to access variables by reference
- added `new Action(args, expression\`\`)` support
- added `ws:show` prop which is converted to data-ws-show under the hood

All these features are necessary to represent all webhook form features.
They also may be handy in tests.
This commit is contained in:
Bogdan Chadkin
2025-01-08 18:37:27 +07:00
committed by GitHub
parent 9faf5b8e61
commit 178ad00bc3
10 changed files with 370 additions and 147 deletions

View File

@ -131,17 +131,21 @@ const $componentOptions = computed(
});
}
for (const [name, meta] of templates) {
const label = getInstanceLabel({ component: name }, meta);
if (meta.category === "hidden" || meta.category === "internal") {
continue;
}
const componentMeta = metas.get(name);
const label =
meta.label ??
componentMeta?.label ??
getInstanceLabel({ component: name }, meta);
componentOptions.push({
tokens: ["components", label, meta.category],
type: "component",
component: name,
label,
category: meta.category,
icon: meta.icon ?? metas.get(name)?.icon ?? "",
icon: meta.icon ?? componentMeta?.icon ?? "",
order: meta.order,
});
}

View File

@ -68,13 +68,17 @@ const $metas = computed(
});
}
for (const [name, templateMeta] of templates) {
const componentMeta = componentMetas.get(name);
metas.push({
name,
category: templateMeta.category ?? "hidden",
order: templateMeta.order,
label: getInstanceLabel({ component: name }, templateMeta),
label:
templateMeta.label ??
componentMeta?.label ??
getInstanceLabel({ component: name }, templateMeta),
description: templateMeta.description,
icon: templateMeta.icon ?? componentMetas.get(name)?.icon ?? "",
icon: templateMeta.icon ?? componentMeta?.icon ?? "",
});
}
const metasByCategory = mapGroupBy(metas, (meta) => meta.category);

View File

@ -241,7 +241,6 @@
border-bottom-width: 1px;
border-left-width: 1px;
outline-width: 1px;
min-height: 20px;
}
:where(label.w-input-label) {
box-sizing: border-box;

View File

@ -5,6 +5,7 @@ export { meta as Heading } from "./heading.template";
export { meta as Paragraph } from "./paragraph.template";
export { meta as Link } from "./link.template";
export { meta as Button } from "./button.template";
export { meta as Form } from "./webhook-form.template";
export { meta as Blockquote } from "./blockquote.template";
export { meta as List } from "./list.template";
export { meta as ListItem } from "./list-item.template";

View File

@ -0,0 +1,47 @@
import {
$,
ActionValue,
expression,
PlaceholderValue,
Variable,
type TemplateMeta,
} from "@webstudio-is/template";
const formState = new Variable("formState", "initial");
export const meta: TemplateMeta = {
category: "data",
order: 1,
description: "Collect user data and send it to any webhook.",
template: (
<$.Form
state={expression`${formState}`}
onStateChange={
new ActionValue(["state"], expression`${formState} = state`)
}
>
<$.Box
ws:label="Form Content"
ws:show={expression`${formState} === 'initial' || ${formState} === 'error'`}
>
<$.Label>{new PlaceholderValue("Name")}</$.Label>
<$.Input name="name" />
<$.Label>{new PlaceholderValue("Email")}</$.Label>
<$.Input name="email" />
<$.Button>{new PlaceholderValue("Submit")}</$.Button>
</$.Box>
<$.Box
ws:label="Success Message"
ws:show={expression`${formState} === 'success'`}
>
{new PlaceholderValue("Thank you for getting in touch!")}
</$.Box>
<$.Box
ws:label="Error Message"
ws:show={expression`${formState} === 'error'`}
>
{new PlaceholderValue("Sorry, something went wrong.")}
</$.Box>
</$.Form>
),
};

View File

@ -1,126 +1,23 @@
import { WebhookFormIcon } from "@webstudio-is/icons/svg";
import type { WsComponentMeta, WsComponentPropsMeta } from "@webstudio-is/sdk";
import { showAttribute } from "@webstudio-is/react-sdk";
import { form } from "@webstudio-is/sdk/normalize.css";
import { props } from "./__generated__/webhook-form.props";
import { meta as baseMeta } from "./form.ws";
export const meta: WsComponentMeta = {
...baseMeta,
category: "data",
label: "Webhook Form",
description: "Collect user data and send it to any webhook.",
order: 1,
icon: WebhookFormIcon,
type: "container",
constraints: {
relation: "ancestor",
component: { $nin: ["Form", "Button", "Link"] },
},
presetStyle: {
form,
},
states: [
{ selector: "[data-state=error]", label: "Error" },
{ selector: "[data-state=success]", label: "Success" },
],
template: [
{
type: "instance",
component: "Form",
variables: {
formState: { initialValue: "initial" },
},
props: [
{
type: "expression",
name: "state",
code: "formState",
},
{
type: "action",
name: "onStateChange",
value: [
{ type: "execute", args: ["state"], code: `formState = state` },
],
},
],
children: [
{
type: "instance",
label: "Form Content",
component: "Box",
props: [
{
type: "expression",
name: showAttribute,
code: "formState === 'initial' || formState === 'error'",
},
],
children: [
{
type: "instance",
component: "Label",
children: [{ type: "text", value: "Name", placeholder: true }],
},
{
type: "instance",
component: "Input",
props: [{ type: "string", name: "name", value: "name" }],
children: [],
},
{
type: "instance",
component: "Label",
children: [{ type: "text", value: "Email", placeholder: true }],
},
{
type: "instance",
component: "Input",
props: [{ type: "string", name: "name", value: "email" }],
children: [],
},
{
type: "instance",
component: "Button",
children: [{ type: "text", value: "Submit", placeholder: true }],
},
],
},
{
type: "instance",
label: "Success Message",
component: "Box",
props: [
{
type: "expression",
name: showAttribute,
code: "formState === 'success'",
},
],
children: [
{
type: "text",
value: "Thank you for getting in touch!",
placeholder: true,
},
],
},
{
type: "instance",
label: "Error Message",
component: "Box",
props: [
{
type: "expression",
name: showAttribute,
code: "formState === 'error'",
},
],
children: [
{
type: "text",
value: "Sorry, something went wrong.",
placeholder: true,
},
],
},
],
},
],
};
export const propsMeta: WsComponentPropsMeta = {

View File

@ -19,6 +19,7 @@
"@webstudio-is/css-data": "workspace:*",
"@webstudio-is/css-engine": "workspace:*",
"@webstudio-is/sdk": "workspace:*",
"@webstudio-is/react-sdk": "workspace:*",
"react": "18.3.0-canary-14898b6a9-20240318"
},
"devDependencies": {

View File

@ -1,12 +1,16 @@
import { expect, test } from "vitest";
import { showAttribute } from "@webstudio-is/react-sdk";
import {
$,
ActionValue,
AssetValue,
expression,
ExpressionValue,
PageValue,
ParameterValue,
PlaceholderValue,
renderTemplate,
Variable,
} from "./jsx";
import { css } from "./css";
@ -291,3 +295,179 @@ test("avoid generating style data without styles", () => {
expect(styleSourceSelections).toEqual([]);
expect(styles).toEqual([]);
});
test("render variable used in prop expression", () => {
const count = new Variable("count", 1);
const { props, dataSources } = renderTemplate(
<$.Body ws:id="body" data-count={expression`${count}`}></$.Body>
);
expect(props).toEqual([
{
id: "body:data-count",
instanceId: "body",
name: "data-count",
type: "expression",
value: "$ws$dataSource$0",
},
]);
expect(dataSources).toEqual([
{
type: "variable",
id: "0",
scopeInstanceId: "body",
name: "count",
value: { type: "number", value: 1 },
},
]);
});
test("render variable used in child expression", () => {
const count = new Variable("count", 1);
const { instances, dataSources } = renderTemplate(
<$.Body ws:id="body">{expression`${count}`}</$.Body>
);
expect(instances).toEqual([
{
type: "instance",
id: "body",
component: "Body",
children: [{ type: "expression", value: "$ws$dataSource$0" }],
},
]);
expect(dataSources).toEqual([
{
type: "variable",
id: "0",
scopeInstanceId: "body",
name: "count",
value: { type: "number", value: 1 },
},
]);
});
test("compose expression from multiple variables", () => {
const count = new Variable("count", 1);
const step = new Variable("step", 2);
const { props, dataSources } = renderTemplate(
<$.Body
ws:id="body"
data-count={expression`Count is ${count} + ${step}`}
></$.Body>
);
expect(props).toEqual([
{
id: "body:data-count",
instanceId: "body",
name: "data-count",
type: "expression",
value: "Count is $ws$dataSource$0 + $ws$dataSource$1",
},
]);
expect(dataSources).toEqual([
{
type: "variable",
id: "0",
scopeInstanceId: "body",
name: "count",
value: { type: "number", value: 1 },
},
{
type: "variable",
id: "1",
scopeInstanceId: "body",
name: "step",
value: { type: "number", value: 2 },
},
]);
});
test("preserve same variable on multiple instances", () => {
const count = new Variable("count", 1);
const { props, dataSources } = renderTemplate(
<$.Body ws:id="body" data-count={expression`${count}`}>
<$.Box ws:id="box" data-count={expression`${count}`}></$.Box>
</$.Body>
);
expect(props).toEqual([
{
id: "body:data-count",
instanceId: "body",
name: "data-count",
type: "expression",
value: "$ws$dataSource$0",
},
{
id: "box:data-count",
instanceId: "box",
name: "data-count",
type: "expression",
value: "$ws$dataSource$0",
},
]);
expect(dataSources).toEqual([
{
type: "variable",
id: "0",
scopeInstanceId: "body",
name: "count",
value: { type: "number", value: 1 },
},
]);
});
test("render variable inside of action", () => {
const count = new Variable("count", 1);
const { props, dataSources } = renderTemplate(
<$.Body
ws:id="body"
data-count={expression`${count}`}
onInc={new ActionValue(["step"], expression`${count} = ${count} + step`)}
></$.Body>
);
expect(props).toEqual([
{
id: "body:data-count",
instanceId: "body",
name: "data-count",
type: "expression",
value: "$ws$dataSource$0",
},
{
id: "body:onInc",
instanceId: "body",
name: "onInc",
type: "action",
value: [
{
type: "execute",
args: ["step"],
code: "$ws$dataSource$0 = $ws$dataSource$0 + step",
},
],
},
]);
expect(dataSources).toEqual([
{
type: "variable",
id: "0",
scopeInstanceId: "body",
name: "count",
value: { type: "number", value: 1 },
},
]);
});
test("render ws:show attribute", () => {
const { props } = renderTemplate(
<$.Body ws:id="body" ws:show={true}></$.Body>
);
expect(props).toEqual([
{
id: "body:data-ws-show",
instanceId: "body",
name: showAttribute,
type: "boolean",
value: true,
},
]);
});

View File

@ -1,6 +1,8 @@
import { Fragment, type JSX, type ReactNode } from "react";
import { encodeDataSourceVariable } from "@webstudio-is/sdk";
import type {
Breakpoint,
DataSource,
Instance,
Instances,
Prop,
@ -10,8 +12,38 @@ import type {
StyleSourceSelection,
WebstudioFragment,
} from "@webstudio-is/sdk";
import { showAttribute } from "@webstudio-is/react-sdk";
import type { TemplateStyleDecl } from "./css";
export class Variable {
name: string;
initialValue: unknown;
constructor(name: string, initialValue: unknown) {
this.name = name;
this.initialValue = initialValue;
}
}
class Expression {
chunks: string[];
variables: Variable[];
constructor(chunks: string[], variables: Variable[]) {
this.chunks = chunks;
this.variables = variables;
}
serialize(variableIds: string[]): string {
const values = variableIds.map(encodeDataSourceVariable);
return String.raw({ raw: this.chunks }, ...values);
}
}
export const expression = (
chunks: TemplateStringsArray,
...variables: Variable[]
): Expression => {
return new Expression(Array.from(chunks), variables);
};
export class ExpressionValue {
value: string;
constructor(expression: string) {
@ -34,9 +66,15 @@ export class ResourceValue {
}
export class ActionValue {
value: { type: "execute"; args: string[]; code: string };
constructor(args: string[], code: string) {
this.value = { type: "execute", args, code };
args: string[];
expression: Expression;
constructor(args: string[], code: string | Expression) {
this.args = args;
if (typeof code === "string") {
this.expression = new Expression([code], []);
} else {
this.expression = code;
}
}
}
@ -65,6 +103,12 @@ export class PlaceholderValue {
}
}
const isChildValue = (child: unknown) =>
typeof child === "string" ||
child instanceof PlaceholderValue ||
child instanceof ExpressionValue ||
child instanceof Expression;
const traverseJsx = (
element: JSX.Element,
callback: (
@ -80,13 +124,7 @@ const traverseJsx = (
const result: Instance["children"] = [];
if (element.type === Fragment) {
for (const child of children) {
if (typeof child === "string") {
continue;
}
if (child instanceof PlaceholderValue) {
continue;
}
if (child instanceof ExpressionValue) {
if (isChildValue(child)) {
continue;
}
result.push(...traverseJsx(child, callback));
@ -96,13 +134,7 @@ const traverseJsx = (
const child = callback(element, children);
result.push(child);
for (const child of children) {
if (typeof child === "string") {
continue;
}
if (child instanceof PlaceholderValue) {
continue;
}
if (child instanceof ExpressionValue) {
if (isChildValue(child)) {
continue;
}
traverseJsx(child, callback);
@ -118,6 +150,7 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => {
const styleSources: StyleSource[] = [];
const styleSourceSelections: StyleSourceSelection[] = [];
const styles: StyleDecl[] = [];
const dataSources = new Map<Variable, DataSource>();
const ids = new Map<unknown, string>();
const getId = (key: unknown) => {
let id = ids.get(key);
@ -128,6 +161,30 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => {
}
return id;
};
const getVariableId = (instanceId: string, variable: Variable) => {
const id = getId(variable);
if (dataSources.has(variable)) {
return id;
}
let value: Extract<DataSource, { type: "variable" }>["value"];
if (typeof variable.initialValue === "string") {
value = { type: "string", value: variable.initialValue };
} else if (typeof variable.initialValue === "number") {
value = { type: "number", value: variable.initialValue };
} else if (typeof variable.initialValue === "boolean") {
value = { type: "boolean", value: variable.initialValue };
} else {
value = { type: "json", value: variable.initialValue };
}
dataSources.set(variable, {
type: "variable",
scopeInstanceId: instanceId,
id,
name: variable.name,
value,
});
return id;
};
// lazily create breakpoint
const getBreakpointId = () => {
if (breakpoints.length > 0) {
@ -142,7 +199,9 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => {
};
const children = traverseJsx(root, (element, children) => {
const instanceId = element.props?.["ws:id"] ?? getId(element);
for (const [name, value] of Object.entries({ ...element.props })) {
for (const entry of Object.entries({ ...element.props })) {
const [_name, value] = entry;
let [name] = entry;
if (name === "ws:id" || name === "ws:label" || name === "children") {
continue;
}
@ -166,8 +225,19 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => {
}
continue;
}
if (name === "ws:show") {
name = showAttribute;
}
const propId = `${instanceId}:${name}`;
const base = { id: propId, instanceId, name };
if (value instanceof Expression) {
const values = value.variables.map((variable) =>
getVariableId(instanceId, variable)
);
const expression = value.serialize(values);
props.push({ ...base, type: "expression", value: expression });
continue;
}
if (value instanceof ExpressionValue) {
props.push({ ...base, type: "expression", value: value.value });
continue;
@ -181,7 +251,13 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => {
continue;
}
if (value instanceof ActionValue) {
props.push({ ...base, type: "action", value: [value.value] });
const code = value.expression.serialize(
value.expression.variables.map((variable) =>
getVariableId(instanceId, variable)
)
);
const action = { type: "execute" as const, args: value.args, code };
props.push({ ...base, type: "action", value: [action] });
continue;
}
if (value instanceof AssetValue) {
@ -214,15 +290,25 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => {
...(element.props?.["ws:label"]
? { label: element.props?.["ws:label"] }
: undefined),
children: children.map((child): Instance["children"][number] =>
typeof child === "string"
? { type: "text", value: child }
: child instanceof PlaceholderValue
? { type: "text", value: child.value, placeholder: true }
: child instanceof ExpressionValue
? { type: "expression", value: child.value }
: { type: "id", value: child.props?.["ws:id"] ?? getId(child) }
),
children: children.map((child): Instance["children"][number] => {
if (typeof child === "string") {
return { type: "text", value: child };
}
if (child instanceof PlaceholderValue) {
return { type: "text", value: child.value, placeholder: true };
}
if (child instanceof Expression) {
const values = child.variables.map((variable) =>
getVariableId(instanceId, variable)
);
const expression = child.serialize(values);
return { type: "expression", value: expression };
}
if (child instanceof ExpressionValue) {
return { type: "expression", value: child.value };
}
return { type: "id", value: child.props?.["ws:id"] ?? getId(child) };
}),
};
instances.push(instance);
return { type: "id", value: instance.id };
@ -235,8 +321,8 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => {
styleSources,
styleSourceSelections,
styles,
dataSources: Array.from(dataSources.values()),
assets: [],
dataSources: [],
resources: [],
};
};
@ -261,7 +347,8 @@ type ComponentProps = Record<string, unknown> &
"ws:id"?: string;
"ws:label"?: string;
"ws:style"?: TemplateStyleDecl[];
children?: ReactNode | ExpressionValue;
"ws:show"?: boolean | Expression;
children?: ReactNode | ExpressionValue | Expression | PlaceholderValue;
};
type Component = { displayName: string } & ((

3
pnpm-lock.yaml generated
View File

@ -1985,6 +1985,9 @@ importers:
'@webstudio-is/css-engine':
specifier: workspace:*
version: link:../css-engine
'@webstudio-is/react-sdk':
specifier: workspace:*
version: link:../react-sdk
'@webstudio-is/sdk':
specifier: workspace:*
version: link:../sdk