feat(web): sidebar filter UX polish — align ops, group steps by workflow (#647)

* fix(web): align operator column across sidebar filter rows

Dimension select was `flex-1`, which made the op column drift horizontally
between rows — especially on boolean dimensions where the value input is
omitted. Fix by pinning the dimension and op selects to fixed widths so
only the value column flexes, keeping the middle "is/in/not in" aligned
across every row.

* feat(web): group workflow steps by workflow in sidebar filter dropdowns

The Workflow step filter previously flattened steps from every workflow
into one unlabelled list, making it impossible to tell which workflow a
step belonged to. Tag each step option with its workflow name and render
the single- and multi-select dropdowns with a labelled group + separator
per workflow. Other enum dimensions stay flat (backward-compatible via
an optional `group` field on the option).

* refactor(web): extract shared helper for sidebar filter option groups

Both the single- and multi-select value dropdowns built group buckets
with the same consecutive-key loop, which implicitly assumed options of
the same group arrived back-to-back. Extract a `buildOptionGroups`
helper backed by a Map so the adjacency assumption goes away and the
rendering call sites shrink to one line each. Also include the stable
`option.value` in the cmdk `CommandItem` value so same-titled steps
inside one workflow don't collide on identity/search.

* fix(web): truncate sidebar filter single-select value within popover

The single-select value trigger and the free-text input were `flex-1`
without `min-w-0`, so a long value (e.g. repository slug) pushed the
row wider than the popover and bled the remove button past the right
edge. The multi-select trigger already had `min-w-0`, which is why the
"in" operator rendered correctly. Add `min-w-0` to both so the flex
items can shrink and the built-in SelectValue `line-clamp-1` ellipsis
engages.
This commit is contained in:
José Almeida
2026-04-20 19:32:36 +01:00
committed by GitHub
parent 0b5919c2a2
commit d209d2ce2a
6 changed files with 304 additions and 38 deletions
@@ -1,7 +1,17 @@
"use client";
import { IconX } from "@tabler/icons-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@kandev/ui/select";
import { Fragment } from "react";
import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
@@ -14,8 +24,9 @@ import type {
import { DIMENSION_METAS, getDimensionMeta, getOpLabel } from "./filter-dimension-registry";
import { useFilterValueOptions } from "./use-filter-value-options";
import { FilterMultiSelect } from "./filter-multi-select";
import { buildOptionGroups, hasGroupedOptions } from "./filter-option-groups";
type ValueOption = { value: string; label: string; color?: string };
type ValueOption = { value: string; label: string; color?: string; group?: string };
function OptionLabel({ option }: { option: ValueOption }) {
return (
@@ -91,7 +102,7 @@ export function FilterClauseEditor({ clause, onChange, onRemove }: Props) {
>
<SelectTrigger
size="sm"
className="h-7 flex-1 text-xs"
className="h-7 w-32 shrink-0 text-xs"
data-testid="filter-dimension-select"
>
<SelectValue />
@@ -106,7 +117,11 @@ export function FilterClauseEditor({ clause, onChange, onRemove }: Props) {
</Select>
<Select value={clause.op} onValueChange={(v) => handleOpChange(v as FilterOp)}>
<SelectTrigger size="sm" className="h-7 w-24 text-xs" data-testid="filter-op-select">
<SelectTrigger
size="sm"
className="h-7 w-24 shrink-0 text-xs"
data-testid="filter-op-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -157,7 +172,7 @@ function ValueInput({
value={String(clause.value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={meta.placeholder ?? "Value"}
className="h-7 flex-1 text-xs"
className="h-7 min-w-0 flex-1 text-xs"
data-testid="filter-value-input"
/>
);
@@ -175,21 +190,56 @@ function ValueInput({
const current = String(clause.value ?? "");
return (
<Select value={current} onValueChange={(v) => onChange(v)}>
<SelectTrigger size="sm" className="h-7 flex-1 text-xs" data-testid="filter-value-select">
<SelectTrigger
size="sm"
className="h-7 min-w-0 flex-1 text-xs"
data-testid="filter-value-select"
>
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent>
{options.length === 0 && (
{options.length === 0 ? (
<SelectItem value="__empty__" disabled className="text-xs">
No options
</SelectItem>
) : (
<GroupedSelectItems options={options} />
)}
</SelectContent>
</Select>
);
}
function GroupedSelectItems({ options }: { options: ValueOption[] }) {
if (!hasGroupedOptions(options)) {
return (
<>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<OptionLabel option={opt} />
</SelectItem>
))}
</SelectContent>
</Select>
</>
);
}
const groups = buildOptionGroups(options);
return (
<>
{groups.map((g, idx) => (
<Fragment key={g.heading || `__ungrouped__${idx}`}>
{idx > 0 && <SelectSeparator />}
<SelectGroup>
{g.heading && <SelectLabel>{g.heading}</SelectLabel>}
{g.items.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<OptionLabel option={opt} />
</SelectItem>
))}
</SelectGroup>
</Fragment>
))}
</>
);
}
@@ -1,12 +1,21 @@
"use client";
import { useState } from "react";
import { Fragment, useState } from "react";
import { IconChevronDown } from "@tabler/icons-react";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@kandev/ui/command";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@kandev/ui/command";
import { cn } from "@/lib/utils";
import { buildOptionGroups, hasGroupedOptions } from "./filter-option-groups";
export type MultiSelectOption = { value: string; label: string; color?: string };
export type MultiSelectOption = { value: string; label: string; color?: string; group?: string };
type Props = {
options: MultiSelectOption[];
@@ -61,25 +70,7 @@ export function FilterMultiSelect({
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>No options.</CommandEmpty>
{options.map((opt) => {
const checked = selectedSet.has(opt.value);
return (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => toggle(opt.value)}
data-checked={checked}
data-testid="filter-value-multi-option"
data-value={opt.value}
data-active={checked}
>
{opt.color && (
<span className={cn("mr-1 block h-2 w-2 shrink-0 rounded-full", opt.color)} />
)}
<span className="truncate">{opt.label}</span>
</CommandItem>
);
})}
<GroupedOptions options={options} selectedSet={selectedSet} onToggle={toggle} />
</CommandList>
</Command>
</PopoverContent>
@@ -87,6 +78,81 @@ export function FilterMultiSelect({
);
}
function GroupedOptions({
options,
selectedSet,
onToggle,
}: {
options: MultiSelectOption[];
selectedSet: Set<string>;
onToggle: (value: string) => void;
}) {
if (!hasGroupedOptions(options)) {
return (
<>
{options.map((opt) => (
<OptionRow
key={opt.value}
option={opt}
checked={selectedSet.has(opt.value)}
onSelect={() => onToggle(opt.value)}
/>
))}
</>
);
}
const groups = buildOptionGroups(options);
return (
<>
{groups.map((g, idx) => (
<Fragment key={g.heading || `__ungrouped__${idx}`}>
{idx > 0 && <CommandSeparator />}
<CommandGroup heading={g.heading || undefined}>
{g.items.map((opt) => (
<OptionRow
key={opt.value}
option={opt}
checked={selectedSet.has(opt.value)}
onSelect={() => onToggle(opt.value)}
/>
))}
</CommandGroup>
</Fragment>
))}
</>
);
}
function OptionRow({
option,
checked,
onSelect,
}: {
option: MultiSelectOption;
checked: boolean;
onSelect: () => void;
}) {
return (
<CommandItem
// cmdk identifies and filters items by `value`; include `option.value`
// so same-titled steps under one workflow don't collide.
value={[option.group, option.label, option.value].filter(Boolean).join(" ")}
onSelect={onSelect}
data-checked={checked}
data-testid="filter-value-multi-option"
data-value={option.value}
data-active={checked}
>
{option.color && (
<span className={cn("mr-1 block h-2 w-2 shrink-0 rounded-full", option.color)} />
)}
<span className="truncate">{option.label}</span>
</CommandItem>
);
}
function MultiSelectSummary({
selected,
labelByValue,
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { buildOptionGroups, hasGroupedOptions } from "./filter-option-groups";
describe("buildOptionGroups", () => {
it("collects options into buckets keyed by group, preserving first-seen order", () => {
const result = buildOptionGroups([
{ value: "a1", group: "Alpha" },
{ value: "b1", group: "Beta" },
{ value: "a2", group: "Alpha" },
]);
expect(result).toEqual([
{
heading: "Alpha",
items: [
{ value: "a1", group: "Alpha" },
{ value: "a2", group: "Alpha" },
],
},
{ heading: "Beta", items: [{ value: "b1", group: "Beta" }] },
]);
});
it("does not require options with the same group to be consecutive", () => {
const result = buildOptionGroups([
{ value: "a1", group: "Alpha" },
{ value: "b1", group: "Beta" },
{ value: "a2", group: "Alpha" },
{ value: "b2", group: "Beta" },
]);
expect(result.map((g) => g.heading)).toEqual(["Alpha", "Beta"]);
expect(result[0]?.items.map((i) => i.value)).toEqual(["a1", "a2"]);
expect(result[1]?.items.map((i) => i.value)).toEqual(["b1", "b2"]);
});
it("treats missing group as an empty-string heading", () => {
const input: Array<{ value: string; group?: string }> = [{ value: "x" }, { value: "y" }];
const result = buildOptionGroups(input);
expect(result).toEqual([{ heading: "", items: input }]);
});
});
describe("hasGroupedOptions", () => {
it("returns true when any option has a group", () => {
expect(hasGroupedOptions([{ group: "A" }, {}])).toBe(true);
});
it("returns false when no option has a group", () => {
expect(hasGroupedOptions([{}, { group: "" }])).toBe(false);
});
});
@@ -0,0 +1,16 @@
export function buildOptionGroups<T extends { group?: string }>(
options: T[],
): Array<{ heading: string; items: T[] }> {
const buckets = new Map<string, T[]>();
for (const opt of options) {
const key = opt.group ?? "";
const bucket = buckets.get(key);
if (bucket) bucket.push(opt);
else buckets.set(key, [opt]);
}
return [...buckets.entries()].map(([heading, items]) => ({ heading, items }));
}
export function hasGroupedOptions(options: Array<{ group?: string }>): boolean {
return options.some((o) => o.group);
}
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
import { workflowStepOptions } from "./use-filter-value-options";
function snapshot(
id: string,
name: string,
steps: Array<{ id: string; title: string; position: number; color?: string }>,
): WorkflowSnapshotData {
return {
workflowId: id,
workflowName: name,
steps: steps.map((s) => ({
id: s.id,
title: s.title,
color: s.color ?? "bg-neutral-400",
position: s.position,
})),
tasks: [],
};
}
describe("workflowStepOptions", () => {
it("tags each step with its workflow name as the group", () => {
const snapshots = {
wf1: snapshot("wf1", "Alpha", [
{ id: "s1", title: "Backlog", position: 0 },
{ id: "s2", title: "Review", position: 1 },
]),
wf2: snapshot("wf2", "Beta", [{ id: "s3", title: "Done", position: 0 }]),
};
const options = workflowStepOptions(snapshots);
expect(options.map((o) => [o.label, o.group])).toEqual([
["Backlog", "Alpha"],
["Review", "Alpha"],
["Done", "Beta"],
]);
});
it("orders workflows alphabetically and steps by position within each workflow", () => {
const snapshots = {
b: snapshot("b", "Beta", [
{ id: "b2", title: "Second", position: 2 },
{ id: "b1", title: "First", position: 1 },
]),
a: snapshot("a", "Alpha", [{ id: "a1", title: "Only", position: 0 }]),
};
const options = workflowStepOptions(snapshots);
expect(options.map((o) => o.group)).toEqual(["Alpha", "Beta", "Beta"]);
expect(options.map((o) => o.label)).toEqual(["Only", "First", "Second"]);
});
it("falls back to workflow id when name is empty", () => {
const snapshots = {
wfX: snapshot("wfX", "", [{ id: "s1", title: "Step", position: 0 }]),
};
expect(workflowStepOptions(snapshots)[0]?.group).toBe("wfX");
});
it("dedupes steps that appear under multiple workflows, keeping the first occurrence", () => {
const snapshots = {
a: snapshot("a", "Alpha", [{ id: "shared", title: "Shared", position: 0 }]),
b: snapshot("b", "Beta", [{ id: "shared", title: "Shared", position: 0 }]),
};
const options = workflowStepOptions(snapshots);
expect(options).toHaveLength(1);
expect(options[0]?.group).toBe("Alpha");
});
});
@@ -6,7 +6,7 @@ import type { AppState } from "@/lib/state/store";
import type { FilterDimension } from "@/lib/state/slices/ui/sidebar-view-types";
import { getExecutorLabel } from "@/lib/executor-icons";
type Option = { value: string; label: string; color?: string };
type Option = { value: string; label: string; color?: string; group?: string };
type Snapshots = AppState["kanbanMulti"]["snapshots"];
type ReposByWorkspace = AppState["repositories"]["itemsByWorkspaceId"];
@@ -17,14 +17,22 @@ function workflowOptions(snapshots: Snapshots): Option[] {
}));
}
function workflowStepOptions(snapshots: Snapshots): Option[] {
const seen = new Map<string, { label: string; color: string }>();
for (const snap of Object.values(snapshots)) {
for (const step of snap.steps) {
if (!seen.has(step.id)) seen.set(step.id, { label: step.title, color: step.color });
export function workflowStepOptions(snapshots: Snapshots): Option[] {
const out: Option[] = [];
const seen = new Set<string>();
const workflows = Object.values(snapshots).sort((a, b) =>
(a.workflowName || a.workflowId).localeCompare(b.workflowName || b.workflowId),
);
for (const snap of workflows) {
const group = snap.workflowName || snap.workflowId;
const steps = [...snap.steps].sort((a, b) => a.position - b.position);
for (const step of steps) {
if (seen.has(step.id)) continue;
seen.add(step.id);
out.push({ value: step.id, label: step.title, color: step.color, group });
}
}
return [...seen.entries()].map(([value, { label, color }]) => ({ value, label, color }));
return out;
}
function executorTypeOptions(snapshots: Snapshots): Option[] {