Skip to content

Commit 4d7bbf4

Browse files
PROG-66: alpha-sort name-based filter dropdowns (#29)
* feat(web): PROG-66 alpha-sort name-based filter dropdowns The board and Agenda filter dropdowns listed their options in store insertion order, so a long Arc/Product/tag list was unscannable. Sort name-based options (initiative, product, repo, arc, tag) alphabetically via a shared sortByName helper. Logical vocabularies — priority (and the status columns) — keep their meaningful order and are left untouched. sortByName copies its input before sorting so it's safe to pass a live store array (e.g. workspace.tags) without reordering the store. * test(web): PROG-66 cover alphabetical filter-option sort Unit-test sortByName: case-insensitive alphabetical order and that it leaves the input array (the store) unmutated. --------- Co-authored-by: bkennedy <bryan@mysteryexperience.com>
1 parent eb3960e commit 4d7bbf4

5 files changed

Lines changed: 60 additions & 25 deletions

File tree

docs/REFERENCE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,9 @@ canonical key — entirely client-side from the loaded workspace (D22).
392392
statuses; Backlog hides behind a toggle by default. Filters (initiative,
393393
product, repo, arc, tag, priority) live in URL query params, so any
394394
filtered board is bookmarkable — this is how per-container boards are
395-
covered without existing (D23). The current filter selection is also
395+
covered without existing (D23). Name-based filter dropdowns (initiative,
396+
product, repo, arc, tag) list their options alphabetically; priority keeps
397+
its logical order (PROG-66). The Agenda filters sort the same way. The current filter selection is also
396398
mirrored to `localStorage` (`progress:board-filters`) and re-applied when the
397399
board is reopened with a bare URL, so a choice sticks across navigation;
398400
"Clear filters" clears the memory too (PROG-58). Drag-and-drop reorders cards

src/client/boardFilters.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// is unit-tested here; the end-to-end persistence (navigate away, come back,
33
// filter still applied) is covered by e2e/board-filters.spec.ts. Run `bun test`.
44
import { describe, expect, it } from "bun:test";
5-
import { filtersToRestore } from "./boardFilters";
5+
import { filtersToRestore, sortByName } from "./boardFilters";
66

77
describe("filtersToRestore", () => {
88
it("restores the saved query when the board opens unfiltered", () => {
@@ -25,3 +25,18 @@ describe("filtersToRestore", () => {
2525
expect(filtersToRestore("", "")).toBeNull();
2626
});
2727
});
28+
29+
// Alphabetical filter-dropdown options (PROG-66).
30+
describe("sortByName", () => {
31+
it("orders items alphabetically by name, case-insensitively", () => {
32+
const items = [{ name: "Zebra" }, { name: "apple" }, { name: "Mango" }];
33+
expect(sortByName(items).map((i) => i.name)).toEqual(["apple", "Mango", "Zebra"]);
34+
});
35+
36+
it("does not mutate the input (the store array stays in its original order)", () => {
37+
const items = [{ name: "b" }, { name: "a" }];
38+
const sorted = sortByName(items);
39+
expect(items.map((i) => i.name)).toEqual(["b", "a"]);
40+
expect(sorted).not.toBe(items);
41+
});
42+
});

src/client/boardFilters.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,12 @@ export function saveBoardFilters(search: string): void {
3434
export function filtersToRestore(currentSearch: string, saved: string): string | null {
3535
return !currentSearch && saved ? saved : null;
3636
}
37+
38+
// Order name-based filter dropdown options alphabetically (PROG-66) so a long
39+
// Arc / Product / Repo / Initiative / tag list is scannable. Returns a new array
40+
// (the input may be a live store array, e.g. workspace.tags). Logical
41+
// vocabularies — status, priority — keep their meaningful order and must NOT use
42+
// this; their fixed sequence is the order they read in.
43+
export function sortByName<T extends { name: string }>(items: T[]): T[] {
44+
return [...items].sort((a, b) => a.name.localeCompare(b.name));
45+
}

src/client/pages/Agenda.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { useMemo } from "react";
1313
import { Link, useLocation, useSearch } from "wouter";
1414
import type { WireIssue, WireTag, WorkspacePayload } from "../../shared/types";
15+
import { sortByName } from "../boardFilters";
1516
import { type AgendaBucket, bucketOf, formatDueDate, relativeDue, todayISO } from "../dates";
1617
import { STATUS_LABELS } from "../labels";
1718
import PriorityIndicator from "../PriorityIndicator";
@@ -102,22 +103,26 @@ export default function Agenda({ workspace }: { workspace: WorkspacePayload }) {
102103
<FilterSelect
103104
label="Product"
104105
value={filters.product}
105-
options={workspace.products.filter((p) => !p.archivedAt).map((p) => [p.id, p.name])}
106+
options={sortByName(workspace.products.filter((p) => !p.archivedAt)).map((p) => [
107+
p.id,
108+
p.name,
109+
])}
106110
onChange={(v) => setParam("product", v)}
107111
/>
108112
<FilterSelect
109113
label="Arc"
110114
value={filters.arc}
111-
options={workspace.arcs
112-
.filter((a) => !a.archivedAt)
113-
.filter((a) => !filters.product || a.productId === filters.product)
114-
.map((a) => [a.id, a.name])}
115+
options={sortByName(
116+
workspace.arcs
117+
.filter((a) => !a.archivedAt)
118+
.filter((a) => !filters.product || a.productId === filters.product),
119+
).map((a) => [a.id, a.name])}
115120
onChange={(v) => setParam("arc", v)}
116121
/>
117122
<FilterSelect
118123
label="Tag"
119124
value={filters.tag}
120-
options={workspace.tags.map((t) => [t.id, t.name])}
125+
options={sortByName(workspace.tags).map((t) => [t.id, t.name])}
121126
onChange={(v) => setParam("tag", v)}
122127
/>
123128
{filtersActive && (

src/client/pages/Home.tsx

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
} from "../../shared/constants";
4545
import { rankBetween } from "../../shared/rank";
4646
import { reorder, type ColumnMap } from "../boardOrder";
47-
import { filtersToRestore, loadBoardFilters, saveBoardFilters } from "../boardFilters";
47+
import { filtersToRestore, loadBoardFilters, saveBoardFilters, sortByName } from "../boardFilters";
4848
import { recentlyCompleted } from "../boardDone";
4949
import type { WireIssue, WireTag, WorkspacePayload } from "../../shared/types";
5050
import { openCreateIssue } from "../commands/controller";
@@ -349,42 +349,46 @@ export default function Home({ workspace }: { workspace: WorkspacePayload }) {
349349
<FilterSelect
350350
label="Initiative"
351351
value={filters.initiative}
352-
options={workspace.initiatives
353-
.filter((i) => !i.archivedAt)
354-
.map((i) => [i.id, i.name])}
352+
options={sortByName(workspace.initiatives.filter((i) => !i.archivedAt)).map((i) => [
353+
i.id,
354+
i.name,
355+
])}
355356
onChange={(v) => setParam("initiative", v)}
356357
/>
357358
<FilterSelect
358359
label="Product"
359360
value={filters.product}
360-
options={workspace.products
361-
.filter((p) => !p.archivedAt)
362-
.filter((p) => !filters.initiative || p.initiativeId === filters.initiative)
363-
.map((p) => [p.id, p.name])}
361+
options={sortByName(
362+
workspace.products
363+
.filter((p) => !p.archivedAt)
364+
.filter((p) => !filters.initiative || p.initiativeId === filters.initiative),
365+
).map((p) => [p.id, p.name])}
364366
onChange={(v) => setParam("product", v)}
365367
/>
366368
<FilterSelect
367369
label="Repo"
368370
value={filters.repo}
369-
options={workspace.repos
370-
.filter((r) => !r.archivedAt)
371-
.filter((r) => !filters.product || r.productId === filters.product)
372-
.map((r) => [r.id, r.name])}
371+
options={sortByName(
372+
workspace.repos
373+
.filter((r) => !r.archivedAt)
374+
.filter((r) => !filters.product || r.productId === filters.product),
375+
).map((r) => [r.id, r.name])}
373376
onChange={(v) => setParam("repo", v)}
374377
/>
375378
<FilterSelect
376379
label="Arc"
377380
value={filters.arc}
378-
options={workspace.arcs
379-
.filter((a) => !a.archivedAt)
380-
.filter((a) => !filters.product || a.productId === filters.product)
381-
.map((a) => [a.id, a.name])}
381+
options={sortByName(
382+
workspace.arcs
383+
.filter((a) => !a.archivedAt)
384+
.filter((a) => !filters.product || a.productId === filters.product),
385+
).map((a) => [a.id, a.name])}
382386
onChange={(v) => setParam("arc", v)}
383387
/>
384388
<FilterSelect
385389
label="Tag"
386390
value={filters.tag}
387-
options={workspace.tags.map((t) => [t.id, t.name])}
391+
options={sortByName(workspace.tags).map((t) => [t.id, t.name])}
388392
onChange={(v) => setParam("tag", v)}
389393
/>
390394
<FilterSelect

0 commit comments

Comments
 (0)