fix: pre-fill the test panel JSON args editor and align its placeholder (#10871)

* fix: pre-fill the test panel JSON args editor and align its placeholder

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reseed the JSON args editor when the preprocessor tab is selected

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: seed schema defaults and own-property args in the JSON payload

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow the schema in an untouched JSON payload, ignore same-tab clicks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: latch JSON editor ownership from Monaco, drop the late remounts

The pristine check read the bound `code` value, which trails the buffer by
SimpleEditor's 200ms debounce — a reseed arriving in that window overwrote text
already typed. Latch ownership from Monaco's own change event instead, via a new
undebounced `input` event guarded so `setCode`'s `setValue` does not read as an
edit.

Both `.then(() => argsRender++)` bumps are gone: the arg views now remount at the
tab transition only, and follow the schema in through `initialCode` when
inference resolves, so a remount can no longer land on an in-progress payload.

`FlowPreviewContent.selectInput` overwrote the editor on select but not on
deselect, leaving the abandoned input's payload over reverted args.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-08-27 18:51:08 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 90b40fffc3
commit fb82f36e6d
10 changed files with 239 additions and 42 deletions
+2 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import SchemaForm from '$lib/components/SchemaForm.svelte'
import JsonInputs from '$lib/components/JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import JobLoader from '$lib/components/JobLoader.svelte'
import { Button } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
@@ -161,7 +162,6 @@
let args: Record<string, any> = $state({})
let isValid: boolean = $state(true)
let jsonView: boolean = $state(false)
let jsonEditor: JsonInputs | undefined = $state(undefined)
let schemaHeight = $state(0)
// Test
@@ -1171,20 +1171,17 @@
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={() => {
jsonEditor?.setCode(JSON.stringify(args ?? {}, null, '\t'))
}}
/>
</div>
{#if jsonView}
<div class="py-2" style="height: {Math.max(schemaHeight, 300)}px">
<JsonInputs
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
args = e.detail
}
}}
initialCode={argsToJsonPayload(schema, args)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
@@ -34,6 +34,8 @@
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import Toggle from './Toggle.svelte'
import JsonInputs from './JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import type { Schema } from '$lib/common'
import FlowHistoryJobPicker from './FlowHistoryJobPicker.svelte'
import type { DurationStatus, GraphModuleState } from './graph'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
@@ -264,8 +266,12 @@
previewArgs.val = input
inputSelected = type
preventEscape = true
jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t'))
}
// Deselecting restores the args the same way selecting replaced them, so both branches
// owe the editor an overwrite — it holds a payload for the input being left behind.
jsonEditor?.setCode(
argsToJsonPayload(flowStore.val.schema as Schema | undefined, previewArgs.val)
)
}
export function refresh() {
@@ -510,8 +516,7 @@
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t'))
on:change={() => {
refresh()
}}
/>
@@ -526,6 +531,10 @@
previewArgs.val = e.detail
}
}}
initialCode={argsToJsonPayload(
flowStore.val.schema as Schema | undefined,
previewArgs.val
)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
+37 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
const dispatch = createEventDispatcher()
@@ -8,18 +8,48 @@
updateOnBlur?: boolean
placeholder?: string
selected?: boolean
/** Content the editor opens with, and keeps following while the buffer is untouched — so a
* payload nobody has typed into tracks the schema instead of going stale. The first edit
* hands the buffer to the user and later changes stop overwriting it. */
initialCode?: string
}
let {
updateOnBlur = true,
placeholder = 'Write a JSON payload. The input schema will be inferred.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}',
selected = false
selected = false,
initialCode = ''
}: Props = $props()
let pendingJson = $state('')
let pendingJson = $state(untrack(() => initialCode))
// The last content this component wrote, kept only to skip a reseed that would replace the
// buffer with what it already holds — `setValue` resets the cursor and the undo stack.
let seededCode = untrack(() => initialCode)
// Latched from Monaco's own change event, never from `pendingJson`: that trails the buffer by
// SimpleEditor's debounce, a window in which typed text still looks like the seeded payload
// and a reseed lands on top of it.
let userEdited = false
let simpleEditor: SimpleEditor | undefined = $state(undefined)
let focusTrap: HTMLElement | undefined = $state()
$effect(() => {
const next = initialCode
untrack(() => {
if (next !== seededCode && !userEdited) {
seed(next)
}
})
})
// `SimpleEditor.setCode` cancels the change burst its own `setValue` opens, so reseeding
// never dispatches `select` — the payload reaches `args` only when the user edits it.
function seed(code: string) {
seededCode = code
userEdited = false
pendingJson = code
simpleEditor?.setCode(code)
}
function updatePayloadFromJson(jsonInput: string) {
if (jsonInput === undefined || jsonInput === null || jsonInput.trim() === '') {
dispatch('select', undefined)
@@ -33,8 +63,10 @@
}
}
/** Authoritative overwrite: replaces the buffer whether or not it has been typed into, and
* re-establishes it as the content to keep following. */
export function setCode(code: string) {
simpleEditor?.setCode(code)
seed(code)
}
export function resetSelected(dispatchEvent?: boolean) {
@@ -59,6 +91,7 @@
<div class="h-full rounded-md border">
<SimpleEditor
bind:this={simpleEditor}
on:input={() => (userEdited = true)}
on:focus={() => {
if (updateOnBlur) {
dispatch('focus')
+8 -2
View File
@@ -19,6 +19,7 @@
import { page } from '$app/state'
import { replaceState } from '$app/navigation'
import JsonInputs from '$lib/components/JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import { untrack } from 'svelte'
@@ -53,6 +54,8 @@
args = scriptArgs
psCommonParams = commonParams
reloadArgs++
// `reloadArgs` only keys the form; the JSON editor reads its payload once, at mount.
syncJsonEditor()
}
export async function run(overrideScheduledForStr?: string | undefined | null) {
@@ -199,8 +202,10 @@
return result
}
export function setCode(code: string) {
jsonEditor?.setCode(code)
/** Rewrite the open JSON editor from the current args. Only for args replaced from outside
* the editor: entering the JSON view already starts from whatever `args` holds. */
export function syncJsonEditor() {
jsonEditor?.setCode(argsToJsonPayload(runnable?.schema, args))
}
$effect(() => {
overrideTag
@@ -320,6 +325,7 @@
args = enforceDisabledDefaults(e.detail)
}
}}
initialCode={argsToJsonPayload(runnable.schema, args)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
+56 -16
View File
@@ -122,6 +122,7 @@
import { updateDelegateToGitRepoConfig, insertAdditionalInventories } from '$lib/ansibleUtils'
import { copilotInfo } from '$lib/aiStore'
import JsonInputs from '$lib/components/JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import Toggle from './Toggle.svelte'
import { deepEqual } from 'fast-equals'
import { usePreparedAssetSqlQueries } from '$lib/infer.svelte'
@@ -310,6 +311,10 @@
let moduleTestState: Record<string, { args: Record<string, any>; schema: Schema }> = $state({})
let testPanelArgs: Record<string, any> = $state({})
let testPanelSchema: Schema = $state(emptySchema())
// Bumped whenever the args under test are replaced from outside the arg panel. Both arg
// views key off it: without a bump the JSON editor keeps showing, and on the next
// keystroke commits, the payload it was seeded with for the previous args.
let argsRender = $state(0)
// editorCode is what the editor shows; code always holds the main script content
let editorCode: string = $state(code)
// Sync editorCode when code changes externally (template reset, copilot,
@@ -329,7 +334,13 @@
})
function switchToModule(modulePath: string) {
if (activeModuleTab !== null && modules && activeModuleTab !== modulePath) {
// Re-clicking the tab you are already on is a no-op. Re-running the body would reset this
// module's test state whenever its inference is still pending or has failed (the catch
// leaves `moduleTestState` unwritten), losing both the filled-in args and the arg views.
if (activeModuleTab === modulePath) {
return
}
if (activeModuleTab !== null && modules) {
// Switching from another module: save its content and test state
modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode }
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
@@ -345,13 +356,20 @@
} else {
testPanelArgs = {}
testPanelSchema = emptySchema()
// Inference lands after the bump below, so the editor opens on `{}` and the arg
// views follow the schema in once it arrives. Remounting them again on arrival
// instead would discard anything typed while it was in flight.
inferModuleSchema()
}
argsRender++
}
}
function switchToMain() {
if (activeModuleTab !== null && modules) {
if (activeModuleTab === null) {
return
}
if (modules) {
// Save current module content and test state
modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode }
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
@@ -360,6 +378,7 @@
editorCode = code
lastSyncedCode = code
editor?.setCode(editorCode)
argsRender++
}
// Whether the open file is tested as a runnable of its own. A `__mod` helper
@@ -854,6 +873,7 @@
export function setArgs(nargs: Record<string, any>) {
args = nargs
argsRender++
}
export async function runTest(opts?: { cascade?: boolean; skipDdlGuard?: boolean }) {
@@ -1654,15 +1674,30 @@
$effect(() => {
!hasPreprocessor && (selectedTab = 'main')
})
// `main` and `preprocessor` describe the same args under different schemas; every other tab
// (`diagram`) runs against main's schema, so it collapses into `main` here.
let lastSchemaTab = untrack(() => (selectedTab === 'preprocessor' ? 'preprocessor' : 'main'))
$effect(() => {
// Only depend on selectedTab (preprocessor ↔ main toggle).
// Code changes are handled by the editor on:change handler and
// explicit inferSchema calls (initContent, onMount), so we read
// `code` inside untrack to avoid a redundant double-inference race.
selectedTab && untrack(() => code && inferSchema(code))
selectedTab &&
untrack(() => {
const schemaTab = selectedTab === 'preprocessor' ? 'preprocessor' : 'main'
const switched = schemaTab !== lastSchemaTab
lastSchemaTab = schemaTab
if (!code) return
// Bump on the switch itself, not on the inference it starts: the other tab's schema
// only lands once that resolves, and remounting the arg views then would discard
// anything typed while it was in flight. An untouched editor follows the schema in.
if (switched) {
argsRender++
}
inferSchema(code)
})
})
let argsRender = $state(0)
export async function updateArgs(newArgs: Record<string, any>) {
if (Object.keys(newArgs).length > 0) {
args = { ...newArgs }
@@ -2299,19 +2334,24 @@
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (onModuleArgs) {
testPanelArgs = e.detail
} else {
args = e.detail
{#key argsRender}
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (onModuleArgs) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
}}
initialCode={onModuleArgs
? argsToJsonPayload(testPanelSchema, testPanelArgs)
: argsToJsonPayload(schema, args)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
{/key}
</div>
{:else}
<div class="px-4">
@@ -64,6 +64,9 @@
const CHANGE_TIMEOUT = 200
let changeTimeoutId: number | undefined = undefined
// Monaco fires onDidChangeModelContent synchronously from within `setValue`, so without
// this an authoritative overwrite reads as a user edit on the `input` event.
let applyingCode = false
let divEl: HTMLDivElement | null = null
let editor = $state<meditor.IStandaloneCodeEditor | null>(null)
@@ -74,6 +77,10 @@
let width = $state(0)
let initialized = $state(false)
let placeholderVisible = $state(false)
// Monaco's content origin. The placeholder is a plain overlay on the editor container, so
// without these it sits over the line-number gutter and off the line-1 baseline.
let contentLeft = $state(0)
let contentLineHeight = $state(0)
let mounted = $state(false)
let valueAfterDispose: string | undefined = undefined
@@ -179,7 +186,12 @@
if (ncode != code) {
code = ncode
}
editor?.setValue(ncode)
applyingCode = true
try {
editor?.setValue(ncode)
} finally {
applyingCode = false
}
// setValue emits a change event of its own; drop the burst it opens so an edit
// made right after an authoritative overwrite still counts as a leading change.
cancelPendingChanges()
@@ -454,6 +466,12 @@
changeTimeoutId = undefined
updateCode()
}, CHANGE_TIMEOUT)
// `change` trails the buffer by CHANGE_TIMEOUT, too late for a consumer that has to
// know the moment the buffer stopped being the one it wrote. `input` says only that,
// carrying no value: read `getCode()` for what is on screen.
if (!applyingCode) {
dispatch('input')
}
if (leading) {
updateCode()
}
@@ -533,6 +551,13 @@
}
if (placeholder) {
const syncPlaceholderOrigin = () => {
if (!editor) return
contentLeft = editor.getLayoutInfo().contentLeft
contentLineHeight = editor.getOption(meditor.EditorOption.lineHeight)
}
syncPlaceholderOrigin()
editor.onDidLayoutChange(syncPlaceholderOrigin)
editor.onDidChangeModelContent(() => {
if (!editor) return
const value = editor.getValue()
@@ -755,9 +780,10 @@
{#if placeholder}
<div
id="placeholder"
class="absolute text-gray-500 text-sm pointer-events-none font-mono z-10 {placeholderVisible
class="absolute text-tertiary pointer-events-none font-mono z-10 {placeholderVisible
? ''
: 'hidden'}"
style="left: {contentLeft}px; top: {yPadding}px; font-size: {fontSize}px; line-height: {contentLineHeight}px;"
>
{@html placeholder}
</div>
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { argsToJsonPayload } from './schema'
import type { Schema } from './common'
const schemaOf = (...names: string[]): Schema =>
({
$schema: undefined,
type: 'object',
properties: Object.fromEntries(names.map((n) => [n, { type: 'string' }])),
required: []
}) as Schema
describe('argsToJsonPayload', () => {
it('spells out every schema property in schema order, unset ones as null', () => {
// `0`, `false` and `''` are values, not gaps: only a missing arg becomes `null`.
expect(argsToJsonPayload(schemaOf('a', 'b', 'c'), { c: 0, a: false })).toBe(
JSON.stringify({ a: false, b: null, c: 0 }, null, '\t')
)
})
it('keeps args the schema does not declare, after the declared ones', () => {
expect(argsToJsonPayload(schemaOf('a'), { z: 9, a: 1 })).toBe(
JSON.stringify({ a: 1, z: 9 }, null, '\t')
)
})
it('falls back to the schema default for an absent arg, but not over an explicit null', () => {
// `args` only carries defaults once a `SchemaForm` has mounted for that schema, and the
// JSON view alone never mounts one — seeding `null` there would commit `null` over the
// argument's default on the first keystroke.
const schema = schemaOf('a', 'b')
schema.properties.a.default = 'hi'
schema.properties.b.default = 42
expect(argsToJsonPayload(schema, {})).toBe(JSON.stringify({ a: 'hi', b: 42 }, null, '\t'))
expect(argsToJsonPayload(schema, { a: null })).toBe(
JSON.stringify({ a: null, b: 42 }, null, '\t')
)
})
it('keeps declared args named after Object.prototype members', () => {
// A plain `nargs[key]` read returns the inherited function for an unset `constructor`,
// and `JSON.stringify` drops function-valued properties — the argument would vanish.
expect(argsToJsonPayload(schemaOf('constructor', 'toString', 'ok'), {})).toBe(
JSON.stringify({ constructor: null, toString: null, ok: null }, null, '\t')
)
})
it('keeps undeclared args named after Object.prototype members', () => {
// On a plain `{}` accumulator, `'constructor' in payload` is true before anything is
// assigned to it.
expect(argsToJsonPayload(undefined, { constructor: 'x', toString: 'y', ok: 1 })).toBe(
JSON.stringify({ constructor: 'x', toString: 'y', ok: 1 }, null, '\t')
)
})
it('handles a missing schema or missing args', () => {
expect(argsToJsonPayload(undefined, undefined)).toBe('{}')
expect(argsToJsonPayload(schemaOf('a'), undefined)).toBe(
JSON.stringify({ a: null }, null, '\t')
)
})
})
+30
View File
@@ -52,3 +52,33 @@ export function schemaToObject(schema: Schema, args: Record<string, any>): Objec
})
return object
}
/** Args as the JSON payload the JSON editor starts from. Every schema property is spelled out,
* so an argument with no value yet still shows its name; args the schema does not declare are
* kept, since what the editor holds replaces the args wholesale on the next keystroke. */
export function argsToJsonPayload(
schema: Schema | undefined,
args: Record<string, any> | undefined
): string {
const nargs = args ?? {}
// Null prototype: an arg named after an `Object.prototype` member (`constructor`,
// `toString`) has to be an own key here, or the `in` check below reads it as already
// present and its value never reaches the payload.
const payload: Record<string, any> = Object.create(null)
const props = schema?.properties ?? {}
// Schema order first, so the payload reads like the form it replaces.
for (const key of Object.keys(props)) {
// Own-property read: an arg named after an `Object.prototype` member (`constructor`,
// `toString`) would otherwise come back as the inherited function, which `JSON.stringify`
// drops. An arg that is merely absent falls back to the schema default — `args` only
// carries defaults once a `SchemaForm` has mounted, which the JSON view alone never does.
payload[key] =
(Object.prototype.hasOwnProperty.call(nargs, key) ? nargs[key] : props[key]?.default) ?? null
}
for (const [key, value] of Object.entries(nargs)) {
if (!(key in payload)) {
payload[key] = value
}
}
return JSON.stringify(payload, null, '\t')
}
@@ -732,9 +732,6 @@
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
runForm?.setCode(JSON.stringify(args ?? {}, null, '\t'))
}}
/>
{/if}
</div>
@@ -817,7 +814,7 @@
const nargs = JSON.parse(JSON.stringify(e.detail))
args = nargs
if (jsonView) {
runForm?.setCode(JSON.stringify(args ?? {}, null, '\t'))
runForm?.syncJsonEditor()
}
}}
/>
@@ -388,7 +388,7 @@
if (!held || !current || block?.label !== 'retry' || block['dbt_retry_job']) return
args = { ...current, command: { ...block, dbt_retry_job: held } }
if (jsonView) {
runForm?.setCode(JSON.stringify(args, null, '\t'))
runForm?.syncJsonEditor()
}
})
.catch(() => {})
@@ -430,7 +430,7 @@
}
}
if (jsonView) {
runForm?.setCode(JSON.stringify(args, null, '\t'))
runForm?.syncJsonEditor()
}
}
@@ -934,9 +934,6 @@
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
runForm?.setCode(JSON.stringify(args ?? {}, null, '\t'))
}}
/>
{/if}
</div>
@@ -1051,7 +1048,7 @@
const nargs = JSON.parse(JSON.stringify(e.detail))
args = nargs
if (jsonView) {
runForm?.setCode(JSON.stringify(args ?? {}, null, '\t'))
runForm?.syncJsonEditor()
}
}}
/>