From 5013dca5e68501314971130d529d1e4553763bff Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 16:09:46 -0400 Subject: [PATCH 01/10] feat(jira): adds custom-order schema, store actions, prune wiring --- .../components/dashboard/DashboardPage.tsx | 6 +- src/app/stores/view.ts | 62 +++++- tests/components/DashboardPage.test.tsx | 40 ++++ tests/stores/view-jira-order.test.ts | 203 ++++++++++++++++++ 4 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 tests/stores/view-jira-order.test.ts diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 505fbb1e..ea3d1cc8 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -9,7 +9,7 @@ import PullRequestsTab from "./PullRequestsTab"; import TrackedTab from "./TrackedTab"; import PersonalSummaryStrip from "./PersonalSummaryStrip"; import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, isTabUnscoped, updateJiraConfig, type TrackedUser } from "../../stores/config"; -import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; +import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema, pruneJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE } from "../../stores/view"; import DependenciesTab from "./DependenciesTab"; import { isDependencyPr, expandBotLogins, needsBodyFallback, parseRenovateBody, type VersionInfo } from "../../lib/dependency-detection"; import { isRepoExcludedFromDependencies } from "../../lib/dependency-exclusion"; @@ -479,6 +479,10 @@ export default function DashboardPage() { if (!isJiraAuthenticated()) return; setJiraIssues(result.issues); + if (scope === JIRA_CUSTOM_ORDER_SCOPE) { + pruneJiraCustomOrder(new Set(result.issues.map((i) => i.key))); + } + // Auto-prune tracked Jira items that are done or deleted (scope-independent). // Resolves status from current search results first, then bulkFetches only // keys not covered (items from a different scope than the current view). diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index d35c7f7c..b3aa05f4 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -7,6 +7,9 @@ export const VIEW_STORAGE_KEY = "github-tracker:view"; const IGNORED_ITEMS_CAP = 500; const TRACKED_ITEMS_CAP = 200; export const LOCKED_REPOS_CAP = 50; +export const JIRA_CUSTOM_ORDER_CAP = 500; +export const JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH = 50; +export const JIRA_CUSTOM_ORDER_SCOPE = "assigned" as const; export const TrackedItemSchema = z.object({ id: z.number(), @@ -59,7 +62,7 @@ export const JiraFiltersSchema = z.object({ scope: z.enum(["assigned", "reported", "watching"]).or(z.string().regex(/^[a-zA-Z0-9_\-]+$/).max(100)).default("assigned"), statusCategory: z.enum(["all", "new", "indeterminate"]).default("all"), priority: z.enum(["all", "Highest", "High", "Medium", "Low", "Lowest"]).default("all"), - sortField: z.string().default("status"), + sortField: z.string().default("custom"), sortDirection: z.enum(["asc", "desc"]).default("asc"), }); @@ -100,13 +103,13 @@ export const ViewStateSchema = z.object({ issues: IssueFiltersSchema.default({ scope: "involves_me", role: "all", comments: "all", user: "all" }), pullRequests: PullRequestFiltersSchema.default({ scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }), actions: ActionsFiltersSchema.default({ conclusion: "all", event: "all" }), - jiraAssigned: JiraFiltersSchema.default({ scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }), + jiraAssigned: JiraFiltersSchema.default({ scope: "assigned", statusCategory: "all", priority: "all", sortField: "custom", sortDirection: "asc" }), dependencies: DependencyFiltersSchema.default({ updateType: "all", bot: "all" }), }).default({ issues: { scope: "involves_me", role: "all", comments: "all", user: "all" }, pullRequests: { scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, - jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }, + jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "custom", sortDirection: "asc" }, dependencies: { updateType: "all", bot: "all" }, }), showPrRuns: z.boolean().default(false), @@ -127,6 +130,7 @@ export const ViewStateSchema = z.object({ lockedRepos: z.record(z.string(), z.array(z.string().max(200)).max(LOCKED_REPOS_CAP)).default({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }), trackedItems: z.array(TrackedItemSchema).max(TRACKED_ITEMS_CAP).default([]), dependencyExpandedGroups: z.array(z.string()).default(["mergeable"]), + jiraCustomOrder: z.array(z.string().max(JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH)).max(JIRA_CUSTOM_ORDER_CAP).default([]), }); export type ViewState = z.infer; @@ -171,6 +175,13 @@ function loadViewState(): ViewState { } } } + if (Array.isArray(obj.jiraCustomOrder)) { + obj.jiraCustomOrder = obj.jiraCustomOrder + .filter((item): item is string => typeof item === "string" && item.length <= JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH) + .slice(0, JIRA_CUSTOM_ORDER_CAP); + } else if (obj.jiraCustomOrder !== undefined) { + delete obj.jiraCustomOrder; + } const result = ViewStateSchema.safeParse(parsed); if (result.success) return result.data; return ViewStateSchema.parse({}); @@ -209,7 +220,7 @@ export function resetViewState(): void { issues: { scope: "involves_me", role: "all", comments: "all", user: "all" }, pullRequests: { scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, - jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }, + jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "custom", sortDirection: "asc" }, dependencies: { updateType: "all", bot: "all" }, }, showPrRuns: false, @@ -219,6 +230,7 @@ export function resetViewState(): void { lockedRepos: { issues: [], pullRequests: [], actions: [], jiraAssigned: [] }, trackedItems: [], dependencyExpandedGroups: ["mergeable"], + jiraCustomOrder: [], }); }) ); @@ -565,6 +577,28 @@ export function moveTrackedItem( ); } +export function setJiraCustomOrder(order: string[]): void { + setViewState( + produce((draft) => { + draft.jiraCustomOrder = order.length > JIRA_CUSTOM_ORDER_CAP + ? order.slice(0, JIRA_CUSTOM_ORDER_CAP) + : order; + }) + ); +} + +export function pruneJiraCustomOrder(activeJiraKeys: Set): void { + const current = untrack(() => viewState.jiraCustomOrder); + if (current.length === 0) return; + const filtered = current.filter((key) => activeJiraKeys.has(key)); + if (filtered.length === current.length) return; + setViewState( + produce((draft) => { + draft.jiraCustomOrder = filtered; + }) + ); +} + export function pruneClosedTrackedItems(pruneKeys: Set): void { setViewState( produce((draft) => { @@ -576,6 +610,26 @@ export function pruneClosedTrackedItems(pruneKeys: Set): void { } export function initViewPersistence(): void { + if (typeof window !== "undefined") { + const handleViewStorage = (e: StorageEvent) => { + if (e.key !== VIEW_STORAGE_KEY || e.newValue === null) return; + let parsed: unknown; + try { + parsed = JSON.parse(e.newValue); + } catch { + return; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return; + const result = ViewStateSchema.pick({ jiraCustomOrder: true }).safeParse(parsed); + if (!result.success) return; + const incoming = result.data.jiraCustomOrder; + if (JSON.stringify(incoming) === JSON.stringify(untrack(() => viewState.jiraCustomOrder))) return; + setViewState(produce((draft) => { draft.jiraCustomOrder = incoming; })); + }; + window.addEventListener("storage", handleViewStorage); + onCleanup(() => window.removeEventListener("storage", handleViewStorage)); + } + let debounceTimer: ReturnType | undefined; let pendingJson: string | undefined; createEffect(() => { diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index c3e01de8..a70bb02c 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2500,6 +2500,46 @@ describe("DashboardPage — dependency exclusions", () => { // ── Dependencies tab — abandonedDepsMap + dashboardIssueUrls reset on auth clear ─ +describe("DashboardPage — pruneJiraCustomOrder wiring", () => { + it("pruneJiraCustomOrder is called with assigned-scope issue keys (import wiring check)", () => { + const { pruneJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, setJiraCustomOrder } = viewStore; + + setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3"]); + + const scope = "assigned"; + const resultIssueKeys = ["PROJ-1", "PROJ-3"]; + + if (scope === JIRA_CUSTOM_ORDER_SCOPE) { + pruneJiraCustomOrder(new Set(resultIssueKeys)); + } + + expect(viewStore.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-3"]); + }); + + it("pruneJiraCustomOrder is NOT called when scope is not assigned", () => { + const { pruneJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, setJiraCustomOrder } = viewStore; + + setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3"]); + + const scope: string = "reported"; + const resultIssueKeys = ["OTHER-1"]; + + if (scope === JIRA_CUSTOM_ORDER_SCOPE) { + pruneJiraCustomOrder(new Set(resultIssueKeys)); + } + + expect(viewStore.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2", "PROJ-3"]); + }); + + it("DashboardPage imports pruneJiraCustomOrder and JIRA_CUSTOM_ORDER_SCOPE from view store", async () => { + const dashboardSource = await import("../../src/app/components/dashboard/DashboardPage"); + expect(dashboardSource).toBeDefined(); + + expect(viewStore.pruneJiraCustomOrder).toBeTypeOf("function"); + expect(viewStore.JIRA_CUSTOM_ORDER_SCOPE).toBe("assigned"); + }); +}); + describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clear", () => { it("Dependencies tab disappears after auth clear (abandonedDepsMap reset)", async () => { // The module-level signals abandonedDepsMap and dashboardIssueUrls are reset diff --git a/tests/stores/view-jira-order.test.ts b/tests/stores/view-jira-order.test.ts new file mode 100644 index 00000000..ca7ef9f0 --- /dev/null +++ b/tests/stores/view-jira-order.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createRoot } from "solid-js"; +import { + viewState, + resetViewState, + setJiraCustomOrder, + pruneJiraCustomOrder, + initViewPersistence, + ViewStateSchema, + VIEW_STORAGE_KEY, + JIRA_CUSTOM_ORDER_CAP, +} from "../../src/app/stores/view"; + +describe("jira custom order store actions", () => { + beforeEach(() => { + resetViewState(); + }); + + describe("setJiraCustomOrder", () => { + it("replaces the array wholesale", () => { + setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3"]); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2", "PROJ-3"]); + }); + + it("overwrites a previous order", () => { + setJiraCustomOrder(["PROJ-1", "PROJ-2"]); + setJiraCustomOrder(["PROJ-3", "PROJ-1"]); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-3", "PROJ-1"]); + }); + + it("truncates to JIRA_CUSTOM_ORDER_CAP when longer", () => { + const oversized = Array.from({ length: JIRA_CUSTOM_ORDER_CAP + 50 }, (_, i) => `KEY-${i}`); + setJiraCustomOrder(oversized); + expect(viewState.jiraCustomOrder.length).toBe(JIRA_CUSTOM_ORDER_CAP); + expect(viewState.jiraCustomOrder[0]).toBe("KEY-0"); + expect(viewState.jiraCustomOrder[JIRA_CUSTOM_ORDER_CAP - 1]).toBe(`KEY-${JIRA_CUSTOM_ORDER_CAP - 1}`); + }); + + it("accepts an empty array", () => { + setJiraCustomOrder(["PROJ-1"]); + setJiraCustomOrder([]); + expect(viewState.jiraCustomOrder).toEqual([]); + }); + + it("does not truncate when exactly at cap", () => { + const exact = Array.from({ length: JIRA_CUSTOM_ORDER_CAP }, (_, i) => `KEY-${i}`); + setJiraCustomOrder(exact); + expect(viewState.jiraCustomOrder.length).toBe(JIRA_CUSTOM_ORDER_CAP); + }); + }); + + describe("pruneJiraCustomOrder", () => { + it("drops stale keys and preserves order of active ones", () => { + setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3", "PROJ-4"]); + pruneJiraCustomOrder(new Set(["PROJ-1", "PROJ-3"])); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-3"]); + }); + + it("is a no-op when the order is empty", () => { + pruneJiraCustomOrder(new Set(["PROJ-1"])); + expect(viewState.jiraCustomOrder).toEqual([]); + }); + + it("is a no-op when all keys are active", () => { + setJiraCustomOrder(["PROJ-1", "PROJ-2"]); + pruneJiraCustomOrder(new Set(["PROJ-1", "PROJ-2", "PROJ-3"])); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2"]); + }); + + it("clears the order when no keys are active", () => { + setJiraCustomOrder(["PROJ-1", "PROJ-2"]); + pruneJiraCustomOrder(new Set(["PROJ-99"])); + expect(viewState.jiraCustomOrder).toEqual([]); + }); + }); + + describe("resetViewState", () => { + it("clears jiraCustomOrder", () => { + setJiraCustomOrder(["PROJ-1", "PROJ-2"]); + resetViewState(); + expect(viewState.jiraCustomOrder).toEqual([]); + }); + + it("resets sortField to 'custom'", () => { + resetViewState(); + expect(viewState.tabFilters.jiraAssigned.sortField).toBe("custom"); + }); + }); + + describe("schema defaults", () => { + it("defaults jiraAssigned.sortField to 'custom' on fresh parse", () => { + const result = ViewStateSchema.parse({}); + expect(result.tabFilters.jiraAssigned.sortField).toBe("custom"); + }); + + it("defaults jiraCustomOrder to empty array on fresh parse", () => { + const result = ViewStateSchema.parse({}); + expect(result.jiraCustomOrder).toEqual([]); + }); + + it("preserves existing sortField from persisted state", () => { + const result = ViewStateSchema.parse({ + tabFilters: { + jiraAssigned: { sortField: "priority", sortDirection: "asc", scope: "assigned", statusCategory: "all", priority: "all" }, + }, + }); + expect(result.tabFilters.jiraAssigned.sortField).toBe("priority"); + }); + }); +}); + +describe("cross-tab sync for jiraCustomOrder", () => { + let dispose: (() => void) | undefined; + + beforeEach(() => { + resetViewState(); + }); + + afterEach(() => { + dispose?.(); + dispose = undefined; + }); + + function setupPersistence() { + createRoot((d) => { + dispose = d; + initViewPersistence(); + }); + } + + function dispatchStorageEvent(newValue: string | null) { + window.dispatchEvent(new StorageEvent("storage", { + key: VIEW_STORAGE_KEY, + newValue, + })); + } + + it("updates jiraCustomOrder from another tab's storage write", () => { + setupPersistence(); + const blob = JSON.stringify({ jiraCustomOrder: ["PROJ-A", "PROJ-B"] }); + dispatchStorageEvent(blob); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-A", "PROJ-B"]); + }); + + it("ignores storage events for unrelated keys", () => { + setupPersistence(); + setJiraCustomOrder(["PROJ-1"]); + window.dispatchEvent(new StorageEvent("storage", { + key: "some-other-key", + newValue: JSON.stringify({ jiraCustomOrder: ["PROJ-X"] }), + })); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1"]); + }); + + it("rejects incoming blob exceeding JIRA_CUSTOM_ORDER_CAP (safeParse fails)", () => { + setupPersistence(); + setJiraCustomOrder(["PROJ-1"]); + const oversized = Array.from({ length: JIRA_CUSTOM_ORDER_CAP + 100 }, (_, i) => `KEY-${i}`); + dispatchStorageEvent(JSON.stringify({ jiraCustomOrder: oversized })); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1"]); + }); + + it("does not touch other viewState fields when syncing jiraCustomOrder", () => { + setupPersistence(); + const blob = JSON.stringify({ + lastActiveTab: "actions", + jiraCustomOrder: ["PROJ-Z"], + }); + dispatchStorageEvent(blob); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-Z"]); + expect(viewState.lastActiveTab).toBe("issues"); + }); + + it("handles malformed JSON in newValue without throwing", () => { + setupPersistence(); + setJiraCustomOrder(["PROJ-1"]); + expect(() => dispatchStorageEvent("not valid json")).not.toThrow(); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1"]); + }); + + it("ignores null newValue", () => { + setupPersistence(); + setJiraCustomOrder(["PROJ-1"]); + dispatchStorageEvent(null); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1"]); + }); + + it("is a no-op when incoming order matches current (dedup guard)", () => { + setupPersistence(); + setJiraCustomOrder(["PROJ-1", "PROJ-2"]); + const blob = JSON.stringify({ jiraCustomOrder: ["PROJ-1", "PROJ-2"] }); + dispatchStorageEvent(blob); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2"]); + }); + + it("removes listener after dispose so subsequent events have no effect", () => { + setupPersistence(); + dispose?.(); + dispose = undefined; + dispatchStorageEvent(JSON.stringify({ jiraCustomOrder: ["PROJ-NEW"] })); + expect(viewState.jiraCustomOrder).toEqual([]); + }); +}); From 7ba710b7e0212354814700892c1454af442d62b2 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 16:19:37 -0400 Subject: [PATCH 02/10] feat(jira): adds applyCustomOrder pure ordering helper --- src/app/lib/grouping.ts | 35 ++++++++++++++++++++++++ tests/lib/grouping.test.ts | 54 +++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/app/lib/grouping.ts b/src/app/lib/grouping.ts index 3cfc0d82..ea4e9a9a 100644 --- a/src/app/lib/grouping.ts +++ b/src/app/lib/grouping.ts @@ -90,6 +90,41 @@ export function orderRepoGroups( return [...locked, ...unlocked]; } +/** + * Orders `items` by `order` (a list of keys produced by `keyFn`), appending any + * items not referenced in `order` at the end, in their original relative order. + * If two items share the same key, the later one in `items` wins. `order` is + * walked as-is and is not itself deduplicated — a key appearing twice in `order` + * will pull the matching item twice into the result. + * Returns `items` unchanged (same reference) when `order` is empty. + */ +export function applyCustomOrder(items: T[], order: string[], keyFn: (item: T) => string): T[] { + if (order.length === 0) return items; + + const map = new Map(); + for (const item of items) { + map.set(keyFn(item), item); + } + + const referenced = new Set(); + const result: T[] = []; + for (const key of order) { + const item = map.get(key); + if (item !== undefined) { + result.push(item); + referenced.add(key); + } + } + + for (const [key, item] of map) { + if (!referenced.has(key)) { + result.push(item); + } + } + + return result; +} + /** * Three-tier involvement check for scope filtering. * Shared by IssuesTab and PullRequestsTab — keep both call sites in sync. diff --git a/tests/lib/grouping.test.ts b/tests/lib/grouping.test.ts index e0b01ecb..5f84bd05 100644 --- a/tests/lib/grouping.test.ts +++ b/tests/lib/grouping.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { groupByRepo, computePageLayout, slicePageGroups, isUserInvolved, ensureLockedRepoGroups, type RepoGroup } from "../../src/app/lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, isUserInvolved, ensureLockedRepoGroups, applyCustomOrder, type RepoGroup } from "../../src/app/lib/grouping"; interface Item { repoFullName: string; @@ -287,3 +287,55 @@ describe("ensureLockedRepoGroups", () => { expect(result[1].workflows).toEqual([]); }); }); + +describe("applyCustomOrder", () => { + const keyFn = (item: Item) => String(item.id); + + it("returns items in the exact order when order fully matches", () => { + const items = [makeItem("org/a", 1), makeItem("org/a", 2), makeItem("org/a", 3)]; + const result = applyCustomOrder(items, ["3", "1", "2"], keyFn); + expect(result.map((i) => i.id)).toEqual([3, 1, 2]); + }); + + it("appends unlisted items at the end in original relative order", () => { + const items = [makeItem("org/a", 1), makeItem("org/a", 2), makeItem("org/a", 3), makeItem("org/a", 4)]; + const result = applyCustomOrder(items, ["3", "1"], keyFn); + expect(result.map((i) => i.id)).toEqual([3, 1, 2, 4]); + }); + + it("returns items unchanged by reference when order is empty", () => { + const items = [makeItem("org/a", 1), makeItem("org/a", 2)]; + const result = applyCustomOrder(items, [], keyFn); + expect(result).toBe(items); // same reference — no copy + }); + + it("silently ignores order keys not present in items", () => { + const items = [makeItem("org/a", 1), makeItem("org/a", 2)]; + const result = applyCustomOrder(items, ["1", "99"], keyFn); + expect(result.map((i) => i.id)).toEqual([1, 2]); + expect(result).toHaveLength(2); // no phantom entry for the unmatched "99" key + }); + + it("keeps only the later item when two items share the same key", () => { + const first = makeItem("org/a", 1); + const second = makeItem("org/b", 1); // same keyFn() result ("1") as `first` + // Route both through the "unreferenced" append path so the assertion + // exercises dedup at map-build time, independent of the `order` walk. + const result = applyCustomOrder([first, second], ["nonexistent"], keyFn); + expect(result).toHaveLength(1); + expect(result[0].repoFullName).toBe("org/b"); + }); + + it("documents duplicate-entry behavior when a key appears twice in order", () => { + // applyCustomOrder does not deduplicate `order` itself — a repeated key + // pulls the same item into the result once per occurrence, per the + // function's own doc comment ("order is walked as-is and is not itself + // deduplicated"). This test pins that behavior rather than leaving it + // unspecified. + const item = makeItem("org/a", 1); + const result = applyCustomOrder([item], ["1", "1"], keyFn); + expect(result).toHaveLength(2); + expect(result[0]).toBe(item); + expect(result[1]).toBe(item); + }); +}); From 90cf24b1412689b0e2e213833ce6658c3e106965 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 16:20:33 -0400 Subject: [PATCH 03/10] test(jira): adds integration coverage for pruneJiraCustomOrder wiring --- tests/components/DashboardPage.test.tsx | 103 ++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index a70bb02c..b918df06 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2540,6 +2540,109 @@ describe("DashboardPage — pruneJiraCustomOrder wiring", () => { }); }); +describe("DashboardPage — pruneJiraCustomOrder on refresh", () => { + it("prunes jiraCustomOrder entries absent from assigned-scope results", async () => { + // Fully isolated module reload: we need jiraAuth, isJiraAuthenticated, and JiraClient + // to behave differently from the default mocks. + vi.resetModules(); + authClearCallbacks.length = 0; + + const mockSearchJql = vi.fn().mockResolvedValue({ + issues: [ + { + id: "1001", key: "PROJ-1", self: "https://test.atlassian.net/rest/api/3/issue/1001", + fields: { + summary: "First issue", status: { id: "1", name: "To Do", statusCategory: { id: 2, key: "new" as const, name: "To Do" } }, + priority: { id: "3", name: "Medium" }, assignee: null, + project: { id: "10000", key: "PROJ", name: "Project" }, + }, + }, + { + id: "1003", key: "PROJ-3", self: "https://test.atlassian.net/rest/api/3/issue/1003", + fields: { + summary: "Third issue", status: { id: "1", name: "To Do", statusCategory: { id: 2, key: "new" as const, name: "To Do" } }, + priority: { id: "3", name: "Medium" }, assignee: null, + project: { id: "10000", key: "PROJ", name: "Project" }, + }, + }, + ], + total: 2, maxResults: 100, startAt: 0, + }); + + vi.doMock("../../src/app/stores/auth", () => ({ + clearAuth: vi.fn(), + expireToken: vi.fn(), + token: () => "fake-token", + user: () => ({ login: "testuser", avatar_url: "", name: "Test User" }), + isAuthenticated: () => true, + onAuthCleared: vi.fn((cb: () => void) => { authClearCallbacks.push(cb); }), + DASHBOARD_STORAGE_KEY: "github-tracker:dashboard", + DEP_META_STORAGE_KEY: "github-tracker:dep-meta", + jiraAuth: vi.fn(() => ({ + cloudId: "cloud-123", + accessToken: "tok", + siteUrl: "https://test.atlassian.net", + siteName: "Test Site", + })), + isJiraAuthenticated: vi.fn(() => true), + setJiraAuth: vi.fn(), + clearJiraAuth: vi.fn(), + ensureJiraTokenValid: vi.fn().mockResolvedValue(true), + })); + + const MockJiraClient = vi.fn(function (this: Record) { + this.searchJql = mockSearchJql; + this.bulkFetch = vi.fn().mockResolvedValue({ issues: [], errors: [] }); + }); + vi.doMock("../../src/app/services/jira-client", () => ({ + JiraClient: MockJiraClient, + JiraProxyClient: vi.fn(), + JiraApiError: class JiraApiError extends Error { + status: number; + constructor(status: number, _body: unknown, message: string) { + super(message); + this.status = status; + } + }, + DEFAULT_FIELDS: ["summary", "status", "priority", "assignee", "project", "updated", "issuetype", "created"], + })); + + vi.doMock("../../src/app/services/poll", () => ({ + fetchAllData: vi.fn().mockResolvedValue({ + issues: [], pullRequests: [], workflowRuns: [], errors: [], + }), + createPollCoordinator: vi.fn().mockImplementation( + (_getInterval: unknown, fetchAll: () => Promise) => { + void fetchAll().catch(() => {}); + return { isRefreshing: () => false, lastRefreshAt: () => null, manualRefresh: vi.fn(), destroy: vi.fn() }; + } + ), + createHotPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + createEventsPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + rebuildHotSets: vi.fn(), + seedHotSetsFromTargeted: vi.fn(), + clearHotSets: vi.fn(), + getHotPollGeneration: vi.fn().mockReturnValue(0), + })); + + // Fresh imports after mock registration + const freshView = await import("../../src/app/stores/view"); + const freshConfig = await import("../../src/app/stores/config"); + const freshDash = await import("../../src/app/components/dashboard/DashboardPage"); + + freshView.resetViewState(); + freshConfig.resetConfig(); + freshConfig.updateJiraConfig({ enabled: true, siteUrl: "https://test.atlassian.net", siteName: "Test Site", authMethod: "oauth" }); + freshView.setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3"]); + + render(() => ); + + await waitFor(() => { + expect(freshView.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-3"]); + }, { timeout: 3000 }); + }); +}); + describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clear", () => { it("Dependencies tab disappears after auth clear (abandonedDepsMap reset)", async () => { // The module-level signals abandonedDepsMap and dashboardIssueUrls are reset From b28380c5bfac2fa83b228f65c7c4f8bab05f70ef Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 16:58:03 -0400 Subject: [PATCH 04/10] feat(jira): adds custom-order UI in JiraAssignedTab --- .../components/dashboard/JiraAssignedTab.tsx | 560 ++++++++++++------ src/app/components/shared/SortDropdown.tsx | 4 +- .../dashboard/JiraAssignedTab.test.tsx | 391 +++++++++++- 3 files changed, 759 insertions(+), 196 deletions(-) diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx index cee4d8bd..eddb3112 100644 --- a/src/app/components/dashboard/JiraAssignedTab.tsx +++ b/src/app/components/dashboard/JiraAssignedTab.tsx @@ -1,11 +1,12 @@ import { createEffect, createMemo, createSignal, For, Show, on } from "solid-js"; import type { JiraIssue } from "../../../shared/jira-types"; -import { viewState, setTabFilter, resetAllTabFilters, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded } from "../../stores/view"; +import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE } from "../../stores/view"; import { config } from "../../stores/config"; import JiraFieldValue from "./JiraFieldValue"; import { jiraStatusCategoryClass, stripParenthetical } from "../../lib/format"; import { isSafeJiraSiteUrl } from "../../lib/url"; -import { groupByRepo, computePageLayout, slicePageGroups, ensureLockedRepoGroups, orderRepoGroups } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, ensureLockedRepoGroups, orderRepoGroups, applyCustomOrder } from "../../lib/grouping"; +import { withScrollLock } from "../../lib/scroll"; import PaginationControls from "../shared/PaginationControls"; import FilterPopover from "../shared/FilterPopover"; import LoadingSpinner from "../shared/LoadingSpinner"; @@ -121,6 +122,46 @@ function IssueTypeFallbackIcon(props: { name: string }) { ); } +// FLIP animation: record positions before a custom-order move, animate slide after +// DOM updates. Modeled on TrackedTab.tsx's recordPositions/animateMove/prefersReducedMotion +// trio (TrackedTab.tsx:31-59) — deliberately duplicated here rather than extracted into a +// shared utility (see plan Task 3, Step 5). +// +// Deviation from TrackedTab.tsx: TrackedTab's animateMove only guards with +// `if (prefersReducedMotion()) return;`, which skips the animation but leaves the +// preceding state mutation unprotected against scroll jump. That guard is kept here too +// (defense-in-depth), but the primary reduced-motion routing decision lives in +// handleCustomMove below, which decides whether to call animateMove at all or route the +// mutation through withScrollLock (src/app/lib/scroll.ts) instead. +const itemRefs = new Map(); +const prefersReducedMotion = () => + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +function recordPositions(): Map { + const snapshot = new Map(); + for (const [key, el] of itemRefs) { + snapshot.set(key, el.getBoundingClientRect()); + } + return snapshot; +} + +function animateMove(before: Map) { + if (prefersReducedMotion()) return; + requestAnimationFrame(() => { + for (const [key, el] of itemRefs) { + const old = before.get(key); + if (!old) continue; + const now = el.getBoundingClientRect(); + const dy = old.top - now.top; + if (Math.abs(dy) < 1) continue; + el.animate( + [{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }], + { duration: 200, easing: "ease-in-out" } + ); + } + }); +} + export default function JiraAssignedTab(props: JiraAssignedTabProps) { const [page, setPage] = createSignal(0); @@ -172,6 +213,9 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { }); const filteredSorted = createMemo(() => { + if (filters().sortField === "custom") { + return applyCustomOrder(filtered(), viewState.jiraCustomOrder, (issue) => issue.key); + } const items = [...filtered()]; const field = filters().sortField; const dir = filters().sortDirection; @@ -244,10 +288,19 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { return orderRepoGroups(withLocked, lockedForTab); }); + const isCustomMode = () => filters().sortField === "custom"; + + // itemsWithGroupKey() is a 1:1, order-preserving map over filteredSorted() (adds + // repoFullName, never filters/reorders), so paginating it directly yields the same + // slices filteredSorted() would, while giving renderIssueRow's shared JiraItem rows + // the project key it needs for the Step 4 badge without re-deriving it. + const customPageCount = () => Math.max(1, Math.ceil(itemsWithGroupKey().length / ITEMS_PER_PAGE)); + const customPageItems = () => itemsWithGroupKey().slice(page() * ITEMS_PER_PAGE, (page() + 1) * ITEMS_PER_PAGE); + const pageLayout = createMemo(() => computePageLayout(repoGroups(), ITEMS_PER_PAGE)); - const pageCount = createMemo(() => pageLayout().pageCount); + const pageCount = createMemo(() => (isCustomMode() ? customPageCount() : pageLayout().pageCount)); const pageGroups = createMemo(() => - slicePageGroups(repoGroups(), pageLayout().boundaries, pageCount(), page()) + slicePageGroups(repoGroups(), pageLayout().boundaries, pageLayout().pageCount, page()) ); const projectKeys = createMemo(() => repoGroups().map((g) => g.repoFullName)); @@ -266,6 +319,234 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { setAllExpanded(TAB_KEY, keys, true); }); + // Reordering is only meaningful — and safe — against the canonical, unfiltered + // "assigned" scope: filtered() must exclude nothing so filteredSorted()'s key list + // is the complete set, matching what Task 4's prune gate guards against. + const canReorder = () => + filters().scope === JIRA_CUSTOM_ORDER_SCOPE && filters().statusCategory === "all" && filters().priority === "all"; + + const [reordering, setReordering] = createSignal(false); + + function handleCustomMove(jiraKey: string, direction: "up" | "down") { + if (reordering()) return; + const order = filteredSorted().map((i) => i.key); + const idx = order.indexOf(jiraKey); + if (idx === -1) return; + const targetIdx = direction === "up" ? idx - 1 : idx + 1; + if (targetIdx < 0 || targetIdx >= order.length) return; + const newPage = Math.floor(targetIdx / ITEMS_PER_PAGE); + const crossesPage = newPage !== page(); + const next = [...order]; + [next[idx], next[targetIdx]] = [next[targetIdx], next[idx]]; + const applyMove = () => { + setJiraCustomOrder(next); + if (crossesPage) setPage(newPage); + }; + setReordering(true); + if (prefersReducedMotion()) { + // Reduced motion: no animation ever, but still guard against a viewport jump + // from the mutation itself (matches withFlipAnimation's fallback, scroll.ts:27-29). + withScrollLock(applyMove); + setReordering(false); + } else if (crossesPage) { + // Cross-page moves must skip the FLIP animation entirely (spike pl-feas-2): the + // old page's rows become detached before animateMove's rAF callback runs, producing + // a broken/misleading animation. Jump straight to the new page instead. + applyMove(); + setReordering(false); + } else { + const before = recordPositions(); + applyMove(); + animateMove(before); + // Matches animateMove's `duration: 200` — keep these two values in sync. + setTimeout(() => setReordering(false), 200); + } + } + + function renderIssueRow(issue: JiraItem, boundary?: { isFirst: boolean; isLast: boolean }) { + const isPinned = () => pinnedJiraKeys().has(issue.key); + const browseUrl = () => isSafeJiraSiteUrl(props.siteUrl) ? `${props.siteUrl}/browse/${issue.key}` : "#"; + const isIssueExpanded = () => expandByDefault() ? !toggledIssues().has(issue.key) : toggledIssues().has(issue.key); + const detailPanelId = `jira-detail-${issue.key}`; + const reorderTitle = () => !canReorder() ? "Switch to Assigned to me with no filters to reorder" : undefined; + return ( +
{ itemRefs.set(issue.key, el); }} + > + +
+ + +
+
+
+
{ + if ((e.target as HTMLElement).closest("a, button")) return; + toggleExpanded(issue.key); + }} + > +
+
+ + {(type) => { + const [imgFailed, setImgFailed] = createSignal(false); + return ( + + } + > + {type().name} setImgFailed(true)} + /> + + + ); + }} + + + {issue.key} + + + {issue.repoFullName} + + + + {issue.fields.summary} + + +
+ +

+ {issue.fields.summary} +

+
+
+
+ + + {stripParenthetical(issue.fields.priority!.name)} + + + + {issue.fields.status.name} + +
+ + + + +
+ +
+ 0} + fallback={ +

+ No custom fields configured — add them in Settings. +

+ } + > +
+ + {(field) => { + const val = () => (issue.fields as Record)[field.id]; + return ( + + + {field.name}: + + + + ); + }} + +
+
+
+
+
+
+ ); + } + return (
{/* Filter + sort toolbar */} @@ -315,7 +596,11 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { @@ -324,20 +609,35 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { {filtered().length} issue{filtered().length !== 1 ? "s" : ""} + + + { setTabFilter("jiraAssigned", "sortField", field); setTabFilter("jiraAssigned", "sortDirection", dir); setPage(0); }} /> - setAllExpanded(TAB_KEY, projectKeys(), true)} - onCollapseAll={() => setAllExpanded(TAB_KEY, projectKeys(), false)} - /> + + setAllExpanded(TAB_KEY, projectKeys(), true)} + onCollapseAll={() => setAllExpanded(TAB_KEY, projectKeys(), false)} + /> +
@@ -347,193 +647,65 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { - {/* Jira project groups + locked stubs */} - 0}> -
- - {(group) => { - const isEmpty = () => group.items.length === 0; - const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[TAB_KEY] ?? {})[group.repoFullName]; - - return ( -
-
- + +
+ +
+ + {(issue) => renderIssueRow(issue)} + +
+
+ +
+ No matching issues in {group.repoFullName} +
- - -
- -
- - {(issue) => { - const isPinned = () => pinnedJiraKeys().has(issue.key); - const browseUrl = () => isSafeJiraSiteUrl(props.siteUrl) ? `${props.siteUrl}/browse/${issue.key}` : "#"; - const isIssueExpanded = () => expandByDefault() ? !toggledIssues().has(issue.key) : toggledIssues().has(issue.key); - const detailPanelId = `jira-detail-${issue.key}`; - return ( -
-
{ - if ((e.target as HTMLElement).closest("a, button")) return; - toggleExpanded(issue.key); - }} - > -
-
- - {(type) => { - const [imgFailed, setImgFailed] = createSignal(false); - return ( - - } - > - {type().name} setImgFailed(true)} - /> - - - ); - }} - - - {issue.key} - - - - {issue.fields.summary} - - -
- -

- {issue.fields.summary} -

-
-
-
- - - {stripParenthetical(issue.fields.priority!.name)} - - - - {issue.fields.status.name} - -
- - - - -
- -
- 0} - fallback={ -

- No custom fields configured — add them in Settings. -

- } - > -
- - {(field) => { - const val = () => (issue.fields as Record)[field.id]; - return ( - - - {field.name}: - - - - ); - }} - -
-
-
-
-
- ); - }} -
-
-
- -
- No matching issues in {group.repoFullName}
-
-
- ); - }} - - + ); + }} + + + } + > +
+ + {(issue, index) => + renderIssueRow(issue, { + isFirst: page() === 0 && index() === 0, + isLast: page() === pageCount() - 1 && index() === customPageItems().length - 1, + }) + } + +
+
1}>
void; + placeholder?: string; } interface FlatOption { @@ -57,6 +58,7 @@ export default function SortDropdown(props: SortDropdownProps) { optionTextValue="label" value={flatOptions().find((o) => o.value === selected()) ?? null} onChange={(opt) => handleChange(opt?.value ?? null)} + placeholder={props.placeholder ?? "Sort by"} itemComponent={(itemProps) => ( - >{(state) => state.selectedOption()?.label ?? "Sort by"} + >{(state) => state.selectedOption()?.label ?? (props.placeholder ?? "Sort by")} diff --git a/tests/components/dashboard/JiraAssignedTab.test.tsx b/tests/components/dashboard/JiraAssignedTab.test.tsx index 7541f9a1..2ff1ca78 100644 --- a/tests/components/dashboard/JiraAssignedTab.test.tsx +++ b/tests/components/dashboard/JiraAssignedTab.test.tsx @@ -5,6 +5,7 @@ import { render, screen } from "@solidjs/testing-library"; let mockTrackedItems: Array<{ source: string; jiraKey?: string }> = []; let mockJiraFilters: { scope: string; statusCategory: string; priority: string; sortField: string; sortDirection: string } = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }; +let mockJiraCustomOrder: string[] = []; vi.mock("../../../src/app/stores/view", () => ({ viewState: new Proxy({} as Record, { @@ -13,6 +14,7 @@ vi.mock("../../../src/app/stores/view", () => ({ if (key === "tabFilters") return { jiraAssigned: mockJiraFilters }; if (key === "lockedRepos") return {}; if (key === "expandedRepos") return { jiraAssigned: new Proxy({}, { get: () => true }) }; + if (key === "jiraCustomOrder") return mockJiraCustomOrder; return undefined; }, }), @@ -22,6 +24,8 @@ vi.mock("../../../src/app/stores/view", () => ({ trackItem: vi.fn(), untrackJiraItem: vi.fn(), setAllExpanded: vi.fn(), + setJiraCustomOrder: vi.fn(), + JIRA_CUSTOM_ORDER_SCOPE: "assigned", })); vi.mock("../../../src/app/stores/config", () => ({ @@ -31,7 +35,7 @@ vi.mock("../../../src/app/stores/config", () => ({ import JiraAssignedTab, { _resetJiraTabState } from "../../../src/app/components/dashboard/JiraAssignedTab"; import type { JiraIssue } from "../../../src/shared/jira-types"; import { config } from "../../../src/app/stores/config"; -import { trackItem, untrackJiraItem, setAllExpanded, setTabFilter } from "../../../src/app/stores/view"; +import { trackItem, untrackJiraItem, setAllExpanded, setTabFilter, setJiraCustomOrder } from "../../../src/app/stores/view"; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -66,12 +70,19 @@ function makeIssue( const SITE_URL = "https://mysite.atlassian.net"; +// Builds a jiraAssigned filter object defaulting to the canonical, unfiltered +// "assigned" + "custom" state that gates reordering (canReorder() in the component). +function customFilters(overrides: Partial = {}): typeof mockJiraFilters { + return { scope: "assigned", statusCategory: "all", priority: "all", sortField: "custom", sortDirection: "asc", ...overrides }; +} + // ── Tests ───────────────────────────────────────────────────────────────────── describe("JiraAssignedTab", () => { beforeEach(() => { mockTrackedItems = []; mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }; + mockJiraCustomOrder = []; _resetJiraTabState(); vi.clearAllMocks(); }); @@ -552,4 +563,382 @@ describe("JiraAssignedTab", () => { (config as Record).jira = { customScopes: [] }; }); }); + + // ── Custom order (Task 3) ────────────────────────────────────────────────── + + describe("custom order — flat rendering", () => { + it("renders a flat list with no group headers in custom mode (fresh/default state)", () => { + mockJiraFilters = customFilters(); + const issues = [ + makeIssue("ALPHA-1", "ALPHA"), + makeIssue("BETA-1", "BETA"), + ]; + const { container } = render(() => ); + + // Group headers are wrapped in a "group/repo-header" div — none should render. + // (Per-row expand/collapse chevrons also carry aria-expanded, so that alone + // isn't a reliable signal that a *group* header is absent. classList.contains + // is used instead of a CSS class selector because happy-dom's querySelector + // rejects the unescaped "/" in "group/repo-header" as an invalid selector.) + const hasGroupHeader = Array.from(container.querySelectorAll("div")).some((el) => + el.classList.contains("group/repo-header") + ); + expect(hasGroupHeader).toBe(false); + // Rows still render directly + const rows = screen.getAllByRole("listitem"); + expect(rows).toHaveLength(2); + expect(screen.getByText("ALPHA-1")).toBeTruthy(); + expect(screen.getByText("BETA-1")).toBeTruthy(); + }); + + it("shows a per-row project badge for each issue in custom mode", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("ALPHA-1", "ALPHA")]; + render(() => ); + + const row = screen.getAllByRole("listitem")[0]; + const badge = row.querySelector(".badge-ghost"); + expect(badge?.textContent).toBe("ALPHA"); + }); + + it("hides ExpandCollapseButtons in custom mode", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("ALPHA-1", "ALPHA")]; + render(() => ); + + expect(screen.queryByRole("button", { name: /expand all/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /collapse all/i })).toBeNull(); + }); + + it("shows 'Custom order' placeholder text in the SortDropdown trigger while in custom mode", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1")]; + render(() => ); + + expect(screen.getByText("Custom order")).toBeTruthy(); + }); + + it("restores grouped display with RepoLockControls and hides per-row project badges when sorted by Priority", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "priority", sortDirection: "asc" }; + const issues = [makeIssue("ALPHA-1", "ALPHA")]; + render(() => ); + + // Group header present + expect(screen.getByRole("button", { expanded: true })).toBeTruthy(); + // RepoLockControls present (pin button) + expect(screen.getByRole("button", { name: /pin alpha to top of list/i })).toBeTruthy(); + // No per-row project badge + const row = screen.getAllByRole("listitem")[0]; + expect(row.querySelector(".badge-ghost")).toBeNull(); + // No arrow buttons + expect(screen.queryByRole("button", { name: /move up:/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /move down:/i })).toBeNull(); + }); + + it("does not show RepoLockControls or group headers in custom mode", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("ALPHA-1", "ALPHA")]; + render(() => ); + + expect(screen.queryByRole("button", { name: /pin alpha to top of list/i })).toBeNull(); + }); + + it("auto-expands all project groups on first entry to a grouped sort", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "priority", sortDirection: "asc" }; + const issues = [ + makeIssue("ALPHA-1", "ALPHA"), + makeIssue("BETA-1", "BETA"), + ]; + render(() => ); + + expect(vi.mocked(setAllExpanded)).toHaveBeenCalledWith("jiraAssigned", ["ALPHA", "BETA"], true); + }); + }); + + describe("custom order — arrow button interactions", () => { + it("clicking move-down swaps a row with the next and persists the full order via setJiraCustomOrder", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2")]; + render(() => ); + + const downBtn = screen.getByRole("button", { name: "Move down: PROJ-1" }); + downBtn.click(); + + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledTimes(1); + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(["PROJ-2", "PROJ-1"]); + }); + + it("clicking move-up swaps a row with the previous and persists the full order via setJiraCustomOrder", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2")]; + render(() => ); + + const upBtn = screen.getByRole("button", { name: "Move up: PROJ-2" }); + upBtn.click(); + + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledTimes(1); + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(["PROJ-2", "PROJ-1"]); + }); + + it("disables the up arrow on the true first item and does not disable the down arrow merely for being the current page's last item", () => { + mockJiraFilters = customFilters(); + const issues = Array.from({ length: 27 }, (_, i) => makeIssue(`PROJ-${i + 1}`)); + render(() => ); + + const upFirst = screen.getByRole("button", { name: "Move up: PROJ-1" }) as HTMLButtonElement; + const downPageEdge = screen.getByRole("button", { name: "Move down: PROJ-25" }) as HTMLButtonElement; + expect(upFirst.disabled).toBe(true); + expect(downPageEdge.disabled).toBe(false); + }); + + it("disables the down arrow only on the true last item across pages, and enables the up arrow on the first item of a non-first page", () => { + mockJiraFilters = customFilters(); + const issues = Array.from({ length: 27 }, (_, i) => makeIssue(`PROJ-${i + 1}`)); + render(() => ); + + screen.getByRole("button", { name: /next page/i }).click(); + + const upPageStart = screen.getByRole("button", { name: "Move up: PROJ-26" }) as HTMLButtonElement; + const downLast = screen.getByRole("button", { name: "Move down: PROJ-27" }) as HTMLButtonElement; + expect(upPageStart.disabled).toBe(false); + expect(downLast.disabled).toBe(true); + }); + + it("disables arrow buttons when a status filter is active", () => { + mockJiraFilters = customFilters({ statusCategory: "new" }); + const issues = [makeIssue("PROJ-1", "PROJ", "new"), makeIssue("PROJ-2", "PROJ", "new")]; + render(() => ); + + const downBtn = screen.getByRole("button", { name: "Move down: PROJ-1" }) as HTMLButtonElement; + expect(downBtn.disabled).toBe(true); + expect(downBtn.getAttribute("title")).toBe("Switch to Assigned to me with no filters to reorder"); + }); + + it("disables arrow buttons when a priority filter is active", () => { + mockJiraFilters = customFilters({ priority: "High" }); + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate", "High"), makeIssue("PROJ-2", "PROJ", "indeterminate", "High")]; + render(() => ); + + const downBtn = screen.getByRole("button", { name: "Move down: PROJ-1" }) as HTMLButtonElement; + expect(downBtn.disabled).toBe(true); + }); + + it("disables arrow buttons when scope is not 'assigned'", () => { + mockJiraFilters = customFilters({ scope: "reported" }); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2")]; + render(() => ); + + const downBtn = screen.getByRole("button", { name: "Move down: PROJ-1" }) as HTMLButtonElement; + expect(downBtn.disabled).toBe(true); + }); + + it("does not call setJiraCustomOrder when clicking a disabled arrow button while a filter is active (no data loss)", () => { + mockJiraCustomOrder = ["PROJ-1", "PROJ-2"]; + mockJiraFilters = customFilters({ priority: "High" }); + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate", "High"), makeIssue("PROJ-2", "PROJ", "indeterminate", "Medium")]; + render(() => ); + + const downBtn = screen.getByRole("button", { name: "Move down: PROJ-1" }) as HTMLButtonElement; + expect(downBtn.disabled).toBe(true); + downBtn.click(); + + // The disabled attribute blocks the click entirely — setJiraCustomOrder is never + // called, so the full recorded order (including PROJ-2, which the active priority + // filter excludes from filteredSorted()) can never be silently truncated. + expect(vi.mocked(setJiraCustomOrder)).not.toHaveBeenCalled(); + }); + + it("disables both arrow buttons immediately after a click, before the animation/timeout settles", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2"), makeIssue("PROJ-3")]; + render(() => ); + + const downBtn = screen.getByRole("button", { name: "Move down: PROJ-2" }) as HTMLButtonElement; + const upBtn = screen.getByRole("button", { name: "Move up: PROJ-2" }) as HTMLButtonElement; + expect(downBtn.disabled).toBe(false); + + downBtn.click(); + + expect(downBtn.disabled).toBe(true); + expect(upBtn.disabled).toBe(true); + }); + + it("clicking an arrow twice in immediate succession only applies the first move", () => { + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2"), makeIssue("PROJ-3")]; + render(() => ); + + const downBtn = screen.getByRole("button", { name: "Move down: PROJ-1" }) as HTMLButtonElement; + downBtn.click(); + downBtn.click(); + + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledTimes(1); + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(["PROJ-2", "PROJ-1", "PROJ-3"]); + }); + + it("renders a newly-appearing issue (not yet in jiraCustomOrder) at the bottom of the list with functional arrow buttons", () => { + mockJiraCustomOrder = ["PROJ-2", "PROJ-1"]; + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2"), makeIssue("PROJ-3")]; + render(() => ); + + const rows = screen.getAllByRole("listitem"); + const keys = rows.map((el) => el.querySelector(".font-mono")?.textContent); + expect(keys).toEqual(["PROJ-2", "PROJ-1", "PROJ-3"]); + + const upBtn = screen.getByRole("button", { name: "Move up: PROJ-3" }) as HTMLButtonElement; + expect(upBtn.disabled).toBe(false); + upBtn.click(); + + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(["PROJ-2", "PROJ-3", "PROJ-1"]); + }); + }); + + describe("custom order — FLIP animation / reduced motion", () => { + afterEach(() => { + delete (Element.prototype as unknown as { animate?: unknown }).animate; + }); + + it("invokes Element.prototype.animate for a same-page move", () => { + vi.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as MediaQueryList); + let rectCallCount = 0; + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(() => { + rectCallCount += 1; + return { top: rectCallCount * 10, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; + }); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); + const animateSpy = vi.fn(); + Element.prototype.animate = animateSpy; + + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2"), makeIssue("PROJ-3")]; + render(() => ); + + screen.getByRole("button", { name: "Move down: PROJ-1" }).click(); + + expect(animateSpy).toHaveBeenCalled(); + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(["PROJ-2", "PROJ-1", "PROJ-3"]); + }); + + it("does not invoke Element.prototype.animate and advances the page for a move crossing forward into the next page", () => { + vi.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as MediaQueryList); + const animateSpy = vi.fn(); + Element.prototype.animate = animateSpy; + + mockJiraFilters = customFilters(); + const issues = Array.from({ length: 27 }, (_, i) => makeIssue(`PROJ-${i + 1}`)); + render(() => ); + + screen.getByRole("button", { name: "Move down: PROJ-25" }).click(); + + expect(animateSpy).not.toHaveBeenCalled(); + expect(screen.getByText(/page 2 of 2/i)).toBeTruthy(); + const expected = issues.map((i) => i.key); + [expected[24], expected[25]] = [expected[25], expected[24]]; + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(expected); + }); + + it("does not invoke Element.prototype.animate and returns to the previous page for a move crossing backward", () => { + vi.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as MediaQueryList); + mockJiraFilters = customFilters(); + const issues = Array.from({ length: 27 }, (_, i) => makeIssue(`PROJ-${i + 1}`)); + render(() => ); + + screen.getByRole("button", { name: /next page/i }).click(); + expect(screen.getByText(/page 2 of 2/i)).toBeTruthy(); + + const animateSpy = vi.fn(); + Element.prototype.animate = animateSpy; + screen.getByRole("button", { name: "Move up: PROJ-26" }).click(); + + expect(animateSpy).not.toHaveBeenCalled(); + expect(screen.getByText(/page 1 of 2/i)).toBeTruthy(); + const expected = issues.map((i) => i.key); + [expected[24], expected[25]] = [expected[25], expected[24]]; + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(expected); + }); + + it("with prefers-reduced-motion, calls window.scrollTo instead of animating for a same-page move", () => { + vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true } as MediaQueryList); + vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const animateSpy = vi.fn(); + Element.prototype.animate = animateSpy; + + mockJiraFilters = customFilters(); + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2")]; + render(() => ); + + screen.getByRole("button", { name: "Move down: PROJ-1" }).click(); + + expect(window.scrollTo).toHaveBeenCalled(); + expect(animateSpy).not.toHaveBeenCalled(); + expect(vi.mocked(setJiraCustomOrder)).toHaveBeenCalledWith(["PROJ-2", "PROJ-1"]); + }); + + it("with prefers-reduced-motion, calls window.scrollTo instead of animating for a cross-page move", () => { + vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true } as MediaQueryList); + vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const animateSpy = vi.fn(); + Element.prototype.animate = animateSpy; + + mockJiraFilters = customFilters(); + const issues = Array.from({ length: 27 }, (_, i) => makeIssue(`PROJ-${i + 1}`)); + render(() => ); + + screen.getByRole("button", { name: "Move down: PROJ-25" }).click(); + + expect(window.scrollTo).toHaveBeenCalled(); + expect(animateSpy).not.toHaveBeenCalled(); + expect(screen.getByText(/page 2 of 2/i)).toBeTruthy(); + }); + }); + + describe("custom order — Clear button / reset button interplay", () => { + it("clicking Clear while sorted by Priority with a filter active only clears status/priority filters, not sortField or scope", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "High", sortField: "priority", sortDirection: "asc" }; + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate", "High")]; + render(() => ); + + screen.getByRole("button", { name: /clear/i }).click(); + + expect(vi.mocked(setTabFilter)).toHaveBeenCalledWith("jiraAssigned", "statusCategory", "all"); + expect(vi.mocked(setTabFilter)).toHaveBeenCalledWith("jiraAssigned", "priority", "all"); + const fieldsChanged = vi.mocked(setTabFilter).mock.calls.map((call) => call[1]); + expect(fieldsChanged).not.toContain("sortField"); + expect(fieldsChanged).not.toContain("scope"); + }); + + it("shows the '↺ Custom order' reset button when sortField is not custom, and hides it in custom mode", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "priority", sortDirection: "asc" }; + const issues = [makeIssue("PROJ-1")]; + const { unmount } = render(() => ); + // Matched by its exact "↺ Custom order" text, which disambiguates it from + // the SortDropdown trigger — in custom mode the trigger's own accessible + // name also contains "Custom order" (via its placeholder prop), so a bare + // /custom order/i regex would match both buttons. + expect(screen.getByRole("button", { name: /↺\s*custom order/i })).toBeTruthy(); + unmount(); + + mockJiraFilters = customFilters(); + render(() => ); + expect(screen.queryByRole("button", { name: /↺\s*custom order/i })).toBeNull(); + }); + + it("clicking the reset button switches sortField to custom and resets the page to 0", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "priority", sortDirection: "asc" }; + const issues = [ + ...Array.from({ length: 15 }, (_, i) => makeIssue(`ALPHA-${i + 1}`, "ALPHA")), + ...Array.from({ length: 15 }, (_, i) => makeIssue(`BETA-${i + 1}`, "BETA")), + ]; + render(() => ); + + screen.getByRole("button", { name: /next page/i }).click(); + expect(screen.getByText(/page 2 of 2/i)).toBeTruthy(); + + screen.getByRole("button", { name: /custom order/i }).click(); + + expect(vi.mocked(setTabFilter)).toHaveBeenCalledWith("jiraAssigned", "sortField", "custom"); + expect(screen.getByText(/page 1 of 2/i)).toBeTruthy(); + }); + }); }); From 65ea33a981e1bfb6b1232cb57b310d43af7d1327 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 16:59:52 -0400 Subject: [PATCH 05/10] docs: adds Jira custom-order sort option --- docs/USER_GUIDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 23eb9ec5..390ea186 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -572,7 +572,9 @@ When Jira is connected, a **Jira** tab appears in the tab bar. It shows all open **Filters:** Status category (New, In Progress) and priority (Highest through Lowest) filters are available in the filter popover. -**Grouping:** Issues are grouped by Jira project key, similar to how GitHub items are grouped by repo. +**Grouping:** Issues are grouped by Jira project key, similar to how GitHub items are grouped by repo — except when Custom order is active (see below), which shows one flat list. + +**Custom order:** By default, issues show in one flat list across all projects, ranked in whatever order you've arranged them (each row shows its project as a small badge). Use the up/down arrows on each row to move it — moves work across page boundaries and the page view follows the row. Arrows are only enabled while viewing "Assigned to me" with no status/priority filter applied (your arrangement is still visible otherwise, just not editable from that view). Pick any other option from the sort dropdown to switch to grouped/sorted display instead; a small "Custom order" button next to the dropdown switches back. Project group locking (pinning a project to the top) is only available in grouped views. **Pagination:** Client-side over up to 100 fetched issues. From 9e337d0c5efcc7483d8d1bfde1f6bc99f07c7e82 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 17:27:17 -0400 Subject: [PATCH 06/10] fix(jira): addresses phase 4 review findings for custom-order reordering --- .../components/dashboard/JiraAssignedTab.tsx | 76 ++++++++--- src/app/stores/view.ts | 7 +- tests/components/DashboardPage.test.tsx | 121 ++++++++++++++++++ tests/stores/view-jira-order.test.ts | 5 + tests/stores/view.test.ts | 81 ++++++++++++ 5 files changed, 268 insertions(+), 22 deletions(-) diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx index eddb3112..2c00bef0 100644 --- a/src/app/components/dashboard/JiraAssignedTab.tsx +++ b/src/app/components/dashboard/JiraAssignedTab.tsx @@ -137,18 +137,21 @@ const itemRefs = new Map(); const prefersReducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; -function recordPositions(): Map { +function recordPositions(keys: string[]): Map { const snapshot = new Map(); - for (const [key, el] of itemRefs) { - snapshot.set(key, el.getBoundingClientRect()); + for (const key of keys) { + const el = itemRefs.get(key); + if (el) snapshot.set(key, el.getBoundingClientRect()); } return snapshot; } -function animateMove(before: Map) { +function animateMove(before: Map, keys: string[]) { if (prefersReducedMotion()) return; requestAnimationFrame(() => { - for (const [key, el] of itemRefs) { + for (const key of keys) { + const el = itemRefs.get(key); + if (!el) continue; const old = before.get(key); if (!old) continue; const now = el.getBoundingClientRect(); @@ -270,12 +273,32 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { }); type JiraItem = JiraIssue & { repoFullName: string }; - const itemsWithGroupKey = createMemo(() => - filteredSorted().map((issue): JiraItem => ({ - ...issue, - repoFullName: issue.fields.project?.key ?? "OTHER", - })) - ); + + // Cache wrapper objects by issue key so that a reorder (same JiraIssue references, + // new array order) reuses the SAME JiraItem object per issue. This preserves + // reference equality for 's keyed reconciliation, letting it move DOM nodes + // instead of tearing down / rebuilding every row on each arrow-click (Finding 4). + const itemsWithGroupKeyCache = new Map(); + const itemsWithGroupKey = createMemo(() => { + const result = filteredSorted().map((issue): JiraItem => { + const cached = itemsWithGroupKeyCache.get(issue.key); + if (cached && cached.source === issue) return cached.wrapped; + const wrapped: JiraItem = { ...issue, repoFullName: issue.fields.project?.key ?? "OTHER" }; + itemsWithGroupKeyCache.set(issue.key, { source: issue, wrapped }); + return wrapped; + }); + // Prune stale cache entries for issues that left the list (e.g. after a data + // refresh or filter change). The cache naturally self-limits to the active issue + // count (capped at ~500 elsewhere in the ecosystem), so this is a hygiene measure + // rather than a hard cap. + if (itemsWithGroupKeyCache.size > result.length) { + const activeKeys = new Set(result.map(item => item.key)); + for (const key of itemsWithGroupKeyCache.keys()) { + if (!activeKeys.has(key)) itemsWithGroupKeyCache.delete(key); + } + } + return result; + }); const repoGroups = createMemo(() => { const groups = groupByRepo(itemsWithGroupKey()); @@ -294,8 +317,21 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { // repoFullName, never filters/reorders), so paginating it directly yields the same // slices filteredSorted() would, while giving renderIssueRow's shared JiraItem rows // the project key it needs for the Step 4 badge without re-deriving it. - const customPageCount = () => Math.max(1, Math.ceil(itemsWithGroupKey().length / ITEMS_PER_PAGE)); - const customPageItems = () => itemsWithGroupKey().slice(page() * ITEMS_PER_PAGE, (page() + 1) * ITEMS_PER_PAGE); + // + // Both are createMemo (not plain arrow functions) because customPageItems is called + // 3+ times per render and does a real .slice() each time (Finding 1). + const customPageCount = createMemo(() => Math.max(1, Math.ceil(itemsWithGroupKey().length / ITEMS_PER_PAGE))); + const customPageItems = createMemo(() => itemsWithGroupKey().slice(page() * ITEMS_PER_PAGE, (page() + 1) * ITEMS_PER_PAGE)); + + // Prune itemRefs to current-page items in custom mode so the map does not grow + // unbounded across pages, filter changes, and data refreshes (Finding 2 & 3). + createEffect(() => { + if (!isCustomMode()) return; + const pageItemKeys = new Set(customPageItems().map(item => item.key)); + for (const key of itemRefs.keys()) { + if (!pageItemKeys.has(key)) itemRefs.delete(key); + } + }); const pageLayout = createMemo(() => computePageLayout(repoGroups(), ITEMS_PER_PAGE)); const pageCount = createMemo(() => (isCustomMode() ? customPageCount() : pageLayout().pageCount)); @@ -347,20 +383,22 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { // Reduced motion: no animation ever, but still guard against a viewport jump // from the mutation itself (matches withFlipAnimation's fallback, scroll.ts:27-29). withScrollLock(applyMove); - setReordering(false); } else if (crossesPage) { // Cross-page moves must skip the FLIP animation entirely (spike pl-feas-2): the // old page's rows become detached before animateMove's rAF callback runs, producing // a broken/misleading animation. Jump straight to the new page instead. applyMove(); - setReordering(false); } else { - const before = recordPositions(); + const pageKeys = customPageItems().map(i => i.key); + const before = recordPositions(pageKeys); applyMove(); - animateMove(before); - // Matches animateMove's `duration: 200` — keep these two values in sync. - setTimeout(() => setReordering(false), 200); + animateMove(before, pageKeys); } + // Uniform 200ms lockout across all branches — prevents rapid clicks / key-repeat + // from queuing moves in the reduced-motion and cross-page paths where the lockout + // was previously reset synchronously (Finding 5). Value matches animateMove's + // `duration: 200` — keep them in sync. + setTimeout(() => setReordering(false), 200); } function renderIssueRow(issue: JiraItem, boundary?: { isFirst: boolean; isLast: boolean }) { diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index b3aa05f4..826e5dea 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -578,11 +578,12 @@ export function moveTrackedItem( } export function setJiraCustomOrder(order: string[]): void { + const deduped = [...new Set(order)]; setViewState( produce((draft) => { - draft.jiraCustomOrder = order.length > JIRA_CUSTOM_ORDER_CAP - ? order.slice(0, JIRA_CUSTOM_ORDER_CAP) - : order; + draft.jiraCustomOrder = deduped.length > JIRA_CUSTOM_ORDER_CAP + ? deduped.slice(0, JIRA_CUSTOM_ORDER_CAP) + : deduped; }) ); } diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index b918df06..9a928555 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2516,6 +2516,14 @@ describe("DashboardPage — pruneJiraCustomOrder wiring", () => { expect(viewStore.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-3"]); }); + // NOTE: this is a supplementary unit-level check on the gate condition in + // isolation — it reimplements `if (scope === JIRA_CUSTOM_ORDER_SCOPE)` inline + // rather than exercising DashboardPage's real fetchJiraAssigned(), so it + // would still pass even if the actual guard at DashboardPage.tsx were + // inverted or removed. The primary regression test for that guard is + // "does NOT prune jiraCustomOrder when a non-assigned scope is active" in + // the "pruneJiraCustomOrder on refresh" describe block below, which mounts + // the real component and lets the real fetch run. it("pruneJiraCustomOrder is NOT called when scope is not assigned", () => { const { pruneJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, setJiraCustomOrder } = viewStore; @@ -2641,6 +2649,119 @@ describe("DashboardPage — pruneJiraCustomOrder on refresh", () => { expect(freshView.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-3"]); }, { timeout: 3000 }); }); + + it("does NOT prune jiraCustomOrder when a non-assigned scope is active", async () => { + // This is the primary regression test for the scope gate at + // DashboardPage.tsx (`if (scope === JIRA_CUSTOM_ORDER_SCOPE)`). It mirrors + // the positive-case test above — same mocking scaffolding, real + // mount, real fetchJiraAssigned() — but activates scope + // "reported" before mount and returns Jira results that deliberately + // share NO keys with the existing jiraCustomOrder. If the gate were ever + // inverted or removed, the real prune call would wipe jiraCustomOrder + // down to [] here; the unit-level check in the "wiring" describe block + // above cannot catch that because it reimplements the gate inline instead + // of exercising the production code path. + vi.resetModules(); + authClearCallbacks.length = 0; + + const mockSearchJql = vi.fn().mockResolvedValue({ + issues: [ + { + id: "2001", key: "OTHER-1", self: "https://test.atlassian.net/rest/api/3/issue/2001", + fields: { + summary: "Reported issue", status: { id: "1", name: "To Do", statusCategory: { id: 2, key: "new" as const, name: "To Do" } }, + priority: { id: "3", name: "Medium" }, assignee: null, + project: { id: "20000", key: "OTHER", name: "Other Project" }, + }, + }, + ], + total: 1, maxResults: 100, startAt: 0, + }); + + vi.doMock("../../src/app/stores/auth", () => ({ + clearAuth: vi.fn(), + expireToken: vi.fn(), + token: () => "fake-token", + user: () => ({ login: "testuser", avatar_url: "", name: "Test User" }), + isAuthenticated: () => true, + onAuthCleared: vi.fn((cb: () => void) => { authClearCallbacks.push(cb); }), + DASHBOARD_STORAGE_KEY: "github-tracker:dashboard", + DEP_META_STORAGE_KEY: "github-tracker:dep-meta", + jiraAuth: vi.fn(() => ({ + cloudId: "cloud-123", + accessToken: "tok", + siteUrl: "https://test.atlassian.net", + siteName: "Test Site", + })), + isJiraAuthenticated: vi.fn(() => true), + setJiraAuth: vi.fn(), + clearJiraAuth: vi.fn(), + ensureJiraTokenValid: vi.fn().mockResolvedValue(true), + })); + + const MockJiraClient = vi.fn(function (this: Record) { + this.searchJql = mockSearchJql; + this.bulkFetch = vi.fn().mockResolvedValue({ issues: [], errors: [] }); + }); + vi.doMock("../../src/app/services/jira-client", () => ({ + JiraClient: MockJiraClient, + JiraProxyClient: vi.fn(), + JiraApiError: class JiraApiError extends Error { + status: number; + constructor(status: number, _body: unknown, message: string) { + super(message); + this.status = status; + } + }, + DEFAULT_FIELDS: ["summary", "status", "priority", "assignee", "project", "updated", "issuetype", "created"], + })); + + vi.doMock("../../src/app/services/poll", () => ({ + fetchAllData: vi.fn().mockResolvedValue({ + issues: [], pullRequests: [], workflowRuns: [], errors: [], + }), + createPollCoordinator: vi.fn().mockImplementation( + (_getInterval: unknown, fetchAll: () => Promise) => { + void fetchAll().catch(() => {}); + return { isRefreshing: () => false, lastRefreshAt: () => null, manualRefresh: vi.fn(), destroy: vi.fn() }; + } + ), + createHotPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + createEventsPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + rebuildHotSets: vi.fn(), + seedHotSetsFromTargeted: vi.fn(), + clearHotSets: vi.fn(), + getHotPollGeneration: vi.fn().mockReturnValue(0), + })); + + // Fresh imports after mock registration + const freshView = await import("../../src/app/stores/view"); + const freshConfig = await import("../../src/app/stores/config"); + const freshDash = await import("../../src/app/components/dashboard/DashboardPage"); + + freshView.resetViewState(); + freshConfig.resetConfig(); + freshConfig.updateJiraConfig({ enabled: true, siteUrl: "https://test.atlassian.net", siteName: "Test Site", authMethod: "oauth" }); + freshView.setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3"]); + // Activate a non-"assigned" scope BEFORE mount, so the immediate + // on-mount fetchJiraAssigned() call reads scope "reported" from + // viewState.tabFilters.jiraAssigned — exactly the branch the gate at + // DashboardPage.tsx is supposed to skip pruning for. + freshView.setTabFilter("jiraAssigned", "scope", "reported"); + + render(() => ); + + // Wait for the real fetchJiraAssigned() to actually invoke searchJql... + await waitFor(() => { + expect(mockSearchJql).toHaveBeenCalled(); + }, { timeout: 3000 }); + // ...then flush the microtask/timer queue so the code after the awaited + // searchJql call (setJiraIssues + the scope-gated prune call) has + // actually finished running before we assert on the result. + await new Promise((r) => setTimeout(r, 200)); + + expect(freshView.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2", "PROJ-3"]); + }); }); describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clear", () => { diff --git a/tests/stores/view-jira-order.test.ts b/tests/stores/view-jira-order.test.ts index ca7ef9f0..14e76808 100644 --- a/tests/stores/view-jira-order.test.ts +++ b/tests/stores/view-jira-order.test.ts @@ -47,6 +47,11 @@ describe("jira custom order store actions", () => { setJiraCustomOrder(exact); expect(viewState.jiraCustomOrder.length).toBe(JIRA_CUSTOM_ORDER_CAP); }); + + it("deduplicates keys, keeping first occurrence and relative order", () => { + setJiraCustomOrder(["A", "B", "A", "C"]); + expect(viewState.jiraCustomOrder).toEqual(["A", "B", "C"]); + }); }); describe("pruneJiraCustomOrder", () => { diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index d679d01b..155e7767 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -1051,4 +1051,85 @@ describe("loadViewState — cap-guard integration", () => { expect(mod.viewState.lockedRepos["issues"]).toEqual(["org/repo", "org/other"]); }); + + it("truncates oversized jiraCustomOrder arrays to JIRA_CUSTOM_ORDER_CAP", async () => { + const bigArray = Array.from({ length: 600 }, (_, i) => `KEY-${i}`); + localStorageMock.setItem(VIEW_KEY, JSON.stringify({ + jiraCustomOrder: bigArray, + })); + + vi.resetModules(); + const mod = await import("../../src/app/stores/view"); + + expect(mod.viewState.jiraCustomOrder.length).toBe(mod.JIRA_CUSTOM_ORDER_CAP); + expect(mod.viewState.jiraCustomOrder[0]).toBe("KEY-0"); + expect(mod.viewState.jiraCustomOrder[mod.JIRA_CUSTOM_ORDER_CAP - 1]).toBe( + `KEY-${mod.JIRA_CUSTOM_ORDER_CAP - 1}` + ); + }); + + it("filters non-string elements from jiraCustomOrder arrays", async () => { + localStorageMock.setItem(VIEW_KEY, JSON.stringify({ + jiraCustomOrder: [42, "PROJ-1", null, true, "PROJ-2"], + })); + + vi.resetModules(); + const mod = await import("../../src/app/stores/view"); + + expect(mod.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2"]); + }); + + it("drops (not truncates) jiraCustomOrder entries longer than JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH", async () => { + const maxLen = 50; + const tooLong = "X".repeat(maxLen + 1); // 51 chars — over the limit + localStorageMock.setItem(VIEW_KEY, JSON.stringify({ + jiraCustomOrder: ["PROJ-1", tooLong], + })); + + vi.resetModules(); + const mod = await import("../../src/app/stores/view"); + + // Sanity: confirm the constant matches what this test assumes before trusting the assertions below. + expect(mod.JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH).toBe(maxLen); + // The oversized entry is dropped entirely, NOT truncated to a 50-char prefix. + // If the guard truncated instead of dropping, the array would be + // ["PROJ-1", "X".repeat(50)] with length 2 — assert both the exact + // array and the absence of a truncated variant to distinguish the two behaviors. + expect(mod.viewState.jiraCustomOrder).toEqual(["PROJ-1"]); + expect(mod.viewState.jiraCustomOrder).not.toContain("X".repeat(maxLen)); + }); + + it("deletes a non-array (string) jiraCustomOrder value so ViewStateSchema's default applies", async () => { + localStorageMock.setItem(VIEW_KEY, JSON.stringify({ + jiraCustomOrder: "not-an-array", + })); + + vi.resetModules(); + const mod = await import("../../src/app/stores/view"); + + expect(mod.viewState.jiraCustomOrder).toEqual([]); + }); + + it("deletes a non-array (number) jiraCustomOrder value so ViewStateSchema's default applies", async () => { + localStorageMock.setItem(VIEW_KEY, JSON.stringify({ + jiraCustomOrder: 42, + })); + + vi.resetModules(); + const mod = await import("../../src/app/stores/view"); + + expect(mod.viewState.jiraCustomOrder).toEqual([]); + }); + + it("defaults jiraCustomOrder to [] when the key is missing from the raw blob", async () => { + localStorageMock.setItem(VIEW_KEY, JSON.stringify({ + lastActiveTab: "jiraAssigned", + })); + + vi.resetModules(); + const mod = await import("../../src/app/stores/view"); + + expect(mod.viewState.jiraCustomOrder).toEqual([]); + expect(mod.viewState.lastActiveTab).toBe("jiraAssigned"); + }); }); From 372bdab9f781bd5548a2c273db1214069d0080bf Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 18:14:54 -0400 Subject: [PATCH 07/10] fix(jira): addresses quality-gate layer 1.5 domain review findings --- .../components/dashboard/JiraAssignedTab.tsx | 9 ++- src/app/stores/view.ts | 16 ++++- .../dashboard/JiraAssignedTab.test.tsx | 40 ++++++++++++ tests/stores/view-jira-order.test.ts | 62 +++++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx index 2c00bef0..8d3790cc 100644 --- a/src/app/components/dashboard/JiraAssignedTab.tsx +++ b/src/app/components/dashboard/JiraAssignedTab.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, Show, on } from "solid-js"; +import { createEffect, createMemo, createSignal, For, Show, on, onCleanup } from "solid-js"; import type { JiraIssue } from "../../../shared/jira-types"; import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE } from "../../stores/view"; import { config } from "../../stores/config"; @@ -363,7 +363,12 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { const [reordering, setReordering] = createSignal(false); + // Clear the module-level itemRefs map when the component unmounts (e.g. tab + // switch) so detached DOM references are not leaked across mount cycles. + onCleanup(() => itemRefs.clear()); + function handleCustomMove(jiraKey: string, direction: "up" | "down") { + if (!canReorder()) return; if (reordering()) return; const order = filteredSorted().map((i) => i.key); const idx = order.indexOf(jiraKey); @@ -411,7 +416,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
{ itemRefs.set(issue.key, el); }} + ref={(el) => { if (isCustomMode()) itemRefs.set(issue.key, el); }} >
diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 826e5dea..55838154 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -130,7 +130,8 @@ export const ViewStateSchema = z.object({ lockedRepos: z.record(z.string(), z.array(z.string().max(200)).max(LOCKED_REPOS_CAP)).default({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }), trackedItems: z.array(TrackedItemSchema).max(TRACKED_ITEMS_CAP).default([]), dependencyExpandedGroups: z.array(z.string()).default(["mergeable"]), - jiraCustomOrder: z.array(z.string().max(JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH)).max(JIRA_CUSTOM_ORDER_CAP).default([]), + jiraCustomOrder: z.array(z.string().max(JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH)).max(JIRA_CUSTOM_ORDER_CAP).default([]) + .transform((arr) => [...new Set(arr)]), }); export type ViewState = z.infer; @@ -345,6 +346,10 @@ export function resetAllTabFilters( } else if (tab === "pullRequests") { draft.tabFilters.pullRequests = PullRequestFiltersSchema.parse({}); } else if (tab === "jiraAssigned") { + // WARNING: This resets sortField and scope as a side effect (back to defaults + // "custom" and "assigned"). JiraAssignedTab.tsx's Clear button uses two targeted + // setTabFilter calls instead of this function specifically to avoid that. + // Do not casually wire this back in for Jira without accounting for those resets. draft.tabFilters.jiraAssigned = JiraFiltersSchema.parse({}); } else if (tab === "actions") { draft.tabFilters.actions = ActionsFiltersSchema.parse({}); @@ -578,7 +583,10 @@ export function moveTrackedItem( } export function setJiraCustomOrder(order: string[]): void { - const deduped = [...new Set(order)]; + // Drop entries exceeding per-item length cap (consistent with loadViewState's guard), + // then dedup (schema transform only fires on parse, not on direct produce() mutations) + const sanitized = order.filter((k) => k.length <= JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH); + const deduped = [...new Set(sanitized)]; setViewState( produce((draft) => { draft.jiraCustomOrder = deduped.length > JIRA_CUSTOM_ORDER_CAP @@ -621,6 +629,10 @@ export function initViewPersistence(): void { return; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return; + // Guard: if the incoming blob doesn't contain jiraCustomOrder at all, skip sync. + // Without this, Zod's .default([]) would backfill the missing key with an empty + // array and silently wipe the current tab's real order. + if (!("jiraCustomOrder" in (parsed as Record))) return; const result = ViewStateSchema.pick({ jiraCustomOrder: true }).safeParse(parsed); if (!result.success) return; const incoming = result.data.jiraCustomOrder; diff --git a/tests/components/dashboard/JiraAssignedTab.test.tsx b/tests/components/dashboard/JiraAssignedTab.test.tsx index 2ff1ca78..8984b755 100644 --- a/tests/components/dashboard/JiraAssignedTab.test.tsx +++ b/tests/components/dashboard/JiraAssignedTab.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; // ── Module mocks ────────────────────────────────────────────────────────────── @@ -797,6 +798,7 @@ describe("JiraAssignedTab", () => { describe("custom order — FLIP animation / reduced motion", () => { afterEach(() => { delete (Element.prototype as unknown as { animate?: unknown }).animate; + vi.restoreAllMocks(); }); it("invokes Element.prototype.animate for a same-page move", () => { @@ -891,6 +893,44 @@ describe("JiraAssignedTab", () => { expect(animateSpy).not.toHaveBeenCalled(); expect(screen.getByText(/page 2 of 2/i)).toBeTruthy(); }); + + it("preserves DOM element identity (same node, not recreated) after a same-page reorder", () => { + // The mock store's setJiraCustomOrder is a no-op, so clicking an arrow + // button does not reorder the DOM. To test actual DOM identity we drive + // the reorder through the reactive prop layer: pass a signal-backed + // issues array, change mockJiraCustomOrder, and trigger a re-render by + // updating the signal (same JiraIssue objects, new array reference). + // The itemsWithGroupKeyCache inside the component should keep the same + // wrapped JiraItem objects for unchanged source issues, so 's keyed + // reconciliation moves the DOM nodes instead of tearing down / rebuilding. + mockJiraFilters = customFilters(); + mockJiraCustomOrder = ["PROJ-1", "PROJ-2", "PROJ-3"]; + + const issueA = makeIssue("PROJ-1"); + const issueB = makeIssue("PROJ-2"); + const issueC = makeIssue("PROJ-3"); + + const [issues, setIssues] = createSignal([issueA, issueB, issueC]); + render(() => ); + + // Capture the actual DOM node for PROJ-2's row before the reorder + const rowsBefore = screen.getAllByRole("listitem"); + expect(rowsBefore[1].querySelector(".font-mono")?.textContent).toBe("PROJ-2"); + const proj2NodeBefore = rowsBefore[1]; + + // Swap PROJ-1 and PROJ-2 in the custom order, then poke the signal to + // trigger a reactive re-evaluation of filtered → filteredSorted → + // itemsWithGroupKey → customPageItems → . + mockJiraCustomOrder = ["PROJ-2", "PROJ-1", "PROJ-3"]; + setIssues([issueA, issueB, issueC]); + + // After the reactive update, PROJ-2 should be at index 0. The critical + // assertion: the DOM node is the SAME object reference — moved by , + // not torn down and rebuilt. + const rowsAfter = screen.getAllByRole("listitem"); + expect(rowsAfter[0].querySelector(".font-mono")?.textContent).toBe("PROJ-2"); + expect(rowsAfter[0]).toBe(proj2NodeBefore); + }); }); describe("custom order — Clear button / reset button interplay", () => { diff --git a/tests/stores/view-jira-order.test.ts b/tests/stores/view-jira-order.test.ts index 14e76808..ae734bb3 100644 --- a/tests/stores/view-jira-order.test.ts +++ b/tests/stores/view-jira-order.test.ts @@ -9,6 +9,7 @@ import { ViewStateSchema, VIEW_STORAGE_KEY, JIRA_CUSTOM_ORDER_CAP, + JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH, } from "../../src/app/stores/view"; describe("jira custom order store actions", () => { @@ -52,6 +53,13 @@ describe("jira custom order store actions", () => { setJiraCustomOrder(["A", "B", "A", "C"]); expect(viewState.jiraCustomOrder).toEqual(["A", "B", "C"]); }); + + it("drops entries exceeding per-item max length", () => { + const tooLong = "K".repeat(JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH + 1); + const atLimit = "K".repeat(JIRA_CUSTOM_ORDER_KEY_MAX_LENGTH); + setJiraCustomOrder(["PROJ-1", tooLong, atLimit, "PROJ-2"]); + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1", atLimit, "PROJ-2"]); + }); }); describe("pruneJiraCustomOrder", () => { @@ -111,6 +119,43 @@ describe("jira custom order store actions", () => { }); expect(result.tabFilters.jiraAssigned.sortField).toBe("priority"); }); + + it("deduplicates jiraCustomOrder at schema level during parse", () => { + const result = ViewStateSchema.parse({ + jiraCustomOrder: ["A", "B", "A", "C", "B"], + }); + expect(result.jiraCustomOrder).toEqual(["A", "B", "C"]); + }); + + it("deduplicates jiraCustomOrder at schema level during safeParse", () => { + const result = ViewStateSchema.safeParse({ + jiraCustomOrder: ["X", "Y", "X"], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.jiraCustomOrder).toEqual(["X", "Y"]); + } + }); + + it("schema dedup shrinks array — cannot violate max cap", () => { + // An array at exactly the cap with all duplicates should parse fine and dedup + const atCap = Array.from({ length: JIRA_CUSTOM_ORDER_CAP }, () => "SAME-KEY"); + const result = ViewStateSchema.safeParse({ jiraCustomOrder: atCap }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.jiraCustomOrder).toEqual(["SAME-KEY"]); + } + }); + + it("schema pick + safeParse deduplicates for cross-tab sync path", () => { + const result = ViewStateSchema.pick({ jiraCustomOrder: true }).safeParse({ + jiraCustomOrder: ["P-1", "P-2", "P-1", "P-3"], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.jiraCustomOrder).toEqual(["P-1", "P-2", "P-3"]); + } + }); }); }); @@ -205,4 +250,21 @@ describe("cross-tab sync for jiraCustomOrder", () => { dispatchStorageEvent(JSON.stringify({ jiraCustomOrder: ["PROJ-NEW"] })); expect(viewState.jiraCustomOrder).toEqual([]); }); + + it("does not wipe jiraCustomOrder when incoming blob is missing the key entirely", () => { + setupPersistence(); + setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3"]); + // Simulate a stale blob from a pre-feature tab that doesn't have jiraCustomOrder + const staleBlob = JSON.stringify({ lastActiveTab: "issues" }); + dispatchStorageEvent(staleBlob); + // Current order must be preserved — NOT wiped to [] + expect(viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2", "PROJ-3"]); + }); + + it("deduplicates incoming jiraCustomOrder from cross-tab sync", () => { + setupPersistence(); + const blob = JSON.stringify({ jiraCustomOrder: ["A", "B", "A", "C"] }); + dispatchStorageEvent(blob); + expect(viewState.jiraCustomOrder).toEqual(["A", "B", "C"]); + }); }); From e6c027809be785195b7dc7e679188747ca4678f1 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 16 Aug 2026 18:35:08 -0400 Subject: [PATCH 08/10] fix(jira): skips pruning when Jira result set is truncated --- .../components/dashboard/DashboardPage.tsx | 8 +- tests/components/DashboardPage.test.tsx | 116 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index ea3d1cc8..ecb1ecae 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -479,7 +479,13 @@ export default function DashboardPage() { if (!isJiraAuthenticated()) return; setJiraIssues(result.issues); - if (scope === JIRA_CUSTOM_ORDER_SCOPE) { + // Only prune when the result set is complete. `result.total` reflects the true + // match count while `result.issues` is capped at maxResults — if more issues + // exist than were returned (pagination truncation), skip pruning: treating + // "not in this page" as "no longer exists" would permanently destroy + // user-curated custom-order positions for issues that are simply on a later + // page, not actually gone (closed, reassigned, or resolved). + if (scope === JIRA_CUSTOM_ORDER_SCOPE && result.total <= result.issues.length) { pruneJiraCustomOrder(new Set(result.issues.map((i) => i.key))); } diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 9a928555..0f51c767 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -2762,6 +2762,122 @@ describe("DashboardPage — pruneJiraCustomOrder on refresh", () => { expect(freshView.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2", "PROJ-3"]); }); + + it("does NOT prune jiraCustomOrder when the assigned-scope result is truncated by pagination", async () => { + // Regression test for the truncation guard at DashboardPage.tsx + // (`result.total <= result.issues.length`). searchJql returns only 2 + // issues but reports total: 5 — simulating a user with more than + // maxResults assigned non-done issues, where the API silently paginates. + // pruneJiraCustomOrder must NOT run here: treating "not in this page" as + // "no longer exists" would permanently delete the custom-order position + // for issues that are simply on a later page, not actually gone. + vi.resetModules(); + authClearCallbacks.length = 0; + + const mockSearchJql = vi.fn().mockResolvedValue({ + issues: [ + { + id: "1001", key: "PROJ-1", self: "https://test.atlassian.net/rest/api/3/issue/1001", + fields: { + summary: "First issue", status: { id: "1", name: "To Do", statusCategory: { id: 2, key: "new" as const, name: "To Do" } }, + priority: { id: "3", name: "Medium" }, assignee: null, + project: { id: "10000", key: "PROJ", name: "Project" }, + }, + }, + { + id: "1002", key: "PROJ-2", self: "https://test.atlassian.net/rest/api/3/issue/1002", + fields: { + summary: "Second issue", status: { id: "1", name: "To Do", statusCategory: { id: 2, key: "new" as const, name: "To Do" } }, + priority: { id: "3", name: "Medium" }, assignee: null, + project: { id: "10000", key: "PROJ", name: "Project" }, + }, + }, + ], + // total (5) exceeds issues.length (2): the API result is truncated. + total: 5, maxResults: 100, startAt: 0, + }); + + vi.doMock("../../src/app/stores/auth", () => ({ + clearAuth: vi.fn(), + expireToken: vi.fn(), + token: () => "fake-token", + user: () => ({ login: "testuser", avatar_url: "", name: "Test User" }), + isAuthenticated: () => true, + onAuthCleared: vi.fn((cb: () => void) => { authClearCallbacks.push(cb); }), + DASHBOARD_STORAGE_KEY: "github-tracker:dashboard", + DEP_META_STORAGE_KEY: "github-tracker:dep-meta", + jiraAuth: vi.fn(() => ({ + cloudId: "cloud-123", + accessToken: "tok", + siteUrl: "https://test.atlassian.net", + siteName: "Test Site", + })), + isJiraAuthenticated: vi.fn(() => true), + setJiraAuth: vi.fn(), + clearJiraAuth: vi.fn(), + ensureJiraTokenValid: vi.fn().mockResolvedValue(true), + })); + + const MockJiraClient = vi.fn(function (this: Record) { + this.searchJql = mockSearchJql; + this.bulkFetch = vi.fn().mockResolvedValue({ issues: [], errors: [] }); + }); + vi.doMock("../../src/app/services/jira-client", () => ({ + JiraClient: MockJiraClient, + JiraProxyClient: vi.fn(), + JiraApiError: class JiraApiError extends Error { + status: number; + constructor(status: number, _body: unknown, message: string) { + super(message); + this.status = status; + } + }, + DEFAULT_FIELDS: ["summary", "status", "priority", "assignee", "project", "updated", "issuetype", "created"], + })); + + vi.doMock("../../src/app/services/poll", () => ({ + fetchAllData: vi.fn().mockResolvedValue({ + issues: [], pullRequests: [], workflowRuns: [], errors: [], + }), + createPollCoordinator: vi.fn().mockImplementation( + (_getInterval: unknown, fetchAll: () => Promise) => { + void fetchAll().catch(() => {}); + return { isRefreshing: () => false, lastRefreshAt: () => null, manualRefresh: vi.fn(), destroy: vi.fn() }; + } + ), + createHotPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + createEventsPollCoordinator: vi.fn().mockImplementation(() => ({ destroy: vi.fn() })), + rebuildHotSets: vi.fn(), + seedHotSetsFromTargeted: vi.fn(), + clearHotSets: vi.fn(), + getHotPollGeneration: vi.fn().mockReturnValue(0), + })); + + // Fresh imports after mock registration + const freshView = await import("../../src/app/stores/view"); + const freshConfig = await import("../../src/app/stores/config"); + const freshDash = await import("../../src/app/components/dashboard/DashboardPage"); + + freshView.resetViewState(); + freshConfig.resetConfig(); + freshConfig.updateJiraConfig({ enabled: true, siteUrl: "https://test.atlassian.net", siteName: "Test Site", authMethod: "oauth" }); + // PROJ-3 is not present in the (truncated) search results but must + // survive because the result set is incomplete. + freshView.setJiraCustomOrder(["PROJ-1", "PROJ-2", "PROJ-3"]); + + render(() => ); + + // Wait for the real fetchJiraAssigned() to actually invoke searchJql... + await waitFor(() => { + expect(mockSearchJql).toHaveBeenCalled(); + }, { timeout: 3000 }); + // ...then flush the microtask/timer queue so the code after the awaited + // searchJql call (setJiraIssues + the truncation-gated prune call) has + // actually finished running before we assert on the result. + await new Promise((r) => setTimeout(r, 200)); + + expect(freshView.viewState.jiraCustomOrder).toEqual(["PROJ-1", "PROJ-2", "PROJ-3"]); + }); }); describe("DashboardPage — abandonedDepsMap and dashboardIssueUrls on auth clear", () => { From f9e5c3d0e0aea9a38dddbf42b7e7a1808a4061a0 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 17 Aug 2026 17:14:14 -0400 Subject: [PATCH 09/10] fix(jira): addresses pr-review findings and quality-gate hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves 13 of 14 /pr-review findings on the custom-order feature (1 correctly determined invalid — SortDropdown's placeholder prop is the live Kobalte fallback path, not a no-op). Switches the stale jiraCustomOrder prune guard from result.total (a field the real Jira Enhanced JQL Search endpoint never returns) to result.nextPageToken. Implements merge-on-write cross-tab sync in initViewPersistence(): at write time, re-read on-disk state and overlay only the fields this tab actually changed, instead of blindly overwriting with a stale full snapshot. Quality-gate then found and fixed additional issues in the same mechanism: a debounce bug where onCleanup nested inside createEffect fired on every recomputation instead of only true disposal, a schema- validation gap letting unvalidated on-disk localStorage content persist indefinitely, and a data-preservation edge case for keys missing from a stale on-disk blob. Adds a genuine two-tab concurrency test and a live post-mount sortField mode-switch test (via a lazily-created reactive signal in the test mock). --- .../components/dashboard/DashboardPage.tsx | 17 +- .../components/dashboard/JiraAssignedTab.tsx | 39 +- src/app/stores/view.ts | 115 ++++- tests/components/DashboardPage.test.tsx | 305 ++++---------- .../dashboard/JiraAssignedTab.test.tsx | 68 ++- tests/components/shared/SortDropdown.test.tsx | 40 ++ tests/stores/view-jira-order.test.ts | 9 + tests/stores/view.test.ts | 392 +++++++++++++++++- 8 files changed, 703 insertions(+), 282 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index ecb1ecae..db63861a 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -479,13 +479,16 @@ export default function DashboardPage() { if (!isJiraAuthenticated()) return; setJiraIssues(result.issues); - // Only prune when the result set is complete. `result.total` reflects the true - // match count while `result.issues` is capped at maxResults — if more issues - // exist than were returned (pagination truncation), skip pruning: treating - // "not in this page" as "no longer exists" would permanently destroy - // user-curated custom-order positions for issues that are simply on a later - // page, not actually gone (closed, reassigned, or resolved). - if (scope === JIRA_CUSTOM_ORDER_SCOPE && result.total <= result.issues.length) { + // Only prune when the result set is complete. The Enhanced JQL Search + // endpoint (search/jql) does not return a `total` match count — it uses + // cursor-based pagination via `nextPageToken` instead. A defined + // nextPageToken means more results exist beyond this page (pagination + // truncation); its absence means this page is the complete result set. + // Treating "not in this page" as "no longer exists" on a truncated page + // would permanently destroy user-curated custom-order positions for + // issues that are simply on a later page, not actually gone (closed, + // reassigned, or resolved). + if (scope === JIRA_CUSTOM_ORDER_SCOPE && result.nextPageToken === undefined) { pruneJiraCustomOrder(new Set(result.issues.map((i) => i.key))); } diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx index 8d3790cc..04f46af7 100644 --- a/src/app/components/dashboard/JiraAssignedTab.tsx +++ b/src/app/components/dashboard/JiraAssignedTab.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, For, Show, on, onCleanup } from "solid-js"; import type { JiraIssue } from "../../../shared/jira-types"; -import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE } from "../../stores/view"; +import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, JIRA_CUSTOM_SORT_FIELD } from "../../stores/view"; import { config } from "../../stores/config"; import JiraFieldValue from "./JiraFieldValue"; import { jiraStatusCategoryClass, stripParenthetical } from "../../lib/format"; @@ -90,6 +90,14 @@ let _jiraExpandInitialized = false; export function _resetJiraTabState() { _jiraExpandInitialized = false; + itemRefs.clear(); +} + +// Test-only accessor for the module-level itemRefs Map (see below) — lets +// tests verify the mode-exit cleanup effect actually clears stale DOM refs +// without exposing itemRefs itself. +export function _getItemRefsCount(): number { + return itemRefs.size; } const ISSUE_TYPE_ICONS: Record = Object.assign( @@ -124,7 +132,7 @@ function IssueTypeFallbackIcon(props: { name: string }) { // FLIP animation: record positions before a custom-order move, animate slide after // DOM updates. Modeled on TrackedTab.tsx's recordPositions/animateMove/prefersReducedMotion -// trio (TrackedTab.tsx:31-59) — deliberately duplicated here rather than extracted into a +// trio — deliberately duplicated here rather than extracted into a // shared utility (see plan Task 3, Step 5). // // Deviation from TrackedTab.tsx: TrackedTab's animateMove only guards with @@ -216,7 +224,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { }); const filteredSorted = createMemo(() => { - if (filters().sortField === "custom") { + if (filters().sortField === JIRA_CUSTOM_SORT_FIELD) { return applyCustomOrder(filtered(), viewState.jiraCustomOrder, (issue) => issue.key); } const items = [...filtered()]; @@ -311,7 +319,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { return orderRepoGroups(withLocked, lockedForTab); }); - const isCustomMode = () => filters().sortField === "custom"; + const isCustomMode = () => filters().sortField === JIRA_CUSTOM_SORT_FIELD; // itemsWithGroupKey() is a 1:1, order-preserving map over filteredSorted() (adds // repoFullName, never filters/reorders), so paginating it directly yields the same @@ -326,7 +334,10 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { // Prune itemRefs to current-page items in custom mode so the map does not grow // unbounded across pages, filter changes, and data refreshes (Finding 2 & 3). createEffect(() => { - if (!isCustomMode()) return; + if (!isCustomMode()) { + itemRefs.clear(); + return; + } const pageItemKeys = new Set(customPageItems().map(item => item.key)); for (const key of itemRefs.keys()) { if (!pageItemKeys.has(key)) itemRefs.delete(key); @@ -362,10 +373,16 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { filters().scope === JIRA_CUSTOM_ORDER_SCOPE && filters().statusCategory === "all" && filters().priority === "all"; const [reordering, setReordering] = createSignal(false); + let reorderTimeoutId: ReturnType | undefined; // Clear the module-level itemRefs map when the component unmounts (e.g. tab // switch) so detached DOM references are not leaked across mount cycles. - onCleanup(() => itemRefs.clear()); + // Also clear any pending reordering-lockout timeout so it doesn't fire + // setReordering on a disposed component after a tab switch mid-lockout. + onCleanup(() => { + itemRefs.clear(); + clearTimeout(reorderTimeoutId); + }); function handleCustomMove(jiraKey: string, direction: "up" | "down") { if (!canReorder()) return; @@ -386,7 +403,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { setReordering(true); if (prefersReducedMotion()) { // Reduced motion: no animation ever, but still guard against a viewport jump - // from the mutation itself (matches withFlipAnimation's fallback, scroll.ts:27-29). + // from the mutation itself (matches withFlipAnimation's reduced-motion fallback in scroll.ts). withScrollLock(applyMove); } else if (crossesPage) { // Cross-page moves must skip the FLIP animation entirely (spike pl-feas-2): the @@ -403,7 +420,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { // from queuing moves in the reduced-motion and cross-page paths where the lockout // was previously reset synchronously (Finding 5). Value matches animateMove's // `duration: 200` — keep them in sync. - setTimeout(() => setReordering(false), 200); + reorderTimeoutId = setTimeout(() => setReordering(false), 200); } function renderIssueRow(issue: JiraItem, boundary?: { isFirst: boolean; isLast: boolean }) { @@ -652,12 +669,12 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { {filtered().length} issue{filtered().length !== 1 ? "s" : ""} - +