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 eddb14c702e8..58bdd5f2ccc8 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 657d744466b6..c4eefeba521d 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 111b1679e3d8..000000000000 --- 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 000000000000..a335d159e958 --- /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 e0705c1c8a01..82fe73db2032 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 0e94ea322c52..894746fc689a 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} />