From d209d2ce2a3cf5f945444fe8c4fec8fc820b6e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:32:36 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20sidebar=20filter=20UX=20polish=20?= =?UTF-8?q?=E2=80=94=20align=20ops,=20group=20steps=20by=20workflow=20(#64?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../sidebar-filter/filter-clause-editor.tsx | 68 +++++++++-- .../sidebar-filter/filter-multi-select.tsx | 110 ++++++++++++++---- .../filter-option-groups.test.ts | 50 ++++++++ .../sidebar-filter/filter-option-groups.ts | 16 +++ .../use-filter-value-options.test.ts | 76 ++++++++++++ .../use-filter-value-options.ts | 22 ++-- 6 files changed, 304 insertions(+), 38 deletions(-) create mode 100644 apps/web/components/task/sidebar-filter/filter-option-groups.test.ts create mode 100644 apps/web/components/task/sidebar-filter/filter-option-groups.ts create mode 100644 apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts diff --git a/apps/web/components/task/sidebar-filter/filter-clause-editor.tsx b/apps/web/components/task/sidebar-filter/filter-clause-editor.tsx index 087f971d8e..4fe0c485f8 100644 --- a/apps/web/components/task/sidebar-filter/filter-clause-editor.tsx +++ b/apps/web/components/task/sidebar-filter/filter-clause-editor.tsx @@ -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) { > @@ -106,7 +117,11 @@ export function FilterClauseEditor({ clause, onChange, onRemove }: Props) { onChange(v)}> - + - {options.length === 0 && ( + {options.length === 0 ? ( No options + ) : ( + )} + + + ); +} + +function GroupedSelectItems({ options }: { options: ValueOption[] }) { + if (!hasGroupedOptions(options)) { + return ( + <> {options.map((opt) => ( ))} - - + + ); + } + + const groups = buildOptionGroups(options); + + return ( + <> + {groups.map((g, idx) => ( + + {idx > 0 && } + + {g.heading && {g.heading}} + {g.items.map((opt) => ( + + + + ))} + + + ))} + ); } diff --git a/apps/web/components/task/sidebar-filter/filter-multi-select.tsx b/apps/web/components/task/sidebar-filter/filter-multi-select.tsx index d023b494b6..cf69ef2d94 100644 --- a/apps/web/components/task/sidebar-filter/filter-multi-select.tsx +++ b/apps/web/components/task/sidebar-filter/filter-multi-select.tsx @@ -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({ No options. - {options.map((opt) => { - const checked = selectedSet.has(opt.value); - return ( - toggle(opt.value)} - data-checked={checked} - data-testid="filter-value-multi-option" - data-value={opt.value} - data-active={checked} - > - {opt.color && ( - - )} - {opt.label} - - ); - })} + @@ -87,6 +78,81 @@ export function FilterMultiSelect({ ); } +function GroupedOptions({ + options, + selectedSet, + onToggle, +}: { + options: MultiSelectOption[]; + selectedSet: Set; + onToggle: (value: string) => void; +}) { + if (!hasGroupedOptions(options)) { + return ( + <> + {options.map((opt) => ( + onToggle(opt.value)} + /> + ))} + + ); + } + + const groups = buildOptionGroups(options); + + return ( + <> + {groups.map((g, idx) => ( + + {idx > 0 && } + + {g.items.map((opt) => ( + onToggle(opt.value)} + /> + ))} + + + ))} + + ); +} + +function OptionRow({ + option, + checked, + onSelect, +}: { + option: MultiSelectOption; + checked: boolean; + onSelect: () => void; +}) { + return ( + + {option.color && ( + + )} + {option.label} + + ); +} + function MultiSelectSummary({ selected, labelByValue, diff --git a/apps/web/components/task/sidebar-filter/filter-option-groups.test.ts b/apps/web/components/task/sidebar-filter/filter-option-groups.test.ts new file mode 100644 index 0000000000..eb2c0e07eb --- /dev/null +++ b/apps/web/components/task/sidebar-filter/filter-option-groups.test.ts @@ -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); + }); +}); diff --git a/apps/web/components/task/sidebar-filter/filter-option-groups.ts b/apps/web/components/task/sidebar-filter/filter-option-groups.ts new file mode 100644 index 0000000000..3088ebfd25 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/filter-option-groups.ts @@ -0,0 +1,16 @@ +export function buildOptionGroups( + options: T[], +): Array<{ heading: string; items: T[] }> { + const buckets = new Map(); + 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); +} diff --git a/apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts b/apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts new file mode 100644 index 0000000000..97f93b68a2 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts @@ -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"); + }); +}); diff --git a/apps/web/components/task/sidebar-filter/use-filter-value-options.ts b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts index 482970a2b0..7ac6522566 100644 --- a/apps/web/components/task/sidebar-filter/use-filter-value-options.ts +++ b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts @@ -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(); - 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(); + 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[] {