feat(frontend): add frontend (JS) scripts to apps

This commit is contained in:
Ruben Fiszel
2023-03-13 19:29:35 +01:00
parent f4b67bea26
commit ace6e90bc3
17 changed files with 316 additions and 180 deletions
-1
View File
@@ -1722,7 +1722,6 @@ fn list_completed_jobs_query(
sqlb.and_where("result @> ?".bind(&result.replace("'", "''")));
}
tracing::info!("{:?}", sqlb.sql());
sqlb
}
#[derive(Deserialize, Clone)]
@@ -5,6 +5,7 @@
import type { AppInput, EvalAppInput, UploadAppInput } from '../../inputType'
import type { AppViewerContext } from '../../types'
import { accessPropertyByPath } from '../../utils'
import { computeGlobalContext, eval_like } from './eval'
type T = string | number | boolean | Record<string | number, any> | undefined
@@ -24,13 +25,13 @@
}
}
const { worldStore } = getContext<AppViewerContext>('AppViewerContext')
const { worldStore, state } = getContext<AppViewerContext>('AppViewerContext')
$: state = $worldStore?.state
$: stateId = $worldStore?.state
let timeout: NodeJS.Timeout | undefined = undefined
const debounce_ms = 50
function debounce(cb: () => void) {
function debounce(cb: () => Promise<void>) {
if (timeout) {
clearTimeout(timeout)
}
@@ -40,20 +41,22 @@
$: lastInput && $worldStore && debounce(handleConnection)
$: lastInput &&
lastInput.type == 'template' &&
$stateId &&
$state &&
debounce(() => (value = getValue(lastInput)))
debounce(async () => (value = await getValue(lastInput)))
$: lastInput &&
lastInput.type == 'eval' &&
$state &&
debounce(() => (value = evalExpr(lastInput)))
$stateId &&
state &&
debounce(async () => (value = await evalExpr(lastInput)))
function handleConnection() {
async function handleConnection() {
if (lastInput.type === 'connected') {
$worldStore?.connect<any>(lastInput, onValueChange)
} else if (lastInput.type === 'static' || lastInput.type == 'template') {
value = getValue(lastInput)
value = await getValue(lastInput)
} else if (lastInput.type == 'eval') {
value = evalExpr(lastInput as EvalAppInput)
value = await evalExpr(lastInput as EvalAppInput)
} else if (lastInput.type == 'upload') {
value = (lastInput as UploadAppInput).value
} else {
@@ -61,9 +64,14 @@
}
}
function evalExpr(input: EvalAppInput) {
async function evalExpr(input: EvalAppInput) {
try {
const r = eval_like(input.expr, computeGlobalContext())
const r = await eval_like(
input.expr,
computeGlobalContext($worldStore, id, extraContext),
true,
$state
)
error = ''
return r
} catch (e) {
@@ -72,26 +80,15 @@
}
}
function computeGlobalContext() {
return {
...Object.fromEntries(
Object.entries($worldStore?.outputsById ?? {})
.filter(([k, _]) => k != id)
.map(([key, value]) => {
return [
key,
Object.fromEntries(Object.entries(value ?? {}).map((x) => [x[0], x[1].peak()]))
]
})
),
...extraContext
}
}
export function getValue(input: AppInput) {
async function getValue(input: AppInput) {
if (input.type === 'template' && isCodeInjection(input.eval)) {
try {
const r = eval_like('`' + input.eval + '`', computeGlobalContext())
const r = await eval_like(
'`' + input.eval + '`',
computeGlobalContext($worldStore, id, extraContext),
true,
$state
)
error = ''
return r
} catch (e) {
@@ -104,31 +101,6 @@
}
}
function create_context_function_template(eval_string, context) {
return `
return function (context) {
"use strict";
${
Object.keys(context).length > 0
? `let ${Object.keys(context).map((key) => ` ${key} = context['${key}']`)};`
: ``
}
return ${eval_string};
}
`
}
function make_context_evaluator(eval_string, context) {
let template = create_context_function_template(eval_string, context)
let functor = Function(template)
return functor()
}
function eval_like(text, context = {}) {
let evaluator = make_context_evaluator(text, context)
return evaluator(context)
}
function onValueChange(newValue: any): void {
if (lastInput.type === 'connected' && newValue !== undefined && newValue !== null) {
const { connection } = lastInput
@@ -10,9 +10,11 @@
import { Bug, Loader2 } from 'lucide-svelte'
import { getContext, onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { initOutput } from '../../editor/appUtils'
import type { AppInputs, Runnable } from '../../inputType'
import type { Output } from '../../rx'
import type { AppViewerContext } from '../../types'
import { computeGlobalContext, eval_like } from './eval'
import InputValue from './InputValue.svelte'
import RefreshButton from './RefreshButton.svelte'
@@ -42,7 +44,8 @@
noBackend,
errorByComponent,
mode,
stateId
stateId,
state
} = getContext<AppViewerContext>('AppViewerContext')
onMount(() => {
@@ -96,14 +99,7 @@
let testJob: CompletedJob | undefined = undefined
let testJobLoader: TestJobLoader | undefined = undefined
$: outputs = $worldStore?.outputsById[id] as {
result: Output<Array<any>>
loading: Output<boolean>
}
$: if (outputs?.loading != undefined) {
outputs.loading.set(false, true)
}
$: outputs = initOutput($worldStore, id, { result: undefined, loading: false })
$: outputs?.loading?.set(testIsLoading)
$: schemaStripped = stripSchema(fields, $stateId)
@@ -144,6 +140,23 @@
)
async function executeComponent(noToast = false) {
if (runnable?.type === 'runnableByName' && runnable.inlineScript?.language === 'frontend') {
outputs?.loading?.set(true)
try {
const r = await eval_like(
runnable.inlineScript?.content,
computeGlobalContext($worldStore, id, {}),
false,
$state
)
setResult(r)
$state = $state
} catch (e) {
sendUserToast('Error running frontend script: ' + e.message)
}
outputs?.loading?.set(false)
return
}
if (noBackend) {
if (!noToast) {
sendUserToast('This app is not connected to a windmill backend, it is a static preview')
@@ -216,6 +229,26 @@
}
}
function setResult(res: any) {
outputs.result?.set(res)
result = res
const previousJobId = Object.keys($errorByComponent).find(
(key) => $errorByComponent[key].componentId === id
)
if (previousJobId && !result?.error) {
delete $errorByComponent[previousJobId]
$errorByComponent = $errorByComponent
}
if (gotoUrl && gotoUrl != '' && result?.error == undefined) {
if (gotoNewTab) {
window.open(gotoUrl, '_blank')
} else {
goto(gotoUrl)
}
}
}
$: result?.error && recordError(result.error)
</script>
@@ -232,24 +265,7 @@
const startedAt = new Date(testJob.started_at).getTime()
if (startedAt > lastStartedAt) {
lastStartedAt = startedAt
outputs.result?.set(testJob?.result)
result = testJob.result
const previousJobId = Object.keys($errorByComponent).find(
(key) => $errorByComponent[key].componentId === id
)
if (previousJobId && !result?.error) {
delete $errorByComponent[previousJobId]
$errorByComponent = $errorByComponent
}
if (gotoUrl && gotoUrl != '' && result?.error == undefined) {
if (gotoNewTab) {
window.open(gotoUrl, '_blank')
} else {
goto(gotoUrl)
}
}
setResult(e.detail.result)
}
}
}}
@@ -0,0 +1,53 @@
import { goto } from '$app/navigation'
import type { World } from '../../rx'
export function computeGlobalContext(
world: World | undefined,
id: string | undefined,
extraContext: any = {}
) {
return {
...Object.fromEntries(
Object.entries(world?.outputsById ?? {})
.filter(([k, _]) => k != id)
.map(([key, value]) => {
return [
key,
Object.fromEntries(Object.entries(value ?? {}).map((x) => [x[0], x[1].peak()]))
]
})
),
...extraContext
}
}
function create_context_function_template(eval_string, context, noReturn: boolean) {
return `
return async function (context, state, goto) {
"use strict";
${
Object.keys(context).length > 0
? `let ${Object.keys(context).map((key) => ` ${key} = context['${key}']`)};`
: ``
}
${noReturn ? `return ${eval_string}` : eval_string}
}
`
}
function make_context_evaluator(
eval_string,
context,
noReturn: boolean
): (context, state, goto) => Promise<any> {
let template = create_context_function_template(eval_string, context, noReturn)
let functor = Function(template)
return functor()
}
export async function eval_like(text, context = {}, noReturn: boolean = true, state: any = {}) {
let evaluator = make_context_evaluator(text, context, noReturn)
return await evaluator(context, state, async (x) => {
await goto(x)
})
}
@@ -88,7 +88,8 @@
openDebugRun: writable(undefined),
focusedGrid,
stateId: writable(0),
parentWidth: writable(0)
parentWidth: writable(0),
state: writable({})
})
setContext<AppEditorContext>('AppEditorContext', {
@@ -62,7 +62,8 @@
openDebugRun: writable(undefined),
focusedGrid: writable(undefined),
stateId: writable(0),
parentWidth: writable(0)
parentWidth: writable(0),
state: writable({})
})
setContext<AppEditorContext>('AppEditorContext', {
@@ -47,7 +47,6 @@ export function getNextGridItemId(app: App): string {
}
export function createNewGridItem(grid: GridItem[], id: string, data: AppComponent): GridItem {
const newComponent = {
resizable: true,
draggable: true,
@@ -105,7 +104,6 @@ export function insertNewGridItem(
app.subgrids = {}
}
// We only want to set subgrids when we are not moving
if (!keepId) {
for (let i = 0; i < (data.numberOfSubgrids ?? 0); i++) {
@@ -113,8 +111,9 @@ export function insertNewGridItem(
}
}
const key = focusedGrid ? `${focusedGrid?.parentComponentId}-${focusedGrid?.subGridIndex ?? 0}` : undefined
const key = focusedGrid
? `${focusedGrid?.parentComponentId}-${focusedGrid?.subGridIndex ?? 0}`
: undefined
let grid = focusedGrid ? app.subgrids[key!] : app.grid
const newItem = createNewGridItem(grid, id, data)
@@ -177,8 +176,6 @@ export function deleteGridItem(
return components
}
type AvailableSpace = {
left: number
right: number
@@ -286,11 +283,17 @@ function isOverlapping(item1: any, item2: any) {
}
type Outputtable<Type> = {
-readonly [Property in keyof Type]: Output<Type[Property]>;
};
-readonly [Property in keyof Type]: Output<Type[Property]>
}
export function initOutput<I extends Record<string, any>>(world: World, id: string, init: I): Outputtable<I> {
export function initOutput<I extends Record<string, any>>(
world: World | undefined,
id: string,
init: I
): Outputtable<I> {
if (!world) {
return {} as any
}
const output = world.outputsById[id] as Outputtable<I>
if (init) {
for (const key in init) {
@@ -1,4 +1,5 @@
<script lang="ts">
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
import { classNames } from '$lib/utils'
import { X } from 'lucide-svelte'
import { getContext } from 'svelte'
@@ -10,7 +11,7 @@
import PanelSection from '../settingsPanel/common/PanelSection.svelte'
import ComponentOutputViewer from './ComponentOutputViewer.svelte'
const { connectingInput, staticOutputs, worldStore, selectedComponent, app } =
const { connectingInput, staticOutputs, worldStore, selectedComponent, app, state } =
getContext<AppViewerContext>('AppViewerContext')
function connectInput(componentId: string, path: string) {
@@ -155,5 +156,12 @@
</div>
{/each}
</div>
<PanelSection noPadding titlePadding="px-4 pt-2 pb-0.5" title="State">
<div class="mx-2 px-1 border w-full mb-8">
{#key $state}
<ObjectViewer json={$state} />
{/key}
</div>
</PanelSection>
</div>
</PanelSection>
@@ -3,6 +3,7 @@
import { Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
import FlowScriptPicker from '$lib/components/flows/pickers/FlowScriptPicker.svelte'
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { Script, type Preview } from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { initialCode } from '$lib/script_helpers'
@@ -145,7 +146,29 @@
</Drawer>
<div class="flex flex-col px-4 py-2 gap-2 text-sm" in:fly={{ duration: 50 }}>
<div>Choose a language:</div>
<div class="mt-4 flex justify-between gap-8 mb-2">
<div class="font-bold items-baseline">Choose a language:</div>
<div class="flex gap-2">
<Button
on:click={() => picker?.openDrawer()}
size="xs"
color="blue"
startIcon={{ icon: faCodeBranch }}
btnClasses="truncate"
>
fork a detached script
</Button>
<Button
on:click={() => dispatch('delete')}
size="xs"
color="red"
variant="border"
btnClasses="truncate"
>
Cancel
</Button>
</div>
</div>
<div class="flex gap-2 flex-row flex-wrap">
{#each langs as lang}
<FlowScriptPicker
@@ -164,6 +187,7 @@
createInlineScriptByLanguage(Script.language.DENO, name, 'pgsql')
}}
/>
<!-- <FlowScriptPicker
label={`MySQL`}
lang="mysql"
@@ -172,26 +196,34 @@
}}
/> -->
</div>
<div>
<div class="text-xs mb-1 mt-2"
>Frontend only script:&nbsp;<Tooltip
>Frontend scripts are executed in the browser and can manipulate the app context directly</Tooltip
></div
>
<FlowScriptPicker
label={`JavaScript`}
lang="javascript"
on:click={() => {
const newInlineScript = {
content: `// read outputs and ctx
console.log(ctx.email)
<div class="mt-4">
<Button
on:click={() => picker?.openDrawer()}
size="xs"
color="blue"
startIcon={{ icon: faCodeBranch }}
btnClasses="truncate"
>
or fork a detached/Workspace/Hub script
</Button>
<Button
on:click={() => dispatch('delete')}
size="xs"
color="red"
variant="border"
startIcon={{ icon: faTrash }}
btnClasses="truncate"
>
Delete
</Button>
// access a global state store
if (!state.foo) { state.foo = 0 }
state.foo += 1
// you can also navigate to another page
//await goto("?foo=bar")
return state.foo`,
language: 'frontend',
path: 'frontend script',
schema: undefined
}
dispatch('new', newInlineScript)
}}
/>
</div>
</div>
@@ -15,6 +15,8 @@
import { deepEqual } from 'fast-equals'
import type { AppInput } from '../../inputType'
import Kbd from '$lib/components/common/kbd/Kbd.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { buildExtraLib } from '../../utils'
let inlineScriptEditorDrawer: InlineScriptEditorDrawer
@@ -25,7 +27,8 @@
export let fields: Record<string, AppInput> = {}
export let syncFields: boolean = false
const { runnableComponents, stateId } = getContext<AppViewerContext>('AppViewerContext')
const { runnableComponents, stateId, worldStore } =
getContext<AppViewerContext>('AppViewerContext')
let editor: Editor
let validCode = true
@@ -48,11 +51,13 @@
onMount(async () => {
if (inlineScript && !inlineScript.schema) {
inlineScript.schema = await inferInlineScriptSchema(
inlineScript?.language,
inlineScript?.content,
emptySchema()
)
if (inlineScript.language != 'frontend') {
inlineScript.schema = await inferInlineScriptSchema(
inlineScript?.language,
inlineScript?.content,
emptySchema()
)
}
}
if (inlineScript.schema) {
loadSchemaAndInputsByName()
@@ -63,7 +68,7 @@
async function loadSchemaAndInputsByName() {
if (syncFields) {
const newSchema = inlineScript.schema
const newSchema = inlineScript.schema ?? emptySchema()
const newFields = computeFields(newSchema, defaultUserInput, fields)
if (!deepEqual(newFields, fields)) {
@@ -74,9 +79,16 @@
}
let isMac = navigator.userAgent.indexOf('Mac OS X') !== -1
$: extraLib =
inlineScript.language == 'frontend' && worldStore
? buildExtraLib($worldStore?.outputsById ?? {}, id, false)
: undefined
</script>
<InlineScriptEditorDrawer {editor} bind:this={inlineScriptEditorDrawer} bind:inlineScript />
{#if inlineScript.language != 'frontend'}
<InlineScriptEditorDrawer {editor} bind:this={inlineScriptEditorDrawer} bind:inlineScript />
{/if}
<div class="h-full flex flex-col gap-1">
<div class="flex justify-between w-full gap-2 px-2 pt-1 flex-row items-center">
@@ -109,20 +121,23 @@
<svelte:fragment slot="text">Delete</svelte:fragment>
</Popover>
{/if}
<Popover notClickable placement="bottom">
<Button
size="xs"
color="light"
btnClasses="!px-2 !bg-gray-100 hover:!bg-gray-200"
aria-label="Open full editor"
on:click={() => {
inlineScriptEditorDrawer?.openDrawer()
}}
>
<Maximize2 size={14} />
</Button>
<svelte:fragment slot="text">Open full editor</svelte:fragment>
</Popover>
{#if inlineScript.language != 'frontend'}
<Popover notClickable placement="bottom">
<Button
size="xs"
color="light"
btnClasses="!px-2 !bg-gray-100 hover:!bg-gray-200"
aria-label="Open full editor"
on:click={() => {
inlineScriptEditorDrawer?.openDrawer()
}}
>
<Maximize2 size={14} />
</Button>
<svelte:fragment slot="text">Open full editor</svelte:fragment>
</Popover>
{/if}
<Button
variant="border"
size="xs"
@@ -172,30 +187,44 @@
</div>
<div class="border h-full">
<Editor
bind:this={editor}
class="flex flex-1 grow h-full"
lang={scriptLangToEditorLang(inlineScript?.language)}
bind:code={inlineScript.content}
fixedOverflowWidgets={true}
cmdEnterAction={async () => {
runLoading = true
await $runnableComponents[id]?.()
runLoading = false
}}
on:change={async (e) => {
if (inlineScript) {
const oldSchema = JSON.stringify(inlineScript.schema)
if (inlineScript.schema == undefined) {
inlineScript.schema = emptySchema()
{#if inlineScript.language != 'frontend'}
<Editor
bind:this={editor}
class="flex flex-1 grow h-full"
lang={scriptLangToEditorLang(inlineScript?.language)}
bind:code={inlineScript.content}
fixedOverflowWidgets={true}
cmdEnterAction={async () => {
runLoading = true
await $runnableComponents[id]?.()
runLoading = false
}}
on:change={async (e) => {
if (inlineScript && inlineScript.language != 'frontend') {
const oldSchema = JSON.stringify(inlineScript.schema)
if (inlineScript.schema == undefined) {
inlineScript.schema = emptySchema()
}
await inferInlineScriptSchema(inlineScript?.language, e.detail, inlineScript.schema)
if (JSON.stringify(inlineScript.schema) != oldSchema) {
inlineScript = inlineScript
loadSchemaAndInputsByName()
}
}
await inferInlineScriptSchema(inlineScript?.language, e.detail, inlineScript.schema)
if (JSON.stringify(inlineScript.schema) != oldSchema) {
inlineScript = inlineScript
loadSchemaAndInputsByName()
}
}
}}
/>
}}
/>
{:else}
<SimpleEditor
cmdEnterAction={async () => {
runLoading = true
await $runnableComponents[id]?.()
runLoading = false
}}
class="h-full"
{extraLib}
bind:code={inlineScript.content}
lang="javascript"
/>
{/if}
</div>
</div>
@@ -2,6 +2,7 @@
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import type Editor from '$lib/components/Editor.svelte'
import ScriptEditor from '$lib/components/ScriptEditor.svelte'
import type { Preview } from '$lib/gen'
import { faSave } from '@fortawesome/free-solid-svg-icons'
import type { InlineScript } from '../../types'
@@ -24,7 +25,7 @@
editor?.setCode(inlineScript.content)
}}
>
{#if inlineScript}
{#if inlineScript && inlineScript.language != 'frontend'}
<ScriptEditor
noSyncFromGithub
lang={inlineScript.language}
@@ -92,7 +92,11 @@
let isPointerUp = detail.isPointerUp
let citems: FilledItem<T>[]
if (isPointerUp) {
citems = JSON.parse(JSON.stringify(initItems))
try {
citems = JSON.parse(JSON.stringify(initItems))
} catch (e) {
citems = JSON.parse(JSON.stringify(items))
}
initItems = undefined
} else {
if (initItems == undefined) {
@@ -50,7 +50,7 @@
let anima
const inActivate = () => {
if (shadowElement && shadow) {
if (shadowElement && shadow != undefined) {
let subgrid = shadowElement.closest('.subgrid')
let irect = shadowElement.getBoundingClientRect()
let shadowBound
+12 -11
View File
@@ -44,14 +44,14 @@ export interface BaseAppComponent extends Partial<Aligned> {
configuration: Record<
string,
GeneralAppInput &
(
| StaticAppInput
| ConnectedAppInput
| UserAppInput
| RowAppInput
| EvalAppInput
| UploadAppInput
)
(
| StaticAppInput
| ConnectedAppInput
| UserAppInput
| RowAppInput
| EvalAppInput
| UploadAppInput
)
>
card: boolean | undefined
customCss?: ComponentCustomCSS
@@ -84,9 +84,9 @@ export type GridItem = FilledItem<{
export type InlineScript = {
content: string
language: Preview.language
path: string
schema: Schema
language: Preview.language | 'frontend'
path?: string
schema?: Schema
}
export type App = {
@@ -134,6 +134,7 @@ export type AppViewerContext = {
focusedGrid: Writable<FocusedGrid | undefined>
stateId: Writable<number>
parentWidth: Writable<number>
state: Writable<Record<string, any>>
}
export type AppEditorContext = {
@@ -0,0 +1,11 @@
<script>
export let width = 16
export let height = 16
</script>
<svg xmlns="http://www.w3.org/2000/svg" {width} {height} viewBox="0 0 630 630">
<rect width="630" height="630" fill="#f7df1e" />
<path
d="m423.2 492.19c12.69 20.72 29.2 35.95 58.4 35.95 24.53 0 40.2-12.26 40.2-29.2 0-20.3-16.1-27.49-43.1-39.3l-14.8-6.35c-42.72-18.2-71.1-41-71.1-89.2 0-44.4 33.83-78.2 86.7-78.2 37.64 0 64.7 13.1 84.2 47.4l-46.1 29.6c-10.15-18.2-21.1-25.37-38.1-25.37-17.34 0-28.33 11-28.33 25.37 0 17.76 11 24.95 36.4 35.95l14.8 6.34c50.3 21.57 78.7 43.56 78.7 93 0 53.3-41.87 82.5-98.1 82.5-54.98 0-90.5-26.2-107.88-60.54zm-209.13 5.13c9.3 16.5 17.76 30.45 38.1 30.45 19.45 0 31.72-7.61 31.72-37.2v-201.3h59.2v202.1c0 61.3-35.94 89.2-88.4 89.2-47.4 0-74.85-24.53-88.81-54.075z"
/>
</svg>
@@ -4,18 +4,23 @@
import PostgresIcon from '$lib/components/icons/PostgresIcon.svelte'
import type { SvelteComponent } from 'svelte'
import { BashIcon, GoIcon, PythonIcon, TypeScriptIcon } from './'
import JavaScript from './JavaScript.svelte'
export let lang: SupportedLanguage | 'pgsql' | 'mysql'
export let lang: SupportedLanguage | 'pgsql' | 'mysql' | 'javascript'
export let width = 30
export let height = 30
export let scale = 1
const langToComponent: Record<SupportedLanguage | 'pgsql' | 'mysql', typeof SvelteComponent> = {
const langToComponent: Record<
SupportedLanguage | 'pgsql' | 'mysql' | 'javascript',
typeof SvelteComponent
> = {
go: GoIcon,
python3: PythonIcon,
deno: TypeScriptIcon,
bash: BashIcon,
pgsql: PostgresIcon,
mysql: MySQLIcon
mysql: MySQLIcon,
javascript: JavaScript
}
</script>
@@ -6,7 +6,7 @@
export let disabled: boolean = false
export let label: string
export let lang: SupportedLanguage | 'pgsql' | 'mysql' | undefined = undefined
export let lang: SupportedLanguage | 'pgsql' | 'mysql' | 'javascript' | undefined = undefined
export let icon: IconDefinition | undefined = undefined
export let iconColor: string | undefined = undefined
</script>