mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 16:03:21 +00:00
fix: allow renaming of backend runnables in the UI
This commit is contained in:
@@ -6,9 +6,8 @@
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import { LayoutDashboard, Loader2, Plus, Code2, FlaskConical } from 'lucide-svelte'
|
||||
import { LayoutDashboard, Loader2, Plus, Code2 } from 'lucide-svelte'
|
||||
import { importStore } from '../apps/store'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import YAML from 'yaml'
|
||||
|
||||
let drawer: Drawer | undefined = undefined
|
||||
@@ -16,9 +15,7 @@
|
||||
|
||||
let importType: 'yaml' | 'json' = $state('yaml')
|
||||
|
||||
// Modal states
|
||||
let appTypeModalOpen = $state(false)
|
||||
let featurePreviewModalOpen = $state(false)
|
||||
|
||||
async function importRaw() {
|
||||
$importStore = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw)
|
||||
@@ -37,11 +34,6 @@
|
||||
|
||||
function selectFullCode() {
|
||||
appTypeModalOpen = false
|
||||
featurePreviewModalOpen = true
|
||||
}
|
||||
|
||||
function confirmFullCode() {
|
||||
featurePreviewModalOpen = false
|
||||
goto(`${base}/apps_raw/add?nodraft=true`)
|
||||
}
|
||||
</script>
|
||||
@@ -116,59 +108,16 @@
|
||||
<h3 class="font-semibold text-primary">Full-code App</h3>
|
||||
<p class="text-xs text-tertiary mt-1">
|
||||
Build with React or Svelte with full control and a powerful AI agent.
|
||||
|
||||
<br /><br />
|
||||
Better for complex apps or apps that require full flexibility and control.
|
||||
</p>
|
||||
</div>
|
||||
<br /><br />
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Badge color="yellow">
|
||||
<FlaskConical size={12} class="mr-1" />
|
||||
Preview (unstable)
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Feature Preview Warning Modal -->
|
||||
<Modal kind="X" bind:open={featurePreviewModalOpen} title="Feature Preview Notice">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800"
|
||||
>
|
||||
<FlaskConical size={24} class="text-yellow-600 dark:text-yellow-400 shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p class="font-semibold mb-2">Full-code Apps are in Feature Preview</p>
|
||||
<p>
|
||||
This feature is not yet stable and may change significantly before it reaches General
|
||||
Availability (GA), which is expected in <strong>January 2026</strong>.
|
||||
</p>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1 text-yellow-700 dark:text-yellow-300">
|
||||
<li>APIs and interfaces may change without notice</li>
|
||||
<li>Some functionality may be incomplete or buggy</li>
|
||||
<li>Not recommended for production use yet</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
onClick={() => {
|
||||
featurePreviewModalOpen = false
|
||||
appTypeModalOpen = true
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
<Button unifiedSize="md" variant="accent" on:click={confirmFullCode}
|
||||
>I understand, continue</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Raw JSON -->
|
||||
<Drawer bind:this={drawer} size="800px">
|
||||
<DrawerContent
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import Popover from '../Popover.svelte'
|
||||
|
||||
import type { Runnable } from '../apps/inputType'
|
||||
import { getNextId } from '$lib/components/flows/idUtils'
|
||||
import { getNextId, forbiddenIds } from '$lib/components/flows/idUtils'
|
||||
import { rawAppLintStore } from './lintStore'
|
||||
import RunnableRow from './RunnableRow.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
interface Props {
|
||||
selectedRunnable: string | undefined
|
||||
@@ -17,6 +18,46 @@
|
||||
|
||||
let { selectedRunnable = $bindable(), runnables, onSelect }: Props = $props()
|
||||
|
||||
let editingId: string | undefined = $state(undefined)
|
||||
|
||||
const validIdPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
||||
|
||||
function renameRunnable(oldId: string, newId: string) {
|
||||
if (newId === oldId) {
|
||||
editingId = undefined
|
||||
return
|
||||
}
|
||||
if (!newId) {
|
||||
sendUserToast('ID cannot be empty', true)
|
||||
editingId = undefined
|
||||
return
|
||||
}
|
||||
if (!validIdPattern.test(newId)) {
|
||||
sendUserToast('ID must be a valid identifier (letters, digits, underscores)', true)
|
||||
editingId = undefined
|
||||
return
|
||||
}
|
||||
if (newId in runnables) {
|
||||
sendUserToast(`ID "${newId}" is already in use`, true)
|
||||
editingId = undefined
|
||||
return
|
||||
}
|
||||
if (forbiddenIds.includes(newId)) {
|
||||
sendUserToast(`"${newId}" is a reserved keyword`, true)
|
||||
editingId = undefined
|
||||
return
|
||||
}
|
||||
|
||||
runnables[newId] = runnables[oldId]
|
||||
delete runnables[oldId]
|
||||
|
||||
if (selectedRunnable === oldId) {
|
||||
selectedRunnable = newId
|
||||
onSelect?.(newId)
|
||||
}
|
||||
editingId = undefined
|
||||
}
|
||||
|
||||
// Subscribe to lint store for reactive updates
|
||||
let lintSnapshot = $state(rawAppLintStore.getSnapshot())
|
||||
|
||||
@@ -75,6 +116,7 @@
|
||||
{id}
|
||||
{runnable}
|
||||
isSelected={selectedRunnable === id}
|
||||
isEditing={editingId === id}
|
||||
onSelect={() => {
|
||||
selectedRunnable = id
|
||||
onSelect?.(id)
|
||||
@@ -85,6 +127,9 @@
|
||||
selectedRunnable = undefined
|
||||
}
|
||||
}}
|
||||
onRename={(newId) => renameRunnable(id, newId)}
|
||||
onRequestEdit={() => (editingId = id)}
|
||||
onCancelEdit={() => (editingId = undefined)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -1,53 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import { Ellipsis } from 'lucide-svelte'
|
||||
import { Ellipsis, Pencil } from 'lucide-svelte'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Runnable } from '../apps/inputType'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
runnable: Runnable
|
||||
isSelected: boolean
|
||||
isEditing: boolean
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
onRename: (newId: string) => void
|
||||
onRequestEdit: () => void
|
||||
onCancelEdit: () => void
|
||||
}
|
||||
|
||||
let { id, runnable, isSelected, onSelect, onDelete }: Props = $props()
|
||||
let {
|
||||
id,
|
||||
runnable,
|
||||
isSelected,
|
||||
isEditing,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onRequestEdit,
|
||||
onCancelEdit
|
||||
}: Props = $props()
|
||||
|
||||
let dropdownOpen = $state(false)
|
||||
let editValue = $state(id)
|
||||
let textInputElement: TextInput | undefined = $state()
|
||||
|
||||
function finishEdit() {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed && trimmed !== id) {
|
||||
onRename(trimmed)
|
||||
} else {
|
||||
onCancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
finishEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
editValue = id
|
||||
onCancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isEditing && textInputElement) {
|
||||
editValue = id
|
||||
tick().then(() => {
|
||||
textInputElement?.focus()
|
||||
textInputElement?.select()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<button
|
||||
{id}
|
||||
class="relative w-full gap-1 flex items-center h-6 rounded-md px-1 group
|
||||
{isSelected ? 'bg-surface-accent-selected text-accent' : 'hover:bg-surface-hover'}"
|
||||
onclick={onSelect}
|
||||
>
|
||||
<Badge color="indigo" class={isSelected ? 'bg-surface-tertiary' : ''}>{id}</Badge>
|
||||
<span class="text-xs truncate font-normal pr-6">{runnable?.name}</span>
|
||||
<DropdownV2
|
||||
items={[
|
||||
{
|
||||
displayName: 'Delete',
|
||||
action: onDelete,
|
||||
type: 'delete'
|
||||
}
|
||||
]}
|
||||
class={twMerge(
|
||||
'absolute -translate-y-1/2 top-1/2 right-1 hidden group-hover:block',
|
||||
dropdownOpen ? 'block' : ''
|
||||
)}
|
||||
bind:open={dropdownOpen}
|
||||
{#if isEditing}
|
||||
<div
|
||||
class="w-full flex items-center gap-1 h-6 rounded-md px-1
|
||||
{isSelected ? 'bg-surface-accent-selected text-accent' : ''}"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
iconOnly
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Ellipsis }}
|
||||
nonCaptureEvent
|
||||
/>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</button>
|
||||
<TextInput
|
||||
bind:this={textInputElement}
|
||||
bind:value={editValue}
|
||||
inputProps={{
|
||||
onkeydown: handleInputKeydown,
|
||||
onblur: () => finishEdit(),
|
||||
type: 'text'
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
{id}
|
||||
class="relative w-full gap-1 flex items-center h-6 rounded-md px-1 group
|
||||
{isSelected ? 'bg-surface-accent-selected text-accent' : 'hover:bg-surface-hover'}"
|
||||
onclick={onSelect}
|
||||
>
|
||||
<Badge color="indigo" class={isSelected ? 'bg-surface-tertiary' : ''}>{id}</Badge>
|
||||
<span class="text-xs truncate font-normal pr-6">{runnable?.name}</span>
|
||||
<DropdownV2
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => onRequestEdit()
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
action: onDelete,
|
||||
type: 'delete'
|
||||
}
|
||||
]}
|
||||
class={twMerge(
|
||||
'absolute -translate-y-1/2 top-1/2 right-1 hidden group-hover:block',
|
||||
dropdownOpen ? 'block' : ''
|
||||
)}
|
||||
bind:open={dropdownOpen}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
iconOnly
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Ellipsis }}
|
||||
nonCaptureEvent
|
||||
/>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user