Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -303,15 +303,23 @@ const ParameterForm = forwardRef<
});
ParameterForm.displayName = "ParameterForm";

const saveVariable = (variable: undefined | DataSource, formData: FormData) => {
type ValueVariableType = Extract<
VariableType,
"string" | "number" | "boolean" | "json"
>;

const saveVariable = (
variable: undefined | DataSource,
type: ValueVariableType,
formData: FormData
) => {
const dataSourceId = variable?.id ?? nanoid();
// preserve existing instance scope when edit
const scopeInstanceId =
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
if (scopeInstanceId === undefined) {
return;
}
const type = z.string().parse(formData.get("type"));
const name = z.string().parse(formData.get("name"));
const value = z.string().nullable().parse(formData.get("value"));
let variableValue: Extract<DataSource, { type: "variable" }>["value"];
Expand Down Expand Up @@ -349,13 +357,15 @@ const saveVariable = (variable: undefined | DataSource, formData: FormData) => {
const useValuePanelRef = ({
ref,
variable,
type,
}: {
ref: Ref<undefined | PanelApi>;
variable?: DataSource;
type: ValueVariableType;
}) => {
useImperativeHandle(ref, () => ({
save: (formData) => {
saveVariable(variable, formData);
saveVariable(variable, type, formData);
},
}));
};
Expand All @@ -369,7 +379,7 @@ const StringForm = forwardRef<
}
>(({ variable, value: unknownValue, onChange }, ref) => {
const value = typeof unknownValue === "string" ? unknownValue : "";
useValuePanelRef({ ref, variable });
useValuePanelRef({ ref, variable, type: "string" });
const valueId = useId();
return (
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
Expand Down Expand Up @@ -429,7 +439,7 @@ const NumberForm = forwardRef<
valueRef.current?.setCustomValidity(validateNumberValue(value));
setValueError("");
}, [value]);
useValuePanelRef({ ref, variable });
useValuePanelRef({ ref, variable, type: "number" });
const valueId = useId();
return (
<>
Expand Down Expand Up @@ -465,7 +475,7 @@ const BooleanForm = forwardRef<
}
>(({ variable, value: unknownValue, onChange }, ref) => {
const value = typeof unknownValue === "boolean" ? unknownValue : false;
useValuePanelRef({ ref, variable });
useValuePanelRef({ ref, variable, type: "boolean" });
const valueId = useId();
return (
<>
Expand Down Expand Up @@ -515,7 +525,7 @@ const JsonForm = forwardRef<
valueRef.current?.setCustomValidity(validateJsonValue(value));
setValueError("");
}, [value]);
useValuePanelRef({ ref, variable });
useValuePanelRef({ ref, variable, type: "json" });
return (
<>
<input
Expand Down Expand Up @@ -728,6 +738,7 @@ const VariablePreview = ({
css={{ position: "absolute", inset: 0 }}
>
<Button
type="button"
color="neutral"
disabled={hasPendingResources}
onClick={onLoadData}
Expand Down Expand Up @@ -908,6 +919,7 @@ const VariablePopoverContent = ({
variableType === "graphql-resource") && (
<Tooltip content="Copy resource as cURL command" side="bottom">
<Button
type="button"
aria-label="Copy resource as cURL command"
prefix={<CopyIcon />}
color="ghost"
Expand All @@ -920,6 +932,7 @@ const VariablePopoverContent = ({
variableType === "system-resource") && (
<Tooltip content="Refresh resource data" side="bottom">
<Button
type="button"
aria-label="Refresh resource data"
prefix={<RefreshIcon />}
color="ghost"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ const EmptyVariables = () => {
</Flex>
<Flex justify="center" align="center">
<VariablePopoverTrigger>
<Button prefix={<PlusIcon />}>Create data variable</Button>
<Button type="button" prefix={<PlusIcon />}>
Create data variable
</Button>
</VariablePopoverTrigger>
</Flex>
</Flex>
Expand Down Expand Up @@ -279,6 +281,7 @@ export const VariablesSection = () => {
suffix={
<VariablePopoverTrigger>
<SectionTitleButton
type="button"
prefix={<PlusIcon />}
// open panel when add new varable
onClick={() => {
Expand Down
5 changes: 5 additions & 0 deletions apps/builder/app/builder/shared/binding-popover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ test("evaluateExpressionWithinScope works", () => {
})
).toEqual(2);
});

test("evaluateExpressionWithinScope treats empty expression as undefined", () => {
expect(evaluateExpressionWithinScope("", {})).toBeUndefined();
expect(evaluateExpressionWithinScope(" ", {})).toBeUndefined();
});
31 changes: 22 additions & 9 deletions apps/builder/app/builder/shared/binding-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
formatValuePreview,
type EditorApi,
} from "./expression-editor";
import { normalizeEditorValue } from "~/shared/code-editor-base";

/**
* Check if a value is a primitive that can be safely stringified.
Expand Down Expand Up @@ -78,6 +79,10 @@ export const evaluateExpressionWithinScope = (
expression: string,
scope: Record<string, unknown>
) => {
if (expression.trim() === "") {
return;
}

const variables = new Map<string, unknown>();
for (const [name, value] of Object.entries(scope)) {
const decodedName = decodeDataSourceVariable(name);
Expand All @@ -100,15 +105,16 @@ const BindingPanel = ({
scope: Record<string, unknown>;
aliases: Map<string, string>;
valueError?: string;
value: string;
value?: string;
onChange: () => void;
onSave: (value: string, invalid: boolean) => void;
}) => {
const editorApiRef = useRef<undefined | EditorApi>(undefined);
const [expression, setExpression] = useState(value);
const normalizedValue = normalizeEditorValue(value);
const [expression, setExpression] = useState(normalizedValue);
const usedIdentifiers = useMemo(
() => getExpressionIdentifiers(value),
[value]
() => getExpressionIdentifiers(normalizedValue),
[normalizedValue]
);
const [errorsCount, setErrorsCount] = useState<number>(0);
const [touched, setTouched] = useState(false);
Expand Down Expand Up @@ -372,7 +378,7 @@ export const BindingPopover = ({
aliases: Map<string, string>;
variant: BindingVariant;
validate?: (value: unknown) => undefined | string;
value: string;
value?: string;
onChange: (newValue: string) => void;
onRemove: (evaluatedValue: unknown) => void;
}) => {
Expand All @@ -385,7 +391,10 @@ export const BindingPopover = ({
return;
}

const valueError = validate?.(evaluateExpressionWithinScope(value, scope));
const normalizedValue = normalizeEditorValue(value);
const valueError = validate?.(
evaluateExpressionWithinScope(normalizedValue, scope)
);
return (
<FloatingPanel
placement="left-start"
Expand Down Expand Up @@ -419,7 +428,7 @@ export const BindingPopover = ({
event.preventDefault();
// inline variables and close dialog
const evaluatedValue = evaluateExpressionWithinScope(
value,
normalizedValue,
scope
);

Expand All @@ -443,7 +452,7 @@ export const BindingPopover = ({
scope={scope}
aliases={aliases}
valueError={valueError}
value={value}
value={normalizedValue}
onChange={() => {
hasUnsavedChange.current = true;
}}
Expand All @@ -467,7 +476,11 @@ export const BindingPopover = ({
/>
}
>
<BindingButton variant={variant} error={valueError} value={value} />
<BindingButton
variant={variant}
error={valueError}
value={normalizedValue}
/>
</FloatingPanel>
);
};
14 changes: 14 additions & 0 deletions apps/builder/app/builder/shared/expression-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { javascript } from "@codemirror/lang-javascript";
import { allowedArrayMethods, allowedStringMethods } from "@webstudio-is/sdk";
import {
completionPath,
formatValue,
generateCompletionOptions,
getCompletionTarget,
getCompletionReplacement,
Expand Down Expand Up @@ -75,6 +76,19 @@ const getCompletionPath = (value: string) => {
return completionPath(new CompletionContext(state, value.length, true));
};

describe("formatValue", () => {
test("formats missing preview values as an empty editor string", () => {
expect(formatValue(undefined)).toEqual("");
expect(typeof formatValue(undefined)).toEqual("string");
});

test("always returns a string for editor values", () => {
expect(formatValue(() => {})).toEqual("");
expect(formatValue("text")).toEqual('"text"');
expect(formatValue(0)).toEqual("0");
});
});

describe("generateCompletionOptions", () => {
describe("object properties", () => {
test("returns object properties with preview values", () => {
Expand Down
15 changes: 10 additions & 5 deletions apps/builder/app/builder/shared/expression-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
EditorDialogButton,
EditorDialogControl,
type EditorApi,
normalizeEditorValue,
} from "~/shared/code-editor-base";
import {
decodeDataVariableName,
Expand All @@ -50,8 +51,11 @@ import {

export type { EditorApi };

export const formatValue = (value: unknown) => {
export const formatValue = (value: unknown): string => {
try {
if (value === undefined) {
return "";
}
if (Array.isArray(value)) {
// format arrays as multiline
return JSON.stringify(value, null, 2);
Expand All @@ -61,7 +65,7 @@ export const formatValue = (value: unknown) => {
// syntax highlighting as expression instead of block
return `(${JSON.stringify(value, null, 2)})`;
}
return JSON.stringify(value);
return JSON.stringify(value) ?? "";
} catch {
// show nothing when value is invalid
return "";
Expand Down Expand Up @@ -574,10 +578,11 @@ export const ExpressionEditor = ({
color?: "error";
autoFocus?: boolean;
readOnly?: boolean;
value: string;
value?: string;
onChange: (value: string) => void;
onChangeComplete: (value: string) => void;
}) => {
const normalizedValue = normalizeEditorValue(value);
const { nameById, idByName } = useMemo(() => {
const nameById = new Map();
const idByName = new Map();
Expand All @@ -592,10 +597,10 @@ export const ExpressionEditor = ({
}, [aliases]);
const expressionWithUnsetVariables = useMemo(() => {
return unsetExpressionVariables({
expression: value,
expression: normalizedValue,
unsetNameById: nameById,
});
}, [value, nameById]);
}, [normalizedValue, nameById]);
const scopeWithUnsetVariables = useMemo(() => {
const newScope: typeof scope = {};
for (const [identifier, value] of Object.entries(scope)) {
Expand Down
7 changes: 6 additions & 1 deletion apps/builder/app/shared/code-editor-base.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EditorSelection } from "@codemirror/state";
import { expect, test } from "vitest";
import { clampEditorSelection } from "./code-editor-base";
import { clampEditorSelection, normalizeEditorValue } from "./code-editor-base";

test("clampEditorSelection preserves selection within document length", () => {
const selection = EditorSelection.create([
Expand Down Expand Up @@ -35,3 +35,8 @@ test("clampEditorSelection clamps selection to document length", () => {
]);
expect(clampedSelection.mainIndex).toBe(selection.mainIndex);
});

test("normalizeEditorValue defaults undefined to empty string", () => {
expect(normalizeEditorValue(undefined)).toBe("");
expect(normalizeEditorValue("value")).toBe("value");
});
11 changes: 7 additions & 4 deletions apps/builder/app/shared/code-editor-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export const clampEditorSelection = (
);
};

export const normalizeEditorValue = (value: undefined | string) => value ?? "";

export const getCodeEditorCssVars = ({
minHeight,
maxHeight,
Expand Down Expand Up @@ -239,7 +241,7 @@ type EditorContentProps = {
autoFocus?: boolean;
invalid?: boolean;
showShortcuts?: boolean;
value: string;
value?: string;
onChange: (value: string) => void;
onChangeComplete: (value: string) => void;
};
Expand Down Expand Up @@ -385,15 +387,16 @@ export const EditorContent = ({
if (view === undefined) {
return;
}
const nextValue = normalizeEditorValue(value);
// prevent updating when editor has the same state
// and can be the source of new value
if (value === view.state.doc.toString()) {
if (nextValue === view.state.doc.toString()) {
return;
}

view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
selection: clampEditorSelection(view.state.selection, value.length),
changes: { from: 0, to: view.state.doc.length, insert: nextValue },
selection: clampEditorSelection(view.state.selection, nextValue.length),
annotations: [ExternalChange.of(true)],
});
}, [value]);
Expand Down
Loading
Loading