Files
windmill/frontend/src/lib/components/DbColumnFormatEditor.svelte
T
Diego ImbertandClaude Opus 5.5 4fd7d62bd0 feat: rework the db manager: native grid, tabs, sql editor, joined columns (#11340)
* feat: replace ag-grid in the db manager table viewer with a native grid

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add user-managed data, diagram and sql editor tabs to the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add schema autocomplete to the db manager sql editor and polish its layout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add joined foreign key columns and draggable tabs to the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: move db manager tabs on drop instead of mid-drag

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: open followed foreign keys in a new db manager tab

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: scroll large tables, drop stale joins and return to the last tab on close

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: tolerate the db manager tabs going away while switching data table

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep saved joined columns while metadata loads, test joins in every dialect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: put the db manager grid on the input surface in dark mode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: address db manager review nits on joins, boolean keys and sql quoting

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the dark border color on the left pinned column edge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: tone down the db manager tree menu icons

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write json and jsonb values from the db manager through a text cast

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: dim unrelated diagram tables less on hover

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: match json columns as text when deleting a db manager row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: compare postgres json columns as text in exact db manager filters

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: pick the columns the db manager grid shows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: toggle every db manager column from one checkbox

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: word joined columns as a view in the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep data table roles and access visible when unavailable, with the reason

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep data table and instance roles visible in settings when unavailable

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: hide a db manager column from its header menu

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: format db manager columns with a unit, significant digits and color rules

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop the before/after hints from the db manager unit picker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write the euro after the amount and keep units off non-numeric values

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: color rule presets, bold and italic, layered and reorderable rules in the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: decimals, thousands separator, compact notation and alignment in db manager column formats

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: compact db manager format controls, a notation toggle group and a reset button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: db manager format and filter nits

Keep the value's scale when decimals are auto, read boolean color-rule conditions as
booleans, let a rule's text color reach foreign-key links, close the formatter when the
columns picker opens, and filter BigQuery complex columns through TO_JSON_STRING.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 15:58:25 +02:00

363 lines
11 KiB
Svelte

<script lang="ts">
import {
AlignLeft,
AlignRight,
Bold,
GripVertical,
Italic,
Plus,
RotateCcw,
X
} from 'lucide-svelte'
import { Button } from './common'
import Select from './select/Select.svelte'
import TextInput from './text_input/TextInput.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Popover from './meltComponents/Popover.svelte'
import {
formatValue,
RULE_PRESETS,
UNIT_PRESETS,
type ColorRule,
type ColumnFormat
} from './dbTableFormat'
type Props = {
column: string
format: ColumnFormat | undefined
onChange: (format: ColumnFormat) => void
}
let { column, format, onChange }: Props = $props()
const CUSTOM = 'custom'
const AUTO_DECIMALS = -1
let unit = $derived(format?.unit)
let rules = $derived(format?.rules ?? [])
let unitIsPreset = $derived(
!!unit && UNIT_PRESETS.some((p) => p.symbol === unit.symbol && p.position === unit.position)
)
// Kept apart from the format so that picking "Custom" shows the field before anything is typed.
let customUnit = $state(false)
let unitChoice = $derived(
customUnit || (unit?.symbol && !unitIsPreset) ? CUSTOM : (unit?.symbol ?? '')
)
function update(patch: Partial<ColumnFormat>) {
onChange({ ...format, ...patch })
}
function setRule(i: number, patch: Partial<ColorRule>) {
update({ rules: rules.map((r, j) => (j === i ? { ...r, ...patch } : r)) })
}
let addRuleOpen = $state(false)
// Which rule's preview has its presets open.
let restyling: number | undefined = $state()
// Order matters, a later rule overrides an earlier one: rules are dragged by their handle.
let dragFrom: number | undefined = $state()
let dropAt: { index: number; side: 'above' | 'below' } | undefined = $state()
function dropRule() {
if (dragFrom === undefined || !dropAt) return
const moved = rules[dragFrom]
const rest = rules.filter((_, j) => j !== dragFrom)
let to = dropAt.index + (dropAt.side === 'below' ? 1 : 0)
if (dragFrom < to) to--
rest.splice(to, 0, moved)
update({ rules: rest })
}
</script>
{#snippet sample(style: { bg?: string; text?: string; bold?: boolean; italic?: boolean })}
<span
class="flex h-7 w-8 shrink-0 items-center justify-center rounded border"
style:background-color={style.bg}
style:color={style.text}
style:font-weight={style.bold ? 600 : undefined}
style:font-style={style.italic ? 'italic' : undefined}
>
Aa
</span>
{/snippet}
{#snippet presets(onPick: (preset: (typeof RULE_PRESETS)[number]) => void)}
<div class="grid grid-cols-9 gap-1 p-2">
{#each RULE_PRESETS as preset, i (i)}
<button
class="rounded hover:ring-2 hover:ring-border-selected"
title="Use these colors"
onclick={() => onPick(preset)}
>
{@render sample(preset)}
</button>
{/each}
</div>
{/snippet}
<div class="flex w-96 flex-col gap-3 p-3 text-xs" data-testid="db-format-editor">
<div class="flex items-start gap-2">
<div class="flex min-w-0 grow flex-col gap-0.5">
<span class="truncate font-semibold text-emphasis">Format {column}</span>
<span class="text-2xs text-secondary">Only changes how values show in this view.</span>
</div>
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: RotateCcw }}
disabled={!format}
onClick={() => {
customUnit = false
onChange({})
}}
>
Reset
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="font-medium text-secondary">Unit</span>
<div class="flex items-center gap-2">
<Select
class="w-24 shrink-0"
size="sm"
items={[
{ label: 'None', value: '' },
...UNIT_PRESETS.map((p) => ({
label: p.symbol.trim(),
value: p.symbol
})),
{ label: 'Custom', value: CUSTOM }
]}
bind:value={
() => unitChoice,
(v) => {
customUnit = v === CUSTOM
if (v === CUSTOM) update({ unit: { symbol: '', position: 'after' } })
else update({ unit: UNIT_PRESETS.find((p) => p.symbol === v) })
}
}
/>
{#if unitChoice === CUSTOM}
<div class="w-16 shrink-0">
<TextInput
size="sm"
inputProps={{ placeholder: 'kg' }}
bind:value={
() => unit?.symbol ?? '',
(v) => update({ unit: { symbol: v, position: unit?.position ?? 'after' } })
}
/>
</div>
<ToggleButtonGroup
noWFull
bind:selected={
() => unit?.position ?? 'after',
(v) => update({ unit: { symbol: unit?.symbol ?? '', position: v } })
}
>
{#snippet children({ item })}
<ToggleButton value="before" label="Before" {item} small />
<ToggleButton value="after" label="After" {item} small />
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
</div>
<div class="flex flex-col gap-1">
<span class="font-medium text-secondary">Decimals</span>
<div class="flex items-center gap-2">
<Select
class="w-24 shrink-0"
size="sm"
items={[
{ label: 'Auto', value: AUTO_DECIMALS },
...Array.from({ length: 11 }, (_, i) => ({ label: String(i), value: i }))
]}
bind:value={
() => format?.decimals ?? AUTO_DECIMALS,
(v) => update({ decimals: v === AUTO_DECIMALS ? undefined : v })
}
/>
<ToggleButtonGroup
noWFull
bind:selected={
() => format?.notation ?? 'plain',
(v) => update({ notation: v === 'plain' ? undefined : (v as 'thousands' | 'compact') })
}
>
{#snippet children({ item })}
<ToggleButton value="plain" label="1000" tooltip="No separator" {item} small />
<ToggleButton
value="thousands"
label="1,000"
tooltip="Thousands separator"
{item}
small
/>
<ToggleButton
value="compact"
label="Compact"
tooltip="Shorten large numbers: 35.4M"
{item}
small
/>
{/snippet}
</ToggleButtonGroup>
</div>
<span class="text-2xs text-hint">
1234567.891 shows as {formatValue(1234567.891, format) ?? '1234567.891'}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="font-medium text-secondary">Alignment</span>
<ToggleButtonGroup
bind:selected={
() => format?.align ?? 'auto',
(v) => update({ align: v === 'auto' ? undefined : (v as 'left' | 'right') })
}
>
{#snippet children({ item })}
<ToggleButton value="auto" label="Auto" {item} small />
<ToggleButton value="left" label="Left" icon={AlignLeft} {item} small />
<ToggleButton value="right" label="Right" icon={AlignRight} {item} small />
{/snippet}
</ToggleButtonGroup>
</div>
<div class="flex flex-col gap-1">
<span class="font-medium text-secondary">Color rules</span>
{#each rules as rule, i (i)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex items-center gap-1.5"
data-testid="db-format-rule"
ondragover={(e) => {
if (dragFrom === undefined) return
e.preventDefault()
const r = e.currentTarget.getBoundingClientRect()
dropAt = { index: i, side: e.clientY < r.top + r.height / 2 ? 'above' : 'below' }
}}
ondrop={(e) => {
e.preventDefault()
dropRule()
}}
>
{#if dropAt?.index === i && dragFrom !== undefined}
<div
class="pointer-events-none absolute inset-x-0 h-0.5 rounded bg-border-selected"
class:-top-1={dropAt.side === 'above'}
class:-bottom-1={dropAt.side === 'below'}
></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="-mx-1 flex h-7 shrink-0 cursor-grab items-center text-hint hover:text-primary"
title="Drag to reorder"
draggable="true"
ondragstart={(e) => {
dragFrom = i
const row = e.currentTarget.parentElement
if (row) e.dataTransfer?.setDragImage(row, 8, row.offsetHeight / 2)
}}
ondragend={() => {
dragFrom = undefined
dropAt = undefined
}}
>
<GripVertical size={14} />
</div>
<div class="min-w-0 grow">
<TextInput
size="sm"
inputProps={{ placeholder: 'All cells, or >= 4, =paid' }}
bind:value={() => rule.condition, (v) => setRule(i, { condition: v })}
/>
</div>
<input
type="color"
class="!h-7 !w-7 shrink-0 cursor-pointer rounded border bg-transparent !p-0.5"
title="Background color"
value={rule.bg ?? '#ffffff'}
oninput={(e) => setRule(i, { bg: e.currentTarget.value })}
/>
<input
type="color"
class="!h-7 !w-7 shrink-0 cursor-pointer rounded border bg-transparent !p-0.5"
title="Text color"
value={rule.text ?? '#000000'}
oninput={(e) => setRule(i, { text: e.currentTarget.value })}
/>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: Bold }}
selected={!!rule.bold}
title="Bold"
onClick={() => setRule(i, { bold: !rule.bold || undefined })}
/>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: Italic }}
selected={!!rule.italic}
title="Italic"
onClick={() => setRule(i, { italic: !rule.italic || undefined })}
/>
<Popover
contentClasses="z-[10001]"
extraProps={{ 'data-db-format-popover': true }}
floatingConfig={{ strategy: 'fixed', placement: 'bottom-end' }}
bind:isOpen={() => restyling === i, (open) => (restyling = open ? i : undefined)}
>
{#snippet trigger()}
<span title="Pick preset colors">{@render sample(rule)}</span>
{/snippet}
{#snippet content()}
{@render presets((preset) => {
setRule(i, { bg: preset.bg, text: preset.text })
restyling = undefined
})}
{/snippet}
</Popover>
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: X }}
title="Remove rule"
onClick={() => update({ rules: rules.filter((_, j) => j !== i) })}
/>
</div>
{/each}
<div class="flex items-center gap-1.5">
<Popover
contentClasses="z-[10001]"
extraProps={{ 'data-db-format-popover': true }}
floatingConfig={{ strategy: 'fixed', placement: 'bottom-start' }}
bind:isOpen={addRuleOpen}
>
{#snippet trigger()}
<Button variant="subtle" unifiedSize="sm" startIcon={{ icon: Plus }} nonCaptureEvent>
Add rule
</Button>
{/snippet}
{#snippet content()}
{@render presets((preset) => {
update({ rules: [...rules, { condition: '', ...preset }] })
addRuleOpen = false
})}
{/snippet}
</Popover>
</div>
<span class="text-2xs text-hint">
Conditions use the search bar's syntax: text contains, =value is exact, numbers take &gt;,
&gt;=, &lt;, &lt;= and !=. An empty condition matches every cell. Every matching rule applies,
a later one overriding an earlier one.
</span>
</div>
</div>