mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
refactor: edit label colors from a picker dropdown and workspace settings
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNUCuRqmzNukjR7FDewN7W
This commit is contained in:
co-authored by
Claude Opus 5
parent
a20d037020
commit
82d72f56ff
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { Plus, Tag, X } from 'lucide-svelte'
|
||||
import type { Label, LabelColor } from '$lib/gen'
|
||||
import { Plus, Tag } from 'lucide-svelte'
|
||||
import type { Label } from '$lib/gen'
|
||||
import LabelBadge from './labels/LabelBadge.svelte'
|
||||
import LabelColorPicker from './labels/LabelColorPicker.svelte'
|
||||
import { labelCache, labelColorOf, loadLabels, setLabelColor } from './labels/labelStore'
|
||||
import { LABEL_COLOR_SWATCHES } from './labels/labelColors'
|
||||
import LabelPickerDropdown from './labels/LabelPickerDropdown.svelte'
|
||||
import { loadLabels } from './labels/labelStore'
|
||||
|
||||
interface Props {
|
||||
labels: string[] | undefined
|
||||
@@ -22,29 +21,11 @@
|
||||
let { labels = $bindable(), workspace, onchange, class: clazz = '' }: Props = $props()
|
||||
|
||||
let adding = $state(false)
|
||||
let inputValue = $state('')
|
||||
let inputEl: HTMLInputElement | undefined = $state()
|
||||
let existingLabels: Label[] = $state([])
|
||||
let selectedIdx = $state(-1)
|
||||
let picker: LabelPickerDropdown | undefined = $state()
|
||||
|
||||
let suggestions = $derived(
|
||||
existingLabels
|
||||
.filter(
|
||||
(l) =>
|
||||
(!inputValue || l.name.toLowerCase().includes(inputValue.toLowerCase())) &&
|
||||
!(labels ?? []).includes(l.name)
|
||||
)
|
||||
.slice(0, 8)
|
||||
)
|
||||
let trimmedInput = $derived(inputValue.trim())
|
||||
let showCreateNew = $derived(
|
||||
trimmedInput.length > 0 &&
|
||||
!suggestions.some((s) => s.name.toLowerCase() === trimmedInput.toLowerCase()) &&
|
||||
!(labels ?? []).includes(trimmedInput)
|
||||
)
|
||||
|
||||
// The chips need colors before the suggestion list is ever opened, and
|
||||
// `loadLabels` is cached per workspace, so this costs one request per workspace.
|
||||
// The chips need colors before the picker is ever opened, and `loadLabels` is
|
||||
// cached per workspace, so this costs one request per workspace.
|
||||
$effect(() => {
|
||||
if (workspace) {
|
||||
loadLabels(workspace).catch(() => {})
|
||||
@@ -61,25 +42,15 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function pickColor(name: string, color: LabelColor | undefined) {
|
||||
if (!workspace) return
|
||||
existingLabels = await setLabelColor(workspace, name, color)
|
||||
}
|
||||
|
||||
function startAdding() {
|
||||
picker?.reset()
|
||||
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
|
||||
}
|
||||
function addLabel(value: string) {
|
||||
const v = value.trim().slice(0, 50)
|
||||
if (!v) return
|
||||
if (!labels) {
|
||||
labels = []
|
||||
}
|
||||
@@ -87,8 +58,6 @@
|
||||
labels = [...labels, v]
|
||||
onchange?.()
|
||||
}
|
||||
inputValue = ''
|
||||
adding = false
|
||||
}
|
||||
|
||||
function removeLabel(label: string) {
|
||||
@@ -97,110 +66,24 @@
|
||||
onchange?.()
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (selectedIdx >= 0 && selectedIdx < suggestions.length) {
|
||||
addLabel(suggestions[selectedIdx].name)
|
||||
} 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)}
|
||||
<div class="inline-flex items-center">
|
||||
<LabelColorPicker
|
||||
color={labelColorOf($labelCache, workspace, label)}
|
||||
onSelect={(c) => pickColor(label, c)}
|
||||
>
|
||||
{#snippet anchor()}
|
||||
<LabelBadge
|
||||
{label}
|
||||
{workspace}
|
||||
title="Pick a color for {label}"
|
||||
class="rounded-r-none pr-0.5"
|
||||
/>
|
||||
{/snippet}
|
||||
</LabelColorPicker>
|
||||
<button
|
||||
class="text-2xs rounded-r-md pl-0.5 pr-1 py-0.5 bg-surface-sunken hover:text-red-500"
|
||||
aria-label="Remove label {label}"
|
||||
onclick={() => removeLabel(label)}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<LabelBadge {label} {workspace} onRemove={removeLabel} />
|
||||
{/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"
|
||||
<div class="w-40">
|
||||
<LabelPickerDropdown
|
||||
bind:this={picker}
|
||||
bind:open={adding}
|
||||
{workspace}
|
||||
labels={existingLabels}
|
||||
taken={labels ?? []}
|
||||
onPick={addLabel}
|
||||
onLabelsChanged={loadExistingLabels}
|
||||
inputClass="!h-5 !min-h-0 !py-0 text-2xs"
|
||||
/>
|
||||
{#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 flex items-center gap-1.5 text-left text-2xs px-2 py-1 hover:bg-surface-hover {i ===
|
||||
selectedIdx
|
||||
? 'bg-surface-hover'
|
||||
: ''}"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault()
|
||||
addLabel(suggestion.name)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="block w-2 h-2 rounded-full shrink-0 {LABEL_COLOR_SWATCHES[
|
||||
suggestion.color ?? 'blue'
|
||||
]}"
|
||||
></span>
|
||||
{suggestion.name}
|
||||
</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
|
||||
@@ -210,7 +93,7 @@
|
||||
endIcon={{ icon: Plus, props: { size: 8 } }}
|
||||
btnClasses="!gap-0.5"
|
||||
aria-label="Add label"
|
||||
onclick={startAdding}
|
||||
onClick={startAdding}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -16,17 +16,21 @@
|
||||
|
||||
<div class="grid grid-cols-5 gap-1" style="min-width: 140px">
|
||||
{#each colors as color (color)}
|
||||
<!-- The colour lives on an inner span, not on the button: the button's own
|
||||
hover and focus-visible backgrounds would otherwise paint over it, and a
|
||||
just-clicked swatch would sit there showing the hover grey. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
title={color.charAt(0).toUpperCase() + color.slice(1)}
|
||||
aria-label={`Select ${color} color`}
|
||||
onclick={() => onSelect(color)}
|
||||
onClick={() => onSelect(color)}
|
||||
btnClasses={twMerge(
|
||||
'w-6 h-6 p-0 rounded-full hover:scale-110 transition-transform duration-100',
|
||||
swatches[color],
|
||||
selected === color ? 'ring-2 ring-accent' : 'dark:border-gray-600'
|
||||
selected === color ? 'ring-2 ring-accent' : ''
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<span class={twMerge('w-full h-full rounded-full', swatches[color])}></span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import LabelManagerDrawer from '$lib/components/labels/LabelManagerDrawer.svelte'
|
||||
import LabelBadge from '$lib/components/labels/LabelBadge.svelte'
|
||||
import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
@@ -28,7 +27,6 @@
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
ListFilterPlus,
|
||||
Palette,
|
||||
SearchCode
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
@@ -1055,7 +1053,6 @@
|
||||
function itemLabels(x: { labels?: string[]; inherited_labels?: string[] }): string[] {
|
||||
return [...(x.labels ?? []), ...(x.inherited_labels ?? [])]
|
||||
}
|
||||
let labelManagerDrawer: LabelManagerDrawer | undefined = $state()
|
||||
|
||||
let allLabels = $derived(
|
||||
Array.from(new Set(combinedItems?.flatMap((x) => itemLabels(x)) ?? [])).sort()
|
||||
@@ -1604,14 +1601,6 @@
|
||||
{#if label === labelFilter}✗{/if}
|
||||
</LabelBadge>
|
||||
{/each}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
startIcon={{ icon: Palette }}
|
||||
onclick={() => labelManagerDrawer?.open()}
|
||||
>
|
||||
Manage labels
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if filteredItems?.length == 0}
|
||||
@@ -1846,5 +1835,3 @@
|
||||
onDone={reloadItemsAndCounts}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<LabelManagerDrawer bind:this={labelManagerDrawer} workspace={$workspaceStore} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Folder, Tag } from 'lucide-svelte'
|
||||
import { Folder, Tag, X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import { labelBadgeColor } from './labelColors'
|
||||
@@ -19,7 +19,13 @@
|
||||
title?: string
|
||||
class?: string
|
||||
onclick?: (event: MouseEvent) => void
|
||||
/** Trailing content inside the chip, e.g. a remove button. */
|
||||
/**
|
||||
* Renders a remove cross inside the chip, so it carries the chip's own
|
||||
* colour instead of sitting next to it. Not combinable with `clickable`:
|
||||
* that renders the chip itself as a button, and a button cannot nest one.
|
||||
*/
|
||||
onRemove?: (label: string) => void
|
||||
/** Trailing content inside the chip. */
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
@@ -34,6 +40,7 @@
|
||||
title,
|
||||
class: clazz = '',
|
||||
onclick,
|
||||
onRemove,
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
@@ -58,4 +65,17 @@
|
||||
{/if}
|
||||
{label}
|
||||
{@render children?.()}
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
class="-mr-0.5 rounded-sm opacity-60 hover:opacity-100 hover:text-red-500"
|
||||
aria-label="Remove label {label}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove?.(label)
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</Badge>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import ColorSwatchGrid from '$lib/components/common/colorPicker/ColorSwatchGrid.svelte'
|
||||
import type { LabelColor } from '$lib/gen'
|
||||
import { LABEL_COLORS, LABEL_COLOR_SWATCHES } from './labelColors'
|
||||
|
||||
interface Props {
|
||||
color: LabelColor | undefined
|
||||
/** `undefined` clears the color and drops the label's row. */
|
||||
onSelect: (color: LabelColor | undefined) => void
|
||||
anchor: Snippet
|
||||
}
|
||||
|
||||
let { color, onSelect, anchor }: Props = $props()
|
||||
|
||||
let isOpen = $state(false)
|
||||
|
||||
function pick(next: LabelColor | undefined) {
|
||||
isOpen = false
|
||||
onSelect(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
placement="bottom"
|
||||
contentClasses="p-2"
|
||||
floatingConfig={{ strategy: 'absolute' }}
|
||||
usePointerDownOutside
|
||||
bind:isOpen
|
||||
>
|
||||
{#snippet trigger()}
|
||||
{@render anchor()}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<ColorSwatchGrid
|
||||
colors={LABEL_COLORS}
|
||||
swatches={LABEL_COLOR_SWATCHES}
|
||||
selected={color}
|
||||
onSelect={pick}
|
||||
/>
|
||||
{#if color != undefined}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="w-full mt-2"
|
||||
onclick={() => pick(undefined)}
|
||||
>
|
||||
Clear color
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import type { Label } from '$lib/gen'
|
||||
import LabelForm from './LabelForm.svelte'
|
||||
|
||||
interface Props {
|
||||
/** The workspace the label belongs to — not necessarily the one being navigated. */
|
||||
workspace: string | undefined
|
||||
/** The name that was saved. */
|
||||
onSave?: (name: string) => void
|
||||
}
|
||||
|
||||
let { workspace, onSave }: Props = $props()
|
||||
|
||||
let isOpen = $state(false)
|
||||
let existing: Label | undefined = $state()
|
||||
// Bumped on every open so the form remounts with fresh fields rather than
|
||||
// keeping what the previous label left in them.
|
||||
let opened = $state(0)
|
||||
|
||||
export function initNew() {
|
||||
existing = undefined
|
||||
opened += 1
|
||||
isOpen = true
|
||||
}
|
||||
|
||||
export function initEdit(label: Label) {
|
||||
existing = label
|
||||
opened += 1
|
||||
isOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal2
|
||||
bind:isOpen
|
||||
formStyling
|
||||
fixedWidth="sm"
|
||||
fixedHeight="adaptive"
|
||||
title={existing ? 'Edit label' : 'New label'}
|
||||
>
|
||||
{#key opened}
|
||||
<LabelForm
|
||||
{workspace}
|
||||
{existing}
|
||||
autofocus={existing == undefined}
|
||||
onSaved={(name) => {
|
||||
isOpen = false
|
||||
onSave?.(name)
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</Modal2>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import ColorSwatchGrid from '$lib/components/common/colorPicker/ColorSwatchGrid.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Label, LabelColor } from '$lib/gen'
|
||||
import { ArrowLeft } from 'lucide-svelte'
|
||||
import { LABEL_COLORS, LABEL_COLOR_SWATCHES } from './labelColors'
|
||||
import { setLabelColor } from './labelStore'
|
||||
|
||||
interface Props {
|
||||
/** The workspace the label belongs to — not necessarily the one being navigated. */
|
||||
workspace: string | undefined
|
||||
/** The label being edited. Absent means the form creates one. */
|
||||
existing?: Label
|
||||
/** Prefills the name of a new label, e.g. with what was typed in the picker. */
|
||||
initialName?: string
|
||||
/** The name that was saved, so a caller can attach the label to the item it is editing. */
|
||||
onSaved?: (name: string) => void
|
||||
/** Renders the header with a back arrow. Absent means the surface has its own chrome. */
|
||||
onBack?: () => void
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
let { workspace, existing, initialName = '', onSaved, onBack, autofocus }: Props = $props()
|
||||
|
||||
let name = $state(existing?.name ?? initialName)
|
||||
let color: LabelColor | undefined = $state(
|
||||
// A new label saved with no colour would write nothing, so a new form starts on
|
||||
// the colour uncoloured labels already render as.
|
||||
existing ? existing.color : 'blue'
|
||||
)
|
||||
let saving = $state(false)
|
||||
|
||||
let trimmedName = $derived(String(name).trim())
|
||||
|
||||
async function save() {
|
||||
if (!workspace || !trimmedName) {
|
||||
return
|
||||
}
|
||||
saving = true
|
||||
try {
|
||||
await setLabelColor(workspace, trimmedName, color)
|
||||
onSaved?.(trimmedName)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not save label ${trimmedName}: ${err}`, true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if onBack}
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">
|
||||
{existing ? 'Edit label' : 'New label'}
|
||||
</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
iconOnly
|
||||
aria-label="Back to labels"
|
||||
onClick={onBack}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-secondary">Name</span>
|
||||
<TextInput
|
||||
bind:value={name}
|
||||
size="sm"
|
||||
{autofocus}
|
||||
inputProps={{
|
||||
placeholder: 'Label name',
|
||||
// A label's name is the key of every `labels[]` entry that carries it and
|
||||
// nothing rewrites those arrays, so an existing label's name is fixed.
|
||||
disabled: existing != undefined,
|
||||
maxlength: 50,
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if existing}
|
||||
<span class="text-2xs text-hint">To rename a label, edit the items that carry it.</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs text-secondary">Color</span>
|
||||
<ColorSwatchGrid
|
||||
colors={LABEL_COLORS}
|
||||
swatches={LABEL_COLOR_SWATCHES}
|
||||
selected={color}
|
||||
onSelect={(c) => (color = c)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between items-center gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
disabled={color == undefined}
|
||||
onClick={() => (color = undefined)}
|
||||
>
|
||||
Clear color
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!trimmedName}
|
||||
loading={saving}
|
||||
onClick={save}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Drawer } from '$lib/components/common'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Label, LabelColor } from '$lib/gen'
|
||||
import LabelBadge from './LabelBadge.svelte'
|
||||
import LabelColorPicker from './LabelColorPicker.svelte'
|
||||
import { loadLabels, setLabelColor } from './labelStore'
|
||||
|
||||
interface Props {
|
||||
workspace: string | undefined
|
||||
}
|
||||
|
||||
let { workspace }: Props = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let labels: Label[] | undefined = $state()
|
||||
|
||||
export async function open() {
|
||||
labels = undefined
|
||||
drawer?.openDrawer()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!workspace) return
|
||||
labels = await loadLabels(workspace, true)
|
||||
}
|
||||
|
||||
async function pick(name: string, color: LabelColor | undefined) {
|
||||
if (!workspace) return
|
||||
try {
|
||||
labels = await setLabelColor(workspace, name, color)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not update label ${name}: ${err}`, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="32rem">
|
||||
<DrawerContent title="Labels" on:close={drawer?.closeDrawer}>
|
||||
{#if labels == undefined}
|
||||
<Skeleton layout={[[2], 1, [2], 1, [2]]} />
|
||||
{:else if labels.length === 0}
|
||||
<p class="text-sm text-secondary">
|
||||
No labels yet. Labels appear here once you add one to a script, flow, app, resource,
|
||||
variable, schedule or folder.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary mb-4">
|
||||
A color applies to the label everywhere it is used in this workspace.
|
||||
</p>
|
||||
<div class="flex flex-col divide-y">
|
||||
{#each labels as label (label.name)}
|
||||
<div class="flex items-center py-2">
|
||||
<LabelColorPicker color={label.color} onSelect={(c) => pick(label.name, c)}>
|
||||
{#snippet anchor()}
|
||||
<LabelBadge
|
||||
label={label.name}
|
||||
{workspace}
|
||||
title="Pick a color for {label.name}"
|
||||
/>
|
||||
{/snippet}
|
||||
</LabelColorPicker>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import GenericDropdown from '$lib/components/select/GenericDropdown.svelte'
|
||||
import {
|
||||
inputBaseClass,
|
||||
inputBorderClass,
|
||||
inputSizeClasses
|
||||
} from '$lib/components/text_input/TextInput.svelte'
|
||||
import { clickOutside } from '$lib/utils'
|
||||
import type { Label } from '$lib/gen'
|
||||
import { Pen, Plus } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import LabelForm from './LabelForm.svelte'
|
||||
import { LABEL_COLOR_SWATCHES } from './labelColors'
|
||||
|
||||
interface Props {
|
||||
/** The workspace the labels belong to — not necessarily the one being navigated. */
|
||||
workspace: string | undefined
|
||||
/** Every label of the workspace, coloured or not. */
|
||||
labels: Label[]
|
||||
/** Names already on the item, filtered out of the choices. */
|
||||
taken: string[]
|
||||
open: boolean
|
||||
/** A label was chosen or created — the caller attaches it to its item. */
|
||||
onPick: (name: string) => void
|
||||
/** The registry changed, so the caller can refresh its copy of `labels`. */
|
||||
onLabelsChanged?: () => void
|
||||
inputClass?: string
|
||||
}
|
||||
|
||||
let {
|
||||
workspace,
|
||||
labels,
|
||||
taken,
|
||||
open = $bindable(),
|
||||
onPick,
|
||||
onLabelsChanged,
|
||||
inputClass = ''
|
||||
}: Props = $props()
|
||||
|
||||
type Page = { kind: 'list' } | { kind: 'form'; existing?: Label; initialName: string }
|
||||
|
||||
let page = $state<Page>({ kind: 'list' })
|
||||
let filterText = $state('')
|
||||
let inputEl: HTMLInputElement | undefined = $state()
|
||||
let listHeight = $state(0)
|
||||
let formHeight = $state(0)
|
||||
|
||||
let choices = $derived(
|
||||
labels.filter(
|
||||
(l) =>
|
||||
!taken.includes(l.name) && l.name.toLowerCase().includes(filterText.trim().toLowerCase())
|
||||
)
|
||||
)
|
||||
let trimmedFilter = $derived(filterText.trim())
|
||||
let onForm = $derived(page.kind === 'form')
|
||||
|
||||
// The two pages are stacked, so the frame has to be told which one's height to
|
||||
// take; GenericDropdown measures this element every frame and animates its own
|
||||
// box to match, which is what makes the page change grow and shrink smoothly.
|
||||
let frameHeight = $derived(onForm ? formHeight : listHeight)
|
||||
|
||||
export function reset() {
|
||||
page = { kind: 'list' }
|
||||
filterText = ''
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
inputEl?.focus()
|
||||
}
|
||||
|
||||
function openForm(existing?: Label) {
|
||||
page = { kind: 'form', existing, initialName: existing ? '' : trimmedFilter }
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
page = { kind: 'list' }
|
||||
inputEl?.focus()
|
||||
}
|
||||
|
||||
// Creating a label from the picker means the user wants it on the item, so the
|
||||
// save attaches it. Editing one only recolours it — the pen was clicked to fix a
|
||||
// colour, not to add the label — so it goes back to the list.
|
||||
function onSaved(name: string) {
|
||||
const wasCreating = page.kind === 'form' && page.existing == undefined
|
||||
onLabelsChanged?.()
|
||||
if (wasCreating) {
|
||||
onPick(name)
|
||||
open = false
|
||||
} else {
|
||||
backToList()
|
||||
}
|
||||
}
|
||||
|
||||
function pick(name: string) {
|
||||
onPick(name)
|
||||
open = false
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (onForm) {
|
||||
backToList()
|
||||
} else {
|
||||
open = false
|
||||
}
|
||||
} else if (e.key === 'Enter' && !onForm) {
|
||||
e.preventDefault()
|
||||
if (choices.length > 0) {
|
||||
pick(choices[0].name)
|
||||
} else if (trimmedFilter) {
|
||||
openForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside={{ onClickOutside: () => (open = false) }}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={filterText}
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
placeholder="label"
|
||||
onkeydown={onKeydown}
|
||||
onpointerdown={() => (open = true)}
|
||||
class={twMerge(
|
||||
inputBaseClass,
|
||||
inputSizeClasses.sm,
|
||||
inputBorderClass({ forceFocus: open }),
|
||||
'w-full',
|
||||
inputClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GenericDropdown
|
||||
{open}
|
||||
listAutoWidth={false}
|
||||
getInputRect={inputEl && (() => inputEl!.getBoundingClientRect())}
|
||||
innerClass="w-64"
|
||||
maxHeight={400}
|
||||
>
|
||||
<!-- Both pages stay mounted so the slide has something to slide; the frame clips
|
||||
them and only the active one contributes its height. -->
|
||||
<div
|
||||
class="relative overflow-hidden transition-[height] duration-200 ease-out"
|
||||
style="height: {frameHeight}px"
|
||||
>
|
||||
<div
|
||||
bind:clientHeight={listHeight}
|
||||
class="absolute inset-x-0 top-0 transition-transform duration-200 ease-out"
|
||||
style="transform: translateX({onForm ? '-100%' : '0'})"
|
||||
aria-hidden={onForm}
|
||||
>
|
||||
<ul class="flex flex-col max-h-56 overflow-y-auto">
|
||||
{#each choices as choice (choice.name)}
|
||||
<li class="flex flex-row items-center gap-1 pr-1 hover:bg-surface-hover">
|
||||
<button
|
||||
class="flex flex-row items-center gap-2 min-w-0 flex-1 py-2 pl-4 text-left text-xs text-primary"
|
||||
onclick={() => pick(choice.name)}
|
||||
>
|
||||
<span
|
||||
class="block w-2 h-2 rounded-full shrink-0 {LABEL_COLOR_SWATCHES[
|
||||
choice.color ?? 'blue'
|
||||
]}"
|
||||
></span>
|
||||
<span class="truncate">{choice.name}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
startIcon={{ icon: Pen }}
|
||||
iconOnly
|
||||
aria-label="Edit label {choice.name}"
|
||||
onClick={() => openForm(choice)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
{#if choices.length === 0}
|
||||
<li class="py-6 px-4 text-center text-xs text-secondary">
|
||||
{trimmedFilter ? 'No matching label' : 'No other label'}
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
<div class="flex border-t">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
wrapperClasses="flex-1"
|
||||
btnClasses="rounded-none border-0"
|
||||
startIcon={{ icon: Plus }}
|
||||
onClick={() => openForm()}
|
||||
>
|
||||
New label
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
bind:clientHeight={formHeight}
|
||||
class="absolute inset-x-0 top-0 transition-transform duration-200 ease-out"
|
||||
style="transform: translateX({onForm ? '0' : '100%'})"
|
||||
aria-hidden={!onForm}
|
||||
>
|
||||
{#if page.kind === 'form'}
|
||||
{#key `${page.existing?.name ?? ''}:${page.initialName}`}
|
||||
<div class="p-3">
|
||||
<LabelForm
|
||||
{workspace}
|
||||
existing={page.existing}
|
||||
initialName={page.initialName}
|
||||
autofocus={page.existing == undefined}
|
||||
onBack={backToList}
|
||||
{onSaved}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</GenericDropdown>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
|
||||
import LabelBadge from '$lib/components/labels/LabelBadge.svelte'
|
||||
import LabelEditor from '$lib/components/labels/LabelEditor.svelte'
|
||||
import { loadLabels } from '$lib/components/labels/labelStore'
|
||||
import type { Label } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Pen, Plus } from 'lucide-svelte'
|
||||
|
||||
let labels: Label[] | undefined = $state()
|
||||
let labelEditor: LabelEditor | undefined = $state()
|
||||
|
||||
$effect(() => {
|
||||
const workspace = $workspaceStore
|
||||
if (workspace) {
|
||||
refresh(workspace)
|
||||
}
|
||||
})
|
||||
|
||||
async function refresh(workspace: string) {
|
||||
labels = await loadLabels(workspace, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<LabelEditor
|
||||
bind:this={labelEditor}
|
||||
workspace={$workspaceStore}
|
||||
onSave={() => $workspaceStore && refresh($workspaceStore)}
|
||||
/>
|
||||
|
||||
<SettingsPageHeader
|
||||
title="Labels"
|
||||
description="Labels group scripts, flows, apps, resources, variables, schedules and folders. A color applies to the label everywhere it is used in this workspace."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
onClick={() => labelEditor?.initNew()}
|
||||
>
|
||||
New label
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SettingsPageHeader>
|
||||
|
||||
{#if labels == undefined}
|
||||
<Skeleton layout={[[2], 1, [2], 1, [2]]} />
|
||||
{:else if labels.length === 0}
|
||||
<p class="text-sm text-secondary">
|
||||
No labels yet. Create one here, or add one to a script, flow, app, resource, variable, schedule
|
||||
or folder.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col divide-y border-t border-b">
|
||||
{#each labels as label (label.name)}
|
||||
<div class="flex flex-row items-center justify-between gap-2 py-2">
|
||||
<LabelBadge label={label.name} workspace={$workspaceStore} />
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: Pen }}
|
||||
iconOnly
|
||||
aria-label="Edit label {label.name}"
|
||||
onClick={() => labelEditor?.initEdit(label)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -43,6 +43,7 @@
|
||||
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
|
||||
import { Slack, Target } from 'lucide-svelte'
|
||||
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
|
||||
import LabelSettings from '$lib/components/workspaceSettings/LabelSettings.svelte'
|
||||
|
||||
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
@@ -374,6 +375,7 @@
|
||||
| 'teams'
|
||||
| 'premium'
|
||||
| 'general'
|
||||
| 'labels'
|
||||
| 'webhook'
|
||||
| 'deploy_to'
|
||||
| 'dev_workspace'
|
||||
@@ -1193,6 +1195,12 @@
|
||||
aiId: 'workspace-settings-ai',
|
||||
aiDescription: 'Windmill AI workspace settings'
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
label: 'Labels',
|
||||
aiId: 'workspace-settings-labels',
|
||||
aiDescription: 'Colors for the labels used across this workspace'
|
||||
},
|
||||
{
|
||||
id: 'premium',
|
||||
label: 'Premium plans',
|
||||
@@ -2190,6 +2198,8 @@ export async function main(
|
||||
/>
|
||||
{:else if tab == 'shared_ui'}
|
||||
<SharedUiSettings />
|
||||
{:else if tab == 'labels'}
|
||||
<LabelSettings />
|
||||
{:else if tab == 'trashbin'}
|
||||
<SettingsPageHeader
|
||||
title="Trashbin"
|
||||
|
||||
Reference in New Issue
Block a user