Files
windmill/frontend/src/lib/components/LabelsInput.svelte
T
Guilhem d0f23cc523 feat(frontend): unified EditorHeader with file picker for flow/script/app editors (#9047)
* feat: add EditableInput component

* feat: add EditorHeader for flow editor with file picker entry point

* feat: WorkspaceItemPicker for editor header navigation

* feat: clickable breadcrumb in EditorHeader, scoped picker keyboard nav

* fix: reload flow on URL change and reset highlight in search mode

* feat: editor header layout polish and trigger removal

* feat: apply EditorHeader to script, app, and raw app editors

* fix: show generated initial path for new apps in EditorHeader

* fix: align EditorHeader new-app path with draft drawer's Path component

* fix: read page.params.path in loadApp to reload on URL change

* fix: remount AppEditor when navigating between apps

* fix: clear app/files on URL change so editor remounts with fresh data

* fix: route picker selections to /apps/edit or /apps_raw/edit based on raw_app

* fix: drop Save button from path popover; defer rename to deploy

* refactor: drop pathPopoverContent snippet, bind newEditedPath directly

* refactor: dedupe editor header plumbing (editPathFor, userPathPrefix, breadcrumb snippet)

* fix: freeze breadcrumb during path edit so popover doesn't drift

* fix: drop spinner from path dependency-check; render nothing when no usages

* fix: swallow 404 in checkFlowOnBehalfOf so renaming a flow doesn't toast

* Revert "fix: swallow 404 in checkFlowOnBehalfOf so renaming a flow doesn't toast"

This reverts commit 82dec462ae.

* refactor: drop moveRenameManager dep from EditorHeader; pass onBehalfOfEmail as prop

* refactor: replace breadcrumb-snapshot effect with open/close setter

* refactor: drop unused dirtyPath state from EditorHeader

* fix: surface Path validation error in pen popover

* fix: decouple Path validation error from hideFullPath toggle

* refactor: use InputError for path validation message (slide transition)

* fix: re-derive Path meta from external path changes (sibling sync)

* docs: note Path's meta could be replaced with function-form bindings

* fix: 'Exit & see details' uses deployed path, not live store

* fix: undo/redo shortcut uses shiftKey instead of fragile case-match

* fix: type errors and keep edit pen visible while popover is open

* chore: remove unused meltComponents/Accordion wrapper

* fix: focus search input on picker open via popover openFocus selector

* fix: refocus picker search input on every popover open

* fix: pre-seed picker loaded state from cache so accordion opens at the right place

* fix: breadcrumb and picker track savedPath, not draft-renamed live path

* fix: inject current draft item into picker so breadcrumb scope isn't empty

* docs: add component-level and prop-level docs to EditableInput

* feat: warn that a deploy is needed when path is edited on a saved item

* feat: show same path-change-needs-deploy message in flow/script settings

* fix(flows): persist draft-renamed path through reload and dirty check

* fix: include path in unsaved-changes diff so renames trigger the modal

* feat: nested folders in picker tree and breadcrumb

* fix: per-segment popover state so switching breadcrumbs closes the previous one

* refactor: replace accordion picker with drill-through picker

* refactor(picker): review fixes, drill polish, and breadcrumb collapse

* fix(picker): review fixes — banned bindable, drop sibling-sync, load races, mouse highlight

* fix(picker): smooth-scroll highlighted row into view on open

* fix(picker): second-pass review fixes — load races, cache invalidation, breadcrumb/picker a11y, raw_app routing

* fix(RowIcon): apply size prop to resource_type and fallback divs

* feat(picker): add cross-kind 'All' root; deeper breadcrumb segments open there

* fix(picker,editor): third-pass review — search loading state, pen autofocus, allow empty summary, a11y

* fix(picker,editor): fourth-pass review — own check, flowbuilder arrows, editableinput double-save, customui.path gate, invalidate races

* fix(editor,picker): codex review — fresh URL state per load, granular whitelabel topBar gates

* fix(flows): clear localStorage in auto-reload to break URL-state loop
2026-05-12 14:33:21 +00:00

170 lines
4.1 KiB
Svelte

<script lang="ts">
import Badge from './common/badge/Badge.svelte'
import Button from './common/button/Button.svelte'
import { Plus, Tag, X } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
interface Props {
labels: string[] | undefined
onchange?: () => void
class?: string
}
let { labels = $bindable(), onchange, class: clazz = '' }: Props = $props()
let adding = $state(false)
let inputValue = $state('')
let inputEl: HTMLInputElement | undefined = $state()
let existingLabels: string[] = $state([])
let selectedIdx = $state(-1)
let suggestions = $derived(
existingLabels
.filter(
(l) =>
(!inputValue || l.toLowerCase().includes(inputValue.toLowerCase())) &&
!(labels ?? []).includes(l)
)
.slice(0, 8)
)
let trimmedInput = $derived(inputValue.trim())
let showCreateNew = $derived(
trimmedInput.length > 0 &&
!suggestions.some((s) => s.toLowerCase() === trimmedInput.toLowerCase()) &&
!(labels ?? []).includes(trimmedInput)
)
async function loadExistingLabels() {
try {
const resp = await fetch(`/api/w/${$workspaceStore}/labels/list`)
if (resp.ok) existingLabels = await resp.json()
} catch {}
}
function startAdding() {
adding = true
inputValue = ''
selectedIdx = -1
loadExistingLabels()
setTimeout(() => inputEl?.focus(), 0)
}
function addLabel(value?: string) {
const v = (value ?? inputValue).trim().slice(0, 50)
if (!v) {
adding = false
return
}
if (!labels) {
labels = []
}
if (!labels.includes(v)) {
labels = [...labels, v]
onchange?.()
}
inputValue = ''
adding = false
}
function removeLabel(label: string) {
if (labels) {
labels = labels.filter((l) => l !== label)
onchange?.()
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
if (selectedIdx >= 0 && selectedIdx < suggestions.length) {
addLabel(suggestions[selectedIdx])
} else {
addLabel() // either "Create new" selected or free text
}
} else if (e.key === 'Escape') {
inputValue = ''
adding = false
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const maxIdx = suggestions.length + (showCreateNew ? 1 : 0) - 1
selectedIdx = Math.min(selectedIdx + 1, maxIdx)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIdx = Math.max(selectedIdx - 1, -1)
}
}
function onBlur() {
// Delay to allow click on suggestion
setTimeout(() => {
if (adding) addLabel()
}, 150)
}
</script>
<div class="inline-flex items-center gap-1 ml-0.5 h-5 {clazz}">
{#each labels ?? [] as label (label)}
<Badge color="blue" small>
{label}
<button class="ml-0.5 hover:text-red-500" onclick={() => removeLabel(label)}>
<X size={10} />
</button>
</Badge>
{/each}
{#if adding}
<div class="relative">
<input
bind:this={inputEl}
bind:value={inputValue}
onkeydown={onKeydown}
onblur={onBlur}
class="text-2xs border border-blue-300 rounded px-1.5 py-0 h-5 max-w-32 outline-none focus:ring-1 focus:ring-blue-400"
placeholder="label"
/>
{#if suggestions.length > 0 || showCreateNew}
<div
class="absolute top-6 left-0 z-50 bg-surface border border-light rounded shadow-md max-h-32 overflow-y-auto min-w-32"
>
{#each suggestions as suggestion, i}
<button
class="w-full text-left text-2xs px-2 py-1 hover:bg-surface-hover {i === selectedIdx
? 'bg-surface-hover'
: ''}"
onmousedown={(e) => {
e.preventDefault()
addLabel(suggestion)
}}
>
{suggestion}
</button>
{/each}
{#if showCreateNew}
<button
class="w-full text-left text-2xs px-2 py-1 hover:bg-surface-hover text-blue-600 {selectedIdx ===
suggestions.length
? 'bg-surface-hover'
: ''}"
onmousedown={(e) => {
e.preventDefault()
addLabel()
}}
>
+ Create "{trimmedInput}"
</button>
{/if}
</div>
{/if}
</div>
{:else}
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: Tag }}
endIcon={{ icon: Plus, props: { size: 8 } }}
btnClasses="!gap-0.5"
aria-label="Add label"
onclick={startAdding}
/>
{/if}
</div>