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[] {