mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 16:05:00 +00:00
* 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>
202 lines
5.5 KiB
Svelte
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>
|