diff --git a/apps/builder/app/builder/features/settings-panel/code-editor.stories.tsx b/apps/builder/app/builder/features/settings-panel/code-editor.stories.tsx
deleted file mode 100644
index a1d2449957..0000000000
--- a/apps/builder/app/builder/features/settings-panel/code-editor.stories.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react";
-import { CodeEditor as CodeEditorComponent } from "./code-editor";
-
-export default {
- title: "Builder/CodeEditor",
- component: CodeEditorComponent,
-} satisfies Meta;
-
-const variables = new Map([
- ["#1", "formState"],
- ["#2", "formInitial"],
- ["#3", "count"],
- ["#4", "something"],
- ["#5", "else"],
- ["#6", "hello"],
-]);
-
-export const CodeEditor: StoryObj = {
- render: () => (
- <>
-
Type $ to trigger variables completion
-
- console.info(newCode)}
- />
-
-
- console.info(newCode)}
- />
- >
- ),
-};
diff --git a/apps/builder/app/builder/features/settings-panel/code-editor.tsx b/apps/builder/app/builder/features/settings-panel/code-editor.tsx
deleted file mode 100644
index 94798383d8..0000000000
--- a/apps/builder/app/builder/features/settings-panel/code-editor.tsx
+++ /dev/null
@@ -1,386 +0,0 @@
-import { useCallback, useEffect, useState } from "react";
-import { createPortal } from "react-dom";
-import {
- type DOMConversionMap,
- type DOMConversionOutput,
- type DOMExportOutput,
- type EditorConfig,
- type LexicalNode,
- type NodeKey,
- type SerializedTextNode,
- $applyNodeReplacement,
- TextNode,
- $getRoot,
- $isElementNode,
- createEditor,
- type LexicalEditor,
-} from "lexical";
-import { $generateNodesFromDOM } from "@lexical/html";
-import { LexicalComposer } from "@lexical/react/LexicalComposer";
-import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
-import { ContentEditable } from "@lexical/react/LexicalContentEditable";
-import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
-import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
-import {
- LexicalTypeaheadMenuPlugin,
- MenuOption,
- useBasicTypeaheadTriggerMatch,
-} from "@lexical/react/LexicalTypeaheadMenuPlugin";
-import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
-import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
-import {
- MenuItemButton,
- MenuList,
- css,
- textVariants,
- theme,
-} from "@webstudio-is/design-system";
-
-// mention node is based on
-// https://github.com/facebook/lexical/blob/f15a17564b6267531732ba5cc824b8aaf45f5b54/packages/lexical-playground/src/nodes/MentionNode.ts
-
-const convertMentionElement = (
- domNode: HTMLElement
-): DOMConversionOutput | null => {
- const mentionText = domNode.getAttribute("data-mention-text");
- const mentionId = domNode.getAttribute("data-mention-id");
-
- if (mentionText !== null && mentionId !== null) {
- const node = $createMentionNode(mentionText, mentionId);
- return {
- node,
- };
- }
-
- return null;
-};
-
-const mentionStyle = "background-color: rgba(24, 119, 232, 0.2)";
-
-class MentionNode extends TextNode {
- mentionText: string;
- mentionId: string;
-
- static getType(): string {
- return "mention";
- }
-
- static clone(node: MentionNode): MentionNode {
- return new MentionNode(node.mentionText, node.mentionId, node.__key);
- }
-
- static importJSON(): MentionNode {
- throw Error("Unimplemented");
- }
-
- static importDOM(): DOMConversionMap | null {
- return {
- span: (domNode: HTMLElement) => {
- if (!domNode.hasAttribute("data-lexical-mention")) {
- return null;
- }
- return {
- conversion: convertMentionElement,
- priority: 1,
- };
- },
- };
- }
-
- constructor(mentionText: string, mentionId: string, key?: NodeKey) {
- super(mentionText, key);
- this.mentionText = mentionText;
- this.mentionId = mentionId;
- }
-
- exportJSON(): SerializedTextNode {
- throw Error("Unimplemented");
- }
-
- createDOM(config: EditorConfig): HTMLElement {
- const dom = super.createDOM(config);
- dom.style.cssText = mentionStyle;
- dom.className = "mention";
- return dom;
- }
-
- exportDOM(): DOMExportOutput {
- const element = document.createElement("span");
- element.setAttribute("data-lexical-mention", "true");
- element.setAttribute("data-mention-text", this.mentionText);
- element.setAttribute("data-mention-id", this.mentionId);
- element.textContent = this.__text;
- return { element };
- }
-
- isTextEntity(): true {
- return true;
- }
-
- canInsertTextBefore(): boolean {
- return false;
- }
-
- canInsertTextAfter(): boolean {
- return false;
- }
-}
-
-const $createMentionNode = (
- mentionText: string,
- mentionId: string
-): MentionNode => {
- const mentionNode = new MentionNode(mentionText, mentionId);
- mentionNode.setMode("segmented").toggleDirectionless();
- return $applyNodeReplacement(mentionNode);
-};
-
-const $isMentionNode = (
- node: LexicalNode | null | undefined
-): node is MentionNode => {
- return node instanceof MentionNode;
-};
-
-const findAllSubstrings = (text: string, substrings: string[]) => {
- const offsets = new Set();
- for (const substring of substrings) {
- let index = text.indexOf(substring);
- while (index !== -1) {
- // add start and end offsets of each match
- offsets.add(index);
- offsets.add(index + substring.length);
- index = text.indexOf(substring, index + 1);
- }
- }
- return Array.from(offsets);
-};
-
-const VariableSuggestionsPlugin = ({
- variables,
-}: {
- variables: Map;
-}) => {
- const [editor] = useLexicalComposerContext();
- const checkForMentionMatch = useBasicTypeaheadTriggerMatch("$", {
- minLength: 0,
- });
-
- const [options, setOptions] = useState([]);
-
- const onQueryChange = useCallback(
- (matchingString: string | null) => {
- let matchedIds: string[] = [];
- if (matchingString == null) {
- matchedIds = Array.from(variables.keys());
- } else {
- matchedIds = Array.from(variables)
- .filter(([_id, text]) =>
- // make search case insensitive
- text.toLowerCase().includes(matchingString.toLowerCase())
- )
- .map(([id]) => id);
- }
- setOptions(matchedIds.map((id) => new MenuOption(id)));
- },
- [variables]
- );
-
- const onSelectOption = useCallback(
- (
- selectedOption: MenuOption,
- nodeToReplace: TextNode | null,
- closeMenu: () => void
- ) => {
- editor.update(() => {
- const mentionId = selectedOption.key;
- const mentionText = variables.get(mentionId);
- if (mentionText !== undefined) {
- const mentionNode = $createMentionNode(mentionText, mentionId);
- if (nodeToReplace) {
- nodeToReplace.replace(mentionNode);
- }
- mentionNode.select();
- }
- closeMenu();
- });
- },
- [editor, variables]
- );
-
- useEffect(() => {
- return editor.registerNodeTransform(TextNode, (node) => {
- const offsets = findAllSubstrings(
- node.getTextContent(),
- Array.from(variables.keys())
- );
- // stop before calling any lexical updates to avoid infinite loop
- if (offsets.length === 0) {
- return;
- }
- const nodes = node.splitText(...offsets);
- for (node of nodes) {
- const mentionId = node.getTextContent();
- const mentionText = variables.get(mentionId);
- if (mentionText !== undefined) {
- const mentionNode = $createMentionNode(mentionText, mentionId);
- node.replace(mentionNode);
- }
- }
- });
- }, [editor, variables]);
-
- return (
-
- triggerFn={checkForMentionMatch}
- onQueryChange={onQueryChange}
- onSelectOption={onSelectOption}
- options={options}
- menuRenderFn={(
- anchorElementRef,
- { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }
- ) => {
- if (anchorElementRef.current === null || options.length === 0) {
- return null;
- }
- return createPortal(
-
- {options.map((option, index) => (
- {
- setHighlightedIndex(index);
- }}
- onClick={() => {
- setHighlightedIndex(index);
- selectOptionAndCleanUp(option);
- }}
- >
- {variables.get(option.key)}
-
- ))}
- ,
- anchorElementRef.current
- );
- }}
- />
- );
-};
-
-const traverseNode = (
- node: LexicalNode,
- callback: (node: LexicalNode) => void
-) => {
- callback(node);
- if ($isElementNode(node)) {
- for (const child of node.getChildren()) {
- traverseNode(child, callback);
- }
- }
-};
-
-const onError = (error: unknown) => {
- // eslint-disable-next-line no-console
- console.error(error);
-};
-
-const rootStyle = css({
- ...textVariants.mono,
- boxSizing: "border-box",
- color: theme.colors.foregroundMain,
- borderRadius: theme.borderRadius[4],
- border: `1px solid ${theme.colors.borderMain}`,
- background: theme.colors.backgroundControls,
- paddingTop: 6,
- paddingBottom: 4,
- paddingRight: theme.spacing[2],
- paddingLeft: theme.spacing[3],
- "&:focus": {
- borderColor: theme.colors.borderFocus,
- outline: `1px solid ${theme.colors.borderFocus}`,
- },
-});
-
-const paragraphStyle = css({
- marginTop: 0,
- marginBottom: 0,
-});
-
-/**
- * The main feature of code editor is to provide completion
- * with specified variables
- *
- * Could be used to edit css as well in the future.
- */
-export const CodeEditor = ({
- readOnly = false,
- variables,
- defaultValue,
- onChange,
- onBlur,
-}: {
- readOnly?: boolean;
- variables: Map;
- defaultValue: string;
- onChange: (newCode: string) => void;
- onBlur?: () => void;
-}) => {
- const initialConfig = {
- namespace: "CodeEditor",
- editable: readOnly === false,
- nodes: [MentionNode],
- theme: {
- root: rootStyle.toString(),
- paragraph: paragraphStyle.toString(),
- },
- editorState: (editor: LexicalEditor) => {
- const root = $getRoot();
- const parser = new DOMParser();
- const dom = parser.parseFromString(`${defaultValue}
`, "text/html");
- const nodes = $generateNodesFromDOM(editor, dom);
- root.append(...nodes);
- },
- onError,
- };
-
- return (
-
- }
- placeholder={null}
- ErrorBoundary={LexicalErrorBoundary}
- />
-
-
- {
- const { editorState: _editorState, ...config } = initialConfig;
- const updater = createEditor(config);
- updater.setEditorState(editorState.clone());
- updater.update(() => {
- const root = $getRoot();
- traverseNode(root, (node) => {
- if ($isMentionNode(node)) {
- node.setTextContent(node.mentionId);
- }
- });
- onChange(root.getTextContent());
- });
- }}
- />
-
- );
-};
diff --git a/apps/builder/app/builder/features/settings-panel/variables.tsx b/apps/builder/app/builder/features/settings-panel/variables.tsx
index 8235d23abd..d4055ddc41 100644
--- a/apps/builder/app/builder/features/settings-panel/variables.tsx
+++ b/apps/builder/app/builder/features/settings-panel/variables.tsx
@@ -43,9 +43,9 @@ import {
$instances,
$props,
$selectedInstanceSelector,
+ $variableValuesByInstanceSelector,
} from "~/shared/nano-states";
import { serverSyncStore } from "~/shared/sync";
-import { CodeEditor } from "./code-editor";
import type { PropValue } from "./shared";
import { getStartingValue } from "./props-section/use-props-logic";
import {
@@ -265,6 +265,14 @@ const $selectedInstanceVariables = computed(
}
);
+const $selectedInstanceVariableValues = computed(
+ [$selectedInstanceSelector, $variableValuesByInstanceSelector],
+ (instanceSelector, variableValuesByInstanceSelector) => {
+ const key = JSON.stringify(instanceSelector);
+ return variableValuesByInstanceSelector.get(key) ?? new Map();
+ }
+);
+
const EmptyList = ({ onAdd }: { onAdd: () => void }) => {
return (
@@ -331,6 +339,7 @@ const ListItem = ({
selected,
deletable,
variable,
+ value,
onSelect,
onEdit,
}: {
@@ -338,6 +347,7 @@ const ListItem = ({
selected: boolean;
deletable: boolean;
variable: DataSource;
+ value: unknown;
onSelect: (variableId: DataSource["id"]) => void;
onEdit: (variable: DataSource) => void;
}) => {
@@ -345,9 +355,9 @@ const ListItem = ({
- {variable.type === "variable"
- ? `${variable.name}: ${formatValuePreview(variable.value.value)}`
- : variable.name}
+ {value === undefined
+ ? variable.name
+ : `${variable.name}: ${formatValuePreview(value)}`}
}
id={variable.id}
@@ -436,13 +446,6 @@ const ListPanel = ({
onChange: (value: undefined | PropValue) => void;
}) => {
const matchedVariables = useStore($selectedInstanceVariables);
- const editorVariables = useMemo(() => {
- const variables = new Map();
- for (const variable of matchedVariables) {
- variables.set(encodeDataSourceVariable(variable.id), variable.name);
- }
- return variables;
- }, [matchedVariables]);
const propExpression = prop?.type === "expression" ? prop?.value ?? "" : "";
const exoressionVariables = useMemo(
() => getExpressionVariables(propExpression),
@@ -451,6 +454,22 @@ const ListPanel = ({
const usedVariables = useStore($usedVariables);
const [expression, setExpression] = useState();
+ const variableValues = useStore($selectedInstanceVariableValues);
+ const editorScope = useMemo(() => {
+ const scope: Record = {};
+ for (const [variableId, variableValue] of variableValues) {
+ scope[encodeDataSourceVariable(variableId)] = variableValue;
+ }
+ return scope;
+ }, [variableValues]);
+ const editorAliases = useMemo(() => {
+ const aliases = new Map();
+ for (const variable of matchedVariables) {
+ aliases.set(encodeDataSourceVariable(variable.id), variable.name);
+ }
+ return aliases;
+ }, [matchedVariables]);
+
return (
- {
// skip when expression is not changed
@@ -515,7 +534,14 @@ const ListPanel = ({
}
try {
- validateExpression(expression);
+ validateExpression(expression, {
+ transformIdentifier: (id) => {
+ if (editorAliases.has(id) === false) {
+ throw Error(`Unknown variable "${id}"`);
+ }
+ return id;
+ },
+ });
} catch (error) {
// @todo show errors
(error as Error).message;
diff --git a/apps/builder/app/builder/shared/expression-editor.tsx b/apps/builder/app/builder/shared/expression-editor.tsx
index 75d3d77be0..b3d6957fec 100644
--- a/apps/builder/app/builder/shared/expression-editor.tsx
+++ b/apps/builder/app/builder/shared/expression-editor.tsx
@@ -12,6 +12,7 @@ import {
drawSelection,
dropCursor,
EditorView,
+ tooltips,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import {
@@ -80,6 +81,9 @@ const VariablesData = Facet.define<{
aliases: Aliases;
}>();
+// completion based on
+// https://github.com/codemirror/lang-javascript/blob/4dcee95aee9386fd2c8ad55f93e587b39d968489/src/complete.ts
+
// Defines a completion source that completes from the given scope
// object (for example `globalThis`). Will enter properties
// of the object when completing properties on a directly-named path.
@@ -118,6 +122,8 @@ const scopeCompletionSource: CompletionSource = (context) => {
/**
* Highlight variables and replace their $ws$dataSource$name like labels
* with user names
+ *
+ * https://codemirror.net/examples/decoration/#atomic-ranges
*/
class VariableWidget extends WidgetType {
@@ -186,46 +192,45 @@ const rootStyle = css({
"& .cm-line": {
padding: 0,
},
- "& .cm-tooltip": {
+});
+
+const autocompletionStyle = css({
+ "&.cm-tooltip.cm-tooltip-autocomplete": {
+ ...textVariants.mono,
border: "none",
backgroundColor: "transparent",
- },
- "& .cm-tooltip.cm-tooltip-autocomplete ul": {
- minWidth: 160,
- maxWidth: 260,
- width: "max-content",
- boxSizing: "border-box",
- borderRadius: theme.borderRadius[6],
- backgroundColor: theme.colors.backgroundMenu,
- border: `1px solid ${theme.colors.borderMain}`,
- boxShadow: `${theme.shadows.menuDropShadow}, inset 0 0 0 1px ${theme.colors.borderMenuInner}`,
- padding: theme.spacing[3],
- "& li": {
- ...textVariants.labelsTitleCase,
- textTransform: "none",
- // outline: "none",
- // cursor: "default",
- position: "relative",
- display: "flex",
- alignItems: "center",
- color: theme.colors.foregroundMain,
+ "& ul": {
+ minWidth: 160,
+ maxWidth: 260,
+ width: "max-content",
+ boxSizing: "border-box",
+ borderRadius: theme.borderRadius[6],
+ backgroundColor: theme.colors.backgroundMenu,
+ border: `1px solid ${theme.colors.borderMain}`,
+ boxShadow: `${theme.shadows.menuDropShadow}, inset 0 0 0 1px ${theme.colors.borderMenuInner}`,
padding: theme.spacing[3],
- borderRadius: theme.borderRadius[3],
- "&[aria-selected]": {
+ "& li": {
+ ...textVariants.labelsTitleCase,
+ textTransform: "none",
+ position: "relative",
+ display: "flex",
+ alignItems: "center",
color: theme.colors.foregroundMain,
- backgroundColor: theme.colors.backgroundItemMenuItemHover,
- },
- "& .cm-completionIcon": {
- display: "none",
- },
- "& .cm-completionLabel": {
- flexGrow: 1,
- },
- "& .cm-completionDetail": {
- overflow: "hidden",
- textOverflow: "ellipsis",
- fontStyle: "normal",
- color: theme.colors.hint,
+ padding: theme.spacing[3],
+ borderRadius: theme.borderRadius[3],
+ "&[aria-selected]": {
+ color: theme.colors.foregroundMain,
+ backgroundColor: theme.colors.backgroundItemMenuItemHover,
+ },
+ "& .cm-completionLabel": {
+ flexGrow: 1,
+ },
+ "& .cm-completionDetail": {
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ fontStyle: "normal",
+ color: theme.colors.hint,
+ },
},
},
},
@@ -293,10 +298,18 @@ export const ExpressionEditor = ({
dropCursor(),
bracketMatching(),
closeBrackets(),
+ EditorView.lineWrapping,
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
javascript({}),
VariablesData.of({ scope, aliases }),
- autocompletion({ override: [scopeCompletionSource] }),
+ // render autocomplete in body
+ // to prevent popover scroll overflow
+ tooltips({ parent: document.body }),
+ autocompletion({
+ override: [scopeCompletionSource],
+ icons: false,
+ tooltipClass: () => autocompletionStyle.toString(),
+ }),
variables,
keymap.of([
...closeBracketsKeymap,
@@ -304,10 +317,16 @@ export const ExpressionEditor = ({
...historyKeymap,
...completionKeymap,
// use tab to trigger completion
- { key: "Tab", run: startCompletion },
+ {
+ key: "Tab",
+ preventDefault: true,
+ stopPropagation: true,
+ run: startCompletion,
+ },
]),
EditorView.editable.of(readOnly === false),
EditorState.readOnly.of(readOnly === true),
+ // https://github.com/uiwjs/react-codemirror/blob/5d7a37245ce70e61f215b77dc42a7eaf295c46e7/core/src/useCodeMirror.ts#L57-L70
EditorView.updateListener.of((update) => {
if (
// prevent invoking callback when focus or selection is changed
@@ -330,7 +349,7 @@ export const ExpressionEditor = ({
}, [scope, aliases, readOnly]);
// update editor with react value
-
+ // https://github.com/uiwjs/react-codemirror/blob/5d7a37245ce70e61f215b77dc42a7eaf295c46e7/core/src/useCodeMirror.ts#L158-L169
useEffect(() => {
const view = viewRef.current;
if (view === undefined) {
diff --git a/apps/builder/app/shared/nano-states/props.test.ts b/apps/builder/app/shared/nano-states/props.test.ts
index ff72478204..7d96b33e99 100644
--- a/apps/builder/app/shared/nano-states/props.test.ts
+++ b/apps/builder/app/shared/nano-states/props.test.ts
@@ -3,7 +3,10 @@ import { cleanStores } from "nanostores";
import type { Instance, Page } from "@webstudio-is/sdk";
import { collectionComponent } from "@webstudio-is/react-sdk";
import { $instances } from "./instances";
-import { $propValuesByInstanceSelector } from "./props";
+import {
+ $propValuesByInstanceSelector,
+ $variableValuesByInstanceSelector,
+} from "./props";
import { $pages, $selectedPageId } from "./pages";
import {
$assets,
@@ -374,3 +377,194 @@ test("access parameter value from variables values", () => {
cleanStores($propValuesByInstanceSelector);
});
+
+test("compute variable values for root", () => {
+ $instances.set(
+ toMap([{ id: "body", type: "instance", component: "Body", children: [] }])
+ );
+ selectPageRoot("body");
+ $dataSources.set(
+ toMap([
+ {
+ id: "variableId",
+ scopeInstanceId: "body",
+ type: "variable",
+ name: "variableName",
+ value: { type: "string", value: "initial" },
+ },
+ ])
+ );
+ $dataSourceVariables.set(new Map([["variableId", "success"]]));
+ $props.set(new Map());
+
+ expect($variableValuesByInstanceSelector.get()).toEqual(
+ new Map([
+ [
+ JSON.stringify(["body"]),
+ new Map([["variableId", "success"]]),
+ ],
+ ])
+ );
+
+ cleanStores($variableValuesByInstanceSelector);
+});
+
+test("nest variable values from root to current instance", () => {
+ $instances.set(
+ toMap([
+ {
+ id: "body",
+ type: "instance",
+ component: "Body",
+ children: [
+ { type: "id", value: "box" },
+ { type: "id", value: "text" },
+ ],
+ },
+ { id: "box", type: "instance", component: "Box", children: [] },
+ { id: "text", type: "instance", component: "Text", children: [] },
+ ])
+ );
+ selectPageRoot("body");
+ $dataSources.set(
+ toMap([
+ {
+ id: "bodyVariableId",
+ scopeInstanceId: "body",
+ type: "variable",
+ name: "bodyVariable",
+ value: { type: "string", value: "" },
+ },
+ {
+ id: "boxVariableId",
+ scopeInstanceId: "box",
+ type: "variable",
+ name: "boxVariable",
+ value: { type: "string", value: "" },
+ },
+ {
+ id: "textVariableId",
+ scopeInstanceId: "text",
+ type: "variable",
+ name: "textVariable",
+ value: { type: "string", value: "" },
+ },
+ ])
+ );
+ $dataSourceVariables.set(
+ new Map([
+ ["bodyVariableId", "bodyValue"],
+ ["boxVariableId", "boxValue"],
+ ["textVariableId", "textValue"],
+ ])
+ );
+ $props.set(new Map());
+
+ expect($variableValuesByInstanceSelector.get()).toEqual(
+ new Map([
+ [
+ JSON.stringify(["body"]),
+ new Map([["bodyVariableId", "bodyValue"]]),
+ ],
+ [
+ JSON.stringify(["box", "body"]),
+ new Map([
+ ["bodyVariableId", "bodyValue"],
+ ["boxVariableId", "boxValue"],
+ ]),
+ ],
+ [
+ JSON.stringify(["text", "body"]),
+ new Map([
+ ["bodyVariableId", "bodyValue"],
+ ["textVariableId", "textValue"],
+ ]),
+ ],
+ ])
+ );
+
+ cleanStores($variableValuesByInstanceSelector);
+});
+
+test("compute item values for collection", () => {
+ $instances.set(
+ toMap([
+ {
+ id: "collection",
+ type: "instance",
+ component: collectionComponent,
+ children: [{ type: "id", value: "box" }],
+ },
+ { id: "box", type: "instance", component: "Box", children: [] },
+ ])
+ );
+ selectPageRoot("collection");
+ $dataSources.set(
+ toMap([
+ {
+ id: "dataId",
+ scopeInstanceId: "collection",
+ type: "variable",
+ name: "collectionItem",
+ value: { type: "json", value: ["apple", "banana", "orange"] },
+ },
+ {
+ id: "itemId",
+ scopeInstanceId: "collection",
+ type: "parameter",
+ name: "collectionItem",
+ },
+ ])
+ );
+ $dataSourceVariables.set(new Map([]));
+ $props.set(
+ toMap([
+ {
+ id: "dataPropId",
+ instanceId: "collection",
+ name: "data",
+ type: "expression",
+ value: "$ws$dataSource$dataId",
+ },
+ {
+ id: "itemPropId",
+ instanceId: "collection",
+ name: "item",
+ type: "parameter",
+ value: "itemId",
+ },
+ ])
+ );
+
+ expect($variableValuesByInstanceSelector.get()).toEqual(
+ new Map([
+ [
+ JSON.stringify(["box", "collection[0]", "collection"]),
+ new Map([
+ ["dataId", ["apple", "banana", "orange"]],
+ ["itemId", "apple"],
+ ]),
+ ],
+ [
+ JSON.stringify(["box", "collection[1]", "collection"]),
+ new Map([
+ ["dataId", ["apple", "banana", "orange"]],
+ ["itemId", "banana"],
+ ]),
+ ],
+ [
+ JSON.stringify(["box", "collection[2]", "collection"]),
+ new Map([
+ ["dataId", ["apple", "banana", "orange"]],
+ ["itemId", "orange"],
+ ]),
+ ],
+ [
+ JSON.stringify(["collection"]),
+ new Map([["dataId", ["apple", "banana", "orange"]]]),
+ ],
+ ])
+ );
+
+ cleanStores($variableValuesByInstanceSelector);
+});
diff --git a/apps/builder/app/shared/nano-states/props.ts b/apps/builder/app/shared/nano-states/props.ts
index fcd9a664dc..40969e5d8a 100644
--- a/apps/builder/app/shared/nano-states/props.ts
+++ b/apps/builder/app/shared/nano-states/props.ts
@@ -233,3 +233,109 @@ export const $propValuesByInstanceSelector = computed(
return propValuesByInstanceSelector;
}
);
+
+export const $variableValuesByInstanceSelector = computed(
+ [$instances, $props, $selectedPage, $dataSources, $dataSourceVariables],
+ (instances, props, page, dataSources, dataSourceVariables) => {
+ const propsByInstanceId = groupBy(
+ props.values(),
+ (prop) => prop.instanceId
+ );
+
+ const variablesByInstanceId = groupBy(
+ dataSources.values(),
+ (dataSource) => dataSource.scopeInstanceId
+ );
+
+ // traverse instances tree and compute props within each instance
+ const variableValuesByInstanceSelector = new Map<
+ Instance["id"],
+ Map
+ >();
+ if (page === undefined) {
+ return variableValuesByInstanceSelector;
+ }
+ const traverseInstances = (
+ instanceSelector: InstanceSelector,
+ parentVariableValues: Map
+ ) => {
+ const [instanceId] = instanceSelector;
+ const instance = instances.get(instanceId);
+ if (instance === undefined) {
+ return;
+ }
+
+ const variableValues = new Map(parentVariableValues);
+ variableValuesByInstanceSelector.set(
+ JSON.stringify(instanceSelector),
+ variableValues
+ );
+ const variables = variablesByInstanceId.get(instanceId);
+ if (variables) {
+ for (const variable of variables) {
+ const value = dataSourceVariables.get(variable.id);
+ if (variable.type === "variable") {
+ variableValues.set(variable.id, value ?? variable.value.value);
+ } else if (value !== undefined) {
+ variableValues.set(variable.id, value);
+ }
+ }
+ }
+
+ const propValues = new Map();
+ const props = propsByInstanceId.get(instanceId);
+ const parameters = new Map();
+ if (props) {
+ for (const prop of props) {
+ if (
+ prop.type === "asset" ||
+ prop.type === "page" ||
+ prop.type === "action"
+ ) {
+ continue;
+ }
+ if (prop.type === "expression") {
+ const value = computeExpression(prop.value, variableValues);
+ if (value !== undefined) {
+ propValues.set(prop.name, value);
+ }
+ continue;
+ }
+ if (prop.type === "parameter") {
+ parameters.set(prop.name, prop.value);
+ continue;
+ }
+ propValues.set(prop.name, prop.value);
+ }
+ }
+
+ if (instance.component === collectionComponent) {
+ const data = propValues.get("data");
+ const itemVariableId = parameters.get("item");
+ if (Array.isArray(data) && itemVariableId !== undefined) {
+ data.forEach((item, index) => {
+ const itemVariableValues = new Map(variableValues);
+ itemVariableValues.set(itemVariableId, item);
+ for (const child of instance.children) {
+ if (child.type === "id") {
+ const indexId = getIndexedInstanceId(instanceId, index);
+ traverseInstances(
+ [child.value, indexId, ...instanceSelector],
+ itemVariableValues
+ );
+ }
+ }
+ });
+ }
+ return;
+ }
+ for (const child of instance.children) {
+ if (child.type === "id") {
+ traverseInstances([child.value, ...instanceSelector], variableValues);
+ }
+ }
+ };
+ traverseInstances([page.rootInstanceId], new Map());
+ return variableValuesByInstanceSelector;
+ }
+);
diff --git a/apps/builder/package.json b/apps/builder/package.json
index 5b40555e0f..f20d45f7fb 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -31,7 +31,6 @@
"@fontsource-variable/manrope": "^5.0.15",
"@fontsource/roboto-mono": "^5.0.13",
"@lexical/headless": "^0.12.2",
- "@lexical/html": "^0.12.2",
"@lexical/link": "^0.12.2",
"@lexical/react": "^0.12.2",
"@lexical/selection": "^0.12.2",
diff --git a/packages/sdk-components-react/src/html-embed.tsx b/packages/sdk-components-react/src/html-embed.tsx
index 0bbf6e8897..f1760e2b96 100644
--- a/packages/sdk-components-react/src/html-embed.tsx
+++ b/packages/sdk-components-react/src/html-embed.tsx
@@ -83,7 +83,10 @@ export const HtmlEmbed = forwardRef((props, ref) => {
const { code, executeScriptOnCanvas, clientOnly, ...rest } = props;
// code can be actually undefined when prop is not provided
- if (code === undefined || code.trim().length === 0) {
+ //
+ // cast code to string in case non-string value is computed
+ // from expression
+ if (code === undefined || String(code).trim().length === 0) {
return ;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a6b7a06441..119413fbf6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -94,9 +94,6 @@ importers:
'@lexical/headless':
specifier: ^0.12.2
version: 0.12.2(lexical@0.12.2)
- '@lexical/html':
- specifier: ^0.12.2
- version: 0.12.2(lexical@0.12.2)
'@lexical/link':
specifier: ^0.12.2
version: 0.12.2(lexical@0.12.2)