diff --git a/apps/builder/app/builder/features/command-panel/command-panel.tsx b/apps/builder/app/builder/features/command-panel/command-panel.tsx index df0a20e5a02b..5cd1b6852b68 100644 --- a/apps/builder/app/builder/features/command-panel/command-panel.tsx +++ b/apps/builder/app/builder/features/command-panel/command-panel.tsx @@ -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, }); } diff --git a/apps/builder/app/builder/features/components/components.tsx b/apps/builder/app/builder/features/components/components.tsx index 49edc27b4a24..3686f5308613 100644 --- a/apps/builder/app/builder/features/components/components.tsx +++ b/apps/builder/app/builder/features/components/components.tsx @@ -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); diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/index.css b/fixtures/webstudio-remix-vercel/app/__generated__/index.css index 9a513e561e52..844c0255bde4 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/index.css +++ b/fixtures/webstudio-remix-vercel/app/__generated__/index.css @@ -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; diff --git a/packages/sdk-components-react/src/templates.ts b/packages/sdk-components-react/src/templates.ts index 511ddd26b98e..3435ca768c3d 100644 --- a/packages/sdk-components-react/src/templates.ts +++ b/packages/sdk-components-react/src/templates.ts @@ -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"; diff --git a/packages/sdk-components-react/src/webhook-form.template.tsx b/packages/sdk-components-react/src/webhook-form.template.tsx new file mode 100644 index 000000000000..2c597376a702 --- /dev/null +++ b/packages/sdk-components-react/src/webhook-form.template.tsx @@ -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")} + <$.Input name="name" /> + <$.Label>{new PlaceholderValue("Email")} + <$.Input name="email" /> + <$.Button>{new PlaceholderValue("Submit")} + + <$.Box + ws:label="Success Message" + ws:show={expression`${formState} === 'success'`} + > + {new PlaceholderValue("Thank you for getting in touch!")} + + <$.Box + ws:label="Error Message" + ws:show={expression`${formState} === 'error'`} + > + {new PlaceholderValue("Sorry, something went wrong.")} + + + ), +}; diff --git a/packages/sdk-components-react/src/webhook-form.ws.ts b/packages/sdk-components-react/src/webhook-form.ws.ts index 6c4a9b8cab0d..a0c2c337a221 100644 --- a/packages/sdk-components-react/src/webhook-form.ws.ts +++ b/packages/sdk-components-react/src/webhook-form.ws.ts @@ -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 = { diff --git a/packages/template/package.json b/packages/template/package.json index 7378c4007360..9bc2122a5fa5 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -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": { diff --git a/packages/template/src/jsx.test.tsx b/packages/template/src/jsx.test.tsx index 12a96c0c3b13..d7a1af027504 100644 --- a/packages/template/src/jsx.test.tsx +++ b/packages/template/src/jsx.test.tsx @@ -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}`}> + ); + 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}`} + ); + 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}`} + > + ); + 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}`}> + + ); + 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`)} + > + ); + 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}> + ); + expect(props).toEqual([ + { + id: "body:data-ws-show", + instanceId: "body", + name: showAttribute, + type: "boolean", + value: true, + }, + ]); +}); diff --git a/packages/template/src/jsx.ts b/packages/template/src/jsx.ts index e082bb52059b..ad92d76a742f 100644 --- a/packages/template/src/jsx.ts +++ b/packages/template/src/jsx.ts @@ -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(); const ids = new Map(); 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["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 & "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 } & (( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7675a1076ac..940298ef73a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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