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:
Guilhem Lemouel
2026-09-08 10:37:01 +02:00
co-authored by Claude Opus 5
parent a20d037020
commit 82d72f56ff
11 changed files with 532 additions and 284 deletions
+23 -140
View File
@@ -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}&cross;{/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"