mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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. <img width="504" alt="Screenshot 2025-01-30 at 12 35 21" src="https://github.com/user-attachments/assets/6d042f45-ca4a-49b9-9259-7be2a3c74bdd" />
This commit is contained in:
1 parent
b72a74c825
commit
53ace3be3c
6 files changed
+649
-182
No files matched your search
@@ -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<string, unknown>) => {
|
||||
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 });
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={nameId}>Name</Label>
|
||||
<InputErrorsTooltip errors={error ? [error] : undefined}>
|
||||
<InputField
|
||||
<Combobox<string>
|
||||
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
|
||||
<br />
|
||||
a variable that has been used
|
||||
<br />
|
||||
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 });
|
||||
});
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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`}</$.Text>
|
||||
</$.Box>
|
||||
</$.Body>
|
||||
);
|
||||
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`}</$.Text>
|
||||
</$.Box>
|
||||
</$.Body>
|
||||
);
|
||||
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`}></$.Text>
|
||||
</$.Box>
|
||||
</$.Body>
|
||||
);
|
||||
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}
|
||||
></$.Text>
|
||||
</$.Box>
|
||||
</$.Body>
|
||||
);
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -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<DataSource["name"], DataSource["id"]>();
|
||||
// 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<DataSource["name"]>();
|
||||
const [{ instance: startingInstance }] = instancePath;
|
||||
const instanceIds = findTreeInstanceIdsExcludingSlotDescendants(
|
||||
instances,
|
||||
startingInstance.id
|
||||
);
|
||||
const resourceIds = new Set<Resource["id"]>();
|
||||
|
||||
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<Resource["id"]>();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -177,6 +177,7 @@ export const ComboboxRoot = (props: ComponentProps<typeof Popover>) => {
|
||||
};
|
||||
|
||||
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 = <Item,>({
|
||||
type ComboboxProps<Item> = UseComboboxProps<Item> &
|
||||
Pick<
|
||||
ComponentProps<typeof InputField>,
|
||||
"autoFocus" | "placeholder" | "color" | "suffix" | "onBlur"
|
||||
| "inputRef"
|
||||
| "autoFocus"
|
||||
| "placeholder"
|
||||
| "name"
|
||||
| "color"
|
||||
| "suffix"
|
||||
| "onBlur"
|
||||
| "onInvalid"
|
||||
>;
|
||||
|
||||
export const Combobox = <Item,>({
|
||||
autoFocus,
|
||||
getDescription,
|
||||
// input props
|
||||
inputRef,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
name,
|
||||
color,
|
||||
suffix,
|
||||
onBlur,
|
||||
onInvalid,
|
||||
...props
|
||||
}: ComboboxProps<Item>) => {
|
||||
const combobox = useCombobox<Item>(props);
|
||||
@@ -471,17 +483,21 @@ export const Combobox = <Item,>({
|
||||
<ComboboxAnchor>
|
||||
<InputField
|
||||
{...combobox.getInputProps()}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
color={color}
|
||||
suffix={
|
||||
suffix ?? (
|
||||
suffix ??
|
||||
(props.getItems().length > 0 && (
|
||||
<Flex>
|
||||
<NestedInputButton {...combobox.getToggleButtonProps()} />
|
||||
</Flex>
|
||||
)
|
||||
))
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onInvalid={onInvalid}
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent>
|
||||
|
||||
Reference in new issue
Block a user