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:
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user