From 53ace3be3cd03866a422d84dd65efe80bee4ca8f Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 31 Jan 2025 10:21:00 +0400 Subject: [PATCH] feat: unset variables when create or rename data variables (#4808) Ref https://github.com/webstudio-is/webstudio/issues/4768 Now user can write expressions with not existing variables and create these variables later. Builder will automatically traverse all expressions and bind to newly created variable by name. Additionally user will see suggestions with all unset variables in subtree. Screenshot 2025-01-30 at 12 35 21 --- .../settings-panel/resource-panel.tsx | 62 ++-- .../settings-panel/variable-popover.tsx | 103 ++++-- .../builder/app/shared/data-variables.test.ts | 115 ------- .../app/shared/data-variables.test.tsx | 308 ++++++++++++++++++ apps/builder/app/shared/data-variables.ts | 215 ++++++++++++ .../design-system/src/components/combobox.tsx | 28 +- 6 files changed, 649 insertions(+), 182 deletions(-) delete mode 100644 apps/builder/app/shared/data-variables.test.ts create mode 100644 apps/builder/app/shared/data-variables.test.tsx diff --git a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx index eddb14c702..58bdd5f2cc 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -36,7 +36,6 @@ import { import { TrashIcon, InfoCircleIcon, PlusIcon } from "@webstudio-is/icons"; import { isFeatureEnabled } from "@webstudio-is/feature-flags"; import { humanizeString } from "~/shared/string-utils"; -import { serverSyncStore } from "~/shared/sync"; import { $dataSources, $resources, @@ -55,10 +54,12 @@ import { } from "~/builder/shared/code-editor-base"; import { parseCurl, type CurlRequest } from "./curl"; import { - $selectedInstance, $selectedInstanceKey, + $selectedInstancePath, $selectedPage, } from "~/shared/awareness"; +import { updateWebstudioData } from "~/shared/instance-utils"; +import { restoreTreeVariablesMutable } from "~/shared/data-variables"; const validateUrl = (value: string, scope: Record) => { const evaluatedValue = evaluateExpressionWithinScope(value, scope); @@ -582,10 +583,11 @@ export const ResourceForm = forwardRef< useImperativeHandle(ref, () => ({ save: (formData) => { - const instanceId = $selectedInstance.get()?.id; - if (instanceId === undefined) { + const instancePath = $selectedInstancePath.get(); + if (instancePath === undefined) { return; } + const [{ instance }] = instancePath; const name = z.string().parse(formData.get("name")); const newResource: Resource = { id: resource?.id ?? nanoid(), @@ -598,18 +600,16 @@ export const ResourceForm = forwardRef< const newVariable: DataSource = { id: variable?.id ?? nanoid(), // preserve existing instance scope when edit - scopeInstanceId: variable?.scopeInstanceId ?? instanceId, + scopeInstanceId: variable?.scopeInstanceId ?? instance.id, name, type: "resource", resourceId: newResource.id, }; - serverSyncStore.createTransaction( - [$dataSources, $resources], - (dataSources, resources) => { - dataSources.set(newVariable.id, newVariable); - resources.set(newResource.id, newResource); - } - ); + updateWebstudioData((data) => { + data.dataSources.set(newVariable.id, newVariable); + data.resources.set(newResource.id, newResource); + restoreTreeVariablesMutable({ instancePath, ...data }); + }); }, })); @@ -715,10 +715,11 @@ export const SystemResourceForm = forwardRef< useImperativeHandle(ref, () => ({ save: (formData) => { - const instanceId = $selectedInstance.get()?.id; - if (instanceId === undefined) { + const instancePath = $selectedInstancePath.get(); + if (instancePath === undefined) { return; } + const [{ instance }] = instancePath; const name = z.string().parse(formData.get("name")); const newResource: Resource = { id: resource?.id ?? nanoid(), @@ -731,18 +732,16 @@ export const SystemResourceForm = forwardRef< const newVariable: DataSource = { id: variable?.id ?? nanoid(), // preserve existing instance scope when edit - scopeInstanceId: variable?.scopeInstanceId ?? instanceId, + scopeInstanceId: variable?.scopeInstanceId ?? instance.id, name, type: "resource", resourceId: newResource.id, }; - serverSyncStore.createTransaction( - [$dataSources, $resources], - (dataSources, resources) => { - dataSources.set(newVariable.id, newVariable); - resources.set(newResource.id, newResource); - } - ); + updateWebstudioData((data) => { + data.dataSources.set(newVariable.id, newVariable); + data.resources.set(newResource.id, newResource); + restoreTreeVariablesMutable({ instancePath, ...data }); + }); }, })); @@ -825,10 +824,11 @@ export const GraphqlResourceForm = forwardRef< useImperativeHandle(ref, () => ({ save: (formData) => { - const instanceId = $selectedInstance.get()?.id; - if (instanceId === undefined) { + const instancePath = $selectedInstancePath.get(); + if (instancePath === undefined) { return; } + const [{ instance }] = instancePath; const name = z.string().parse(formData.get("name")); const body = generateObjectExpression( new Map([ @@ -848,18 +848,16 @@ export const GraphqlResourceForm = forwardRef< const newVariable: DataSource = { id: variable?.id ?? nanoid(), // preserve existing instance scope when edit - scopeInstanceId: variable?.scopeInstanceId ?? instanceId, + scopeInstanceId: variable?.scopeInstanceId ?? instance.id, name, type: "resource", resourceId: newResource.id, }; - serverSyncStore.createTransaction( - [$dataSources, $resources], - (dataSources, resources) => { - dataSources.set(newVariable.id, newVariable); - resources.set(newResource.id, newResource); - } - ); + updateWebstudioData((data) => { + data.dataSources.set(newVariable.id, newVariable); + data.resources.set(newResource.id, newResource); + restoreTreeVariablesMutable({ instancePath, ...data }); + }); }, })); diff --git a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx index 657d744466..c4eefeba52 100644 --- a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx +++ b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx @@ -19,6 +19,7 @@ import { CopyIcon, RefreshIcon, UpgradeIcon } from "@webstudio-is/icons"; import { Box, Button, + Combobox, DialogClose, DialogTitle, DialogTitleActions, @@ -55,9 +56,10 @@ import { invalidateResource, getComputedResource, $userPlanFeatures, + $instances, + $props, } from "~/shared/nano-states"; -import { serverSyncStore } from "~/shared/sync"; -import { $selectedInstance } from "~/shared/awareness"; +import { $selectedInstance, $selectedInstancePath } from "~/shared/awareness"; import { BindingPopoverProvider } from "~/builder/shared/binding-popover"; import { EditorDialog, @@ -70,6 +72,11 @@ import { SystemResourceForm, } from "./resource-panel"; import { generateCurl } from "./curl"; +import { updateWebstudioData } from "~/shared/instance-utils"; +import { + findUnsetVariableNames, + restoreTreeVariablesMutable, +} from "~/shared/data-variables"; const $variablesByName = computed( [$selectedInstance, $dataSources], @@ -84,6 +91,22 @@ const $variablesByName = computed( } ); +const $unsetVariableNames = computed( + [$selectedInstancePath, $instances, $props, $dataSources, $resources], + (instancePath, instances, props, dataSources, resources) => { + if (instancePath === undefined) { + return []; + } + return findUnsetVariableNames({ + instancePath, + instances, + props, + dataSources, + resources, + }); + } +); + const NameField = ({ variableId, defaultValue, @@ -95,6 +118,7 @@ const NameField = ({ const [error, setError] = useState(""); const nameId = useId(); const variablesByName = useStore($variablesByName); + const unsetVariableNames = useStore($unsetVariableNames); const validateName = useCallback( (value: string) => { if ( @@ -110,22 +134,39 @@ const NameField = ({ }, [variablesByName, variableId] ); + const [value, setValue] = useState(defaultValue); useEffect(() => { - ref.current?.setCustomValidity(validateName(defaultValue)); - }, [defaultValue, validateName]); + ref.current?.setCustomValidity(validateName(value)); + }, [value, validateName]); return ( - inputRef={ref} name="name" id={nameId} - autoComplete="off" color={error ? "error" : undefined} - defaultValue={defaultValue} - onChange={(event) => { - event.target.setCustomValidity(validateName(event.target.value)); + itemToString={(item) => item ?? ""} + getDescription={() => ( + <> + Enter a new variable or select +
+ a variable that has been used +
+ in expressions but not yet created + + )} + getItems={() => unsetVariableNames} + value={value} + onItemSelect={(newValue) => { + ref.current?.setCustomValidity(validateName(newValue)); + setValue(newValue); + setError(""); + }} + onChange={(newValue = "") => { + ref.current?.setCustomValidity(validateName(newValue)); + setValue(newValue); setError(""); }} onBlur={() => ref.current?.checkValidity()} @@ -247,13 +288,18 @@ const ParameterForm = forwardRef< >(({ variable }, ref) => { useImperativeHandle(ref, () => ({ save: (formData) => { + const instancePath = $selectedInstancePath.get(); + if (instancePath === undefined) { + return; + } // only existing parameter variables can be renamed if (variable === undefined) { return; } const name = z.string().parse(formData.get("name")); - serverSyncStore.createTransaction([$dataSources], (dataSources) => { - dataSources.set(variable.id, { ...variable, name }); + updateWebstudioData((data) => { + data.dataSources.set(variable.id, { ...variable, name }); + restoreTreeVariablesMutable({ instancePath, ...data }); }); }, })); @@ -272,30 +318,29 @@ const useValuePanelRef = ({ }) => { useImperativeHandle(ref, () => ({ save: (formData) => { - const instanceId = $selectedInstance.get()?.id; - if (instanceId === undefined) { + const instancePath = $selectedInstancePath.get(); + if (instancePath === undefined) { return; } + const [{ instance: selectedInstance }] = instancePath; const dataSourceId = variable?.id ?? nanoid(); // preserve existing instance scope when edit - const scopeInstanceId = variable?.scopeInstanceId ?? instanceId; + const scopeInstanceId = variable?.scopeInstanceId ?? selectedInstance.id; const name = z.string().parse(formData.get("name")); - serverSyncStore.createTransaction( - [$dataSources, $resources], - (dataSources, resources) => { - // cleanup resource when value variable is set - if (variable?.type === "resource") { - resources.delete(variable.resourceId); - } - dataSources.set(dataSourceId, { - id: dataSourceId, - scopeInstanceId, - name, - type: "variable", - value: variableValue, - }); + updateWebstudioData((data) => { + // cleanup resource when value variable is set + if (variable?.type === "resource") { + data.resources.delete(variable.resourceId); } - ); + data.dataSources.set(dataSourceId, { + id: dataSourceId, + scopeInstanceId, + name, + type: "variable", + value: variableValue, + }); + restoreTreeVariablesMutable({ instancePath, ...data }); + }); }, })); }; diff --git a/apps/builder/app/shared/data-variables.test.ts b/apps/builder/app/shared/data-variables.test.ts deleted file mode 100644 index 111b1679e3..0000000000 --- a/apps/builder/app/shared/data-variables.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { expect, test, vi } from "vitest"; -import { - computeExpression, - decodeDataVariableName, - encodeDataVariableName, - restoreExpressionVariables, - unsetExpressionVariables, -} from "./data-variables"; - -test("encode data variable name when necessary", () => { - expect(encodeDataVariableName("formState")).toEqual("formState"); - expect(encodeDataVariableName("Collection Item")).toEqual( - "Collection$32$Item" - ); - expect(encodeDataVariableName("$my$Variable")).toEqual("$36$my$36$Variable"); -}); - -test("dencode data variable name", () => { - expect(decodeDataVariableName(encodeDataVariableName("formState"))).toEqual( - "formState" - ); - expect( - decodeDataVariableName(encodeDataVariableName("Collection Item")) - ).toEqual("Collection Item"); -}); - -test("dencode data variable name with dollar sign", () => { - expect( - decodeDataVariableName(encodeDataVariableName("$my$Variable")) - ).toEqual("$my$Variable"); - expect(decodeDataVariableName("$my$Variable")).toEqual("$my$Variable"); -}); - -test("unset expression variables", () => { - expect( - unsetExpressionVariables({ - expression: `$ws$dataSource$myId + arbitaryVariable`, - unsetNameById: new Map([["myId", "My Variable"]]), - }) - ).toEqual("My$32$Variable + arbitaryVariable"); -}); - -test("ignore not existing variables in expressions", () => { - expect( - unsetExpressionVariables({ - expression: `$ws$dataSource$myId + arbitaryVariable`, - unsetNameById: new Map(), - }) - ).toEqual("$ws$dataSource$myId + arbitaryVariable"); -}); - -test("restore expression variables", () => { - expect( - restoreExpressionVariables({ - expression: `My$32$Variable + missingVariable`, - maskedIdByName: new Map([["My Variable", "myId"]]), - }) - ).toEqual("$ws$dataSource$myId + missingVariable"); -}); - -test("compute expression with decoded ids", () => { - expect( - computeExpression("$ws$dataSource$myId", new Map([["myId", "value"]])) - ).toEqual("value"); -}); - -test("compute expression with decoded names", () => { - expect( - computeExpression("My$32$Name", new Map([["My Name", "value"]])) - ).toEqual("value"); -}); - -test("compute expression when invalid syntax", () => { - // prevent error message in test report - const spy = vi.spyOn(console, "error"); - spy.mockImplementationOnce(() => {}); - expect(computeExpression("https://github.com", new Map())).toEqual(undefined); - expect(spy).toHaveBeenCalledOnce(); - spy.mockRestore(); -}); - -test("compute expression with nested field of undefined without error", () => { - const spy = vi.spyOn(console, "error"); - const variables = new Map([["myVariable", undefined]]); - expect(computeExpression("myVariable.field", variables)).toEqual(undefined); - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); -}); - -test("compute literal expression when variable is json object", () => { - const jsonObject = { hello: "world", subObject: { world: "hello" } }; - const variables = new Map([["jsonVariable", jsonObject]]); - expect(computeExpression("`${jsonVariable}`", variables)).toEqual( - `{"hello":"world","subObject":{"world":"hello"}}` - ); - expect(computeExpression("`${jsonVariable.subObject}`", variables)).toEqual( - `{"world":"hello"}` - ); -}); - -test("compute literal expression when object is frozen", () => { - const jsonObject = Object.freeze({ - hello: "world", - subObject: { world: "hello" }, - }); - const variables = new Map([["jsonVariable", jsonObject]]); - expect(computeExpression("`${jsonVariable.subObject}`", variables)).toEqual( - `{"world":"hello"}` - ); -}); - -test("compute unset variables as undefined", () => { - expect(computeExpression(`a`, new Map())).toEqual(undefined); - expect(computeExpression("`${a}`", new Map())).toEqual("undefined"); -}); diff --git a/apps/builder/app/shared/data-variables.test.tsx b/apps/builder/app/shared/data-variables.test.tsx new file mode 100644 index 0000000000..a335d159e9 --- /dev/null +++ b/apps/builder/app/shared/data-variables.test.tsx @@ -0,0 +1,308 @@ +import { expect, test, vi } from "vitest"; +import { + computeExpression, + decodeDataVariableName, + encodeDataVariableName, + findUnsetVariableNames, + restoreExpressionVariables, + restoreTreeVariablesMutable, + unsetExpressionVariables, +} from "./data-variables"; +import { + $, + ActionValue, + expression, + renderData, + ResourceValue, + Variable, +} from "@webstudio-is/template"; +import type { InstancePath } from "./awareness"; +import type { Instances } from "@webstudio-is/sdk"; + +test("encode data variable name when necessary", () => { + expect(encodeDataVariableName("formState")).toEqual("formState"); + expect(encodeDataVariableName("Collection Item")).toEqual( + "Collection$32$Item" + ); + expect(encodeDataVariableName("$my$Variable")).toEqual("$36$my$36$Variable"); +}); + +test("dencode data variable name", () => { + expect(decodeDataVariableName(encodeDataVariableName("formState"))).toEqual( + "formState" + ); + expect( + decodeDataVariableName(encodeDataVariableName("Collection Item")) + ).toEqual("Collection Item"); +}); + +test("dencode data variable name with dollar sign", () => { + expect( + decodeDataVariableName(encodeDataVariableName("$my$Variable")) + ).toEqual("$my$Variable"); + expect(decodeDataVariableName("$my$Variable")).toEqual("$my$Variable"); +}); + +test("unset expression variables", () => { + expect( + unsetExpressionVariables({ + expression: `$ws$dataSource$myId + arbitaryVariable`, + unsetNameById: new Map([["myId", "My Variable"]]), + }) + ).toEqual("My$32$Variable + arbitaryVariable"); +}); + +test("ignore not existing variables in expressions", () => { + expect( + unsetExpressionVariables({ + expression: `$ws$dataSource$myId + arbitaryVariable`, + unsetNameById: new Map(), + }) + ).toEqual("$ws$dataSource$myId + arbitaryVariable"); +}); + +test("restore expression variables", () => { + expect( + restoreExpressionVariables({ + expression: `My$32$Variable + missingVariable`, + maskedIdByName: new Map([["My Variable", "myId"]]), + }) + ).toEqual("$ws$dataSource$myId + missingVariable"); +}); + +test("compute expression with decoded ids", () => { + expect( + computeExpression("$ws$dataSource$myId", new Map([["myId", "value"]])) + ).toEqual("value"); +}); + +test("compute expression with decoded names", () => { + expect( + computeExpression("My$32$Name", new Map([["My Name", "value"]])) + ).toEqual("value"); +}); + +test("compute expression when invalid syntax", () => { + // prevent error message in test report + const spy = vi.spyOn(console, "error"); + spy.mockImplementationOnce(() => {}); + expect(computeExpression("https://github.com", new Map())).toEqual(undefined); + expect(spy).toHaveBeenCalledOnce(); + spy.mockRestore(); +}); + +test("compute expression with nested field of undefined without error", () => { + const spy = vi.spyOn(console, "error"); + const variables = new Map([["myVariable", undefined]]); + expect(computeExpression("myVariable.field", variables)).toEqual(undefined); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); +}); + +test("compute literal expression when variable is json object", () => { + const jsonObject = { hello: "world", subObject: { world: "hello" } }; + const variables = new Map([["jsonVariable", jsonObject]]); + expect(computeExpression("`${jsonVariable}`", variables)).toEqual( + `{"hello":"world","subObject":{"world":"hello"}}` + ); + expect(computeExpression("`${jsonVariable.subObject}`", variables)).toEqual( + `{"world":"hello"}` + ); +}); + +test("compute literal expression when object is frozen", () => { + const jsonObject = Object.freeze({ + hello: "world", + subObject: { world: "hello" }, + }); + const variables = new Map([["jsonVariable", jsonObject]]); + expect(computeExpression("`${jsonVariable.subObject}`", variables)).toEqual( + `{"world":"hello"}` + ); +}); + +test("compute unset variables as undefined", () => { + expect(computeExpression(`a`, new Map())).toEqual(undefined); + expect(computeExpression("`${a}`", new Map())).toEqual("undefined"); +}); + +const getInstancePath = ( + instances: Instances, + instanceSelector: string[] +): InstancePath => { + const instancePath: InstancePath = []; + for (let index = 0; index < instanceSelector.length; index += 1) { + const instanceId = instanceSelector[index]; + const instance = instances.get(instanceId); + if (instance) { + instancePath.push({ + instance, + instanceSelector: instanceSelector.slice(index), + }); + } + } + return instancePath; +}; + +test("find unset variable names", () => { + const resourceVariable = new ResourceValue("resourceVariable", { + url: expression`six`, + method: "post", + headers: [{ name: "auth", value: expression`seven` }], + body: expression`eight`, + }); + const resourceProp = new ResourceValue("resourceProp", { + url: expression`nine`, + method: "post", + headers: [{ name: "auth", value: expression`ten` }], + body: expression`eleven`, + }); + const data = renderData( + <$.Body ws:id="body" data-prop={expression`two`}> + <$.Box ws:id="box" data-prop={expression`three`}> + <$.Text + ws:id="text" + data-variables={expression`${resourceVariable}`} + data-resource={resourceProp} + data-action={new ActionValue(["five"], expression`four + five`)} + >{expression`one`} + + + ); + expect( + findUnsetVariableNames({ + instancePath: getInstancePath(data.instances, ["body"]), + ...data, + }) + ).toEqual([ + "one", + "two", + "three", + "four", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + ]); +}); + +test("restore tree variables in children", () => { + const oneBody = new Variable("one", "one value of body"); + const oneBox = new Variable("one", "one value of box"); + const data = renderData( + <$.Body ws:id="body" data-variables={expression`${oneBody}`}> + <$.Box ws:id="box" data-variables={expression`${oneBox}`}> + <$.Text ws:id="text">{expression`one`} + + + ); + restoreTreeVariablesMutable({ + instancePath: getInstancePath(data.instances, ["box", "body"]), + ...data, + }); + expect(data.dataSources.get("1")).toEqual( + expect.objectContaining({ name: "one", scopeInstanceId: "box" }) + ); + expect(data.instances.get("text")?.children).toEqual([ + { type: "expression", value: "$ws$dataSource$1" }, + ]); +}); + +test("restore tree variables in props", () => { + const oneBody = new Variable("one", "one value of body"); + const oneBox = new Variable("one", "one value of box"); + const twoBox = new Variable("two", "two value of box"); + const data = renderData( + <$.Body ws:id="body" data-variables={expression`${oneBody}`}> + <$.Box + ws:id="box" + data-variables={expression`${oneBox} ${twoBox}`} + data-one={expression`one`} + data-action={new ActionValue(["one"], expression`one + two + three`)} + > + <$.Text ws:id="text" data-two={expression`one + two + three`}> + + + ); + restoreTreeVariablesMutable({ + instancePath: getInstancePath(data.instances, ["box", "body"]), + ...data, + }); + expect(data.dataSources.get("1")).toEqual( + expect.objectContaining({ name: "one", scopeInstanceId: "box" }) + ); + expect(data.dataSources.get("2")).toEqual( + expect.objectContaining({ name: "two", scopeInstanceId: "box" }) + ); + expect(data.props.get("box:data-one")?.value).toEqual("$ws$dataSource$1"); + expect(data.props.get("text:data-two")?.value).toEqual( + "$ws$dataSource$1 + $ws$dataSource$2 + three" + ); + expect(data.props.get("box:data-action")?.value).toEqual([ + { type: "execute", args: ["one"], code: "one + $ws$dataSource$2 + three" }, + ]); +}); + +test("restore tree variables in resources", () => { + const oneBody = new Variable("one", "one value of body"); + const oneBox = new Variable("one", "one value of box"); + const resourceVariable = new ResourceValue("resourceVariable", { + url: expression`one + 1`, + method: "post", + headers: [{ name: "auth", value: expression`one + 1` }], + body: expression`one + 1`, + }); + const resourceProp = new ResourceValue("resourceProp", { + url: expression`one + 2`, + method: "post", + headers: [{ name: "auth", value: expression`one + 2` }], + body: expression`one + 2`, + }); + const data = renderData( + <$.Body ws:id="body" data-variables={expression`${oneBody}`}> + <$.Box ws:id="box" data-variables={expression`${oneBox}`}> + <$.Text + ws:id="text" + data-variables={expression`${resourceVariable}`} + data-resource={resourceProp} + > + + + ); + restoreTreeVariablesMutable({ + instancePath: getInstancePath(data.instances, ["box", "body"]), + ...data, + }); + expect(data.dataSources.get("1")).toEqual( + expect.objectContaining({ name: "one", scopeInstanceId: "box" }) + ); + expect(data.props.get("text:data-variables")?.value).toEqual( + `$ws$dataSource$2` + ); + expect(data.dataSources.get("2")).toEqual( + expect.objectContaining({ + name: "resourceVariable", + scopeInstanceId: "text", + resourceId: "resource:2", + }) + ); + expect(data.resources.get("resource:2")).toEqual({ + id: "resource:2", + name: "resourceVariable", + url: "$ws$dataSource$1 + 1", + method: "post", + headers: [{ name: "auth", value: "$ws$dataSource$1 + 1" }], + body: "$ws$dataSource$1 + 1", + }); + expect(data.props.get("text:data-resource")?.value).toEqual(`resource:3`); + expect(data.resources.get("resource:3")).toEqual({ + id: "resource:3", + name: "resourceProp", + url: "$ws$dataSource$1 + 2", + method: "post", + headers: [{ name: "auth", value: "$ws$dataSource$1 + 2" }], + body: "$ws$dataSource$1 + 2", + }); +}); diff --git a/apps/builder/app/shared/data-variables.ts b/apps/builder/app/shared/data-variables.ts index e0705c1c8a..82fe73db20 100644 --- a/apps/builder/app/shared/data-variables.ts +++ b/apps/builder/app/shared/data-variables.ts @@ -1,13 +1,20 @@ import { type DataSource, + type DataSources, + type Instances, + type Props, + Resource, + type Resources, decodeDataVariableId, encodeDataVariableId, + findTreeInstanceIdsExcludingSlotDescendants, transpileExpression, } from "@webstudio-is/sdk"; import { createJsonStringifyProxy, isPlainObject, } from "@webstudio-is/sdk/runtime"; +import type { InstancePath } from "./awareness"; const allowedJsChars = /[A-Za-z_]/; @@ -173,3 +180,211 @@ export const computeExpression = ( console.error(error); } }; + +export const findMaskedVariables = ({ + instancePath, + dataSources, +}: { + instancePath: InstancePath; + dataSources: DataSources; +}) => { + const maskedVariables = new Map(); + // start from the root to descendant + // so child variables override parent variables + for (const { instance } of instancePath.slice().reverse()) { + for (const dataSource of dataSources.values()) { + if (dataSource.scopeInstanceId === instance.id) { + maskedVariables.set(dataSource.name, dataSource.id); + } + } + } + return maskedVariables; +}; + +export const findUnsetVariableNames = ({ + instancePath, + instances, + props, + dataSources, + resources, +}: { + instancePath: InstancePath; + instances: Instances; + props: Props; + dataSources: DataSources; + resources: Resources; +}) => { + const unsetVariables = new Set(); + const [{ instance: startingInstance }] = instancePath; + const instanceIds = findTreeInstanceIdsExcludingSlotDescendants( + instances, + startingInstance.id + ); + const resourceIds = new Set(); + + const collectUnsetVariables = ( + expression: string, + exclude: string[] = [] + ) => { + transpileExpression({ + expression, + replaceVariable: (identifier) => { + const id = decodeDataVariableId(identifier); + if (id === undefined && exclude.includes(identifier) === false) { + unsetVariables.add(decodeDataVariableName(identifier)); + } + }, + }); + }; + + for (const instance of instances.values()) { + if (instanceIds.has(instance.id) === false) { + continue; + } + for (const child of instance.children) { + if (child.type === "expression") { + collectUnsetVariables(child.value); + } + } + } + + for (const prop of props.values()) { + if (instanceIds.has(prop.instanceId) === false) { + continue; + } + if (prop.type === "expression") { + collectUnsetVariables(prop.value); + continue; + } + if (prop.type === "action") { + for (const action of prop.value) { + collectUnsetVariables(action.code, action.args); + } + continue; + } + if (prop.type === "resource") { + resourceIds.add(prop.value); + continue; + } + } + + for (const dataSource of dataSources.values()) { + if ( + instanceIds.has(dataSource.scopeInstanceId) && + dataSource.type === "resource" + ) { + resourceIds.add(dataSource.resourceId); + } + } + + for (const resource of resources.values()) { + if (resourceIds.has(resource.id) === false) { + continue; + } + collectUnsetVariables(resource.url); + for (const header of resource.headers) { + collectUnsetVariables(header.value); + } + if (resource.body) { + collectUnsetVariables(resource.body); + } + } + return Array.from(unsetVariables); +}; + +export const restoreTreeVariablesMutable = ({ + instancePath, + instances, + props, + dataSources, + resources, +}: { + instancePath: InstancePath; + instances: Instances; + props: Props; + dataSources: DataSources; + resources: Resources; +}) => { + const maskedVariables = findMaskedVariables({ instancePath, dataSources }); + const [{ instance: startingInstance }] = instancePath; + const instanceIds = findTreeInstanceIdsExcludingSlotDescendants( + instances, + startingInstance.id + ); + const resourceIds = new Set(); + + for (const instance of instances.values()) { + if (instanceIds.has(instance.id) === false) { + continue; + } + for (const child of instance.children) { + if (child.type === "expression") { + child.value = restoreExpressionVariables({ + expression: child.value, + maskedIdByName: maskedVariables, + }); + } + } + } + + for (const prop of props.values()) { + if (instanceIds.has(prop.instanceId) === false) { + continue; + } + if (prop.type === "expression") { + prop.value = restoreExpressionVariables({ + expression: prop.value, + maskedIdByName: maskedVariables, + }); + continue; + } + if (prop.type === "action") { + for (const action of prop.value) { + const maskedVariablesWithoutArgs = new Map(maskedVariables); + for (const arg of action.args) { + maskedVariablesWithoutArgs.delete(arg); + } + action.code = restoreExpressionVariables({ + expression: action.code, + maskedIdByName: maskedVariablesWithoutArgs, + }); + } + continue; + } + if (prop.type === "resource") { + resourceIds.add(prop.value); + continue; + } + } + + for (const dataSource of dataSources.values()) { + if ( + instanceIds.has(dataSource.scopeInstanceId) && + dataSource.type === "resource" + ) { + resourceIds.add(dataSource.resourceId); + } + } + + for (const resource of resources.values()) { + if (resourceIds.has(resource.id) === false) { + continue; + } + resource.url = restoreExpressionVariables({ + expression: resource.url, + maskedIdByName: maskedVariables, + }); + for (const header of resource.headers) { + header.value = restoreExpressionVariables({ + expression: header.value, + maskedIdByName: maskedVariables, + }); + } + if (resource.body) { + resource.body = restoreExpressionVariables({ + expression: resource.body, + maskedIdByName: maskedVariables, + }); + } + } +}; diff --git a/packages/design-system/src/components/combobox.tsx b/packages/design-system/src/components/combobox.tsx index 0e94ea322c..894746fc68 100644 --- a/packages/design-system/src/components/combobox.tsx +++ b/packages/design-system/src/components/combobox.tsx @@ -177,6 +177,7 @@ export const ComboboxRoot = (props: ComponentProps) => { }; const StyledPopoverContent = styled(PopoverContent, { + minWidth: "var(--radix-popper-anchor-width)", "&[data-side=top]": { "--ws-combobox-description-display-top": "block", "--ws-combobox-description-order": 0, @@ -443,16 +444,27 @@ export const useCombobox = ({ type ComboboxProps = UseComboboxProps & Pick< ComponentProps, - "autoFocus" | "placeholder" | "color" | "suffix" | "onBlur" + | "inputRef" + | "autoFocus" + | "placeholder" + | "name" + | "color" + | "suffix" + | "onBlur" + | "onInvalid" >; export const Combobox = ({ - autoFocus, getDescription, + // input props + inputRef, + autoFocus, placeholder, + name, color, suffix, onBlur, + onInvalid, ...props }: ComboboxProps) => { const combobox = useCombobox(props); @@ -471,17 +483,21 @@ export const Combobox = ({ 0 && ( - ) + )) } + onBlur={onBlur} + onInvalid={onInvalid} />