diff --git a/frontend/src/lib/components/details/createAppFromScript.ts b/frontend/src/lib/components/details/createAppFromScript.ts deleted file mode 100644 index 07308dbf20..0000000000 --- a/frontend/src/lib/components/details/createAppFromScript.ts +++ /dev/null @@ -1,1098 +0,0 @@ -import { ccomponents } from '../apps/editor/component' - -export function createAppFromScript(path: string, schema: Record | undefined) { - return { - grid: [ - { - '3': { - fixed: false, - x: 0, - y: 2, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 2, - w: 12, - h: 21, - fullHeight: false - }, - data: { - type: 'verticalsplitpanescomponent', - configuration: {}, - panes: [50, 50], - customCss: structuredClone(ccomponents['verticalsplitpanescomponent'].customCss), - numberOfSubgrids: 2, - id: 'a' - }, - id: 'a' - }, - { - '3': { - fixed: false, - x: 0, - y: 8, - fullHeight: false, - w: 6, - h: 2 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 12, - h: 2 - }, - data: { - type: 'containercomponent', - configuration: {}, - customCss: { - container: { - class: '!p-0', - style: '' - } - }, - actions: undefined, - numberOfSubgrids: 1, - id: 'topbar' - }, - id: 'topbar' - } - ], - fullscreen: false, - unusedInlineScripts: [], - hiddenInlineScripts: [], - css: {}, - norefreshbar: false, - hideLegacyTopBar: true, - subgrids: { - 'a-0': [ - { - '3': { - fixed: false, - x: 0, - y: 1, - w: 3, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 19, - fullHeight: false - }, - data: { - type: 'schemaformcomponent', - configuration: { - displayType: { - type: 'static', - value: false - }, - largeGap: { - type: 'static', - value: false - } - }, - componentInput: { - type: 'static', - fieldType: 'schema', - value: schema - }, - customCss: structuredClone(ccomponents['schemaformcomponent'].customCss), - id: 'c' - }, - id: 'c' - }, - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 1, - h: 1, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 19, - w: 12, - h: 1, - fullHeight: false - }, - data: { - type: 'buttoncomponent', - configuration: { - label: { - type: 'static', - value: 'Submit' - }, - color: { - type: 'static', - value: 'dark' - }, - size: { - type: 'static', - value: 'xs' - }, - fillContainer: { - type: 'static', - value: false - }, - disabled: { - type: 'evalv2', - expr: '!c.valid', - connections: [ - { - componentId: 'c', - id: 'valid' - } - ] - }, - beforeIcon: { - type: 'static', - value: '' - }, - afterIcon: { - type: 'static', - value: '' - }, - triggerOnAppLoad: { - type: 'static', - value: false - }, - onSuccess: { - type: 'oneOf', - selected: 'none', - configuration: { - none: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendToast: { - message: { - type: 'static', - value: '' - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - }, - onError: { - type: 'oneOf', - selected: 'errorOverlay', - configuration: { - errorOverlay: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendErrorToast: { - message: { - type: 'static', - value: '' - }, - appendError: { - type: 'static', - value: true - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - } - }, - componentInput: { - type: 'runnable', - fieldType: 'any', - fields: convertSchemaToFields(schema), - runnable: { - type: 'path', - path: path, - runType: 'script', - schema: schema, - name: path - }, - autoRefresh: true, - recomputeOnInputChanged: true - }, - customCss: structuredClone(ccomponents['buttoncomponent'].customCss), - recomputeIds: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'd' - }, - id: 'd' - } - ], - 'a-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 20, - fullHeight: false - }, - data: { - type: 'tabscomponent', - configuration: { - tabsKind: { - type: 'static', - value: 'tabs' - } - }, - tabs: ['Result', 'Logs'], - customCss: structuredClone(ccomponents['tabscomponent'].customCss), - numberOfSubgrids: 2, - id: 'b', - disabledTabs: [ - { - type: 'static', - value: false, - fieldType: 'boolean' - }, - { - type: 'static', - value: false, - fieldType: 'boolean' - } - ] - }, - id: 'b' - } - ], - 'b-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'displaycomponent', - configuration: {}, - componentInput: { - type: 'connected', - fieldType: 'object', - connection: { - componentId: 'd', - path: 'result' - } - }, - customCss: structuredClone(ccomponents['displaycomponent'].customCss), - id: 'e' - }, - id: 'e' - } - ], - 'b-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidlogcomponent', - configuration: { - jobId: { - type: 'connected', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidlogcomponent'].customCss), - id: 'f' - }, - id: 'f' - } - ], - 'topbar-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'textcomponent', - configuration: { - style: { - type: 'static', - value: 'Body' - }, - copyButton: { - type: 'static', - value: false - }, - tooltip: { - type: 'evalv2', - value: '', - fieldType: 'text', - expr: '`Author: ${ctx.author}`', - connections: [ - { - componentId: 'ctx', - id: 'author' - } - ] - }, - disableNoText: { - type: 'static', - value: true, - fieldType: 'boolean' - } - }, - componentInput: { - type: 'templatev2', - fieldType: 'template', - eval: '${ctx.summary}', - connections: [ - { - id: 'summary', - componentId: 'ctx' - } - ] - }, - customCss: { - text: { - class: 'text-xl font-semibold whitespace-nowrap truncate', - style: '' - }, - container: { - class: '', - style: '' - } - }, - actions: undefined, - horizontalAlignment: 'left', - verticalAlignment: 'center', - id: 'title' - }, - id: 'title' - }, - { - '3': { - fixed: false, - x: 0, - y: 1, - fullHeight: false, - w: 3, - h: 1 - }, - '12': { - fixed: false, - x: 6, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'recomputeallcomponent', - configuration: {}, - customCss: { - container: { - style: '', - class: '' - } - }, - menuItems: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'recomputeall' - }, - id: 'recomputeall' - } - ] - } - } -} - -type Field = { - type: 'static' | 'connected' - value: any - fieldType?: string - format?: string - connection?: { - componentId: string - path: string - } - allowUserResources?: boolean -} - -function convertSchemaToFields(schema: Record | undefined): { [key: string]: Field } { - const fields: { [key: string]: Field } = {} - - if (!schema) { - return fields - } - - Object.entries(schema.properties).forEach(([fieldName, fieldInfo]: [string, any]) => { - fields[fieldName] = { - type: 'connected', - value: fieldInfo.default, - fieldType: fieldInfo.type, - format: fieldInfo.format, - allowUserResources: true, - connection: { - componentId: 'c', - path: `values.${fieldName}` - } - } - }) - - return fields -} - -export function createAppFromFlow(path: string, schema: Record | undefined) { - return { - grid: [ - { - '3': { - fixed: false, - x: 0, - y: 2, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 2, - w: 12, - h: 21, - fullHeight: false - }, - data: { - type: 'verticalsplitpanescomponent', - configuration: {}, - panes: [50, 50], - customCss: structuredClone(ccomponents['verticalsplitpanescomponent'].customCss), - numberOfSubgrids: 2, - id: 'a' - }, - id: 'a' - }, - { - '3': { - fixed: true, - x: 0, - y: 8, - fullHeight: false, - w: 6, - h: 2 - }, - '12': { - fixed: true, - x: 0, - y: 0, - fullHeight: false, - w: 12, - h: 2 - }, - data: { - type: 'containercomponent', - configuration: {}, - customCss: { - container: { - class: '!p-0', - style: '' - } - }, - numberOfSubgrids: 1, - id: 'topbar' - }, - id: 'topbar' - } - ], - fullscreen: false, - unusedInlineScripts: [], - hiddenInlineScripts: [], - css: {}, - norefreshbar: false, - hideLegacyTopBar: true, - subgrids: { - 'a-0': [ - { - '3': { - fixed: false, - x: 0, - y: 1, - w: 3, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 19, - fullHeight: false - }, - data: { - type: 'schemaformcomponent', - configuration: { - displayType: { - type: 'static', - value: false - }, - largeGap: { - type: 'static', - value: false - } - }, - componentInput: { - type: 'static', - fieldType: 'schema', - value: schema - }, - customCss: structuredClone(ccomponents['schemaformcomponent'].customCss), - id: 'c' - }, - id: 'c' - }, - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 1, - h: 1, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 19, - w: 12, - h: 1, - fullHeight: false - }, - data: { - type: 'buttoncomponent', - configuration: { - label: { - type: 'static', - value: 'Submit' - }, - color: { - type: 'static', - value: 'dark' - }, - size: { - type: 'static', - value: 'xs' - }, - fillContainer: { - type: 'static', - value: false - }, - disabled: { - type: 'evalv2', - expr: '!c.valid', - connections: [ - { - componentId: 'c', - id: 'valid' - } - ] - }, - beforeIcon: { - type: 'static', - value: '' - }, - afterIcon: { - type: 'static', - value: '' - }, - triggerOnAppLoad: { - type: 'static', - value: false - }, - onSuccess: { - type: 'oneOf', - selected: 'none', - configuration: { - none: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendToast: { - message: { - type: 'static', - value: '' - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - }, - onError: { - type: 'oneOf', - selected: 'errorOverlay', - configuration: { - errorOverlay: {}, - gotoUrl: { - url: { - type: 'static', - value: '' - }, - newTab: { - type: 'static', - value: true - } - }, - setTab: { - setTab: { - type: 'static', - value: [] - } - }, - sendErrorToast: { - message: { - type: 'static', - value: '' - }, - appendError: { - type: 'static', - value: true - } - }, - openModal: { - modalId: { - type: 'static', - value: '' - } - }, - closeModal: { - modalId: { - type: 'static', - value: '' - } - }, - open: { - id: { - type: 'static', - value: '' - } - }, - close: { - id: { - type: 'static', - value: '' - } - } - } - } - }, - componentInput: { - type: 'runnable', - fieldType: 'any', - fields: convertSchemaToFields(schema), - runnable: { - type: 'path', - path: path, - runType: 'flow', - schema: schema, - name: path - }, - autoRefresh: false, - recomputeOnInputChanged: false - }, - customCss: structuredClone(ccomponents['buttoncomponent'].customCss), - recomputeIds: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'd' - }, - id: 'd' - } - ], - 'a-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 20, - fullHeight: false - }, - data: { - type: 'tabscomponent', - configuration: { - tabsKind: { - type: 'static', - value: 'tabs' - } - }, - tabs: ['Result', 'Logs'], - customCss: structuredClone(ccomponents['tabscomponent'].customCss), - numberOfSubgrids: 2, - id: 'b', - disabledTabs: [ - { - type: 'static', - value: false, - fieldType: 'boolean' - }, - { - type: 'static', - value: false, - fieldType: 'boolean' - } - ] - }, - id: 'b' - } - ], - 'b-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidflowstatuscomponent', - configuration: { - jobId: { - type: 'connected', - value: '', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidflowstatuscomponent'].customCss), - id: 'e' - }, - id: 'e' - } - ], - 'b-1': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - w: 2, - h: 8, - fullHeight: false - }, - '12': { - fixed: false, - x: 0, - y: 0, - w: 12, - h: 18, - fullHeight: false - }, - data: { - type: 'jobidlogcomponent', - configuration: { - jobId: { - type: 'connected', - connection: { - componentId: 'd', - path: 'jobId' - } - } - }, - customCss: structuredClone(ccomponents['jobidlogcomponent'].customCss), - id: 'f' - }, - id: 'f' - } - ], - 'topbar-0': [ - { - '3': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - '12': { - fixed: false, - x: 0, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'textcomponent', - configuration: { - style: { - type: 'static', - value: 'Body' - }, - copyButton: { - type: 'static', - value: false - }, - tooltip: { - type: 'evalv2', - value: '', - fieldType: 'text', - expr: '`Author: ${ctx.author}`', - connections: [ - { - componentId: 'ctx', - id: 'author' - } - ] - }, - disableNoText: { - type: 'static', - value: true, - fieldType: 'boolean' - } - }, - componentInput: { - type: 'templatev2', - fieldType: 'template', - eval: '${ctx.summary}', - connections: [ - { - id: 'summary', - componentId: 'ctx' - } - ] - }, - customCss: { - text: { - class: 'text-xl font-semibold whitespace-nowrap truncate', - style: '' - }, - container: { - class: '', - style: '' - } - }, - horizontalAlignment: 'left', - verticalAlignment: 'center', - id: 'title' - }, - id: 'title' - }, - { - '3': { - fixed: false, - x: 0, - y: 1, - fullHeight: false, - w: 3, - h: 1 - }, - '12': { - fixed: false, - x: 6, - y: 0, - fullHeight: false, - w: 6, - h: 1 - }, - data: { - type: 'recomputeallcomponent', - configuration: {}, - customCss: { - container: { - style: '', - class: '' - } - }, - menuItems: [], - horizontalAlignment: 'right', - verticalAlignment: 'center', - id: 'recomputeall' - }, - id: 'recomputeall' - } - ] - } - } -} diff --git a/frontend/src/lib/components/details/createRawAppFromScript.test.ts b/frontend/src/lib/components/details/createRawAppFromScript.test.ts new file mode 100644 index 0000000000..a8f9a9e0e2 --- /dev/null +++ b/frontend/src/lib/components/details/createRawAppFromScript.test.ts @@ -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('') + }) + + 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(`(? !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 `` 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 `&` 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 | undefined): Field[] { + const properties: Record = 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 `` 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 ` ${field.setter}(e.target.checked)} + />` + case 'number': + return ` ${field.setter}(e.target.value)} + />` + case 'enum': + return `` + 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 `` + case 'json': + return `