feat: build a React raw app from the script and flow detail pages (#10337)

* feat: build a React raw app from the script and flow detail pages

* fix: keep generated raw-app state and setter names unique

* fix: make the generated app readable on dark and handle labeled enums

* fix: reserve the undefined binding in generated raw apps

* fix: mask password args, keep __proto__ args, and enforce required inputs

* fix: quote non-identifier arg names, preserve JSX entities, support resource args

* fix: JSON-quote generated arg keys and start resource fields empty

* fix: render array enums as multi-selects and let optional enums be omitted

* fix: stop the generated template naming Math/Array and omit untouched optional json

* fix: enforce required on array-enum multiselects
This commit is contained in:
Ruben Fiszel
2026-07-27 12:53:43 +02:00
committed by GitHub
parent be5e3bbfc4
commit a8ef98edff
7 changed files with 1068 additions and 1115 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest'
import {
RESERVED_LOCALS,
createRawAppFromFlow,
createRawAppFromScript
} from './createRawAppFromScript'
describe('createRawAppFromScript', () => {
it('builds a path runnable and a form calling it', () => {
const app = createRawAppFromScript('u/dev/greet_user', 'Greet a user', {
type: 'object',
order: ['name', 'age', 'mode'],
required: ['name', 'age'],
properties: {
name: { type: 'string' },
age: { type: 'integer', default: 42 },
mode: { type: 'string', enum: ['fast', 'slow'] }
}
})
expect(app.summary).toBe('Greet a user')
expect(app.value.runnables).toEqual({
greet_user: {
name: 'u/dev/greet_user',
type: 'path',
runType: 'script',
path: 'u/dev/greet_user',
schema: expect.objectContaining({ type: 'object' }),
fields: {}
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [name, setName] = useState('')")
expect(appTsx).toContain("const [ageText, setAgeText] = useState('42')")
// Required args are passed unconditionally so they satisfy the non-optional
// type `genWmillTs` derives from the same schema.
expect(appTsx).toContain('age: Number(ageText)')
expect(appTsx).toContain('mode,')
expect(appTsx).toContain('await backend.greet_user({')
expect(appTsx).toContain('<option value="slow">slow</option>')
})
it('lets an emptied optional number input mean "unset"', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
properties: { n: { type: 'number' } }
})
expect(app.value.files['/App.tsx']).toContain("n: nText === '' ? undefined : Number(nText)")
})
it('renames arguments that would shadow the component locals', () => {
const app = createRawAppFromFlow('u/dev/f', 'Flow', {
type: 'object',
required: ['result'],
properties: { result: { type: 'string' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [result_, setResult_] = useState('')")
expect(appTsx).toContain('result: result_')
expect(app.value.runnables['f'].runType).toBe('flow')
})
it('keeps every generated binding unique and syntactically valid', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
order: ['foo', 'setFoo', 'class'],
properties: {
foo: { type: 'string' },
setFoo: { type: 'string' },
class: { type: 'string' }
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [foo, setFoo] = useState('')")
expect(appTsx).toContain("const [setFoo_, setSetFoo_] = useState('')")
expect(appTsx).toContain("const [class_, setClass_] = useState('')")
const declared = [...appTsx.matchAll(/const \[(\w+), (\w+)\]/g)].flatMap((m) => [m[1], m[2]])
expect(new Set(declared).size).toBe(declared.length)
})
it('does not let an argument shadow a global the generated body calls', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
order: ['JSON', 'eval'],
properties: { JSON: { type: 'string' }, eval: { type: 'string' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [JSON_, setJSON_] = useState('')")
expect(appTsx).toContain("const [eval_, setEval_] = useState('')")
// The result panel must still reach the real global.
expect(appTsx).toContain('JSON.stringify(result, null, 2)')
})
it('keeps the undefined sentinel reachable when an argument is named undefined', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
order: ['undefined', 'n'],
properties: { undefined: { type: 'string' }, n: { type: 'number' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [undefined_, setUndefined_] = useState('')")
expect(appTsx).toContain('undefined: undefined_')
// The omitted-optional sentinel and the render guards must still be the
// real global, not the field's value.
expect(appTsx).toContain("nText === '' ? undefined : Number(nText)")
expect(appTsx).toContain('{result !== undefined &&')
expect(appTsx).toContain('{error !== undefined &&')
})
it('masks password arguments and declares them sensitive on the runnable', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['token'],
properties: { token: { type: 'string', password: true }, plain: { type: 'string' } }
})
expect(app.value.files['/App.tsx']).toContain('type="password"')
// The policy's `sensitive_inputs` is derived from these fields, so without
// them the secret is stored in the job args in the clear.
expect(app.value.runnables['s'].fields).toEqual({
token: { type: 'user', value: undefined, sensitive: true }
})
})
it('passes a __proto__ argument as an own property', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['__proto__'],
// Computed key: a plain `__proto__:` here would set this literal's
// prototype and the property would not exist at all.
properties: { ['__proto__']: { type: 'number' } }
})
expect(app.value.files['/App.tsx']).toContain('["__proto__"]: Number(__proto__Text)')
})
it('omits blank optional text instead of sending an empty string', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['req'],
properties: { req: { type: 'string' }, opt: { type: 'string' } }
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("opt: opt === '' ? undefined : opt")
expect(appTsx).toContain('req,')
// The required marker has to be enforced, not just drawn.
expect(appTsx).toMatch(/type="text"\n\t+required\n\t+value=\{req\}/)
})
it('lets a resource argument through as a user-supplied resource', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['db'],
properties: { db: { type: 'object', format: 'resource-postgresql' } }
})
// Without `allowUserResources` the backend swaps the submitted `$res:`
// reference for a placeholder and the argument never resolves.
expect(app.value.runnables['s'].fields).toEqual({
db: { type: 'user', value: undefined, allowUserResources: true }
})
const appTsx = app.value.files['/App.tsx']
// Empty, not a bare `$res:`: an untouched optional field must read as
// omitted and an untouched required one must trip the `required` check.
expect(appTsx).toContain("const [db, setDb] = useState('')")
expect(appTsx).toContain('resource path, e.g. $res:u/user/my_postgresql')
})
it('omits untouched optional object and array fields', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['reqObj'],
properties: {
reqObj: { type: 'object' },
optObj: { type: 'object' },
optArr: { type: 'array' }
}
})
const appTsx = app.value.files['/App.tsx']
// A pre-filled `{}` could never reach the omission branch, so an untouched
// field would override the runnable's own default.
expect(appTsx).toContain("const [optObjText, setOptObjText] = useState('')")
expect(appTsx).toContain("const [optArrText, setOptArrText] = useState('')")
expect(appTsx).toContain("const [reqObjText, setReqObjText] = useState('{}')")
})
it('quotes argument names that cannot be bare property keys', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ["user's-name"],
properties: { "user's-name": { type: 'string' } }
})
// `str` would pick a template literal here, which is not a legal property
// name, so the module would not compile.
expect(app.value.files['/App.tsx']).toContain('"user\'s-name": user_s_name')
})
// Every global the template names is an identifier a schema property could
// shadow, so each one has to be reserved.
it('reserves every global the generated source names', () => {
const app = createRawAppFromScript('u/dev/s', 'T', {
type: 'object',
required: ['a', 'tags'],
properties: {
a: { type: 'string' },
b: { type: 'number' },
c: { type: 'object' },
d: { type: 'boolean' },
tags: { type: 'array', items: { type: 'string' }, enum: ['x', 'y'] },
res: { type: 'object', format: 'resource-postgresql' },
pw: { type: 'string', password: true }
}
})
const src = app.value.files['/App.tsx']
const globals = [
'Array',
'BigInt',
'Boolean',
'Date',
'Error',
'Infinity',
'JSON',
'Map',
'Math',
'NaN',
'Number',
'Object',
'Promise',
'RegExp',
'Set',
'String',
'Symbol',
'console',
'globalThis',
'isNaN',
'parseFloat',
'parseInt',
'undefined'
]
const named = globals.filter((g) => new RegExp(`(?<![.\\w'"])${g}\\b`).test(src))
expect(named.filter((g) => !RESERVED_LOCALS.includes(g))).toEqual([])
})
it('keeps an array enum an array', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['tags'],
properties: {
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], default: ['a'] }
}
})
const appTsx = app.value.files['/App.tsx']
// `genWmillTs` types this `string[]`, so scalar state would not compile.
expect(appTsx).toContain("const [tags, setTags] = useState(['a'] as string[])")
// `required` does constrain a `<select multiple>`, so the marker is enforced.
expect(appTsx).toMatch(/multiple\n\t+required/)
expect(appTsx).toContain('[...e.target.selectedOptions].map((o) => o.value)')
expect(appTsx).toContain('tags,')
})
it('lets an optional enum fall back to the runnable default', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
required: ['req'],
properties: {
req: { type: 'string', enum: ['x', 'y'] },
opt: { type: 'string', enum: ['x', 'y'] }
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain("const [req, setReq] = useState('x')")
expect(appTsx).toContain("const [opt, setOpt] = useState('')")
expect(appTsx).toContain("opt: opt === '' ? undefined : opt")
expect(appTsx).toContain('<option value=""></option>')
})
it('passes enum values through JSX unchanged', () => {
const app = createRawAppFromScript('u/dev/s', 'Tom &amp; Jerry', {
type: 'object',
properties: { mode: { type: 'string', enum: ['a&amp;b', 'plain'] } }
})
const appTsx = app.value.files['/App.tsx']
// A plain attribute/text would let JSX decode the entity, so the runnable
// would receive `a&b` instead of the schema's `a&amp;b`.
expect(appTsx).toContain("<option value={'a&amp;b'}>{'a&amp;b'}</option>")
expect(appTsx).toContain('<h1 className="app-title">{\'Tom &amp; Jerry\'}</h1>')
})
it('keeps value and label apart for labeled enums', () => {
const app = createRawAppFromScript('u/dev/s', undefined, {
type: 'object',
properties: {
mode: {
type: 'string',
enum: [
{ value: 'fast', label: 'Fast (cached)' },
{ value: 'slow', label: 'Slow (fresh)' }
]
}
}
})
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain('<option value="fast">Fast (cached)</option>')
expect(appTsx).toContain('<option value="slow">Slow (fresh)</option>')
// Optional, so it seeds blank and offers an empty option; see the
// optional-enum case below.
expect(appTsx).toContain("const [mode, setMode] = useState('')")
expect(appTsx).not.toContain('[object Object]')
})
})
@@ -0,0 +1,720 @@
import { forbiddenIds } from '$lib/components/flows/idUtils'
import { react19Template } from '$lib/components/raw_apps/templates'
/** Shape the `/apps_raw/add` import handoff consumes (`$importStore` /
* `sessionStorage.rawAppImport`). */
export type RawAppImport = {
summary: string
value: {
files: Record<string, string>
runnables: Record<string, any>
}
}
type FieldKind =
| 'text'
| 'password'
| 'resource'
| 'number'
| 'boolean'
| 'enum'
| 'multienum'
| 'json'
/** Schema enums are either bare values or `{ value, label }` pairs; the option
* submits `value` and displays `label`. */
type EnumOption = { value: string; label: string }
type Field = {
/** Schema property name, i.e. the runnable argument key. */
key: string
kind: FieldKind
/** React state variable holding this field's input value. */
local: string
setter: string
label: string
description: string | undefined
required: boolean
/** Schema `password` flag: masks the input and marks the runnable field
* sensitive, which is what puts it in the policy's `sensitive_inputs`. */
sensitive: boolean
/** `format: resource-*`. Without the matching field flag the backend replaces
* a submitted `$res:` reference with a placeholder, so the arg never
* resolves. */
allowUserResources: boolean
enumValues: EnumOption[]
/** `useState(...)` initial value, as TS source. */
init: string
/** Value passed to `backend.<runnable>({ ... })`, as TS source. */
arg: string
}
// Every identifier the generated component references: its own locals and
// setters, plus the globals its body calls. A runnable argument with one of
// these names gets suffixed, so it can neither redeclare nor shadow them (a
// field named `JSON` would otherwise turn `JSON.stringify(result)` into a call
// on the field's value).
export const RESERVED_LOCALS = [
'React',
'useState',
'backend',
'App',
'run',
'e',
'result',
'setResult',
'error',
'setError',
'running',
'setRunning',
'JSON',
'Number',
'String',
'Error',
'undefined'
]
// Reserved words can't name a `const`, so an argument called `class` or `new`
// gets suffixed like a collision would be. `eval` and `arguments` are illegal
// binding names in a module (always strict), so they belong here too.
const JS_KEYWORDS = [
'arguments',
'await',
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'enum',
'eval',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'implements',
'import',
'in',
'instanceof',
'interface',
'let',
'new',
'null',
'package',
'private',
'protected',
'public',
'return',
'static',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield'
]
/** `u/admin/my_script` -> `my_script`, sanitized to the identifier the raw-app
* editor accepts as a runnable id (letters, digits, underscores). */
function runnableIdFromPath(path: string): string {
const segment = path.split('/').filter(Boolean).pop() ?? ''
const cleaned = segment.replace(/[^A-Za-z0-9_]/g, '_')
const id = /^[A-Za-z_]/.test(cleaned) ? cleaned : `run_${cleaned}`
return cleaned === '' || forbiddenIds.includes(id) ? 'a' : id
}
/** Reserves a state variable and its setter together: an argument named `foo`
* claims both `foo` and `setFoo`, so a sibling argument named `setFoo` is
* pushed to `setFoo_` instead of redeclaring the first one's setter. */
function allocLocal(base: string, taken: string[]): { local: string; setter: string } {
const cleaned = base.replace(/[^A-Za-z0-9_$]/g, '_')
let local = /^[A-Za-z_$]/.test(cleaned) ? cleaned : `_${cleaned}`
let setter = setterName(local)
while (taken.includes(local) || taken.includes(setter) || JS_KEYWORDS.includes(local)) {
local = `${local}_`
setter = setterName(local)
}
taken.push(local, setter)
return { local, setter }
}
function setterName(local: string): string {
return `set${local.charAt(0).toUpperCase()}${local.slice(1)}`
}
function isResourceProp(prop: any): boolean {
return typeof prop?.format === 'string' && prop.format.startsWith('resource-')
}
/** Options live on the property for a scalar enum and on `items` for an array
* one; either shape may carry them. */
function enumSource(prop: any): any[] {
if (Array.isArray(prop?.enum) && prop.enum.length > 0) return prop.enum
if (Array.isArray(prop?.items?.enum) && prop.items.enum.length > 0) return prop.items.enum
return []
}
function fieldKind(prop: any): FieldKind {
if (isResourceProp(prop)) return 'resource'
if (enumSource(prop).length > 0) {
// An array-typed enum is a multiselect: `genWmillTs` types it `string[]`,
// so a scalar `<select>` would generate state the runnable call rejects.
return prop?.type === 'array' ? 'multienum' : 'enum'
}
if (prop?.type === 'boolean') return 'boolean'
if (prop?.type === 'number' || prop?.type === 'integer') return 'number'
if (prop?.type === 'string') return prop.password === true ? 'password' : 'text'
return 'json'
}
// Newline excluded: a template literal can carry it verbatim.
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u0009\u000b-\u001f\u007f]/
/** TS string literal. Prefers the quoting that keeps the generated source
* readable, falling back to `JSON.stringify`'s escaping. */
function str(value: string): string {
if (value.includes('\\') || value.includes('\r') || CONTROL_CHARS.test(value)) {
return JSON.stringify(value)
}
if (!value.includes("'") && !value.includes('\n')) return `'${value}'`
if (!value.includes('`') && !value.includes('${')) return `\`${value}\``
return JSON.stringify(value)
}
/** JSX text that would otherwise be swallowed by the parser (`{`, `<`, `}`) or
* rewritten by it (`&` starts an entity, so a literal `&amp;` would decode to
* `&`) goes through an expression container, which JSX copies verbatim. */
function jsxText(value: string): string {
return /^[^{}<>&]*$/.test(value) ? value : `{${str(value)}}`
}
/** JSX attribute value: a plain quoted string when it can be, an expression
* container otherwise. Entities decode in attributes too, so `&` disqualifies
* the plain form — an enum value must reach the runnable unchanged. */
function jsxAttr(value: string): string {
return /^[^"{}<>&\n]*$/.test(value) ? `"${value}"` : `{${str(value)}}`
}
/** The schema's own description, plus what the control can't convey on its own:
* which resource type to point at, and that an object-typed secret is stored
* encrypted even though its textarea shows it in the clear (the platform's own
* ArgInput says the same rather than masking it). */
function fieldHint(prop: any, kind: FieldKind): string | undefined {
const own =
typeof prop.description === 'string' && prop.description !== '' ? prop.description : undefined
let extra: string | undefined
if (kind === 'resource') {
extra = `resource path, e.g. $res:u/user/my_${String(prop.format).slice('resource-'.length)}`
} else if (kind === 'json' && prop.password === true) {
extra = 'stored as a secret on submit'
}
if (!extra) return own
return own ? `${own} (${extra})` : extra
}
function toFields(schema: Record<string, any> | undefined): Field[] {
const properties: Record<string, any> = schema?.properties ?? {}
const order: string[] = Array.isArray(schema?.order) ? schema.order : []
const keys = [
...order.filter((k) => k in properties),
...Object.keys(properties).filter((k) => !order.includes(k))
]
const required: string[] = Array.isArray(schema?.required) ? schema.required : []
const taken = [...RESERVED_LOCALS]
return keys.map((key) => {
const prop = properties[key] ?? {}
const kind = fieldKind(prop)
const isRequired = required.includes(key)
const { local, setter } = allocLocal(
kind === 'number' || kind === 'json' ? `${key}Text` : key,
taken
)
const enumValues: EnumOption[] = enumSource(prop).map((v: any) =>
v != undefined && typeof v === 'object'
? { value: String(v.value), label: String(v.label ?? v.value) }
: { value: String(v), label: String(v) }
)
let init: string
let arg: string
if (kind === 'boolean') {
init = prop.default === true ? 'true' : 'false'
arg = local
} else if (kind === 'number') {
init = str(prop.default != undefined ? String(prop.default) : '')
// An emptied input means "unset", which only type-checks when the
// argument is optional.
arg = isRequired ? `Number(${local})` : `${local} === '' ? undefined : Number(${local})`
} else if (kind === 'json') {
// Only a required field is pre-filled with an empty collection: a
// non-blank optional textarea can never take the omission branch, so an
// untouched one would override the runnable's own default.
const seed =
prop.default != undefined
? JSON.stringify(prop.default, null, 2)
: isRequired
? JSON.stringify(prop.type === 'array' ? [] : {}, null, 2)
: ''
init = str(seed)
arg = isRequired
? `JSON.parse(${local})`
: `${local}.trim() === '' ? undefined : JSON.parse(${local})`
} else if (kind === 'multienum') {
const defaults = Array.isArray(prop.default) ? prop.default.map((v: any) => String(v)) : []
// Annotated: `useState([])` alone infers `never[]`.
init = `[${defaults.map(str).join(', ')}] as string[]`
arg = isRequired ? local : `${local}.length === 0 ? undefined : ${local}`
} else if (kind === 'enum') {
// An optional enum gets a blank option so the runnable's own default
// stays reachable; a required one always carries a real selection.
const seed = typeof prop.default === 'string' ? prop.default : ''
init = str(seed !== '' || !isRequired ? seed : (enumValues[0]?.value ?? ''))
arg = isRequired ? local : `${local} === '' ? undefined : ${local}`
} else if (kind === 'resource') {
// Starts empty, never at a bare `$res:`: an untouched optional field has
// to read as omitted, and an untouched required one has to trip the
// browser's `required` check rather than ask the backend to resolve an
// empty path. The hint carries the expected shape instead.
init = str(typeof prop.default === 'string' ? prop.default : '')
arg = isRequired ? local : `${local} === '' ? undefined : ${local}`
} else {
init = str(typeof prop.default === 'string' ? prop.default : '')
// Blank optional text is "unset", so the runnable's own default applies
// rather than an empty string overriding it.
arg = isRequired ? local : `${local} === '' ? undefined : ${local}`
}
return {
key,
kind,
local,
setter,
label: typeof prop.title === 'string' && prop.title !== '' ? prop.title : key,
description: fieldHint(prop, kind),
required: isRequired,
sensitive: prop.password === true,
allowUserResources: isResourceProp(prop),
enumValues,
init,
arg
}
})
}
function fieldInput(field: Field): string {
// The `*` marker is only decorative without this: the browser has to block
// the submit, else an empty required field posts '' (or NaN) and fails
// server-side. Two exemptions: `required` on a checkbox would force it on, and
// a required single `<select>` always carries a real selection already. A
// `<select multiple>` can be empty, so it does take the attribute.
const req =
field.required && field.kind !== 'boolean' && field.kind !== 'enum'
? '\n\t\t\t\t\t\trequired'
: ''
switch (field.kind) {
case 'boolean':
return `<input
className="field-checkbox"
type="checkbox"
checked={${field.local}}
onChange={(e) => ${field.setter}(e.target.checked)}
/>`
case 'number':
return `<input
className="field-input"
type="number"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
case 'enum':
return `<select
className="field-input"
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
>
${field.required ? '' : ' <option value=""></option>\n'}${field.enumValues.map((o) => ` <option value=${jsxAttr(o.value)}>${jsxText(o.label)}</option>`).join('\n')}
</select>`
case 'multienum':
// `size` is resolved here and the handler spreads rather than calling
// `Math.min` / `Array.from`: every global the template names is one more
// identifier an argument could shadow, so the template names none.
return `<select
className="field-input field-multiselect"
multiple${req}
size={${Math.min(field.enumValues.length, 6)}}
value={${field.local}}
onChange={(e) =>
${field.setter}([...e.target.selectedOptions].map((o) => o.value))
}
>
${field.enumValues.map((o) => ` <option value=${jsxAttr(o.value)}>${jsxText(o.label)}</option>`).join('\n')}
</select>`
case 'json':
return `<textarea
className="field-input field-textarea"
rows={4}${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
case 'resource':
return `<input
className="field-input"
type="text"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
case 'password':
return `<input
className="field-input"
type="password"
autoComplete="off"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
default:
return `<input
className="field-input"
type="text"${req}
value={${field.local}}
onChange={(e) => ${field.setter}(e.target.value)}
/>`
}
}
function fieldJsx(field: Field): string {
const label = `<span className="field-label">
${jsxText(field.label)}${field.required ? '\n\t\t\t\t\t\t<span className="field-required">*</span>' : ''}
</span>`
const description = field.description
? `\n <span className="field-description">${jsxText(field.description)}</span>`
: ''
return ` <label className="field">
${label}${description}
${fieldInput(field)}
</label>`
}
function generateAppTsx(opts: {
runnableId: string
fields: Field[]
title: string
subtitle: string
}): string {
const { runnableId, fields, title, subtitle } = opts
const states = fields
.map((f) => ` const [${f.local}, ${f.setter}] = useState(${f.init})`)
.join('\n')
const args = fields
.map((f) => {
// Shorthand is an own property even for `__proto__`; the `key: value`
// form is not — there it sets the prototype and the argument never
// reaches the runnable, so that one key needs a computed key.
if (f.arg === f.key) return ` ${f.key}`
// `str` may pick a template literal, which is not a legal property
// name, so keys always take JSON quoting rather than `str`.
const quoted = JSON.stringify(f.key)
const key =
f.key === '__proto__'
? `[${quoted}]`
: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(f.key)
? f.key
: quoted
return ` ${key}: ${f.arg}`
})
.join(',\n')
const callArgs = fields.length === 0 ? '{}' : `{\n${args}\n }`
const form =
fields.length === 0
? ' <p className="app-empty">This runnable takes no arguments.</p>\n'
: fields.map(fieldJsx).join('\n') + '\n'
return `import React, { useState } from 'react'
import { backend } from './wmill'
import './index.css'
const App = () => {
${states}${states === '' ? '' : '\n'} const [result, setResult] = useState(undefined as any)
const [error, setError] = useState(undefined as string | undefined)
const [running, setRunning] = useState(false)
async function run() {
setRunning(true)
setError(undefined)
try {
setResult(await backend.${runnableId}(${callArgs}))
} catch (e) {
setResult(undefined)
setError(e instanceof Error ? e.message : String(e))
}
setRunning(false)
}
return (
<main className="app">
<h1 className="app-title">${jsxText(title)}</h1>
<p className="app-subtitle">${jsxText(subtitle)}</p>
<form
className="app-form"
onSubmit={(e) => {
e.preventDefault()
run()
}}
>
${form} <button className="run-button" type="submit" disabled={running}>
{running ? 'Running...' : 'Run'}
</button>
</form>
{error !== undefined && <pre className="app-error">{error}</pre>}
{result !== undefined && <pre className="app-result">{JSON.stringify(result, null, 2)}</pre>}
</main>
)
}
export default App
`
}
const indexCss = `/* The app renders inside an iframe whose host page has its own theme the
iframe cannot read, so paint an opaque surface here: without one the host's
background shows through and dark text lands on it unreadable. */
:root {
color-scheme: light;
--bg: #ffffff;
--fg: #18181b;
--muted: #71717a;
--border: #d4d4d8;
--surface: #f4f4f5;
--accent: #18181b;
--accent-fg: #ffffff;
--required: #dc2626;
--error-bg: #fef2f2;
--error-fg: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #18181b;
--fg: #f4f4f5;
--muted: #a1a1aa;
--border: #3f3f46;
--surface: #27272a;
--accent: #f4f4f5;
--accent-fg: #18181b;
--required: #f87171;
--error-bg: #431a1a;
--error-fg: #fca5a5;
}
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
}
.app {
max-width: 640px;
margin: 0 auto;
padding: 24px 16px 48px;
font-family:
ui-sans-serif,
system-ui,
sans-serif;
color: var(--fg);
}
.app-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.app-subtitle {
margin: 4px 0 24px;
font-size: 0.8rem;
color: var(--muted);
}
.app-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
}
.field-required {
color: var(--required);
margin-left: 2px;
}
.field-description {
font-size: 0.75rem;
color: var(--muted);
}
.field-input {
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
font-size: 0.875rem;
font-family: inherit;
background: var(--bg);
color: var(--fg);
}
.field-multiselect {
min-height: 72px;
}
.field-textarea {
font-family: ui-monospace, monospace;
resize: vertical;
}
.field-checkbox {
align-self: flex-start;
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.app-empty {
font-size: 0.875rem;
color: var(--muted);
margin: 0;
}
.run-button {
align-self: flex-start;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--accent-fg);
padding: 8px 16px;
font-size: 0.875rem;
cursor: pointer;
}
.run-button:disabled {
opacity: 0.6;
cursor: default;
}
.app-error,
.app-result {
margin-top: 24px;
padding: 12px;
border-radius: 6px;
font-size: 0.8rem;
white-space: pre-wrap;
overflow-x: auto;
}
.app-error {
background: var(--error-bg);
color: var(--error-fg);
}
.app-result {
background: var(--surface);
}
`
function createRawApp(opts: {
path: string
summary: string | undefined
schema: Record<string, any> | undefined
runType: 'script' | 'flow'
}): RawAppImport {
const { path, summary, schema, runType } = opts
const runnableId = runnableIdFromPath(path)
const fields = toFields(schema)
const title = summary && summary !== '' ? summary : path
// `updateRawAppPolicy` derives the policy's `sensitive_inputs` and
// `allow_user_resources` from the runnable's fields, so an argument has to be
// declared here or the secret lands in the job args in the clear / the
// submitted `$res:` reference is replaced by a placeholder.
const runnableFields = Object.fromEntries(
fields
.filter((f) => f.sensitive || f.allowUserResources)
.map((f) => [
f.key,
{
type: 'user',
value: undefined,
...(f.sensitive ? { sensitive: true } : {}),
...(f.allowUserResources ? { allowUserResources: true } : {})
}
])
)
return {
summary: title,
value: {
files: {
...react19Template,
'/App.tsx': generateAppTsx({
runnableId,
fields,
title,
subtitle: `${runType} · ${path}`
}),
'/index.css': indexCss
},
runnables: {
[runnableId]: {
name: path,
type: 'path',
runType,
path,
schema: schema ?? {},
fields: runnableFields
}
}
}
}
}
/** React app scaffold that runs `path` from a form built off its schema. */
export function createRawAppFromScript(
path: string,
summary: string | undefined,
schema: Record<string, any> | undefined
): RawAppImport {
return createRawApp({ path, summary, schema, runType: 'script' })
}
export function createRawAppFromFlow(
path: string,
summary: string | undefined,
schema: Record<string, any> | undefined
): RawAppImport {
return createRawApp({ path, summary, schema, runType: 'flow' })
}
@@ -47,6 +47,27 @@ describe('genWmillTs', () => {
expect(dts).toContain('myflow: (args: { string_input: string }) => Promise<any>;')
})
it('quotes flow input names that are not valid identifiers', () => {
const runnables: Record<string, Runnable> = {
myflow: {
type: 'path',
runType: 'flow',
path: 'u/dev/my_flow',
name: 'My flow',
schema: {
type: 'object',
required: ['user-name'],
properties: { 'user-name': { type: 'string' } }
},
fields: {}
}
}
// `user-name: string` is unparseable, so the whole generated d.ts is
// rejected and every backend call in the editor loses its types.
expect(genWmillTs(runnables)).toContain('myflow: (args: { "user-name": string })')
})
})
describe('normalizeRawAppRuntimeLogs', () => {
+4 -1
View File
@@ -12,7 +12,10 @@ export function schemaToTsType(schema: Schema | SchemaProperty): string {
.map((key: string) => {
const prop = schemaProperties[key]
const isOptional = !schemaRequired?.includes(key)
const prefix = `${key}${isOptional ? '?' : ''}`
// Flow inputs allow names TS cannot use bare, e.g. `user-name`, which
// would emit an unparseable member. A quoted key means the same thing.
const name = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
const prefix = `${name}${isOptional ? '?' : ''}`
let type: string = 'any'
if (prop.type === 'string') {
type = 'string'
@@ -50,8 +50,7 @@
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import { createAppFromFlow } from '$lib/components/details/createAppFromScript'
import { importStore } from '$lib/components/apps/store'
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
@@ -356,9 +355,11 @@
label: 'Build app',
buttonProps: {
onClick: async () => {
const app = createAppFromFlow(flow.path, flow.schema)
$importStore = JSON.parse(JSON.stringify(app))
await goto('/apps/add')
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
// /apps_raw/add hard-reloads (cross-origin isolation), so the
// in-memory importStore would be dropped; hand off via sessionStorage.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await goto('/apps_raw/add')
},
unifiedSize: 'md',
variant: 'subtle',
@@ -22,12 +22,7 @@
} from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import {
enterpriseLicense,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
@@ -77,8 +72,7 @@
import SharedBadge from '$lib/components/SharedBadge.svelte'
import Popover from '$lib/components/Popover.svelte'
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
import { createAppFromScript } from '$lib/components/details/createAppFromScript'
import { importStore } from '$lib/components/apps/store'
import { createRawAppFromScript } from '$lib/components/details/createRawAppFromScript'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import PersistentScriptDrawer from '$lib/components/PersistentScriptDrawer.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
@@ -446,9 +440,11 @@
label: 'Build app',
buttonProps: {
onClick: async () => {
const app = createAppFromScript(script.path, script.schema)
$importStore = JSON.parse(JSON.stringify(app))
await goto('/apps/add')
const app = createRawAppFromScript(script.path, script.summary, script.schema)
// /apps_raw/add hard-reloads (cross-origin isolation), so the
// in-memory importStore would be dropped; hand off via sessionStorage.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await goto('/apps_raw/add')
},
disabled: !showEditButtons,
unifiedSize: 'md',