From 1ec2c974746dea6aa3c8a580bb168d332df41c2c Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 18 Jul 2026 08:45:51 +0100 Subject: [PATCH 1/3] feat: auto-format expressions on open and blur --- .../app/builder/shared/binding-popover.tsx | 4 +- .../app/builder/shared/expression-editor.tsx | 2 + .../app/shared/code-editor-base.test.ts | 62 ++++++++++++++++++- apps/builder/app/shared/code-editor-base.tsx | 53 +++++++++++++++- packages/sdk/package.json | 1 + packages/sdk/src/expression.test.ts | 36 +++++++++++ packages/sdk/src/expression.ts | 30 +++++++++ pnpm-lock.yaml | 9 +++ 8 files changed, 191 insertions(+), 6 deletions(-) diff --git a/apps/builder/app/builder/shared/binding-popover.tsx b/apps/builder/app/builder/shared/binding-popover.tsx index 339cde2ca9e8..3625472ed151 100644 --- a/apps/builder/app/builder/shared/binding-popover.tsx +++ b/apps/builder/app/builder/shared/binding-popover.tsx @@ -189,8 +189,8 @@ const BindingPanel = ({ updateExpression(value); setTouched(false); }} - onChangeComplete={() => { - onSave(expression, errorsCount > 0); + onChangeComplete={(value) => { + onSave(value, errorsCount > 0); setTouched(true); }} /> diff --git a/apps/builder/app/builder/shared/expression-editor.tsx b/apps/builder/app/builder/shared/expression-editor.tsx index fd6f8671b92e..e769d9a267ee 100644 --- a/apps/builder/app/builder/shared/expression-editor.tsx +++ b/apps/builder/app/builder/shared/expression-editor.tsx @@ -29,6 +29,7 @@ import { javascript } from "@codemirror/lang-javascript"; import { textVariants, css, rawTheme } from "@webstudio-is/design-system"; import { decodeDataVariableId, + formatExpression, lintExpression, allowedStringMethods, allowedArrayMethods, @@ -668,6 +669,7 @@ export const ExpressionEditor = ({ invalid={color === "error"} readOnly={readOnly} autoFocus={autoFocus} + format={formatExpression} value={expressionWithUnsetVariables} onChange={(newValue: string) => { const expressionWithRestoredVariables = restoreExpressionVariables({ diff --git a/apps/builder/app/shared/code-editor-base.test.ts b/apps/builder/app/shared/code-editor-base.test.ts index 321b81b6ddea..376ce0583c78 100644 --- a/apps/builder/app/shared/code-editor-base.test.ts +++ b/apps/builder/app/shared/code-editor-base.test.ts @@ -1,6 +1,12 @@ import { EditorSelection } from "@codemirror/state"; -import { expect, test } from "vitest"; -import { clampEditorSelection, normalizeEditorValue } from "./code-editor-base"; +import { EditorView } from "@codemirror/view"; +import { expect, test, vi } from "vitest"; +import { + clampEditorSelection, + formatEditorValue, + formatEditorValueForSync, + normalizeEditorValue, +} from "./code-editor-base"; test("clampEditorSelection preserves selection within document length", () => { const selection = EditorSelection.create([ @@ -40,3 +46,55 @@ test("normalizeEditorValue defaults undefined to empty string", () => { expect(normalizeEditorValue(undefined)).toBe(""); expect(normalizeEditorValue("value")).toBe("value"); }); + +test("formats the initial editor value", () => { + expect( + formatEditorValueForSync({ + value: "unformatted", + format: (value) => `formatted ${value}`, + isInitialValue: true, + hasFocus: true, + }) + ).toEqual("formatted unformatted"); +}); + +test("formats external updates only while the editor is unfocused", () => { + const format = (value: string) => `formatted ${value}`; + + expect( + formatEditorValueForSync({ + value: "unformatted", + format, + isInitialValue: false, + hasFocus: true, + }) + ).toEqual("unformatted"); + expect( + formatEditorValueForSync({ + value: "unformatted", + format, + isInitialValue: false, + hasFocus: false, + }) + ).toEqual("formatted unformatted"); +}); + +test("formats the current document as an editor change", () => { + const onChange = vi.fn(); + const view = new EditorView({ + doc: "unformatted", + extensions: [ + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChange(update.state.doc.toString()); + } + }), + ], + }); + + expect(formatEditorValue(view, (value) => `formatted ${value}`)).toEqual( + "formatted unformatted" + ); + expect(onChange).toHaveBeenCalledWith("formatted unformatted"); + view.destroy(); +}); diff --git a/apps/builder/app/shared/code-editor-base.tsx b/apps/builder/app/shared/code-editor-base.tsx index 561a568a4f6e..8af4255f19bc 100644 --- a/apps/builder/app/shared/code-editor-base.tsx +++ b/apps/builder/app/shared/code-editor-base.tsx @@ -73,6 +73,23 @@ export const clampEditorSelection = ( export const normalizeEditorValue = (value: undefined | string) => value ?? ""; +export const formatEditorValueForSync = ({ + value, + format, + isInitialValue, + hasFocus, +}: { + value: string; + format: undefined | ((value: string) => string); + isInitialValue: boolean; + hasFocus: boolean; +}) => { + if (format && (isInitialValue || hasFocus === false)) { + return format(value); + } + return value; +}; + export const getCodeEditorCssVars = ({ minHeight, maxHeight, @@ -241,11 +258,31 @@ type EditorContentProps = { autoFocus?: boolean; invalid?: boolean; showShortcuts?: boolean; + format?: (value: string) => string; value?: string; onChange: (value: string) => void; onChangeComplete: (value: string) => void; }; +export const formatEditorValue = ( + view: EditorView, + format: undefined | ((value: string) => string) +) => { + const value = view.state.doc.toString(); + const formattedValue = format?.(value) ?? value; + if (formattedValue === value) { + return value; + } + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: formattedValue, + }, + }); + return formattedValue; +}; + export const EditorContent = ({ editorApiRef, extensions = [], @@ -253,6 +290,7 @@ export const EditorContent = ({ autoFocus = false, invalid = false, showShortcuts = false, + format, value, onChange, onChangeComplete, @@ -261,11 +299,14 @@ export const EditorContent = ({ const editorRef = useRef(null); const viewRef = useRef(undefined); + const hasInitialValueRef = useRef(false); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const onChangeCompleteRef = useRef(onChangeComplete); onChangeCompleteRef.current = onChangeComplete; + const formatRef = useRef(format); + formatRef.current = format; useEffect(() => { const abortController = new AbortController(); @@ -368,7 +409,9 @@ export const EditorContent = ({ }), EditorView.domEventHandlers({ blur() { - onChangeCompleteRef.current(view.state.doc.toString()); + onChangeCompleteRef.current( + formatEditorValue(view, formatRef.current) + ); }, cut(event) { // prevent catching cut by global copy paste @@ -387,7 +430,13 @@ export const EditorContent = ({ if (view === undefined) { return; } - const nextValue = normalizeEditorValue(value); + const nextValue = formatEditorValueForSync({ + value: normalizeEditorValue(value), + format: formatRef.current, + isInitialValue: hasInitialValueRef.current === false, + hasFocus: view.hasFocus, + }); + hasInitialValueRef.current = true; // prevent updating when editor has the same state // and can be the source of new value if (nextValue === view.state.doc.toString()) { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f4c07ca76220..1ce2b0931f26 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -71,6 +71,7 @@ "@webstudio-is/wsauth": "workspace:*", "acorn": "^8.14.1", "acorn-walk": "^8.3.4", + "astring": "^1.9.0", "change-case": "^5.4.4", "json-schema-typed": "^8.0.2", "reserved-identifiers": "^1.0.0", diff --git a/packages/sdk/src/expression.test.ts b/packages/sdk/src/expression.test.ts index 0aa421acd732..42bf3da0a78c 100644 --- a/packages/sdk/src/expression.test.ts +++ b/packages/sdk/src/expression.test.ts @@ -5,6 +5,7 @@ import { decodeDataVariableId, encodeDataVariableId, executeExpression, + formatExpression, isLiteralExpression, lintExpression, transpileExpression, @@ -16,6 +17,41 @@ import { allowedStringMethods, } from "./expression"; +describe("format expression", () => { + test("formats complex expressions", () => { + expect( + formatExpression( + `condition?{title:"Hello",items:[1,2,3]}:{title:"Goodbye",items:[]}` + ) + ).toMatchInlineSnapshot(` + "condition ? { + title: \"Hello\", + items: [1, 2, 3] + } : { + title: \"Goodbye\", + items: [] + }" + `); + }); + + test("preserves parentheses around object expressions", () => { + expect(formatExpression(`({title:"Hello",items:[1,2,3]})`)) + .toMatchInlineSnapshot(` + "({ + title: \"Hello\", + items: [1, 2, 3] + })" + `); + }); + + test("leaves invalid and commented expressions unchanged", () => { + expect(formatExpression("value +")).toEqual("value +"); + expect(formatExpression("value /* keep this comment */ + 1")).toEqual( + "value /* keep this comment */ + 1" + ); + }); +}); + describe("lint expression", () => { const error = (from: number, to: number, message: string): Diagnostic => ({ from, diff --git a/packages/sdk/src/expression.ts b/packages/sdk/src/expression.ts index 87293e052156..f73257f9f8b6 100644 --- a/packages/sdk/src/expression.ts +++ b/packages/sdk/src/expression.ts @@ -7,6 +7,7 @@ import { parseExpressionAt, } from "acorn"; import { simple } from "acorn-walk"; +import { generate } from "astring"; import type { DataSource, DataSources } from "./schema/data-sources"; import type { Scope } from "./scope"; import { ROOT_INSTANCE_ID } from "./instances-utils"; @@ -103,6 +104,35 @@ export const allowedStringMethods = new Set( export const allowedArrayMethods = new Set(arrayMethodReturnKindByName.keys()); +export const formatExpression = (expression: string) => { + let hasComments = false; + try { + const program = parse(`(${expression})`, { + ecmaVersion: "latest", + sourceType: "module", + onComment: () => { + hasComments = true; + }, + }); + const statement = program.body[0]; + if ( + hasComments || + statement?.type !== "ExpressionStatement" || + program.body.length !== 1 + ) { + return expression; + } + + const formatted = generate(statement.expression); + if (statement.expression.type === "ObjectExpression") { + return `(${formatted})`; + } + return formatted; + } catch { + return expression; + } +}; + const getVariableValue = ( variableValues: undefined | VariableValues, name: Identifier["name"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c44a21ff49f..17673e445333 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2220,6 +2220,9 @@ importers: acorn-walk: specifier: ^8.3.4 version: 8.3.4 + astring: + specifier: ^1.9.0 + version: 1.9.0 change-case: specifier: ^5.4.4 version: 5.4.4 @@ -6722,6 +6725,10 @@ packages: resolution: {integrity: sha512-97a+l2LBU3Op3bBQEff79i/E4jMD2ZLFD8rHx9B6mXyB2uQwhJQYfiDqUwtfjF4QA1F2qs//N6Cw8LetMbQjcw==} hasBin: true + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -15554,6 +15561,8 @@ snapshots: astring@1.8.4: {} + astring@1.9.0: {} + asynckit@0.4.0: {} atomically@1.7.0: {} From 688e2f87d2391190cef191f4891faf3cd670a536 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Mon, 20 Jul 2026 15:39:21 +0100 Subject: [PATCH 2/3] fix: preserve resource URL expression formatting --- .../settings-panel/resource-panel.test.tsx | 46 ++++++++++++++ .../settings-panel/resource-panel.tsx | 2 +- .../app/builder/shared/binding-popover.tsx | 4 +- .../app/builder/shared/expression-editor.tsx | 2 - .../app/shared/code-editor-base.test.ts | 62 +------------------ apps/builder/app/shared/code-editor-base.tsx | 53 +--------------- packages/sdk/package.json | 1 - packages/sdk/src/expression.test.ts | 36 ----------- packages/sdk/src/expression.ts | 30 --------- pnpm-lock.yaml | 9 --- 10 files changed, 53 insertions(+), 192 deletions(-) create mode 100644 apps/builder/app/builder/features/settings-panel/resource-panel.test.tsx diff --git a/apps/builder/app/builder/features/settings-panel/resource-panel.test.tsx b/apps/builder/app/builder/features/settings-panel/resource-panel.test.tsx new file mode 100644 index 000000000000..4199539227ed --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.test.tsx @@ -0,0 +1,46 @@ +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, expect, test } from "vitest"; +import { TooltipProvider } from "@webstudio-is/design-system"; +import { UrlField } from "./resource-panel"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; + +afterEach(() => { + act(() => root?.unmount()); + root = undefined; + document.body.innerHTML = ""; +}); + +test("preserves resource URL expression newlines in form data", () => { + const expression = `"https://example.com/" +\n "path"`; + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( +
+ + {}} + onCurlPaste={() => {}} + /> + +
+ ); + }); + + const form = container.querySelector("form"); + if (form === null) { + throw new Error("Expected resource form"); + } + expect(new FormData(form).get("url")).toEqual(expression); +}); 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 f444bf0a1450..0adc756f6244 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -117,7 +117,7 @@ export const UrlField = ({ - +