Files
windmill/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte
T
2026-09-16 15:23:20 +02:00

651 lines
22 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte'
import { Sparkles, Plus, List, Ban, ExternalLinkIcon, Loader2 } from 'lucide-svelte'
import type { Policy } from '$lib/gen'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import { sendUserToast } from '$lib/toast'
import { getRawAppOperatingWorkspace } from './rawAppWorkspace'
import Modal from '$lib/components/common/modal/Modal.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Alert } from '$lib/components/common'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { react18Template, react19Template, svelte5Template } from './templates'
import type { Runnable } from './rawAppPolicy'
import {
type DataTableRef,
type RawAppData,
formatDataTableRef,
withAppDatatableRole
} from './dataTableRefUtils'
import {
createDatatableAccessResource,
createDatatablesResource,
createRolesResource,
rolesWorthPicking,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
import RawAppDataTableList from './RawAppDataTableList.svelte'
import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte'
import FileEditorIcon from './FileEditorIcon.svelte'
export type RawAppTemplatePickerResult = {
files: Record<string, string>
runnables: Record<string, Runnable>
data: RawAppData
summary: string
policy: Policy
prompt?: string
}
let {
open = $bindable(),
onStart
}: {
open: boolean
onStart: (result: RawAppTemplatePickerResult, withPrompt: boolean) => void
} = $props()
const templates = [
{ name: 'React 19', icon: 'tsx', files: react19Template },
{ name: 'React 18', icon: 'tsx', files: react18Template },
{ name: 'Svelte 5', icon: 'svelte', files: svelte5Template }
]
let selectedTemplateIndex = $state(0)
let tableCreationEnabled = $state(true)
let selectedDatatable = $state<string | undefined>(undefined)
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
let selectedSchema = $state<string | undefined>(undefined)
let newSchemaName = $state('')
let appSummary = $state('')
let initialPrompt = $state('')
let preWhitelistedTables = $state<DataTableRef[]>([])
/** The role each pre-whitelisted table's data table was browsed as. */
let preWhitelistedRoles = $state<Record<string, string>>({})
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
const getOpWs = getRawAppOperatingWorkspace()
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
const datatables = createDatatablesResource(() => opWs)
const roles = createRolesResource(
() => selectedDatatable,
() => opWs
)
let selectedRole = $state<string | undefined>(undefined)
// Every reader waits for an answer stamped with the current selection: until then `current`
// belongs to the previous data table or role.
const loadedRoles = $derived(
roles.current.datatable === selectedDatatable ? roles.current.roles : []
)
const showRolePicker = $derived(rolesWorthPicking(loadedRoles))
// Saved explicitly rather than left to resolve: "whatever the default is then" moves the app
// the day an admin changes the default.
const effectiveRole = $derived(
selectedRole !== undefined && loadedRoles.includes(selectedRole) ? selectedRole : undefined
)
// An app uses one role per data table: the one picked above is what the table drawer browses
// as and what the list shows, or tables would be added under a role the app is not saved with.
const pickerRoles = $derived(
selectedDatatable !== undefined && effectiveRole !== undefined
? withAppDatatableRole(preWhitelistedRoles, selectedDatatable, effectiveRole)
: preWhitelistedRoles
)
const availableDatatables = $derived(datatables.current)
// `undefined` while the list loads, so this is false until it has answered.
const hasNoDatatables = $derived(availableDatatables?.length === 0)
const rolesSettled = $derived(
hasNoDatatables ||
(selectedDatatable !== undefined && roles.current.datatable === selectedDatatable)
)
// A role is picked on one data table: two data tables can both define an `analyst` that
// means something different, so a name surviving the switch is not the role surviving it.
let rolesPickedOn = $state<string | undefined>(undefined)
$effect(() => {
const loaded = roles.current
if (loaded.datatable !== selectedDatatable) return
const switched = untrack(() => rolesPickedOn) !== selectedDatatable
const current = untrack(() => selectedRole)
if (switched || current === undefined || !loaded.roles.includes(current)) {
// Tables already picked on this data table were browsed as a role: keep that one.
const browsed = selectedDatatable
? untrack(() => preWhitelistedRoles)[selectedDatatable]
: undefined
pickRole(
browsed !== undefined && loaded.roles.includes(browsed)
? browsed
: loaded.roles.includes(loaded.defaultRole)
? loaded.defaultRole
: loaded.roles[0]
)
rolesPickedOn = selectedDatatable
}
})
/** Picks the app's role on the selected data table. Tables picked on it under another role are
* dropped: that role may reach them where this one does not. */
function pickRole(role: string | undefined) {
selectedRole = role
const dt = selectedDatatable
const browsed = dt ? preWhitelistedRoles[dt] : undefined
if (dt === undefined || role === undefined || browsed === undefined || browsed === role) return
preWhitelistedTables = preWhitelistedTables.filter((t) => t.datatable !== dt)
const { [dt]: _, ...rest } = preWhitelistedRoles
preWhitelistedRoles = rest
}
const access = createDatatableAccessResource(
() => selectedDatatable,
() => effectiveRole,
() => opWs,
() => rolesSettled && (loadedRoles.length === 0 || effectiveRole !== undefined)
)
// Under roles, and this caller may use none of them: the app would be saved with queries the
// server refuses.
const noUsableRole = $derived(
rolesSettled &&
selectedDatatable !== undefined &&
roles.current.permissioned &&
loadedRoles.length === 0
)
const rolesUnknown = $derived(rolesSettled && roles.current.failed)
const accessSettled = $derived(
hasNoDatatables ||
(rolesSettled &&
selectedDatatable !== undefined &&
access.current.datatable === selectedDatatable &&
access.current.role === effectiveRole)
)
const accessUnknown = $derived(accessSettled && access.current.failed)
const availableSchemas = $derived(accessSettled ? access.current.schemas : [])
const canCreateSchema = $derived(accessSettled && access.current.canCreateSchema)
// Only an app that keeps the data table is held back by it: with table creation off nothing
// saves it.
const blockedByRole = $derived(
(noUsableRole || rolesUnknown || accessUnknown) && tableCreationEnabled
)
// A role that cannot create schemas has nothing to name, so the mode goes back to the one
// every role has, once that is an answer.
$effect(() => {
if (accessSettled && !accessUnknown && schemaMode === 'new' && !canCreateSchema) {
schemaMode = 'none'
}
})
// Likewise an existing schema the current role no longer reaches is unpicked, so the select
// does not keep showing it.
$effect(() => {
if (
accessSettled &&
selectedSchema !== undefined &&
!availableSchemas.includes(selectedSchema)
) {
selectedSchema = undefined
}
})
let hasAutoSelected = false
$effect(() => {
if (availableDatatables?.length > 0 && !hasAutoSelected) {
hasAutoSelected = true
selectedDatatable = availableDatatables.includes('main') ? 'main' : availableDatatables[0]
}
})
function generateUniqueSchemaName(existingSchemas: string[]): string {
let num = 1
while (existingSchemas.includes(`app${num}`)) {
num++
}
return `app${num}`
}
const newSchemaAlreadyExists = $derived(
schemaMode === 'new' &&
newSchemaName.trim() !== '' &&
(availableSchemas ?? []).includes(newSchemaName.trim())
)
let userEditedSchemaName = $state(false)
$effect(() => {
const schemas = availableSchemas ?? []
if (schemaMode === 'new') {
if (!newSchemaName) {
newSchemaName = generateUniqueSchemaName(schemas)
userEditedSchemaName = false
} else if (!userEditedSchemaName && schemas.includes(newSchemaName)) {
newSchemaName = generateUniqueSchemaName(schemas)
}
}
})
const datatableItems = $derived(toDatatableItems(availableDatatables))
const schemaItems = $derived(toSchemaItems(availableSchemas))
// An existing schema counts only while the current role reaches it: one picked under another
// role would save an app that creates its tables where it cannot.
const effectiveSchema = $derived(
schemaMode === 'new'
? newSchemaName
: schemaMode === 'existing' &&
selectedSchema !== undefined &&
availableSchemas.includes(selectedSchema)
? selectedSchema
: undefined
)
// copilotInfo is a global that stays empty until some ancestor's fetch lands, so
// `enabled` alone cannot tell "no providers" from "not loaded yet" and the modal
// would announce AI as unconfigured while it is merely unknown. Gate on the
// config describing opWs, and load it here so the claim owns its own evidence.
const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs)
// Say where the button leads: the route hands this prompt to a fresh AI
// session for everyone who has one, and drives the docked chat for the rest.
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled)
$effect(() => {
if (open && opWs && !aiConfigLoaded) {
loadCopilot(opWs)
}
})
async function start(withPrompt: boolean) {
const template = templates[selectedTemplateIndex]
if (
tableCreationEnabled &&
schemaMode === 'new' &&
newSchemaName &&
selectedDatatable &&
opWs
) {
try {
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
const dbOps = dbSchemaOpsWithPreviewScripts({
workspace: opWs,
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`,
role: effectiveRole
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
} catch (e) {
console.error('Failed to create schema:', e)
sendUserToast(`Failed to create schema: ${e}`, true)
}
}
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
const keepsDatatable = tableCreationEnabled && selectedDatatable !== undefined
// The roles shown, for the data tables the app ends up using.
const usedDatatables = new Set(preWhitelistedTables.map((t) => t.datatable))
if (keepsDatatable) usedDatatables.add(selectedDatatable!)
const shownRoles = Object.entries(pickerRoles ?? {}).filter(([dt]) => usedDatatables.has(dt))
const appRoles = shownRoles.length > 0 ? Object.fromEntries(shownRoles) : undefined
const data: RawAppData = keepsDatatable
? {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema,
roles: appRoles
}
: { tables: formattedTables, datatable: undefined, schema: undefined, roles: appRoles }
const policy: Policy = {
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
}
open = false
onStart(
{
files: template.files,
runnables: {},
data,
summary: appSummary.trim(),
policy,
prompt: withPrompt ? initialPrompt.trim() : undefined
},
withPrompt
)
}
</script>
{#if open}
<!-- `bind:open` (not `open`) so the inner Modal's X / Esc / click-
outside dismissal propagates back to the parent. Without it the
Modal closes its own UI but the picker's `open` prop stays true,
so the route's `templatePicker → false` watcher never fires and
autosave stays suspended after the dismissal. -->
<Modal kind="X" bind:open title="New App setup">
<div class="flex flex-col gap-6 min-w-sm">
<div>
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
<TextInput
bind:value={appSummary}
inputProps={{
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
}}
/>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
<div class="flex flex-wrap gap-3">
{#each templates as t, i}
<button
onclick={() => (selectedTemplateIndex = i)}
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
? 'bg-surface-accent-selected border border-accent'
: ''} hover:bg-surface-hover border rounded-lg transition-all"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} size={32} />
</div>
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
</button>
{/each}
</div>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
{#if hasNoDatatables}
<Alert type="warning" title="No datatables configured.">
You can still create an app, but for data storage you won't be able to use data tables
which are <b>highly recommended</b>.
<br />
{#if $userStore?.is_admin}
Configure datatables in
<a
href="/workspace_settings?tab=windmill_data_tables"
target="_blank"
class="inline-flex items-center gap-1"
>workspace settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure datatables in workspace settings to enable this
feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
<div class="flex flex-col gap-4 rounded-md p-4 border">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<div class="flex flex-row items-center gap-2">
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
{#if showRolePicker}
<!-- Reads as one phrase, "main as analyst", so the role needs no label. -->
<span class="text-xs text-secondary">as</span>
<Select
id="datatable-role"
disablePortal
items={loadedRoles.map((r) => ({ value: r, label: r }))}
bind:value={() => selectedRole, pickRole}
clearable={false}
placeholder="Role"
size="sm"
class="w-40"
/>
{/if}
{#if noUsableRole || rolesUnknown || accessUnknown}
<span
class="text-xs text-red-600 dark:text-red-400"
title={access.current.error}
>
{rolesUnknown
? 'could not read its roles'
: noUsableRole
? 'no role you can use'
: 'could not reach it'}
</span>
{/if}
</div>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
<div>
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton
value="new"
label="New"
icon={Plus}
disabled={!canCreateSchema}
tooltip={canCreateSchema
? undefined
: noUsableRole
? `You can use no role of ${selectedDatatable}`
: accessUnknown
? `Could not read what may be created in ${selectedDatatable}`
: `${effectiveRole ?? 'This connection'} cannot create schemas in ${selectedDatatable}`}
{item}
size="sm"
/>
<ToggleButton
value="existing"
label="Existing"
icon={List}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{#if schemaMode === 'new'}
<TextInput
bind:value={newSchemaName}
inputProps={{
placeholder: 'Schema name',
oninput: () => (userEditedSchemaName = true)
}}
class="flex-1"
error={newSchemaAlreadyExists}
size="sm"
/>
{:else if schemaMode === 'existing'}
<div class="flex-1">
<Select
disablePortal
items={schemaItems}
bind:value={selectedSchema}
placeholder="Schema"
size="sm"
/>
</div>
{/if}
</div>
{#if newSchemaAlreadyExists}
<span class="text-xs text-red-500"
>Schema "{newSchemaName}" already exists</span
>
{/if}
</div>
</div>
</div>
</div>
<div class="flex items-center">
<Toggle
size="sm"
bind:checked={tableCreationEnabled}
options={{ right: 'Allow AI to create new tables' }}
/>
</div>
<div class="pt-6">
<RawAppDataTableList
dataTableRefs={preWhitelistedTables}
defaultDatatable={selectedDatatable}
defaultSchema={effectiveSchema}
roles={pickerRoles}
standalone
hideDefaultSelector
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={(index) => {
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
}}
/>
</div>
</div>
{/if}
</div>
{#if !$copilotInfo.workspaceDisabled}
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1 flex items-center gap-2">
<Sparkles size={16} class="text-ai" />
Start with AI
<span class="text-xs font-normal text-tertiary">(optional)</span>
</h2>
{#if !aiConfigLoaded}
<div class="flex items-center gap-2 text-xs text-tertiary">
<Loader2 size={14} class="animate-spin" />
Loading AI settings...
</div>
{:else if !isAiEnabled}
<Alert type="info" title="AI is not configured.">
You can still create an app manually but using AI is highly recommended.
<br />
{#if $userStore?.is_admin}
Configure AI in
<a
href="{base}/workspace_settings?tab=ai"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>workspace settings <ExternalLinkIcon size={16} />
</a>
{#if $superadmin}
or
<a
href="{base}/?workspace=admins#superadmin-settings"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>instance settings <ExternalLinkIcon size={16} />
</a>
{/if} to enable this feature.
{:else if $superadmin}
Configure AI in
<a
href="{base}/?workspace=admins#superadmin-settings"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>instance settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure AI in workspace settings to enable this
feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-2">
<TextInput
underlyingInputEl="textarea"
bind:value={initialPrompt}
inputProps={{
rows: 3,
placeholder:
"Describe what you want to build... (e.g., 'Create a todo list app with user authentication')"
}}
/>
<p class="text-xs text-tertiary">
{handsOffToSession
? 'Leave empty to start with a blank template, or describe your app to open an AI session that builds it.'
: 'Leave empty to start with a blank template, or describe your app to get AI assistance right away.'}
</p>
</div>
{/if}
</div>
{/if}
<div class="pt-6 flex justify-end gap-3">
<Button
variant="default"
size="sm"
on:click={() => start(false)}
disabled={!templates[selectedTemplateIndex] ||
newSchemaAlreadyExists ||
!rolesSettled ||
!accessSettled ||
blockedByRole}
>
{$copilotInfo.workspaceDisabled ? 'Start' : 'Start without AI'}
</Button>
{#if isAiEnabled}
<Button
variant="accent"
on:click={() => start(true)}
disabled={!rolesSettled ||
!accessSettled ||
blockedByRole ||
!templates[selectedTemplateIndex] ||
!initialPrompt.trim() ||
newSchemaAlreadyExists}
startIcon={{ icon: Sparkles }}
btnClasses={AIBtnClasses('accent')}
>
{handsOffToSession ? 'Start in AI session' : 'Start with AI'}
</Button>
{/if}
</div>
</div>
</Modal>
{/if}
<RawAppDataTableDrawer
bind:this={dataTableDrawer}
offset={10000}
existingRefs={preWhitelistedTables}
roles={pickerRoles}
onAdd={(refs, browsedRoles, roleChanged) => {
preWhitelistedTables = [
...preWhitelistedTables.filter((t) => !roleChanged.has(t.datatable)),
...refs
]
preWhitelistedRoles = { ...preWhitelistedRoles, ...browsedRoles }
if (selectedDatatable !== undefined && browsedRoles[selectedDatatable] !== undefined) {
selectedRole = browsedRoles[selectedDatatable]
}
}}
/>