Files
windmill/frontend/src/lib/components/LabelsInput.svelte
T
GuilhemandClaude Opus 5 5d5ad4e897 feat: edit folders and groups in a drawer that saves once (#10873)
* fix: portal the confirmation modal so drawers cannot cover it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: log a folder acl grant under the permission it granted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: keep a table's actions column at its right edge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* feat: edit a folder in a drawer that saves once

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* refactor: call the people on a folder or item members

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: edit a folder against the workspace the drawer targets

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* refactor: drop the now-unused sticky actions column

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* docs: correct the script editor drawer's modal placement note

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: pin the actions column without losing the row's hover tint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* feat: show the pinned column's seam only while the table overflows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: draw the pinned column's seam as a shadow so it does not scroll away

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: fade the pinned column's tint in step with its row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* refactor: address review nits on the folder editor and pinned cell

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: keep the folder draft across a user-store refresh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* refactor: extract and test the folder draft's dirty check and permission diff

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: stop the folder editor showing state the server refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: keep a folder draft that no request ever reached the server

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW

* fix: keep unapplied folder edits dirty when a save partially fails

* fix: block folder form edits while a save is in flight

* fix: commit a typed folder label before save snapshots the draft

* fix: count a typed folder label as an unsaved change

* fix: keep escape in the label input from closing what encloses it

* fix: capitalize folder table headers and drop a dead portal target

* refactor: make the confirmation modal portal opt-in per call site

* docs: name the stacking context that actually traps the discard dialog

* fix: report a half-landed member removal so the baseline reconciles

* feat: edit a group in a drawer that saves once

* fix: freeze the group name once the group exists

* fix: revoke the caller's own group acl last so the rest of the save is authorized

* docs: state the group call-ordering invariant once

* fix: report a failing post-save reload instead of dropping the rejection

* fix: hand the folder list reload back so a failure is reported

* fix: treat a rejected group create as inconclusive and catch a throwing onSaved

* revert: stop inferring a group was created from its name being taken

* fix: say when a failed group create may have saved the group anyway

* fix: key the may-have-been-created hint on the name conflict, not the status

* fix: skip the may-have-been-created hint when the group is known to exist

* feat: open a folder's group member from its row

* fix: stop showing the caller as an admin when the read failed

* fix: give up the caller's own folder admin last, and label a create as one

* fix: drop a folder member's acl before its owner entry

* fix: remove a folder owner before their acl, and correct the rls rationale

* docs: say the refusal is on the caller's last admin handle

* fix: defer only the folder rows the caller is an admin through

* docs: describe callerOwners as what the caller passes in

* docs: drop the call-site restatement of the diff's own invariant

* docs: record manager as a legacy group role

* fix: treat a sent request as possibly committed when reconciling

* fix: reconcile on any failed edit, and compare members as a set

* fix: keep write access when only the reconcile read fails

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:22:55 +02:00

202 lines
5.5 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
/** Suggest the labels of this workspace rather than the active one, for an editor
* aimed elsewhere (the folder drawer opened from a cross-workspace picker). */
workspace?: string
/** Text typed into the input but not yet added to `labels`. An editor with a Save
* button needs it: without it that text is invisible to the editor's dirty state,
* so it is silently dropped on close and cannot even enable Save on its own. */
onPendingChange?: (pending: string) => void
}
let {
labels = $bindable(),
onchange,
class: clazz = '',
workspace,
onPendingChange
}: 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)
)
$effect(() => {
onPendingChange?.(adding ? trimmedInput : '')
})
async function loadExistingLabels() {
try {
const resp = await fetch(`/api/w/${workspace ?? $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') {
// Escape cancels the label, and nothing else. Left to bubble it also reaches
// whatever encloses us — a drawer or dialog closes on it, and one guarding on
// unsaved changes reads `pending` before this clears it, so it prompts to
// discard work this key just discarded.
e.preventDefault()
e.stopPropagation()
inputValue = ''
adding = false
onPendingChange?.('')
} 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)
}
/** Add whatever is typed but not yet committed, right now. Blur commits on a 150ms
* grace period, so a caller that reads `labels` in the same tick as the blur — a Save
* button, which blurs this input by being clicked — would miss the last label.
* `adding` is cleared here, so the pending timer then finds nothing to do. */
export function flushPendingLabel(): void {
if (adding) addLabel()
}
</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>