diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 31ea83b3293..75446a20a8b 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -559,13 +559,31 @@ export class ApiClient { if (params?.limit) search.set("limit", String(params.limit)); if (params?.offset) search.set("offset", String(params.offset)); if (params?.workspace_id) search.set("workspace_id", params.workspace_id); + if (params?.q?.trim()) search.set("q", params.q.trim()); if (params?.status) search.set("status", params.status); + if (params?.statuses?.length) search.set("statuses", params.statuses.join(",")); if (params?.priority) search.set("priority", params.priority); + if (params?.priorities?.length) search.set("priorities", params.priorities.join(",")); if (params?.assignee_id) search.set("assignee_id", params.assignee_id); if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); if (params?.creator_id) search.set("creator_id", params.creator_id); if (params?.project_id) search.set("project_id", params.project_id); + if (params?.assignee_filters?.length) { + search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(",")); + } + if (params?.include_no_assignee) search.set("include_no_assignee", "true"); + if (params?.creator_filters?.length) { + search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(",")); + } + if (params?.project_ids?.length) search.set("project_ids", params.project_ids.join(",")); + if (params?.include_no_project) search.set("include_no_project", "true"); + if (params?.label_ids?.length) search.set("label_ids", params.label_ids.join(",")); + if (params?.top_level_only) search.set("top_level_only", "true"); + // No `.length` guard on purpose: an empty ids array must still send + // `ids=` — the server treats a PRESENT-but-empty list as an empty window + // (nothing running), while an absent param means no restriction. + if (params?.ids) search.set("ids", params.ids.join(",")); if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id); if (params?.metadata && Object.keys(params.metadata).length > 0) { search.set("metadata", JSON.stringify(params.metadata)); @@ -580,6 +598,19 @@ export class ApiClient { if (params?.date_end) search.set("date_end", params.date_end); if (params?.sort_by) search.set("sort", params.sort_by); if (params?.sort_direction) search.set("direction", params.sort_direction); + // An ids facet can carry hundreds of UUIDs (agents-working filter) — + // enough to blow the ~8 KB request-line cap of common reverse proxies. + // Route those windows through the POST twin, which takes the SAME + // key/value pairs as a JSON body. + if (params?.ids) { + const raw = await this.fetch("/api/issues/query", { + method: "POST", + body: JSON.stringify(Object.fromEntries(search)), + }); + return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, { + endpoint: "POST /api/issues/query", + }); + } const path = `/api/issues?${search}`; const raw = await this.fetch(path); return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, { diff --git a/packages/core/issues/cache-coordinator.test.ts b/packages/core/issues/cache-coordinator.test.ts index 6e105c69596..4018283df77 100644 --- a/packages/core/issues/cache-coordinator.test.ts +++ b/packages/core/issues/cache-coordinator.test.ts @@ -3,6 +3,7 @@ import { QueryClient, hashKey } from "@tanstack/react-query"; import { applyIssueChange, rollbackIssueChange, + type IssueFlatCache, } from "./cache-coordinator"; import { issueChangedDims } from "./surface/membership"; import { issueKeys, type IssueSortParam } from "./queries"; @@ -25,6 +26,36 @@ const membersKey = issueKeys.myListSorted( sort, ); const inboxKey = inboxKeys.list(WS_ID); +const flatKey = issueKeys.flat(WS_ID, "workspace:all", {}, sort); +const flatTitleKey = issueKeys.flat( + WS_ID, + "workspace:all", + {}, + { sort_by: "title", sort_direction: "asc" }, +); +const flatFilteredKey = issueKeys.flat( + WS_ID, + "workspace:all", + { statuses: ["todo"], priorities: ["high"] }, + sort, +); +const flatUpdatedWindowKey = issueKeys.flat( + WS_ID, + "workspace:all", + {}, + { + ...sort, + date_field: "updated_at", + date_start: "2025-01-01T00:00:00Z", + date_end: "2025-02-01T00:00:00Z", + }, +); +const flatSearchKey = issueKeys.flat( + WS_ID, + "workspace:all", + { q: "Issue 1" }, + sort, +); function makeIssue(idx: number, overrides: Partial = {}): Issue { return { @@ -113,6 +144,76 @@ describe("applyIssueChange", () => { expect(result.staleKeys).toEqual([]); }); + it("patches a loaded flat row without refetching an unrelated position window", () => { + qc.setQueryData(flatKey, { + pages: [{ issues: [issue()], total: 1 }], + pageParams: [0], + }); + + const patch = { title: "renamed in table" }; + const result = applyIssueChange(qc, WS_ID, "issue-1", patch, { + changed: issueChangedDims(patch, issue()), + baseIssue: issue(), + }); + + expect( + qc.getQueryData(flatKey)?.pages[0]?.issues[0]?.title, + ).toBe("renamed in table"); + expect(result.staleKeys.map(hashKey)).not.toContain(hashKey(flatKey)); + + rollbackIssueChange(qc, WS_ID, "issue-1", result); + expect( + qc.getQueryData(flatKey)?.pages[0]?.issues[0]?.title, + ).toBe("Issue 1"); + }); + + it("marks only flat windows whose sort or facet depends on the changed field", () => { + for (const key of [ + flatKey, + flatTitleKey, + flatFilteredKey, + flatUpdatedWindowKey, + ]) { + qc.setQueryData(key, { + pages: [{ issues: [issue()], total: 1 }], + pageParams: [0], + }); + } + + const titlePatch = { title: "renamed" }; + const titleResult = applyIssueChange(qc, WS_ID, "issue-1", titlePatch, { + changed: issueChangedDims(titlePatch, issue()), + baseIssue: issue(), + }); + expect(titleResult.staleKeys.map(hashKey)).toEqual([ + hashKey(flatTitleKey), + hashKey(flatUpdatedWindowKey), + ]); + + const statusPatch = { status: "done" as const }; + const statusResult = applyIssueChange(qc, WS_ID, "issue-1", statusPatch, { + changed: issueChangedDims(statusPatch, issue()), + baseIssue: issue(), + }); + expect(statusResult.staleKeys.map(hashKey)).toContain(hashKey(flatFilteredKey)); + expect(statusResult.staleKeys.map(hashKey)).not.toContain(hashKey(flatKey)); + }); + + it("reconciles a searched flat window when an edited title can change membership", () => { + qc.setQueryData(flatSearchKey, { + pages: [{ issues: [issue()], total: 1 }], + pageParams: [0], + }); + + const patch = { title: "No longer matches" }; + const result = applyIssueChange(qc, WS_ID, "issue-1", patch, { + changed: issueChangedDims(patch, issue()), + baseIssue: issue(), + }); + + expect(result.staleKeys.map(hashKey)).toContain(hashKey(flatSearchKey)); + }); + it("status change: rebuckets loaded cards, patches inbox, adjusts counts for absent-but-member lists", () => { qc.setQueryData(wsKey, bucketed([issue()])); // p1 list loaded but the card is beyond its loaded window — the change diff --git a/packages/core/issues/cache-coordinator.ts b/packages/core/issues/cache-coordinator.ts index f56eac160f2..65d85036840 100644 --- a/packages/core/issues/cache-coordinator.ts +++ b/packages/core/issues/cache-coordinator.ts @@ -1,5 +1,15 @@ -import { hashKey, type QueryClient, type QueryKey } from "@tanstack/react-query"; -import { issueKeys, type MyIssuesFilter } from "./queries"; +import { + hashKey, + type InfiniteData, + type QueryClient, + type QueryKey, +} from "@tanstack/react-query"; +import { + issueKeys, + type IssueFlatFilter, + type IssueSortParam, + type MyIssuesFilter, +} from "./queries"; import { inboxKeys } from "../inbox/queries"; import { patchInboxIssueStatus } from "../inbox/ws-updaters"; import { projectKeys } from "../projects/queries"; @@ -15,7 +25,14 @@ import { listFilterDependsOn, type IssueChangedDims, } from "./surface/membership"; -import type { InboxItem, Issue, ListIssuesCache } from "../types"; +import type { + InboxItem, + Issue, + ListIssuesCache, + ListIssuesResponse, +} from "../types"; + +export type IssueFlatCache = InfiniteData; /** * IssueCacheCoordinator — the one rules table for how a single issue change @@ -64,6 +81,7 @@ export interface IssueCacheChangeResult { /** Pre-change snapshots of every cache this change touched — feed back to * {@link rollbackIssueChange} from onError. */ prevLists: [QueryKey, ListIssuesCache][]; + prevFlatLists: [QueryKey, IssueFlatCache][]; prevDetail: Issue | undefined; prevInboxList: InboxItem[] | undefined; /** Loaded list keys whose server result may have drifted (membership @@ -103,6 +121,108 @@ function bucketedListEntries( ); } +function flatListEntries( + qc: QueryClient, + wsId: string, +): [QueryKey, IssueFlatCache][] { + return qc + .getQueriesData({ queryKey: issueKeys.flatAll(wsId) }) + .filter( + (entry): entry is [QueryKey, IssueFlatCache] => + !!entry[1] && Array.isArray(entry[1].pages), + ); +} + +function flatContractFromKey(key: QueryKey): { + scope: string | undefined; + filter: IssueFlatFilter; + sort: IssueSortParam; +} { + return { + scope: typeof key[3] === "string" ? key[3] : undefined, + filter: (key[4] ?? {}) as IssueFlatFilter, + sort: (key[5] ?? {}) as IssueSortParam, + }; +} + +function patchFieldChanged( + patch: Partial, + base: Issue | undefined, + field: K, +) { + if (!Object.prototype.hasOwnProperty.call(patch, field)) return false; + return !base || !Object.is(patch[field], base[field]); +} + +/** Whether a patch can change a flat window's membership or ordering. A + * loaded row is always patched optimistically; only windows whose server + * contract depends on the changed field need the follow-up refetch. */ +function flatWindowNeedsReconcile( + key: QueryKey, + patch: Partial, + base: Issue | undefined, + changed: IssueChangedDims, +) { + const { scope, filter, sort } = flatContractFromKey(key); + const anyIssueFieldChanged = Object.keys(patch).some((field) => + patchFieldChanged(patch, base, field as keyof Issue), + ); + + if (listFilterDependsOn(scope, filter, changed)) return true; + if (filter.q && patchFieldChanged(patch, base, "title")) return true; + if (changed.status && (filter.statuses?.length ?? 0) > 0) return true; + if ( + patchFieldChanged(patch, base, "priority") && + (filter.priorities?.length ?? 0) > 0 + ) { + return true; + } + if ( + changed.assignee && + ((filter.assignee_filters?.length ?? 0) > 0 || filter.include_no_assignee) + ) { + return true; + } + if (changed.project && ((filter.project_ids?.length ?? 0) > 0 || filter.include_no_project)) { + return true; + } + if (patchFieldChanged(patch, base, "parent_issue_id") && filter.top_level_only) { + return true; + } + if (sort.date_field === "updated_at" && anyIssueFieldChanged) return true; + if ( + sort.date_field === "created_at" && + patchFieldChanged(patch, base, "created_at") + ) { + return true; + } + + switch (sort.sort_by ?? "position") { + case "title": + return patchFieldChanged(patch, base, "title"); + case "status": + return changed.status; + case "priority": + return patchFieldChanged(patch, base, "priority"); + case "created_at": + return patchFieldChanged(patch, base, "created_at"); + case "updated_at": + // Every persisted issue edit advances updated_at even though the + // optimistic request payload does not carry the server timestamp. + return anyIssueFieldChanged; + case "start_date": + return patchFieldChanged(patch, base, "start_date"); + case "due_date": + return patchFieldChanged(patch, base, "due_date"); + case "position": + return patchFieldChanged(patch, base, "position"); + default: + // Custom-property ordering is reconciled by the dedicated property + // mutation/WS pipeline, which has the full property-bag snapshot. + return false; + } +} + export function applyIssueChange( qc: QueryClient, wsId: string, @@ -120,6 +240,7 @@ export function applyIssueChange( ): IssueCacheChangeResult { const { changed, baseIssue } = opts; const prevLists: [QueryKey, ListIssuesCache][] = []; + const prevFlatLists: [QueryKey, IssueFlatCache][] = []; const staleKeys: QueryKey[] = []; let prevIssue: Issue | undefined = baseIssue; @@ -207,6 +328,29 @@ export function applyIssueChange( staleKeys.push(key); } + // Patch loaded flat rows immediately. Only refetch windows whose encoded + // server filter/sort actually depends on this change; e.g. a title edit in + // a position-sorted unfiltered table is fully reconciled by the patch. + for (const [key, data] of flatListEntries(qc, wsId)) { + let found: Issue | undefined; + const pages = data.pages.map((page) => ({ + ...page, + issues: page.issues.map((issue) => { + if (issue.id !== id) return issue; + found = issue; + return { ...issue, ...patch }; + }), + })); + if (found) { + if (!prevIssue) prevIssue = found; + prevFlatLists.push([key, data]); + qc.setQueryData(key, { ...data, pages }); + } + if (flatWindowNeedsReconcile(key, patch, found ?? baseIssue, changed)) { + staleKeys.push(key); + } + } + const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); if (prevDetail) { qc.setQueryData(issueKeys.detail(wsId, id), { @@ -224,7 +368,14 @@ export function applyIssueChange( if (prevInboxList) patchInboxIssueStatus(qc, wsId, id, patch.status); } - return { prevLists, prevDetail, prevInboxList, staleKeys, prevIssue }; + return { + prevLists, + prevFlatLists, + prevDetail, + prevInboxList, + staleKeys, + prevIssue, + }; } /** Restore every snapshot captured by {@link applyIssueChange} — the onError @@ -235,12 +386,15 @@ export function rollbackIssueChange( id: string, result: Pick< IssueCacheChangeResult, - "prevLists" | "prevDetail" | "prevInboxList" + "prevLists" | "prevFlatLists" | "prevDetail" | "prevInboxList" >, ) { for (const [key, snapshot] of result.prevLists) { qc.setQueryData(key, snapshot); } + for (const [key, snapshot] of result.prevFlatLists) { + qc.setQueryData(key, snapshot); + } if (result.prevDetail !== undefined) { qc.setQueryData(issueKeys.detail(wsId, id), result.prevDetail); } diff --git a/packages/core/issues/delete-cache.ts b/packages/core/issues/delete-cache.ts index 7719b63c145..69657a73464 100644 --- a/packages/core/issues/delete-cache.ts +++ b/packages/core/issues/delete-cache.ts @@ -1,4 +1,8 @@ -import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import type { + InfiniteData, + QueryClient, + QueryKey, +} from "@tanstack/react-query"; import { agentActivityKeys, agentRunCountsKeys, @@ -6,7 +10,11 @@ import { agentTasksKeys, } from "../agents/queries"; import { labelKeys } from "../labels/queries"; -import type { Issue, ListIssuesCache } from "../types"; +import type { + Issue, + ListIssuesCache, + ListIssuesResponse, +} from "../types"; import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers"; import { issueKeys } from "./queries"; import { useRecentIssuesStore } from "./stores/recent-issues-store"; @@ -54,6 +62,17 @@ export function collectDeletedIssueCacheMetadata( collectParentFromListCache(parentIssueIds, data, issueId); } + for (const [, data] of qc.getQueriesData< + InfiniteData + >({ queryKey: issueKeys.flatAll(wsId) })) { + for (const page of data?.pages ?? []) { + collectParentId( + parentIssueIds, + page.issues.find((issue) => issue.id === issueId)?.parent_issue_id, + ); + } + } + for (const [, data] of qc.getQueriesData({ queryKey: issueKeys.myAll(wsId), })) { @@ -92,6 +111,24 @@ export function pruneDeletedIssueFromListCaches( old ? removeIssueFromBuckets(old, issueId) : old, ); } + + for (const [key, data] of qc.getQueriesData< + InfiniteData + >({ queryKey: issueKeys.flatAll(wsId) })) { + if (!data?.pages) continue; + const found = data.pages.some((page) => + page.issues.some((issue) => issue.id === issueId), + ); + if (!found) continue; + qc.setQueryData>(key, { + ...data, + pages: data.pages.map((page) => ({ + ...page, + total: Math.max(0, page.total - 1), + issues: page.issues.filter((issue) => issue.id !== issueId), + })), + }); + } } export function pruneDeletedIssueFromParentChildrenCaches( @@ -168,6 +205,7 @@ export function cleanupDeletedIssueCaches( qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.flatAll(wsId) }); // Project Gantt cache lives outside `myAll`, so it needs an explicit // refresh when an issue is removed — the deleted row may have been a // scheduled bar visible right now. diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index 7068b479bad..8dfe277a988 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -15,6 +15,7 @@ import { invalidateIssueDerivatives, invalidateStaleListKeys, rollbackIssueChange, + type IssueFlatCache, } from "./cache-coordinator"; import { issueChangedDims } from "./surface/membership"; import { @@ -206,6 +207,7 @@ export function useCreateIssue() { }, onSettled: () => { qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.flatAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) }); @@ -232,6 +234,7 @@ export function useUpdateIssue() { // before the optimistic update lands. qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }); + qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }); if (patch.status !== undefined) { qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); } @@ -393,6 +396,7 @@ export function useDeleteIssue() { await Promise.all([ qc.cancelQueries({ queryKey: issueKeys.list(wsId) }), qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }), + qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }), ]); const metadata = collectDeletedIssueCacheMetadata(qc, wsId, id); await Promise.all( @@ -404,6 +408,9 @@ export function useDeleteIssue() { const prevMyLists = qc.getQueriesData({ queryKey: issueKeys.myAll(wsId), }); + const prevFlatLists = qc.getQueriesData({ + queryKey: issueKeys.flatAll(wsId), + }); const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); const prevChildren = new Map(); for (const parentId of metadata.parentIssueIds) { @@ -416,7 +423,15 @@ export function useDeleteIssue() { pruneDeletedIssueFromListCaches(qc, wsId, id); pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata); qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) }); - return { id, metadata, prevLists, prevMyLists, prevDetail, prevChildren }; + return { + id, + metadata, + prevLists, + prevMyLists, + prevFlatLists, + prevDetail, + prevChildren, + }; }, onError: (_err, _id, ctx) => { if (ctx?.prevLists) { @@ -429,6 +444,11 @@ export function useDeleteIssue() { qc.setQueryData(key, snapshot); } } + if (ctx?.prevFlatLists) { + for (const [key, snapshot] of ctx.prevFlatLists) { + qc.setQueryData(key, snapshot); + } + } if (ctx?.prevDetail) { qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); } @@ -444,6 +464,7 @@ export function useDeleteIssue() { }, onSettled: (_data, _err, _id, ctx) => { qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.flatAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) }); @@ -470,6 +491,7 @@ export function useBatchUpdateIssues() { const { suppress_run: _suppressRun, handoff_note: _handoffNote, ...patch } = updates; await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); await qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }); + await qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }); if (patch.status !== undefined) { await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); } @@ -481,6 +503,7 @@ export function useBatchUpdateIssues() { // application a cache already carries partial patches, so only the // first snapshot per key is pristine for rollback. const prevListByHash = new Map(); + const prevFlatListByHash = new Map(); const prevDetailById = new Map(); let prevInboxList: InboxItem[] | undefined; const staleKeys: QueryKey[] = []; @@ -494,6 +517,12 @@ export function useBatchUpdateIssues() { const hash = hashKey(key); if (!prevListByHash.has(hash)) prevListByHash.set(hash, [key, snapshot]); } + for (const [key, snapshot] of change.prevFlatLists) { + const hash = hashKey(key); + if (!prevFlatListByHash.has(hash)) { + prevFlatListByHash.set(hash, [key, snapshot]); + } + } if (change.prevDetail) prevDetailById.set(id, change.prevDetail); if (prevInboxList === undefined && change.prevInboxList !== undefined) { prevInboxList = change.prevInboxList; @@ -522,6 +551,7 @@ export function useBatchUpdateIssues() { return { prevLists: [...prevListByHash.values()], + prevFlatLists: [...prevFlatListByHash.values()], prevDetailById, prevInboxList, staleKeys, @@ -535,6 +565,11 @@ export function useBatchUpdateIssues() { qc.setQueryData(key, snapshot); } } + if (ctx?.prevFlatLists) { + for (const [key, snapshot] of ctx.prevFlatLists) { + qc.setQueryData(key, snapshot); + } + } if (ctx?.prevDetailById) { for (const [id, snapshot] of ctx.prevDetailById) { qc.setQueryData(issueKeys.detail(wsId, id), snapshot); @@ -587,6 +622,7 @@ export function useBatchDeleteIssues() { await Promise.all([ qc.cancelQueries({ queryKey: issueKeys.list(wsId) }), qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }), + qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }), ]); const metadataById = new Map( ids.map((id) => [ @@ -609,6 +645,9 @@ export function useBatchDeleteIssues() { const prevMyLists = qc.getQueriesData({ queryKey: issueKeys.myAll(wsId), }); + const prevFlatLists = qc.getQueriesData({ + queryKey: issueKeys.flatAll(wsId), + }); const prevChildren = new Map(); for (const parentId of parentIssueIds) { prevChildren.set( @@ -624,7 +663,14 @@ export function useBatchDeleteIssues() { pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata); } } - return { prevLists, prevMyLists, prevChildren, parentIssueIds, metadataById }; + return { + prevLists, + prevMyLists, + prevFlatLists, + prevChildren, + parentIssueIds, + metadataById, + }; }, onError: (_err, _ids, ctx) => { if (ctx?.prevLists) { @@ -637,6 +683,11 @@ export function useBatchDeleteIssues() { qc.setQueryData(key, snapshot); } } + if (ctx?.prevFlatLists) { + for (const [key, snapshot] of ctx.prevFlatLists) { + qc.setQueryData(key, snapshot); + } + } if (ctx?.prevChildren) { for (const [parentId, snapshot] of ctx.prevChildren) { qc.setQueryData(issueKeys.children(wsId, parentId), snapshot); @@ -663,6 +714,11 @@ export function useBatchDeleteIssues() { qc.setQueryData(key, snapshot); } } + if (ctx?.prevFlatLists) { + for (const [key, snapshot] of ctx.prevFlatLists) { + qc.setQueryData(key, snapshot); + } + } if (ctx?.prevChildren) { for (const [parentId, snapshot] of ctx.prevChildren) { qc.setQueryData(issueKeys.children(wsId, parentId), snapshot); @@ -676,6 +732,7 @@ export function useBatchDeleteIssues() { }, onSettled: (_data, _err, _ids, ctx) => { qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.flatAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) }); diff --git a/packages/core/issues/queries.test.ts b/packages/core/issues/queries.test.ts index bd0448375ff..7029d874496 100644 --- a/packages/core/issues/queries.test.ts +++ b/packages/core/issues/queries.test.ts @@ -11,9 +11,13 @@ import type { } from "../types"; import { CHILDREN_BY_PARENTS_CHUNK_SIZE, + ISSUE_FLAT_PAGE_SIZE, PROJECT_GANTT_MAX_ISSUES, PROJECT_GANTT_PAGE_LIMIT, childrenByParentsOptions, + compareIssuesForSort, + issueFlatExportOptions, + issueFlatListOptions, issueIdentifierOptions, issueKeys, projectGanttIssuesOptions, @@ -22,7 +26,7 @@ import { const WS_ID = "ws-1"; const PROJECT_ID = "project-1"; -function makeIssue(idx: number): Issue { +function makeIssue(idx: number, overrides: Partial = {}): Issue { return { id: `issue-${idx}`, workspace_id: WS_ID, @@ -47,6 +51,7 @@ function makeIssue(idx: number): Issue { properties: {}, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z", + ...overrides, }; } @@ -157,6 +162,237 @@ describe("projectGanttIssuesOptions", () => { }); }); +describe("flat issue table queries", () => { + let qc: QueryClient; + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + qc.clear(); + vi.restoreAllMocks(); + }); + + it("loads one offset page for the interactive table window", async () => { + const listIssues = vi + .fn<(params?: ListIssuesParams) => Promise>() + .mockResolvedValue({ issues: [makeIssue(1)], total: 1 }); + installFakeApi(listIssues); + + const data = await qc.fetchInfiniteQuery( + issueFlatListOptions( + WS_ID, + "project:project-1", + { + project_id: PROJECT_ID, + q: "release train", + statuses: ["todo", "in_progress"], + priorities: ["high"], + assignee_filters: [{ type: "member", id: "member-1" }], + include_no_assignee: true, + creator_filters: [{ type: "agent", id: "agent-1" }], + project_ids: ["project-2"], + include_no_project: true, + label_ids: ["label-1"], + top_level_only: true, + }, + undefined, + { sort_by: "updated_at", sort_direction: "desc" }, + ), + ); + + expect(data.pages).toHaveLength(1); + expect(data.pages[0]?.issues.map((issue) => issue.id)).toEqual([ + "issue-1", + ]); + expect(listIssues).toHaveBeenCalledWith({ + project_id: PROJECT_ID, + q: "release train", + statuses: ["todo", "in_progress"], + priorities: ["high"], + assignee_filters: [{ type: "member", id: "member-1" }], + include_no_assignee: true, + creator_filters: [{ type: "agent", id: "agent-1" }], + project_ids: ["project-2"], + include_no_project: true, + label_ids: ["label-1"], + top_level_only: true, + sort_by: "updated_at", + sort_direction: "desc", + limit: ISSUE_FLAT_PAGE_SIZE, + offset: 0, + }); + }); + + it("walks every page only for an explicit full CSV export", async () => { + const first = Array.from({ length: ISSUE_FLAT_PAGE_SIZE }, (_, index) => + makeIssue(index + 1), + ); + const second = [makeIssue(101), makeIssue(102), makeIssue(103)]; + const listIssues = vi + .fn<(params?: ListIssuesParams) => Promise>() + .mockImplementation(async (params) => ({ + issues: (params?.offset ?? 0) === 0 ? first : second, + total: 103, + })); + installFakeApi(listIssues); + + const issues = await qc.fetchQuery( + issueFlatExportOptions( + WS_ID, + "project:project-1", + { project_id: PROJECT_ID }, + undefined, + { sort_by: "status", sort_direction: "asc" }, + ), + ); + + expect(issues).toHaveLength(103); + expect(listIssues).toHaveBeenCalledTimes(2); + expect(listIssues.mock.calls.map(([params]) => params?.offset)).toEqual([ + 0, + ISSUE_FLAT_PAGE_SIZE, + ]); + }); + + it("does not silently truncate exports above ten thousand issues", async () => { + const total = 10_001; + const listIssues = vi + .fn<(params?: ListIssuesParams) => Promise>() + .mockImplementation(async (params) => { + const offset = params?.offset ?? 0; + const count = Math.min(ISSUE_FLAT_PAGE_SIZE, total - offset); + return { + issues: Array.from({ length: count }, (_, index) => + makeIssue(offset + index + 1), + ), + total, + }; + }); + installFakeApi(listIssues); + + const issues = await qc.fetchQuery( + issueFlatExportOptions(WS_ID, "workspace:all", {}, undefined), + ); + + expect(issues).toHaveLength(total); + expect(listIssues).toHaveBeenCalledTimes(101); + expect(listIssues.mock.calls.at(-1)?.[0]?.offset).toBe(10_000); + }); + + it("keeps paging when the server returns fewer rows than requested", async () => { + const total = 101; + const serverPageSize = 40; + const listIssues = vi + .fn<(params?: ListIssuesParams) => Promise>() + .mockImplementation(async (params) => { + const offset = params?.offset ?? 0; + const count = Math.min(serverPageSize, total - offset); + return { + issues: Array.from({ length: count }, (_, index) => + makeIssue(offset + index + 1), + ), + total, + }; + }); + installFakeApi(listIssues); + + const issues = await qc.fetchQuery( + issueFlatExportOptions(WS_ID, "workspace:all", {}, undefined), + ); + + expect(issues).toHaveLength(total); + expect(listIssues.mock.calls.map(([params]) => params?.offset)).toEqual([ + 0, 40, 80, + ]); + }); + + it("fails explicitly if the export endpoint stops advancing offsets", async () => { + const page = Array.from({ length: ISSUE_FLAT_PAGE_SIZE }, (_, index) => + makeIssue(index + 1), + ); + const listIssues = vi + .fn<(params?: ListIssuesParams) => Promise>() + .mockResolvedValue({ issues: page, total: ISSUE_FLAT_PAGE_SIZE * 2 }); + installFakeApi(listIssues); + + await expect( + qc.fetchQuery( + issueFlatExportOptions(WS_ID, "workspace:all", {}, undefined), + ), + ).rejects.toThrow("Issue export pagination did not advance"); + expect(listIssues).toHaveBeenCalledTimes(2); + }); + + it("deduplicates the three My Issues relations and restores global sort order", async () => { + const shared = makeIssue(1, { status: "done" }); + const backlog = makeIssue(2, { status: "backlog" }); + const todo = makeIssue(3, { status: "todo" }); + const listIssues = vi + .fn<(params?: ListIssuesParams) => Promise>() + .mockImplementation(async (params) => { + if (params?.assignee_id) { + return { issues: [shared], total: 1 }; + } + if (params?.creator_id) { + return { issues: [backlog, shared], total: 2 }; + } + return { issues: [todo], total: 1 }; + }); + installFakeApi(listIssues); + + const issues = await qc.fetchQuery( + issueFlatExportOptions( + WS_ID, + "all", + {}, + "user-1", + { sort_by: "status", sort_direction: "asc" }, + ), + ); + + expect(issues.map((issue) => issue.id)).toEqual([ + backlog.id, + todo.id, + shared.id, + ]); + expect(listIssues).toHaveBeenCalledTimes(3); + }); +}); + +describe("compareIssuesForSort tie-break", () => { + it("orders equal sort values by created_at DESC then id DESC (server ORDER BY parity)", () => { + // Same status AND same created_at — only the id disambiguates. Without a + // unique final key the relative order would depend on input order, which + // is exactly the instability that duplicates/drops rows at page + // boundaries server-side. + const first = makeIssue(1, { created_at: "2025-01-01T00:00:00Z" }); + const second = makeIssue(2, { created_at: "2025-01-01T00:00:00Z" }); + const sort = { sort_by: "status", sort_direction: "asc" } as const; + + expect( + [first, second].sort((a, b) => compareIssuesForSort(a, b, sort)).map((i) => i.id), + ).toEqual(["issue-2", "issue-1"]); + expect( + [second, first].sort((a, b) => compareIssuesForSort(a, b, sort)).map((i) => i.id), + ).toEqual(["issue-2", "issue-1"]); + }); + + it("applies the id tie-break to created_at sorts as well", () => { + const first = makeIssue(1, { created_at: "2025-01-01T00:00:00Z" }); + const second = makeIssue(2, { created_at: "2025-01-01T00:00:00Z" }); + const sort = { sort_by: "created_at", sort_direction: "desc" } as const; + + expect( + [first, second].sort((a, b) => compareIssuesForSort(a, b, sort)).map((i) => i.id), + ).toEqual(["issue-2", "issue-1"]); + expect( + [second, first].sort((a, b) => compareIssuesForSort(a, b, sort)).map((i) => i.id), + ).toEqual(["issue-2", "issue-1"]); + }); +}); + describe("childrenByParentsOptions chunking", () => { let qc: QueryClient; diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index 52eefa93525..9860c9c3ec2 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -1,4 +1,9 @@ -import { keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions, + type QueryClient, +} from "@tanstack/react-query"; import { api } from "../api"; import type { GroupedIssuesResponse, @@ -29,6 +34,19 @@ export const issueKeys = { /** FULL KEY for queryOptions — includes sort. */ listSorted: (wsId: string, sort?: IssueSortParam) => [...issueKeys.list(wsId), sort ?? {}] as const, + flatAll: (wsId: string) => [...issueKeys.all(wsId), "flat"] as const, + flat: ( + wsId: string, + scope: string, + filter: IssueFlatFilter, + sort?: IssueSortParam, + ) => [...issueKeys.flatAll(wsId), scope, filter, sort ?? {}] as const, + flatExport: ( + wsId: string, + scope: string, + filter: IssueFlatFilter, + sort?: IssueSortParam, + ) => [...issueKeys.flatAll(wsId), "export", scope, filter, sort ?? {}] as const, assigneeGroupsAll: (wsId: string) => [...issueKeys.all(wsId), "assignee-groups"] as const, assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) => @@ -130,6 +148,25 @@ export type MyIssuesFilter = Pick< | "involves_user_id" >; +/** Server-side contract for the flat table window. These facets must travel + * with every offset page (and live in the query key); post-filtering a loaded + * page can silently omit matching issues after the current offset. */ +export type IssueFlatFilter = MyIssuesFilter & + Pick< + ListIssuesParams, + | "q" + | "statuses" + | "priorities" + | "assignee_filters" + | "include_no_assignee" + | "creator_filters" + | "project_ids" + | "include_no_project" + | "label_ids" + | "top_level_only" + | "ids" + >; + export type AssigneeGroupedIssuesFilter = Omit< ListGroupedIssuesParams, "group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id" @@ -137,6 +174,7 @@ export type AssigneeGroupedIssuesFilter = Omit< /** Page size per status column. */ export const ISSUE_PAGE_SIZE = 50; +export const ISSUE_FLAT_PAGE_SIZE = 100; /** * Statuses fetched and paginated into the list/board cache — every lifecycle @@ -191,6 +229,15 @@ async function fetchFirstPages(filter: MyIssuesFilter = {}, sort?: IssueSortPara * 50-per-status × 3 widening (deduped) is what the page renders. */ const MERGE_PRIORITY_RANK: Record = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 }; +const MERGE_STATUS_RANK: Record = { + backlog: 0, + todo: 1, + in_progress: 2, + in_review: 3, + done: 4, + blocked: 5, + cancelled: 6, +}; /** * Comparator mirroring the server's ORDER BY semantics (including @@ -199,11 +246,14 @@ const MERGE_PRIORITY_RANK: Record = { urgent: 0, high: 1, medium * relation order (assigned → created → involves) would override the sort the * user picked — e.g. assigned=9 rendering before created=1 (review round 3). */ -function compareIssuesForSort(a: Issue, b: Issue, sort?: IssueSortParam): number { +export function compareIssuesForSort(a: Issue, b: Issue, sort?: IssueSortParam): number { const by = sort?.sort_by ?? "position"; const dir = by !== "position" && sort?.sort_direction === "desc" ? -1 : 1; + // created_at DESC then id DESC, mirroring the server's unique ORDER BY + // suffix — ids disambiguate bulk-created issues that share a timestamp. const tieBreak = () => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); // created_at DESC, server parity + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() || + (a.id < b.id ? 1 : a.id > b.id ? -1 : 0); const missingAware = (av: string | null, bv: string | null): number => { if (!av && !bv) return tieBreak(); @@ -225,12 +275,16 @@ function compareIssuesForSort(a: Issue, b: Issue, sort?: IssueSortParam): number return dir * String(av).localeCompare(String(bv)) || tieBreak(); } switch (by) { + case "status": + return dir * ((MERGE_STATUS_RANK[a.status] ?? 9) - (MERGE_STATUS_RANK[b.status] ?? 9)) || tieBreak(); case "priority": return dir * ((MERGE_PRIORITY_RANK[a.priority] ?? 9) - (MERGE_PRIORITY_RANK[b.priority] ?? 9)) || tieBreak(); case "title": return dir * a.title.localeCompare(b.title) || tieBreak(); case "created_at": - return dir * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + return dir * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) || tieBreak(); + case "updated_at": + return dir * (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) || tieBreak(); case "start_date": return missingAware(a.start_date, b.start_date); case "due_date": @@ -241,6 +295,104 @@ function compareIssuesForSort(a: Issue, b: Issue, sort?: IssueSortParam): number } } +async function fetchAllFlatPages( + filter: IssueFlatFilter, + sort?: IssueSortParam, +): Promise { + const issues: Issue[] = []; + const seenIds = new Set(); + let offset = 0; + while (true) { + const response = await api.listIssues({ + ...filter, + ...sort, + limit: ISSUE_FLAT_PAGE_SIZE, + offset, + }); + let added = 0; + for (const issue of response.issues) { + if (seenIds.has(issue.id)) continue; + seenIds.add(issue.id); + issues.push(issue); + added += 1; + } + if (issues.length >= response.total) break; + if (response.issues.length === 0 || added === 0) { + throw new Error("Issue export pagination did not advance"); + } + // Advance by what the server actually returned. This guarantees progress + // even if an older server clamps the requested page size differently. + offset += response.issues.length; + } + return issues; +} + +async function fetchAllMyFlatIssues( + userId: string, + filter: IssueFlatFilter, + sort?: IssueSortParam, +): Promise { + const relations = await Promise.all([ + fetchAllFlatPages({ ...filter, assignee_id: userId }, sort), + fetchAllFlatPages({ ...filter, creator_id: userId }, sort), + fetchAllFlatPages({ ...filter, involves_user_id: userId }, sort), + ]); + const byId = new Map(); + for (const issues of relations) { + for (const issue of issues) byId.set(issue.id, issue); + } + return [...byId.values()].sort((a, b) => compareIssuesForSort(a, b, sort)); +} + +export function issueFlatListOptions( + wsId: string, + scope: string, + filter: IssueFlatFilter, + userId?: string, + sort?: IssueSortParam, +) { + const allMyIssues = scope === "all" && !!userId; + return infiniteQueryOptions({ + queryKey: issueKeys.flat(wsId, scope, filter, sort), + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + if (allMyIssues) { + const issues = await fetchAllMyFlatIssues(userId, filter, sort); + return { issues, total: issues.length }; + } + return api.listIssues({ + ...filter, + ...sort, + limit: ISSUE_FLAT_PAGE_SIZE, + offset: pageParam, + }); + }, + getNextPageParam: (lastPage, allPages) => { + if (allMyIssues) return undefined; + const loaded = allPages.reduce((count, page) => count + page.issues.length, 0); + return loaded < lastPage.total ? loaded : undefined; + }, + placeholderData: keepPreviousData, + }); +} + +export function issueFlatExportOptions( + wsId: string, + scope: string, + filter: IssueFlatFilter, + userId?: string, + sort?: IssueSortParam, +) { + return queryOptions({ + queryKey: issueKeys.flatExport(wsId, scope, filter, sort), + queryFn: () => + scope === "all" && userId + ? fetchAllMyFlatIssues(userId, filter, sort) + : fetchAllFlatPages(filter, sort), + staleTime: 0, + }); +} + async function fetchAllMyFirstPages(userId: string, sort?: IssueSortParam): Promise { const [byAssignee, byCreator, byInvolves] = await Promise.all([ fetchFirstPages({ assignee_id: userId }, sort), diff --git a/packages/core/issues/stores/index.ts b/packages/core/issues/stores/index.ts index b9534417550..de039572663 100644 --- a/packages/core/issues/stores/index.ts +++ b/packages/core/issues/stores/index.ts @@ -42,12 +42,19 @@ export { useClearFiltersOnWorkspaceChange, SORT_OPTIONS, CARD_PROPERTY_OPTIONS, + TABLE_SYSTEM_COLUMNS, + DEFAULT_TABLE_COLUMNS, type ViewMode, type SortField, type SortDirection, type CardProperties, type ActorFilterValue, type IssueViewState, + type TableSystemColumnKey, + type TableColumnKey, + type TableColumnConfig, + type TableGrouping, + type TableCalculation, } from "./view-store"; export { ISSUE_SURFACE_VIEW_STORAGE_KEY, diff --git a/packages/core/issues/stores/surface-view-store.test.tsx b/packages/core/issues/stores/surface-view-store.test.tsx index 8a0379283a4..73aea899005 100644 --- a/packages/core/issues/stores/surface-view-store.test.tsx +++ b/packages/core/issues/stores/surface-view-store.test.tsx @@ -82,6 +82,58 @@ describe("issue surface view store registry", () => { expect(parsed.state.surfaces["project:b"]).toBeUndefined(); }); + it("persists table columns, order, widths, grouping, and calculation per surface", async () => { + setCurrentWorkspace("acme", "ws_a"); + await flush(); + const projectA = getIssueSurfaceViewStore("project:table-a"); + const projectB = getIssueSurfaceViewStore("project:table-b"); + + projectA.getState().setViewMode("table"); + projectA.getState().toggleTableColumn("identifier"); + projectA.getState().toggleTableColumn("property:estimate"); + projectA + .getState() + .reorderTableColumn("property:estimate", "identifier"); + projectA.getState().setTableColumnWidth("property:estimate", 184); + projectA.getState().setTableGrouping("status"); + projectA.getState().setTableCalculation("average"); + + expect(projectA.getState().viewMode).toBe("table"); + expect( + projectA.getState().tableColumns.map((column) => column.key), + ).toEqual([ + "title", + "status", + "priority", + "assignee", + "due_date", + "labels", + "property:estimate", + "identifier", + ]); + expect( + projectA + .getState() + .tableColumns.find((column) => column.key === "property:estimate") + ?.width, + ).toBe(184); + expect(projectA.getState().tableGrouping).toBe("status"); + expect(projectA.getState().tableCalculation).toBe("average"); + + expect(projectB.getState().viewMode).toBe("board"); + expect( + projectB.getState().tableColumns.some((column) => + column.key.startsWith("property:"), + ), + ).toBe(false); + + const raw = localStorage.getItem(`${ISSUE_SURFACE_VIEW_STORAGE_KEY}:acme`); + const parsed = JSON.parse(raw as string); + expect( + parsed.state.surfaces["project:table-a"].state.tableColumns, + ).toContainEqual({ key: "property:estimate", width: 184 }); + }); + it("rehydrates existing surface stores when the workspace changes", async () => { setCurrentWorkspace("acme", "ws_a"); await flush(); diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index 7a7e2298e6f..4f0d723590b 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -9,7 +9,7 @@ import { ALL_STATUSES } from "../config"; import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage"; import { defaultStorage } from "../../platform/storage"; -export type ViewMode = "board" | "list" | "gantt" | "swimlane"; +export type ViewMode = "board" | "list" | "table" | "gantt" | "swimlane"; export type GanttZoom = "day" | "week" | "month"; /** * Board grouping. Besides the two built-ins, a select-type custom property @@ -20,15 +20,70 @@ export type GanttZoom = "day" | "week" | "month"; export type IssueGrouping = "status" | "assignee" | `property:${string}`; export type SwimlaneGrouping = "parent" | "project" | "assignee"; /** - * Sort key. `property:` sorts client-side by a custom property - * value (number/date types); the server sort param only understands the - * static fields, so property sorts fall back to `position` server-side and - * re-sort in the surface data hook. + * Sort key. `property:` is resolved server-side against the + * active property catalog; stale or unsupported definitions degrade to + * position order. */ -export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title" | `property:${string}`; +export type SortField = + | "position" + | "status" + | "priority" + | "start_date" + | "due_date" + | "created_at" + | "updated_at" + | "title" + | `property:${string}`; export type SortDirection = "asc" | "desc"; export type IssueDateField = "created_at" | "updated_at"; +export type TableSystemColumnKey = + | "title" + | "identifier" + | "status" + | "priority" + | "assignee" + | "labels" + | "project" + | "start_date" + | "due_date" + | "created_at" + | "updated_at" + | "child_progress" + | "creator"; +export type TableColumnKey = TableSystemColumnKey | `property:${string}`; +export interface TableColumnConfig { + key: TableColumnKey; + width?: number; +} +export type TableGrouping = "none" | "status" | "assignee" | `property:${string}`; +export type TableCalculation = "none" | "sum" | "average" | "count"; + +export const TABLE_SYSTEM_COLUMNS: readonly TableSystemColumnKey[] = [ + "title", + "identifier", + "status", + "priority", + "assignee", + "labels", + "project", + "start_date", + "due_date", + "created_at", + "updated_at", + "child_progress", + "creator", +]; + +export const DEFAULT_TABLE_COLUMNS: readonly TableColumnConfig[] = [ + { key: "title", width: 360 }, + { key: "status", width: 150 }, + { key: "priority", width: 130 }, + { key: "assignee", width: 180 }, + { key: "due_date", width: 140 }, + { key: "labels", width: 220 }, +]; + export interface IssueDateFilter { field: IssueDateField; from: string; @@ -64,10 +119,12 @@ export type StaticIssueGrouping = Exclude; export const SORT_OPTIONS: { value: StaticSortField; label: string }[] = [ { value: "position", label: "Manual" }, + { value: "status", label: "Status" }, { value: "priority", label: "Priority" }, { value: "start_date", label: "Start date" }, { value: "due_date", label: "Due date" }, { value: "created_at", label: "Created date" }, + { value: "updated_at", label: "Updated date" }, { value: "title", label: "Title" }, ]; @@ -133,6 +190,13 @@ export interface IssueViewState { * `swimlaneOrders`, plus the sentinel `"none"` for the pinned * no-X lane and `"__orphans__"` for the parent-grouping fallback. */ collapsedSwimlanes: Record; + /** Ordered table columns. Title is mandatory and normalized to the front. */ + tableColumns: TableColumnConfig[]; + tableGrouping: TableGrouping; + tableCollapsedGroups: string[]; + tableCollapsedParents: string[]; + tableHierarchy: boolean; + tableCalculation: TableCalculation; setViewMode: (mode: ViewMode) => void; setGanttZoom: (zoom: GanttZoom) => void; toggleGanttShowCompleted: () => void; @@ -162,6 +226,14 @@ export interface IssueViewState { setSwimlaneOrder: (order: string[]) => void; /** Toggle a lane key in the currently active swimlane grouping. */ toggleSwimlaneCollapsed: (key: string) => void; + toggleTableColumn: (key: TableColumnKey) => void; + reorderTableColumn: (active: TableColumnKey, over: TableColumnKey) => void; + setTableColumnWidth: (key: TableColumnKey, width?: number) => void; + setTableGrouping: (grouping: TableGrouping) => void; + toggleTableGroupCollapsed: (key: string) => void; + toggleTableParentCollapsed: (issueId: string) => void; + toggleTableHierarchy: () => void; + setTableCalculation: (calculation: TableCalculation) => void; } export const viewStoreSlice = (set: StoreApi["setState"]): IssueViewState => ({ @@ -198,6 +270,12 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue swimlaneGrouping: "assignee", swimlaneOrders: { parent: [], project: [], assignee: [] }, collapsedSwimlanes: { parent: [], project: [], assignee: [] }, + tableColumns: DEFAULT_TABLE_COLUMNS.map((column) => ({ ...column })), + tableGrouping: "none", + tableCollapsedGroups: [], + tableCollapsedParents: [], + tableHierarchy: true, + tableCalculation: "none", setViewMode: (mode) => set({ viewMode: mode }), setGanttZoom: (zoom) => set({ ganttZoom: zoom }), @@ -341,6 +419,52 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue collapsedSwimlanes: { ...state.collapsedSwimlanes, [grouping]: next }, }; }), + toggleTableColumn: (key) => + set((state) => { + if (key === "title") return state; + const exists = state.tableColumns.some((column) => column.key === key); + return { + tableColumns: exists + ? state.tableColumns.filter((column) => column.key !== key) + : [...state.tableColumns, { key }], + }; + }), + reorderTableColumn: (active, over) => + set((state) => { + if (active === "title" || over === "title" || active === over) return state; + const from = state.tableColumns.findIndex((column) => column.key === active); + const to = state.tableColumns.findIndex((column) => column.key === over); + if (from < 0 || to < 0) return state; + const tableColumns = [...state.tableColumns]; + const [moved] = tableColumns.splice(from, 1); + if (!moved) return state; + tableColumns.splice(to, 0, moved); + return { tableColumns }; + }), + setTableColumnWidth: (key, width) => + set((state) => ({ + tableColumns: state.tableColumns.map((column) => + column.key === key + ? { ...column, ...(width === undefined ? { width: undefined } : { width }) } + : column, + ), + })), + setTableGrouping: (tableGrouping) => set({ tableGrouping }), + toggleTableGroupCollapsed: (key) => + set((state) => ({ + tableCollapsedGroups: state.tableCollapsedGroups.includes(key) + ? state.tableCollapsedGroups.filter((item) => item !== key) + : [...state.tableCollapsedGroups, key], + })), + toggleTableParentCollapsed: (issueId) => + set((state) => ({ + tableCollapsedParents: state.tableCollapsedParents.includes(issueId) + ? state.tableCollapsedParents.filter((id) => id !== issueId) + : [...state.tableCollapsedParents, issueId], + })), + toggleTableHierarchy: () => + set((state) => ({ tableHierarchy: !state.tableHierarchy })), + setTableCalculation: (tableCalculation) => set({ tableCalculation }), }); export const viewStorePersistOptions = (name: string) => ({ @@ -375,6 +499,12 @@ export const viewStorePersistOptions = (name: string) => ({ swimlaneGrouping: state.swimlaneGrouping, swimlaneOrders: state.swimlaneOrders, collapsedSwimlanes: state.collapsedSwimlanes, + tableColumns: state.tableColumns, + tableGrouping: state.tableGrouping, + tableCollapsedGroups: state.tableCollapsedGroups, + tableCollapsedParents: state.tableCollapsedParents, + tableHierarchy: state.tableHierarchy, + tableCalculation: state.tableCalculation, }), // Default Zustand merge is shallow, so a persisted `cardProperties` snapshot // saved before a new toggle was introduced wins entirely and the new key is @@ -401,6 +531,20 @@ export function mergeViewStatePersisted( // persisted value isn't a plain object. const isRecord = (v: unknown): v is Record => v !== null && typeof v === "object" && !Array.isArray(v); + const persistedTableColumns = Array.isArray(p.tableColumns) + ? p.tableColumns.filter( + (column): column is TableColumnConfig => + !!column && + typeof column === "object" && + typeof (column as TableColumnConfig).key === "string", + ) + : current.tableColumns; + const dedupedTableColumns = Array.from( + new Map(persistedTableColumns.map((column) => [column.key, column])).values(), + ).filter((column) => column.key !== "title"); + const persistedTitle = persistedTableColumns.find( + (column) => column.key === "title", + ); return { ...current, ...p, @@ -414,6 +558,16 @@ export function mergeViewStatePersisted( collapsedSwimlanes: isRecord(p.collapsedSwimlanes) ? { ...current.collapsedSwimlanes, ...p.collapsedSwimlanes } : current.collapsedSwimlanes, + tableColumns: [ + persistedTitle ?? current.tableColumns[0] ?? { key: "title" }, + ...dedupedTableColumns, + ], + tableCollapsedGroups: Array.isArray(p.tableCollapsedGroups) + ? p.tableCollapsedGroups + : current.tableCollapsedGroups, + tableCollapsedParents: Array.isArray(p.tableCollapsedParents) + ? p.tableCollapsedParents + : current.tableCollapsedParents, }; } diff --git a/packages/core/issues/surface/repository.ts b/packages/core/issues/surface/repository.ts index 59036c4687d..db2e5d274bc 100644 --- a/packages/core/issues/surface/repository.ts +++ b/packages/core/issues/surface/repository.ts @@ -1,11 +1,14 @@ import type { UseQueryOptions } from "@tanstack/react-query"; import { issueAssigneeGroupsOptions, + issueFlatExportOptions, + issueFlatListOptions, issueListOptions, myIssueAssigneeGroupsOptions, myIssueListOptions, projectGanttIssuesOptions, type AssigneeGroupedIssuesFilter, + type IssueFlatFilter, type IssueSortParam, } from "../queries"; import type { @@ -40,6 +43,38 @@ export function issueSurfaceListOptions( ) as UseQueryOptions; } +/** Flat cross-status window — feeds the table mode with offset pagination. */ +export function issueSurfaceFlatOptions( + wsId: string, + plan: IssueSurfaceQueryPlan, + sort?: IssueSortParam, + facets: IssueFlatFilter = {}, +) { + return issueFlatListOptions( + wsId, + plan.queryScope ?? plan.scopeKey, + { ...plan.queryFilter, ...facets }, + plan.userId, + sort, + ); +} + +/** Fully materialized flat window used only for an explicit CSV export. */ +export function issueSurfaceFlatExportOptions( + wsId: string, + plan: IssueSurfaceQueryPlan, + sort?: IssueSortParam, + facets: IssueFlatFilter = {}, +) { + return issueFlatExportOptions( + wsId, + plan.queryScope ?? plan.scopeKey, + { ...plan.queryFilter, ...facets }, + plan.userId, + sort, + ); +} + /** Assignee-grouped list — feeds the board's group-by-assignee mode. */ export function issueSurfaceAssigneeGroupsOptions( wsId: string, diff --git a/packages/core/issues/ws-updaters.test.ts b/packages/core/issues/ws-updaters.test.ts index 5002ae4d320..e135907c75f 100644 --- a/packages/core/issues/ws-updaters.test.ts +++ b/packages/core/issues/ws-updaters.test.ts @@ -11,7 +11,10 @@ import { onIssueDeleted, onIssueLabelsChanged, onIssueMetadataChanged, + onIssuePropertiesChanged, onIssueUpdated, + patchIssueLabels, + patchIssueProperties, } from "./ws-updaters"; import { issueKeys } from "./queries"; import { labelKeys } from "../labels/queries"; @@ -181,6 +184,25 @@ describe("onIssueLabelsChanged", () => { labelA, ]); }); + + it("defers label-filtered flat-window invalidation until commit", () => { + const flatKey = issueKeys.flat( + WS_ID, + "workspace:all", + { label_ids: [labelB.id] }, + { sort_by: "position" }, + ); + qc.setQueryData(flatKey, { + pages: [{ issues: [baseIssue], total: 1 }], + pageParams: [0], + }); + + patchIssueLabels(qc, WS_ID, ISSUE_ID, [labelB]); + expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(false); + + onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]); + expectInvalidated(qc, flatKey); + }); }); describe("onIssueMetadataChanged", () => { @@ -220,6 +242,34 @@ describe("onIssueMetadataChanged", () => { }); }); +describe("issue property snapshots", () => { + it("keeps optimistic patches local, then invalidates property windows after commit", () => { + const qc = new QueryClient(); + const flatKey = issueKeys.flat( + WS_ID, + "workspace:all", + {}, + { sort_by: "property:estimate", properties: { estimate: ["3"] } }, + ); + qc.setQueryData(flatKey, { + pages: [{ issues: [baseIssue], total: 1 }], + pageParams: [0], + }); + + patchIssueProperties(qc, WS_ID, ISSUE_ID, { estimate: 3 }); + + expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(false); + expect( + qc.getQueryData<{ pages: { issues: Issue[] }[] }>(flatKey)?.pages[0] + ?.issues[0]?.properties, + ).toEqual({ estimate: 3 }); + + onIssuePropertiesChanged(qc, WS_ID, ISSUE_ID, { estimate: 4 }); + + expectInvalidated(qc, flatKey); + }); +}); + describe("project progress invalidation", () => { let qc: QueryClient; diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index 4e2856d670d..1d57e3f5789 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -6,6 +6,7 @@ import { applyIssueChange, invalidateIssueDerivatives, invalidateStaleListKeys, + type IssueFlatCache, } from "./cache-coordinator"; import { addIssueToBuckets, @@ -16,6 +17,44 @@ import { cleanupDeletedIssueCaches } from "./delete-cache"; import type { Issue, IssueLabelsResponse, IssueMetadata, IssuePropertyValues, Label } from "../types"; import type { ListIssuesCache } from "../types"; +function patchIssueInFlatCaches( + qc: QueryClient, + wsId: string, + issueId: string, + patch: Partial, +) { + for (const [key, data] of qc.getQueriesData({ + queryKey: issueKeys.flatAll(wsId), + })) { + if (!data?.pages) continue; + qc.setQueryData(key, { + ...data, + pages: data.pages.map((page) => ({ + ...page, + issues: page.issues.map((issue) => + issue.id === issueId ? { ...issue, ...patch } : issue, + ), + })), + }); + } +} + +function findIssueInFlatCaches( + qc: QueryClient, + wsId: string, + issueId: string, +) { + for (const [, data] of qc.getQueriesData({ + queryKey: issueKeys.flatAll(wsId), + })) { + for (const page of data?.pages ?? []) { + const issue = page.issues.find((candidate) => candidate.id === issueId); + if (issue) return issue; + } + } + return undefined; +} + export function onIssueCreated( qc: QueryClient, wsId: string, @@ -25,6 +64,7 @@ export function onIssueCreated( if (data) qc.setQueryData(key, addIssueToBuckets(data, issue)); } qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.flatAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) }); if (issue.project_id) { @@ -64,7 +104,8 @@ export function onIssueUpdated( const detailData = qc.getQueryData(issueKeys.detail(wsId, issue.id)); const cachedIssue = detailData ?? - (firstListData ? findIssueLocation(firstListData, issue.id)?.issue : undefined); + (firstListData ? findIssueLocation(firstListData, issue.id)?.issue : undefined) ?? + findIssueInFlatCaches(qc, wsId, issue.id); const oldParentId = detailData?.parent_issue_id ?? cachedIssue?.parent_issue_id ?? null; // The NEW parent comes from the WS payload when parent_issue_id changed @@ -152,10 +193,22 @@ export function onIssueLabelsChanged( wsId: string, issueId: string, labels: Label[], +) { + patchIssueLabels(qc, wsId, issueId, labels); + invalidateIssueLabelDerivatives(qc, wsId); +} + +/** Deterministic label snapshot patch used by optimistic mutation legs. */ +export function patchIssueLabels( + qc: QueryClient, + wsId: string, + issueId: string, + labels: Label[], ) { for (const [key, data] of qc.getQueriesData({ queryKey: issueKeys.list(wsId) })) { if (data) qc.setQueryData(key, patchIssueInBuckets(data, issueId, { labels })); } + patchIssueInFlatCaches(qc, wsId, issueId, { labels }); qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => old ? { ...old, labels } : old, ); @@ -176,9 +229,22 @@ export function onIssueLabelsChanged( ); qc.setQueryData(key, next); } +} + +/** Reconcile server-filtered label windows only after the write commits. */ +export function invalidateIssueLabelDerivatives(qc: QueryClient, wsId: string) { qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) }); + qc.invalidateQueries({ + queryKey: issueKeys.flatAll(wsId), + predicate: (query) => + query.queryKey.some((part) => { + if (!part || typeof part !== "object" || Array.isArray(part)) return false; + const labelIds = (part as { label_ids?: unknown }).label_ids; + return Array.isArray(labelIds) && labelIds.length > 0; + }), + }); } /** @@ -199,6 +265,7 @@ export function onIssueMetadataChanged( for (const [key, data] of qc.getQueriesData({ queryKey: issueKeys.list(wsId) })) { if (data) qc.setQueryData(key, patchIssueInBuckets(data, issueId, { metadata })); } + patchIssueInFlatCaches(qc, wsId, issueId, { metadata }); qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => old ? { ...old, metadata } : old, ); @@ -217,12 +284,7 @@ export function onIssuePropertiesChanged( issueId: string, properties: IssuePropertyValues, ) { - for (const [key, data] of qc.getQueriesData({ queryKey: issueKeys.list(wsId) })) { - if (data) qc.setQueryData(key, patchIssueInBuckets(data, issueId, { properties })); - } - qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => - old ? { ...old, properties } : old, - ); + patchIssueProperties(qc, wsId, issueId, properties); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); // Plain assignee-group caches are never patched in place (their bucket // shape differs) and would otherwise hold stale chips forever under @@ -232,6 +294,24 @@ export function onIssuePropertiesChanged( invalidatePropertyWindowQueries(qc, wsId); } +/** Patch only deterministic entity snapshots. Optimistic mutation legs use + * this helper so they never start a property-filter/sort refetch before the + * server commit (which could return the old bag and stomp the optimistic one). */ +export function patchIssueProperties( + qc: QueryClient, + wsId: string, + issueId: string, + properties: IssuePropertyValues, +) { + for (const [key, data] of qc.getQueriesData({ queryKey: issueKeys.list(wsId) })) { + if (data) qc.setQueryData(key, patchIssueInBuckets(data, issueId, { properties })); + } + patchIssueInFlatCaches(qc, wsId, issueId, { properties }); + qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => + old ? { ...old, properties } : old, + ); +} + /** * Refetch every issue window whose SERVER-side shape depends on property * values: queries filtered by `properties` or sorted by `property:`. diff --git a/packages/core/labels/mutations.ts b/packages/core/labels/mutations.ts index 30acf2ec54e..5446e7ebac7 100644 --- a/packages/core/labels/mutations.ts +++ b/packages/core/labels/mutations.ts @@ -3,7 +3,11 @@ import { api } from "../api"; import { labelKeys } from "./queries"; import { useWorkspaceId } from "../hooks"; import { issueKeys } from "../issues/queries"; -import { onIssueLabelsChanged } from "../issues/ws-updaters"; +import { + invalidateIssueLabelDerivatives, + onIssueLabelsChanged, + patchIssueLabels, +} from "../issues/ws-updaters"; import type { Label, CreateLabelRequest, @@ -157,7 +161,11 @@ export function useAttachLabel(issueId: string) { return useMutation({ mutationFn: (labelId: string) => api.attachLabel(issueId, labelId), onMutate: async (labelId) => { - await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }); + await Promise.all([ + qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }), + qc.cancelQueries({ queryKey: issueKeys.list(wsId) }), + qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }), + ]); const prev = qc.getQueryData(labelKeys.byIssue(wsId, issueId)); // Only patch when we already know the current label set — otherwise // appending `[label]` to an empty array would wipe denormalized @@ -171,13 +179,13 @@ export function useAttachLabel(issueId: string) { if (!label) return { prev }; const next: IssueLabelsResponse = { ...prev, labels: [...prev.labels, label] }; qc.setQueryData(labelKeys.byIssue(wsId, issueId), next); - onIssueLabelsChanged(qc, wsId, issueId, next.labels); + patchIssueLabels(qc, wsId, issueId, next.labels); return { prev }; }, onError: (_err, _id, ctx) => { if (ctx?.prev) { qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev); - onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels); + patchIssueLabels(qc, wsId, issueId, ctx.prev.labels); } }, onSuccess: (data: IssueLabelsResponse) => { @@ -192,6 +200,7 @@ export function useAttachLabel(issueId: string) { }, onSettled: () => { qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }); + invalidateIssueLabelDerivatives(qc, wsId); }, }); } @@ -226,25 +235,30 @@ export function useDetachLabel(issueId: string) { return useMutation({ mutationFn: (labelId: string) => api.detachLabel(issueId, labelId), onMutate: async (labelId) => { - await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }); + await Promise.all([ + qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }), + qc.cancelQueries({ queryKey: issueKeys.list(wsId) }), + qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }), + ]); const prev = qc.getQueryData(labelKeys.byIssue(wsId, issueId)); const next = prev ? { ...prev, labels: prev.labels.filter((l: Label) => l.id !== labelId) } : undefined; if (next) { qc.setQueryData(labelKeys.byIssue(wsId, issueId), next); - onIssueLabelsChanged(qc, wsId, issueId, next.labels); + patchIssueLabels(qc, wsId, issueId, next.labels); } return { prev }; }, onError: (_err, _id, ctx) => { if (ctx?.prev) { qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev); - onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels); + patchIssueLabels(qc, wsId, issueId, ctx.prev.labels); } }, onSettled: () => { qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }); + invalidateIssueLabelDerivatives(qc, wsId); }, }); } diff --git a/packages/core/properties/mutations.test.tsx b/packages/core/properties/mutations.test.tsx new file mode 100644 index 00000000000..11e8c84016a --- /dev/null +++ b/packages/core/properties/mutations.test.tsx @@ -0,0 +1,103 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { setApiInstance } from "../api"; +import type { ApiClient } from "../api/client"; +import type { Issue, IssuePropertiesResponse } from "../types"; +import { issueKeys } from "../issues/queries"; +import { useSetIssueProperty } from "./mutations"; + +vi.mock("../hooks", () => ({ useWorkspaceId: () => "ws-1" })); + +const issue: Issue = { + id: "issue-1", + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title: "Estimate", + description: null, + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "member-1", + parent_issue_id: null, + project_id: null, + position: 1, + stage: null, + start_date: null, + due_date: null, + labels: [], + metadata: {}, + properties: { estimate: 1 }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +function wrapper(qc: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useSetIssueProperty", () => { + afterEach(() => vi.restoreAllMocks()); + + it("does not refetch a property window before the mutation commits", async () => { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const flatKey = issueKeys.flat( + "ws-1", + "workspace:all", + {}, + { sort_by: "property:estimate", properties: { estimate: ["2"] } }, + ); + qc.setQueryData(flatKey, { + pages: [{ issues: [issue], total: 1 }], + pageParams: [0], + }); + + let resolveWrite!: (value: IssuePropertiesResponse) => void; + const setIssueProperty = vi.fn( + () => + new Promise((resolve) => { + resolveWrite = resolve; + }), + ); + setApiInstance({ setIssueProperty } as unknown as ApiClient); + + const { result } = renderHook(() => useSetIssueProperty(), { + wrapper: wrapper(qc), + }); + act(() => { + result.current.mutate({ + issueId: issue.id, + propertyId: "estimate", + value: 2, + }); + }); + + await waitFor(() => + expect( + qc.getQueryData<{ pages: { issues: Issue[] }[] }>(flatKey)?.pages[0] + ?.issues[0]?.properties.estimate, + ).toBe(2), + ); + expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(false); + + await act(async () => { + resolveWrite({ properties: { estimate: 2 } }); + }); + await waitFor(() => + expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(true), + ); + qc.clear(); + }); +}); diff --git a/packages/core/properties/mutations.ts b/packages/core/properties/mutations.ts index 04ef1c3d1d5..981030331e5 100644 --- a/packages/core/properties/mutations.ts +++ b/packages/core/properties/mutations.ts @@ -3,8 +3,13 @@ import { api } from "../api"; import { propertyKeys } from "./queries"; import { useWorkspaceId } from "../hooks"; import { issueKeys } from "../issues/queries"; -import { invalidatePropertyWindowQueries, onIssuePropertiesChanged } from "../issues/ws-updaters"; +import { + invalidatePropertyWindowQueries, + onIssuePropertiesChanged, + patchIssueProperties, +} from "../issues/ws-updaters"; import { findIssueLocation } from "../issues/cache-helpers"; +import type { IssueFlatCache } from "../issues/cache-coordinator"; import type { CreatePropertyRequest, UpdatePropertyRequest, @@ -60,6 +65,14 @@ function readIssueProperties(qc: ReturnType, wsId: string const location = findIssueLocation(data, issueId); if (location) return location.issue.properties ?? {}; } + for (const [, data] of qc.getQueriesData({ + queryKey: issueKeys.flatAll(wsId), + })) { + for (const page of data?.pages ?? []) { + const issue = page.issues.find((candidate) => candidate.id === issueId); + if (issue) return issue.properties ?? {}; + } + } return undefined; } @@ -94,9 +107,10 @@ export function useSetIssueProperty() { await Promise.all([ qc.cancelQueries({ queryKey: issueKeys.detail(wsId, issueId) }), qc.cancelQueries({ queryKey: issueKeys.list(wsId) }), + qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }), ]); const prev = readIssueProperties(qc, wsId, issueId); - onIssuePropertiesChanged(qc, wsId, issueId, { ...(prev ?? {}), [propertyId]: value }); + patchIssueProperties(qc, wsId, issueId, { ...(prev ?? {}), [propertyId]: value }); return { prevValue: prev?.[propertyId], hadBag: prev !== undefined, issueId, propertyId }; }, onError: (_err, _vars, ctx) => { @@ -124,12 +138,13 @@ export function useUnsetIssueProperty() { await Promise.all([ qc.cancelQueries({ queryKey: issueKeys.detail(wsId, issueId) }), qc.cancelQueries({ queryKey: issueKeys.list(wsId) }), + qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }), ]); const prev = readIssueProperties(qc, wsId, issueId); if (prev) { const next = { ...prev }; delete next[propertyId]; - onIssuePropertiesChanged(qc, wsId, issueId, next); + patchIssueProperties(qc, wsId, issueId, next); } return { prevValue: prev?.[propertyId], hadBag: prev !== undefined, issueId, propertyId }; }, @@ -165,7 +180,7 @@ function rollbackSingleKey( const next = { ...current }; if (ctx.prevValue === undefined) delete next[ctx.propertyId]; else next[ctx.propertyId] = ctx.prevValue; - onIssuePropertiesChanged(qc, wsId, ctx.issueId, next); + patchIssueProperties(qc, wsId, ctx.issueId, next); } /** Authoritative reconcile once the LAST in-flight property write settles. */ diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 8daaec69b35..a69933a9481 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -80,8 +80,14 @@ export interface ListIssuesParams { limit?: number; offset?: number; workspace_id?: string; + /** Flat-table quick search. Matches issue title words or an exact issue number. */ + q?: string; status?: IssueStatus; + /** Multi-value table facet. OR within the field. */ + statuses?: IssueStatus[]; priority?: IssuePriority; + /** Multi-value table facet. OR within the field. */ + priorities?: IssuePriority[]; assignee_id?: string; assignee_ids?: string[]; /** @@ -92,6 +98,22 @@ export interface ListIssuesParams { assignee_types?: IssueAssigneeType[]; creator_id?: string; project_id?: string; + /** Actor-aware table facets. OR within each field. */ + assignee_filters?: IssueActorRef[]; + include_no_assignee?: boolean; + creator_filters?: IssueActorRef[]; + project_ids?: string[]; + include_no_project?: boolean; + label_ids?: string[]; + /** Restrict the window to root issues instead of filtering loaded pages. */ + top_level_only?: boolean; + /** + * Hard restriction of the window to the given issue ids (the table's + * agents-working facet sends the live running-issue set). An EMPTY array is + * meaningful and yields an EMPTY window — omit the field entirely for "no + * restriction". + */ + ids?: string[]; /** * Widen the assignee filter to issues where the user is the *indirect* * assignee — assignee is one of the user's owned agents, or a squad that @@ -117,7 +139,16 @@ export interface ListIssuesParams { date_field?: "created_at" | "updated_at"; date_start?: string; date_end?: string; - sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date" | `property:${string}`; + sort_by?: + | "position" + | "status" + | "priority" + | "title" + | "created_at" + | "updated_at" + | "start_date" + | "due_date" + | `property:${string}`; sort_direction?: "asc" | "desc"; } @@ -156,7 +187,16 @@ export interface ListGroupedIssuesParams { date_field?: "created_at" | "updated_at"; date_start?: string; date_end?: string; - sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date" | `property:${string}`; + sort_by?: + | "position" + | "status" + | "priority" + | "title" + | "created_at" + | "updated_at" + | "start_date" + | "due_date" + | `property:${string}`; sort_direction?: "asc" | "desc"; } diff --git a/packages/core/workspace/hooks.ts b/packages/core/workspace/hooks.ts index 088fb1515c7..aa3ae583596 100644 --- a/packages/core/workspace/hooks.ts +++ b/packages/core/workspace/hooks.ts @@ -6,6 +6,30 @@ import { useWorkspaceId } from "../hooks"; import { memberListOptions, agentListOptions, squadListOptions } from "./queries"; import { resolvePublicFileUrl } from "./avatar-url"; +/** + * Pure actor-name resolution over explicit directory snapshots. Async flows + * (e.g. CSV export) must resolve names from directories they have awaited + * themselves — a hook-bound getActorName closes over whatever the queries + * held at render time, silently naming everyone "Unknown*" while the + * directories are still loading. + */ +export function buildActorNameResolver(directories: { + members: readonly { user_id: string; name: string }[]; + agents: readonly { id: string; name: string }[]; + squads: readonly { id: string; name: string }[]; +}) { + const memberNames = new Map(directories.members.map((m) => [m.user_id, m.name])); + const agentNames = new Map(directories.agents.map((a) => [a.id, a.name])); + const squadNames = new Map(directories.squads.map((s) => [s.id, s.name])); + return (type: string, id: string) => { + if (type === "member") return memberNames.get(id) ?? "Unknown"; + if (type === "agent") return agentNames.get(id) ?? "Unknown Agent"; + if (type === "squad") return squadNames.get(id) ?? "Unknown Squad"; + if (type === "system") return "Multica"; + return "System"; + }; +} + export function useActorName() { const wsId = useWorkspaceId(); const { data: members = [] } = useQuery(memberListOptions(wsId)); @@ -27,13 +51,10 @@ export function useActorName() { return s?.name ?? "Unknown Squad"; }, [squads]); - const getActorName = useCallback((type: string, id: string) => { - if (type === "member") return getMemberName(id); - if (type === "agent") return getAgentName(id); - if (type === "squad") return getSquadName(id); - if (type === "system") return "Multica"; - return "System"; - }, [getAgentName, getMemberName, getSquadName]); + const getActorName = useMemo( + () => buildActorNameResolver({ members, agents, squads }), + [agents, members, squads], + ); const getActorInitials = useCallback((type: string, id: string) => { const name = getActorName(type, id); diff --git a/packages/ui/components/ui/data-table.tsx b/packages/ui/components/ui/data-table.tsx index c06e8718b30..b81b3e57074 100644 --- a/packages/ui/components/ui/data-table.tsx +++ b/packages/ui/components/ui/data-table.tsx @@ -6,6 +6,7 @@ import { type Row, type Table as TanstackTable, } from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; import * as React from "react"; // We deliberately use the lower-level shadcn primitives (TableHeader / @@ -38,6 +39,17 @@ interface DataTableProps extends React.ComponentProps<"div"> { // a detail page on row click without nesting an around , // which is invalid HTML. onRowClick?: (row: Row) => void; + // Optional escape hatch for semantic rows such as collapsible group + // headers. Return null/undefined to use the standard data row renderer. + renderRow?: (row: Row) => React.ReactNode; + // A caller-supplied (summary / quick-create rows, for example). + footer?: React.ReactNode; + // Render only the visible row window for large tables. Callers should use + // this when their rows have a stable height; the footer remains part of the + // same scroll surface and is reachable after the virtual row space. + virtualizeRows?: boolean; + virtualRowHeight?: number; + virtualOverscan?: number; } // Headless data-table shell — adapted from Dice UI's data-table @@ -62,6 +74,11 @@ export function DataTable({ actionBar, emptyMessage = "No results.", onRowClick, + renderRow, + footer, + virtualizeRows = false, + virtualRowHeight = 41, + virtualOverscan = 10, className, ...props }: DataTableProps) { @@ -157,12 +174,95 @@ export function DataTable({ [hasExplicitSize, setColumnWidth], ); + const scrollRef = React.useRef(null); + const rows = table.getRowModel().rows; + const getVirtualRowKey = React.useCallback( + (index: number) => rows[index]?.id ?? index, + [rows], + ); + const rowVirtualizer = useVirtualizer({ + count: virtualizeRows ? rows.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => virtualRowHeight, + getItemKey: getVirtualRowKey, + overscan: virtualOverscan, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + const firstVirtualItem = virtualItems[0]; + const lastVirtualItem = virtualItems[virtualItems.length - 1]; + const virtualPaddingTop = firstVirtualItem?.start ?? 0; + const virtualPaddingBottom = lastVirtualItem + ? rowVirtualizer.getTotalSize() - lastVirtualItem.end + : 0; + + const renderDataRow = (row: Row) => { + const customRow = renderRow?.(row); + if (customRow != null) { + return {customRow}; + } + return ( + onRowClick(row) : undefined} + // `group` lets pinned cells track row hover via group-hover (their bg + // is in className, not on the row, so they stay opaque enough to cover + // content scrolling beneath them). + className={cn("group", onRowClick && "cursor-pointer")} + > + {row.getVisibleCells().map((cell) => { + const isPinned = cell.column.getIsPinned(); + const columnHasExplicitSize = hasExplicitSize(cell.column.id); + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + }; + + const renderVirtualSpacer = (position: "top" | "bottom", height: number) => + height > 0 ? ( + + + + ) : null; + return (
-
+
({ // exceeds column.size. Tooltip / dropdown / // hover-card bodies are portaled, so they are // unaffected. - // Pinned header cell uses muted/30 so it blends - // into the header strip rather than appearing as - // a white block under sticky scroll. + // Pinned cells must be opaque: translucent backgrounds + // reveal horizontally scrolled columns underneath. Mix + // muted with background to preserve the same visual tone + // as muted/30 without introducing alpha. className={cn( "relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground", - isPinned && "bg-muted/30 backdrop-blur", + isPinned && + "bg-[color-mix(in_oklab,var(--muted)_30%,var(--background))]", )} style={getCellStyle(header.column, { withBorder: true, @@ -246,58 +348,19 @@ export function DataTable({ ))} - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - onRowClick(row) : undefined - } - // `group` lets pinned cells track row hover via - // group-hover (their bg is in className, not on the - // row, so they stay opaque enough to cover content - // scrolling beneath them). - className={cn( - "group", - onRowClick && "cursor-pointer", - )} - > - {row.getVisibleCells().map((cell) => { - const isPinned = cell.column.getIsPinned(); - const columnHasExplicitSize = hasExplicitSize( - cell.column.id, - ); - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ); + {rows.length ? ( + virtualizeRows ? ( + <> + {renderVirtualSpacer("top", virtualPaddingTop)} + {virtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + return row ? renderDataRow(row) : null; })} - - )) + {renderVirtualSpacer("bottom", virtualPaddingBottom)} + + ) : ( + rows.map(renderDataRow) + ) ) : ( ({ )} + {footer}
{actionBar && diff --git a/packages/ui/package.json b/packages/ui/package.json index 3896ec9310c..942cea09686 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ "@emoji-mart/data": "^1.2.1", "@number-flow/react": "^0.6.1", "@tanstack/react-table": "catalog:", + "@tanstack/react-virtual": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", "cmdk": "^1.1.1", diff --git a/packages/views/issues/components/batch-action-toolbar.tsx b/packages/views/issues/components/batch-action-toolbar.tsx index b59cea80f0e..fd25259fabc 100644 --- a/packages/views/issues/components/batch-action-toolbar.tsx +++ b/packages/views/issues/components/batch-action-toolbar.tsx @@ -47,14 +47,24 @@ export function BatchActionToolbar({ const selection = useIssueSurfaceSelection(); const selectedIds = selection.selectedIds; const clear = selection.clear; - const count = selectedIds.size; + + // The authoritative selection is selectedIds ∩ the visible universe. Acting + // on raw selectedIds while other consumers (Export selected, common fields) + // intersect lets one "N selected" mean different sets — e.g. a realtime + // update drops a selected row from the window, batch still mutates it but + // export omits it. Count, pickers, and every action below share this set. + const selectedIssues = useMemo( + () => issues.filter((i) => selectedIds.has(i.id)), + [issues, selectedIds], + ); + const count = selectedIssues.length; // Reflect the real shared value of the selected issues in each picker; fall // back to an empty (no-checkmark) state when the selection is mixed, instead // of asserting a hardcoded default. const common = useMemo( - () => commonIssueFields(issues.filter((i) => selectedIds.has(i.id))), - [issues, selectedIds], + () => commonIssueFields(selectedIssues), + [selectedIssues], ); const [statusOpen, setStatusOpen] = useState(false); @@ -70,7 +80,7 @@ export function BatchActionToolbar({ if (count === 0) return null; - const ids = Array.from(selectedIds); + const ids = selectedIssues.map((issue) => issue.id); const handleBatchUpdate = async (updates: Partial) => { try { @@ -109,10 +119,8 @@ export function BatchActionToolbar({ // issue is in backlog the confirm modal would only render an empty "won't // start" box — apply directly, matching handleBatchStatus's backlog short- // circuit. A mixed selection still routes through the modal: the non-backlog - // issues will trigger and need confirmation. An empty intersection (selected - // ids not in `issues`) falls through to the modal — safer than skipping. - const selected = issues.filter((i) => selectedIds.has(i.id)); - const allBacklog = selected.length > 0 && selected.every((i) => i.status === "backlog"); + // issues will trigger and need confirmation. + const allBacklog = selectedIssues.every((i) => i.status === "backlog"); if (!allBacklog) { openModal("issue-run-confirm", { issueIds: ids, diff --git a/packages/views/issues/components/data-table-sticky.test.tsx b/packages/views/issues/components/data-table-sticky.test.tsx new file mode 100644 index 00000000000..7a98951e703 --- /dev/null +++ b/packages/views/issues/components/data-table-sticky.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table"; +import { describe, expect, it } from "vitest"; +import { DataTable } from "@multica/ui/components/ui/data-table"; + +type Row = { + title: string; + status: string; +}; + +const columns: ColumnDef[] = [ + { accessorKey: "title", header: "Issue" }, + { accessorKey: "status", header: "Status" }, +]; + +function PinnedTable() { + const table = useReactTable({ + data: [{ title: "Pinned title", status: "In progress" }], + columns, + getCoreRowModel: getCoreRowModel(), + state: { columnPinning: { left: ["title"], right: [] } }, + }); + + return ; +} + +describe("DataTable pinned columns", () => { + it("uses opaque backgrounds so scrolled columns cannot show through", () => { + render(); + + const titleHeader = screen.getByRole("columnheader", { name: /^Issue/ }); + const titleCell = screen.getByRole("cell", { name: "Pinned title" }); + const statusCell = screen.getByRole("cell", { name: "In progress" }); + + expect(titleHeader).toHaveClass( + "bg-[color-mix(in_oklab,var(--muted)_30%,var(--background))]", + ); + expect(titleHeader).not.toHaveClass("bg-muted/30", "backdrop-blur"); + expect(titleCell).toHaveClass( + "bg-background", + "group-hover:bg-[color-mix(in_oklab,var(--muted)_50%,var(--background))]", + ); + expect(titleCell).not.toHaveClass("group-hover:bg-muted/50"); + expect(titleCell).toHaveStyle({ position: "sticky", zIndex: 1 }); + expect(statusCell).not.toHaveStyle({ position: "sticky" }); + }); +}); diff --git a/packages/views/issues/components/infinite-scroll-sentinel.test.tsx b/packages/views/issues/components/infinite-scroll-sentinel.test.tsx new file mode 100644 index 00000000000..e7dddf84e49 --- /dev/null +++ b/packages/views/issues/components/infinite-scroll-sentinel.test.tsx @@ -0,0 +1,51 @@ +/** + * @vitest-environment jsdom + */ +import { render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; + +describe("InfiniteScrollSentinel", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("keeps one observer while loading and callback props change", () => { + let notify!: IntersectionObserverCallback; + const observe = vi.fn(); + const disconnect = vi.fn(); + const Observer = vi.fn(function Observer( + callback: IntersectionObserverCallback, + ) { + notify = callback; + return { observe, disconnect, unobserve: vi.fn(), takeRecords: vi.fn() }; + }); + vi.stubGlobal("IntersectionObserver", Observer); + + const first = vi.fn(); + const second = vi.fn(); + const view = render( + , + ); + + notify( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + expect(first).toHaveBeenCalledTimes(1); + + view.rerender( + , + ); + + expect(Observer).toHaveBeenCalledTimes(1); + expect(disconnect).not.toHaveBeenCalled(); + + notify( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + expect(second).toHaveBeenCalledTimes(1); + view.unmount(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/views/issues/components/infinite-scroll-sentinel.tsx b/packages/views/issues/components/infinite-scroll-sentinel.tsx index c0b77059b48..de5e3bdf281 100644 --- a/packages/views/issues/components/infinite-scroll-sentinel.tsx +++ b/packages/views/issues/components/infinite-scroll-sentinel.tsx @@ -3,8 +3,20 @@ import { useEffect, useRef } from "react"; import { Loader2 } from "lucide-react"; -/** Sentinel that triggers `onVisible` when scrolled into view. */ -export function InfiniteScrollSentinel({ onVisible, loading }: { onVisible: () => void; loading: boolean }) { +/** Sentinel that triggers once per visibility transition. Callback/loading + * changes are read through refs/props without rebuilding the observer, so a + * completed page fetch cannot retrigger while the sentinel remains visible. */ +export function InfiniteScrollSentinel({ + onVisible, + loading, + rootMargin = "100px", + className = "flex items-center justify-center py-2", +}: { + onVisible: () => void; + loading: boolean; + rootMargin?: string; + className?: string; +}) { const sentinelRef = useRef(null); const onVisibleRef = useRef(onVisible); onVisibleRef.current = onVisible; @@ -14,14 +26,14 @@ export function InfiniteScrollSentinel({ onVisible, loading }: { onVisible: () = if (!node) return; const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) onVisibleRef.current(); }, - { rootMargin: "100px" }, + { rootMargin }, ); observer.observe(node); return () => observer.disconnect(); - }, []); + }, [rootMargin]); return ( -
+
{loading && }
); diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 610a77bb0c4..757d33f3a11 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -17,6 +17,7 @@ import { SlidersHorizontal, X, Tag, + Table2, User, UserMinus, UserPen, @@ -68,12 +69,14 @@ import { GROUPING_OPTIONS, SWIMLANE_GROUPINGS, CARD_PROPERTY_OPTIONS, + TABLE_SYSTEM_COLUMNS, type ActorFilterValue, type IssueDateField, type IssueDateFilter, type SortField, type IssueGrouping, type SwimlaneGrouping, + type TableGrouping, type ViewMode, } from "@multica/core/issues/stores/view-store"; import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context"; @@ -137,6 +140,10 @@ const DATE_FIELD_LABEL_KEY: Record 0) without touching the option lists themselves. */ +const NO_COUNT_ISSUES: Issue[] = []; + function useIssueCounts(allIssues: Issue[]) { return useMemo(() => { const status = new Map(); @@ -748,15 +755,19 @@ export function IssuesHeader({ dateFilter = null, onDateFilterChange, isRefreshing = false, + facetCountsExact = true, }: { scopedIssues: Issue[]; - /** The rows the agents-working filter would leave on screen. Scopes the - * chip: it counts the agents working on these rows. */ - workingIssues: Issue[]; + /** The rows the agents-working filter would leave on screen — undefined + * when the set is unknown (chip renders indeterminate). Scopes the chip: + * it counts the agents working on these rows. */ + workingIssues: Issue[] | undefined; allowGantt?: boolean; dateFilter?: IssueDateFilter | null; onDateFilterChange?: (filter: IssueDateFilter | null) => void; isRefreshing?: boolean; + /** See IssueDisplayControls.facetCountsExact. */ + facetCountsExact?: boolean; }) { const { t } = useT("issues"); const scope = useIssuesScopeStore((s) => s.scope); @@ -850,6 +861,7 @@ export function IssuesHeader({ allowGantt={allowGantt} dateFilter={dateFilter} onDateFilterChange={onDateFilterChange} + facetCountsExact={facetCountsExact} />
@@ -864,6 +876,7 @@ export function IssueDisplayControls({ allowGantt = false, dateFilter = null, onDateFilterChange, + facetCountsExact = true, }: { scopedIssues: Issue[]; hideViewToggle?: boolean; @@ -873,6 +886,15 @@ export function IssueDisplayControls({ // /my-issues, actor panel) ignore viewMode === "gantt" and would silently // fall back to List if the option were exposed there. Keep Gantt opt-in. allowGantt?: boolean; + /** + * Whether `scopedIssues` covers the surface's full window. The table's + * offset pagination hands us only the loaded pages — presenting counts + * derived from a partial window as per-option totals under-reports + * (round-2 review P2#3), so the badges are suppressed until the window is + * complete. Filter OPTIONS are unaffected; they come from the + * member/agent/project/label directories. + */ + facetCountsExact?: boolean; }) { const { t } = useT("issues"); const viewMode = useViewStore((s) => s.viewMode); @@ -891,6 +913,9 @@ export function IssueDisplayControls({ const grouping = useViewStore((s) => s.grouping); const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping); const cardProperties = useViewStore((s) => s.cardProperties); + const tableColumns = useViewStore((s) => s.tableColumns); + const tableGrouping = useViewStore((s) => s.tableGrouping ?? "none"); + const tableHierarchy = useViewStore((s) => s.tableHierarchy ?? true); const showSubIssues = useViewStore((s) => s.showSubIssues); const act = useViewStoreApi().getState(); const headerWsId = useWorkspaceId(); @@ -916,8 +941,21 @@ export function IssueDisplayControls({ () => workspaceProperties.filter((p) => p.type === "select"), [workspaceProperties], ); + const tableGroupableProperties = useMemo( + () => + workspaceProperties.filter((p) => + ["select", "multi_select", "checkbox"].includes(p.type), + ), + [workspaceProperties], + ); + const visibleTableColumns = useMemo( + () => new Set(tableColumns.map((column) => column.key)), + [tableColumns], + ); - const counts = useIssueCounts(scopedIssues); + const counts = useIssueCounts( + facetCountsExact ? scopedIssues : NO_COUNT_ISSUES, + ); const showDateFilter = !!onDateFilterChange; // Only count filters whose definition is still active — a filter pinned to @@ -944,12 +982,14 @@ export function IssueDisplayControls({ }); const hasActiveFilters = activeFilterCount > 0; - const SORT_LABEL_KEY: Record = { + const SORT_LABEL_KEY: Record = { position: "sort_manual", + status: "sort_status", priority: "sort_priority", start_date: "sort_start_date", due_date: "sort_due_date", created_at: "sort_created", + updated_at: "sort_updated", title: "sort_title", }; const GROUPING_LABEL_KEY: Record = { @@ -980,6 +1020,7 @@ export function IssueDisplayControls({ : null; const sortPropertyId = propertyIdFromViewKey(sortBy); const groupingPropertyId = propertyIdFromViewKey(grouping); + const tableGroupingPropertyId = propertyIdFromViewKey(tableGrouping); // Property-backed sort/grouping label straight from the definition name; // a stale persisted id (archived/deleted definition) falls back to the // manual/status default label. @@ -990,6 +1031,18 @@ export function IssueDisplayControls({ ? propertyById.get(groupingPropertyId)?.name ?? t(($) => $.display.group_status) : t(($) => $.display[GROUPING_LABEL_KEY[grouping as keyof typeof GROUPING_LABEL_KEY]]); const swimlaneGroupingLabel = t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[swimlaneGrouping]]); + const effectiveTableGrouping = + tableGroupingPropertyId && !propertyById.has(tableGroupingPropertyId) + ? "none" + : tableGrouping; + const tableGroupingLabel = tableGroupingPropertyId + ? propertyById.get(tableGroupingPropertyId)?.name ?? + t(($) => $.table.group_none) + : effectiveTableGrouping === "status" + ? t(($) => $.table.columns.status) + : effectiveTableGrouping === "assignee" + ? t(($) => $.table.columns.assignee) + : t(($) => $.table.group_none); const controlButtonClass = "h-8 w-8 gap-1 px-0 text-muted-foreground md:h-7 md:w-auto md:px-2.5"; return ( @@ -1369,6 +1422,75 @@ export function IssueDisplayControls({
)} + {viewMode === "table" && ( +
+ +
+ + {t(($) => $.table.group_label)} + + + + {tableGroupingLabel} + + + } + /> + + + act.setTableGrouping(value as TableGrouping) + } + > + + {t(($) => $.table.group_none)} + + + {t(($) => $.table.columns.status)} + + + {t(($) => $.table.columns.assignee)} + + {tableGroupableProperties.map((property) => ( + + + {property.name} + + ))} + + + +
+
+ )} +
{t(($) => $.display.ordering_section)} @@ -1431,42 +1553,111 @@ export function IssueDisplayControls({ /> -
- - {t(($) => $.display.card_properties_section)} - -
- {CARD_PROPERTY_OPTIONS.map((opt) => ( - - ))} - {workspaceProperties.map((property) => ( - - ))} + {viewMode === "table" ? ( +
+ + {t(($) => $.table.columns.section)} + +

+ {t(($) => $.table.columns.system_section)} +

+
+ {TABLE_SYSTEM_COLUMNS.map((key) => ( + + ))} +
+ {workspaceProperties.length > 0 && ( + <> +

+ {t(($) => $.table.columns.property_section)} +

+
+ {workspaceProperties.map((property) => { + const key = `property:${property.id}` as const; + return ( + + ); + })} +
+ + )}
-
+ ) : ( +
+ + {t(($) => $.display.card_properties_section)} + +
+ {CARD_PROPERTY_OPTIONS.map((opt) => ( + + ))} + {workspaceProperties.map((property) => ( + + ))} +
+
+ )} @@ -1483,6 +1674,8 @@ export function IssueDisplayControls({ + ), + DropdownMenuLabel: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuRadioGroup: ({ children }: { children: ReactNode }) => children, + DropdownMenuRadioItem: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ render }: { render: ReactNode }) => render, +})); + +const environmentProperty: IssueProperty = { + id: "property-environment", + workspace_id: "workspace-1", + name: "Environment", + type: "select", + config: { options: [] }, + position: 0, + archived: false, + created_at: "2026-07-15T00:00:00Z", + updated_at: "2026-07-15T00:00:00Z", +}; + +function renderPicker() { + renderWithI18n( + Add column} + />, + ); +} + +describe("TableColumnPicker", () => { + beforeEach(() => { + toggleTableColumn.mockClear(); + }); + + it("toggles a system column when its menu item is clicked", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: "Add column" })); + await user.click(screen.getByRole("menuitem", { name: "Project" })); + + expect(toggleTableColumn).toHaveBeenCalledWith("project"); + }); + + it("toggles a custom-property column when its menu item is clicked", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: "Add column" })); + await user.click(screen.getByRole("menuitem", { name: "Environment" })); + + expect(toggleTableColumn).toHaveBeenCalledWith("property:property-environment"); + }); +}); diff --git a/packages/views/issues/components/table-group-row.test.tsx b/packages/views/issues/components/table-group-row.test.tsx new file mode 100644 index 00000000000..17d418c4784 --- /dev/null +++ b/packages/views/issues/components/table-group-row.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithI18n } from "../../test/i18n"; +import { IssueTableGroupRow } from "./table-view"; + +describe("IssueTableGroupRow", () => { + it("keeps full-width row controls anchored during horizontal scrolling", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + renderWithI18n( + + + + +
, + ); + + const group = screen.getByRole("button", { name: /Backlog\s*13/ }); + expect(group).toHaveClass("sticky", "left-4", "w-fit"); + + await user.click(group); + expect(onToggle).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/views/issues/components/table-inline-title.test.tsx b/packages/views/issues/components/table-inline-title.test.tsx new file mode 100644 index 00000000000..7dee04c06c9 --- /dev/null +++ b/packages/views/issues/components/table-inline-title.test.tsx @@ -0,0 +1,72 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { Issue } from "@multica/core/types"; +import { InlineTitle } from "./table-view"; +import type { IssueTableDisplayRow } from "./table-view-model"; + +function makeIssue(title: string): Issue { + return { + id: "issue-1", + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title, + description: null, + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "member-1", + parent_issue_id: null, + project_id: null, + position: 1, + stage: null, + start_date: null, + due_date: null, + labels: [], + metadata: {}, + properties: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; +} + +function makeRow(title: string): Extract< + IssueTableDisplayRow, + { kind: "issue" } +> { + return { + kind: "issue", + key: "issue:issue-1", + issue: makeIssue(title), + depth: 0, + hasChildren: false, + collapsed: false, + }; +} + +describe("InlineTitle", () => { + it("preserves an active draft when a realtime title snapshot arrives", () => { + const props = { + onUpdate: vi.fn(), + onToggleParent: vi.fn(), + toggleLabel: "Toggle sub-issues", + }; + const view = render(); + + fireEvent.click(screen.getByRole("button", { name: "Original" })); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Local draft" } }); + + view.rerender(); + + expect(screen.getByRole("textbox")).toHaveValue("Local draft"); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.getByRole("button", { name: "Remote title" })).toBeTruthy(); + }); +}); diff --git a/packages/views/issues/components/table-issue-search.test.tsx b/packages/views/issues/components/table-issue-search.test.tsx new file mode 100644 index 00000000000..46da0fb82ef --- /dev/null +++ b/packages/views/issues/components/table-issue-search.test.tsx @@ -0,0 +1,37 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { TableIssueSearch } from "./table-view"; + +describe("TableIssueSearch", () => { + it("forwards typing and exposes an accessible clear action", () => { + const onChange = vi.fn(); + const view = render( + , + ); + + fireEvent.change(screen.getByRole("searchbox"), { + target: { value: "MUL-4797" }, + }); + expect(onChange).toHaveBeenLastCalledWith("MUL-4797"); + + view.rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(onChange).toHaveBeenLastCalledWith(""); + }); +}); diff --git a/packages/views/issues/components/table-view-model.test.ts b/packages/views/issues/components/table-view-model.test.ts new file mode 100644 index 00000000000..2c615e043a4 --- /dev/null +++ b/packages/views/issues/components/table-view-model.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "vitest"; +import type { Issue, IssueProperty } from "@multica/core/types"; +import { + TABLE_STRUCTURE_MAX_WINDOW, + buildIssueTableCsv, + buildIssueTableRows, + calculateIssueTableColumn, + getIssueTableSelectionRange, + isTableStructureSuspended, + shouldAutoLoadNextWindowPage, +} from "./table-view-model"; + +function makeIssue(id: string, overrides: Partial = {}): Issue { + const number = Number(id.replace(/\D/g, "")) || 1; + return { + id, + workspace_id: "ws-1", + number, + identifier: `MUL-${number}`, + title: `Issue ${id}`, + description: null, + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: number, + stage: null, + start_date: null, + due_date: null, + labels: [], + metadata: {}, + properties: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function makeProperty( + id: string, + type: string, + options: IssueProperty["config"]["options"] = [], +): IssueProperty { + return { + id, + workspace_id: "ws-1", + name: id, + type, + config: { options }, + position: 0, + archived: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; +} + +const baseOptions = { + grouping: "none" as const, + properties: [] as IssueProperty[], + collapsedGroups: new Set(), + collapsedParents: new Set(), + hierarchy: true, + windowComplete: true, + getActorName: (_type: string, id: string) => id, + getStatusLabel: (status: Issue["status"]) => status, + noValueLabel: "No value", + unassignedLabel: "Unassigned", + trueLabel: "Yes", + falseLabel: "No", +}; + +describe("isTableStructureSuspended", () => { + it("suspends structure only above the ceiling; unknown totals do not suspend", () => { + expect(isTableStructureSuspended(0)).toBe(false); + expect(isTableStructureSuspended(TABLE_STRUCTURE_MAX_WINDOW)).toBe(false); + expect(isTableStructureSuspended(TABLE_STRUCTURE_MAX_WINDOW + 1)).toBe(true); + }); +}); + +describe("shouldAutoLoadNextWindowPage", () => { + const base = { + windowWanted: true, + total: 500, + loadedCount: 100, + hasNextPage: true, + isFetchingNextPage: false, + hasError: false, + }; + + it("advances only while structure is wanted, healthy, and under the ceiling", () => { + expect(shouldAutoLoadNextWindowPage(base)).toBe(true); + expect( + shouldAutoLoadNextWindowPage({ ...base, windowWanted: false }), + ).toBe(false); + expect(shouldAutoLoadNextWindowPage({ ...base, hasNextPage: false })).toBe( + false, + ); + expect( + shouldAutoLoadNextWindowPage({ ...base, isFetchingNextPage: true }), + ).toBe(false); + }); + + it("stops permanently on a fetch error instead of refiring (request storm)", () => { + // After a failed page fetch, hasNextPage stays true and + // isFetchingNextPage returns to false — exactly the state that used to + // re-trigger the loop forever. + expect(shouldAutoLoadNextWindowPage({ ...base, hasError: true })).toBe( + false, + ); + }); + + it("hard-stops at the ceiling even when a stale total says otherwise", () => { + // page 1 reported total=900 (under the ceiling) but the window has since + // grown to 50,000: the fresh total suspends... + expect( + shouldAutoLoadNextWindowPage({ ...base, total: 50_000 }), + ).toBe(false); + // ...and independently of ANY reported total, the loaded count is an + // absolute stop at the ceiling — page 3 must never be requested once + // 1,000 rows are in memory. + expect( + shouldAutoLoadNextWindowPage({ + ...base, + total: 900, + loadedCount: TABLE_STRUCTURE_MAX_WINDOW, + }), + ).toBe(false); + }); +}); + +describe("buildIssueTableRows", () => { + it("renders a parent before its children and honors parent collapse", () => { + const parent = makeIssue("issue-1"); + const child = makeIssue("issue-2", { parent_issue_id: parent.id }); + + const expanded = buildIssueTableRows([child, parent], baseOptions); + expect( + expanded.map((row) => + row.kind === "issue" ? [row.issue.id, row.depth] : row.key, + ), + ).toEqual([ + ["issue-1", 0], + ["issue-2", 1], + ]); + + const collapsed = buildIssueTableRows([child, parent], { + ...baseOptions, + collapsedParents: new Set([parent.id]), + }); + expect(collapsed.map((row) => row.key)).toEqual([parent.id]); + }); + + it("suspends hierarchy and parent-based grouping while the window is incomplete", () => { + const parent = makeIssue("issue-1", { status: "done" }); + const child = makeIssue("issue-2", { + parent_issue_id: parent.id, + status: "todo", + }); + + // Nesting derived from loaded pages re-parents rows as later pages + // arrive; rows must stay in flat sort order until the window completes. + const flat = buildIssueTableRows([child, parent], { + ...baseOptions, + windowComplete: false, + }); + expect( + flat.map((row) => + row.kind === "issue" ? [row.issue.id, row.depth] : row.key, + ), + ).toEqual([ + ["issue-2", 0], + ["issue-1", 0], + ]); + + // Grouped: the child buckets by its OWN status instead of climbing to + // the parent, so its group cannot change when the parent pages in. + const grouped = buildIssueTableRows([child, parent], { + ...baseOptions, + grouping: "status", + windowComplete: false, + }); + expect(grouped.map((row) => row.key)).toEqual([ + "status:todo", + child.id, + "status:done", + parent.id, + ]); + }); + + it("keeps a hierarchy together under the parent custom-property group", () => { + const environment = makeProperty("environment", "select", [ + { id: "web", name: "Web", color: "#000000" }, + { id: "mobile", name: "Mobile", color: "#ffffff" }, + ]); + const parent = makeIssue("issue-1", { + properties: { environment: "web" }, + }); + const child = makeIssue("issue-2", { + parent_issue_id: parent.id, + properties: { environment: "mobile" }, + }); + + const rows = buildIssueTableRows([parent, child], { + ...baseOptions, + grouping: "property:environment", + properties: [environment], + }); + + expect(rows[0]).toMatchObject({ kind: "group", label: "Web", count: 2 }); + expect(rows.map((row) => row.key)).toEqual([ + "property:environment:web", + parent.id, + child.id, + ]); + }); + + it("orders status groups canonically and honors group collapse", () => { + const rows = buildIssueTableRows( + [ + makeIssue("issue-1", { status: "done" }), + makeIssue("issue-2", { status: "backlog" }), + ], + { + ...baseOptions, + grouping: "status", + collapsedGroups: new Set(["status:backlog"]), + }, + ); + + expect(rows.map((row) => row.key)).toEqual([ + "status:backlog", + "status:done", + "issue-1", + ]); + }); +}); + +describe("getIssueTableSelectionRange", () => { + const issueIds = ["issue-1", "issue-2", "issue-3", "issue-4"]; + + it("returns an inclusive range in either direction", () => { + expect( + getIssueTableSelectionRange(issueIds, "issue-1", "issue-4"), + ).toEqual(issueIds); + expect( + getIssueTableSelectionRange(issueIds, "issue-4", "issue-2"), + ).toEqual(["issue-2", "issue-3", "issue-4"]); + }); + + it("returns null when the anchor or target is not visible", () => { + expect(getIssueTableSelectionRange(issueIds, null, "issue-2")).toBeNull(); + expect( + getIssueTableSelectionRange(issueIds, "missing", "issue-2"), + ).toBeNull(); + expect( + getIssueTableSelectionRange(issueIds, "issue-1", "missing"), + ).toBeNull(); + }); +}); + +describe("table calculations and CSV", () => { + it("calculates numeric custom-property sums, averages, and counts", () => { + const issues = [ + makeIssue("issue-1", { properties: { estimate: 3 } }), + makeIssue("issue-2", { properties: { estimate: 5 } }), + makeIssue("issue-3"), + ]; + + expect( + calculateIssueTableColumn(issues, "property:estimate", "sum"), + ).toBe(8); + expect( + calculateIssueTableColumn(issues, "property:estimate", "average"), + ).toBe(4); + expect( + calculateIssueTableColumn(issues, "property:estimate", "count"), + ).toBe(2); + }); + + it("escapes commas, quotes, and newlines in CSV output", () => { + expect( + buildIssueTableCsv( + ["Identifier", "Title"], + [["MUL-1", 'Ship, "verify"\nnext']], + ), + ).toBe('Identifier,Title\r\nMUL-1,"Ship, ""verify""\nnext"'); + }); + + it("neutralizes spreadsheet formulas in headers and string cells", () => { + expect( + buildIssueTableCsv( + ["=Injected", "Value"], + [["+SUM(A1:A2)", -42], ["\tcmd", "@remote"]], + ), + ).toBe( + "'=Injected,Value\r\n'+SUM(A1:A2),-42\r\n'\tcmd,'@remote", + ); + }); +}); diff --git a/packages/views/issues/components/table-view-model.ts b/packages/views/issues/components/table-view-model.ts new file mode 100644 index 00000000000..dbd8e643aef --- /dev/null +++ b/packages/views/issues/components/table-view-model.ts @@ -0,0 +1,406 @@ +import { ALL_STATUSES } from "@multica/core/issues/config"; +import { propertyIdFromViewKey } from "@multica/core/issues/stores/view-store"; +import type { + TableCalculation, + TableColumnKey, + TableGrouping, +} from "@multica/core/issues/stores/view-store"; +import type { + Issue, + IssueProperty, + IssuePropertyValue, +} from "@multica/core/types"; + +/** + * Ceiling for the whole-window structure features (grouping, hierarchy). + * Both are only truthful over a COMPLETE window, and completing the window + * means materializing every offset page — at 100 rows/page an unbounded + * materialize would issue total/100 sequential GETs and pin every entity in + * memory (grouping is persisted view state, so it would re-run on every + * visit). Below the ceiling the table auto-loads the ~10 remaining pages; + * above it, structure features suspend with an explicit notice instead of + * triggering an unbounded download. + */ +export const TABLE_STRUCTURE_MAX_WINDOW = 1000; + +/** True when the window is too large for grouping/hierarchy — see + * TABLE_STRUCTURE_MAX_WINDOW. `total` is the server-reported window size + * (0 while unknown, which must NOT suspend: an unknown window is handled + * by the windowComplete gate, not this ceiling). */ +export function isTableStructureSuspended(total: number): boolean { + return total > TABLE_STRUCTURE_MAX_WINDOW; +} + +/** + * Single decision point for EVERY auto-pagination loop over a table window — + * the structure materialization loop and the working (ids-facet) window + * loop. The two share the main table's query cache when the agents-working + * filter is on, so a loop that skipped these gates would re-open the very + * ceiling the other just enforced (round-5 review P1). Every gate exists + * because its absence was a concrete failure mode: + * + * - `hasError`: a page that keeps failing leaves `hasNextPage` true and + * `isFetchingNextPage` false after each attempt, so an ungated effect + * refires forever — a silent request storm behind a stuck "Loaded X of N" + * (round-4 review P1#1). Terminal errors stop the loop; resuming is an + * explicit user Retry. + * - `loadedCount`: an ABSOLUTE stop at the ceiling, independent of any + * server-reported total. The suspension check alone is not a hard limit + * when totals drift between pages — a stale small page-1 total kept the + * ceiling open while pagination advanced toward a much larger real window + * (round-4 review P1#2). + * - `total` must be the FRESHEST page's total (pagination itself advances on + * the latest page), for the same reason. + */ +export function shouldAutoLoadNextWindowPage(input: { + windowWanted: boolean; + total: number; + loadedCount: number; + hasNextPage: boolean; + isFetchingNextPage: boolean; + hasError: boolean; +}): boolean { + if (!input.windowWanted || input.hasError) return false; + if (isTableStructureSuspended(input.total)) return false; + if (input.loadedCount >= TABLE_STRUCTURE_MAX_WINDOW) return false; + return input.hasNextPage && !input.isFetchingNextPage; +} + +export type IssueTableDisplayRow = + | { + kind: "group"; + key: string; + label: string; + count: number; + collapsed: boolean; + } + | { + kind: "issue"; + key: string; + issue: Issue; + depth: number; + hasChildren: boolean; + collapsed: boolean; + }; + +export interface BuildIssueTableRowsOptions { + grouping: TableGrouping; + properties: IssueProperty[]; + collapsedGroups: ReadonlySet; + collapsedParents: ReadonlySet; + hierarchy: boolean; + /** + * Whether `issues` is the FULL window (no unfetched pages). Hierarchy + * nesting and parent-based group assignment only apply to a complete + * window: deriving structure from loaded pages re-parents rows as later + * pages arrive — a child renders as root, then jumps under its parent / + * into another group mid-scroll (round-2 review P2#3). While incomplete, + * rows stay in flat sort order and group by their own fields, which is + * stable under pagination; the tree assembles once, when the data is all + * there. + */ + windowComplete: boolean; + getActorName: (type: string, id: string) => string; + getStatusLabel: (status: Issue["status"]) => string; + noValueLabel: string; + unassignedLabel: string; + trueLabel: string; + falseLabel: string; +} + +export function getIssueTableSelectionRange( + issueIds: string[], + anchorId: string | null, + targetId: string, +): string[] | null { + if (!anchorId) return null; + const anchorIndex = issueIds.indexOf(anchorId); + const targetIndex = issueIds.indexOf(targetId); + if (anchorIndex === -1 || targetIndex === -1) return null; + + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + return issueIds.slice(start, end + 1); +} + +function propertyValueLabel( + property: IssueProperty | undefined, + value: IssuePropertyValue | undefined, + labels: Pick< + BuildIssueTableRowsOptions, + "noValueLabel" | "trueLabel" | "falseLabel" + >, +) { + if (!property || value === undefined) return labels.noValueLabel; + const propertyOptions = property.config.options ?? []; + if (property.type === "select") { + return ( + propertyOptions.find((option) => option.id === value)?.name ?? + labels.noValueLabel + ); + } + if (property.type === "multi_select") { + const ids = Array.isArray(value) ? value : []; + const names = propertyOptions + .filter((option) => ids.includes(option.id)) + .map((option) => option.name); + return names.length > 0 ? names.join(", ") : labels.noValueLabel; + } + if (property.type === "checkbox") { + return value === true ? labels.trueLabel : labels.falseLabel; + } + return String(value); +} + +function groupDescriptor( + issue: Issue, + options: BuildIssueTableRowsOptions, +) { + if (options.grouping === "status") { + return { + key: `status:${issue.status}`, + label: options.getStatusLabel(issue.status), + }; + } + if (options.grouping === "assignee") { + if (!issue.assignee_type || !issue.assignee_id) { + return { key: "assignee:none", label: options.unassignedLabel }; + } + return { + key: `assignee:${issue.assignee_type}:${issue.assignee_id}`, + label: options.getActorName(issue.assignee_type, issue.assignee_id), + }; + } + const propertyId = propertyIdFromViewKey(options.grouping); + if (propertyId) { + const property = options.properties.find((item) => item.id === propertyId); + const value = issue.properties[propertyId]; + const label = propertyValueLabel( + property, + value, + options, + ); + const valueKey = Array.isArray(value) + ? [...value].sort().join(",") + : value === undefined + ? "none" + : String(value); + return { key: `property:${propertyId}:${valueKey}`, label }; + } + return { key: "none", label: "" }; +} + +function hierarchyRows( + issues: Issue[], + collapsedParents: ReadonlySet, + hierarchy: boolean, +) { + if (!hierarchy) { + return issues.map((issue) => ({ + kind: "issue", + key: issue.id, + issue, + depth: 0, + hasChildren: false, + collapsed: false, + })); + } + + const issueIds = new Set(issues.map((issue) => issue.id)); + const children = new Map(); + for (const issue of issues) { + if (!issue.parent_issue_id || !issueIds.has(issue.parent_issue_id)) continue; + const siblings = children.get(issue.parent_issue_id) ?? []; + siblings.push(issue); + children.set(issue.parent_issue_id, siblings); + } + const roots = issues.filter( + (issue) => !issue.parent_issue_id || !issueIds.has(issue.parent_issue_id), + ); + const rows: IssueTableDisplayRow[] = []; + const visited = new Set(); + + const markHiddenDescendantsVisited = (issue: Issue) => { + for (const child of children.get(issue.id) ?? []) { + if (visited.has(child.id)) continue; + visited.add(child.id); + markHiddenDescendantsVisited(child); + } + }; + + const visit = (issue: Issue, depth: number) => { + if (visited.has(issue.id)) return; + visited.add(issue.id); + const issueChildren = children.get(issue.id) ?? []; + const collapsed = collapsedParents.has(issue.id); + rows.push({ + kind: "issue", + key: issue.id, + issue, + depth, + hasChildren: issueChildren.length > 0, + collapsed, + }); + if (collapsed) { + // The final orphan/cycle recovery pass below must not resurrect rows + // intentionally hidden under a collapsed parent. + markHiddenDescendantsVisited(issue); + return; + } + for (const child of issueChildren) visit(child, depth + 1); + }; + + for (const root of roots) visit(root, 0); + // Cycles and cross-page orphans degrade to top-level rows instead of + // disappearing from the table. + for (const issue of issues) visit(issue, 0); + return rows; +} + +export function buildIssueTableRows( + issues: Issue[], + options: BuildIssueTableRowsOptions, +): IssueTableDisplayRow[] { + // See BuildIssueTableRowsOptions.windowComplete — parent-derived structure + // is only trustworthy (and stable) once every page is loaded. + const applyHierarchy = options.hierarchy && options.windowComplete; + if (options.grouping === "none") { + return hierarchyRows( + issues, + options.collapsedParents, + applyHierarchy, + ); + } + + const issueById = new Map(issues.map((issue) => [issue.id, issue])); + const groupSource = (issue: Issue) => { + if (!applyHierarchy) return issue; + let current = issue; + const seen = new Set(); + while (current.parent_issue_id && !seen.has(current.id)) { + seen.add(current.id); + const parent = issueById.get(current.parent_issue_id); + if (!parent) break; + current = parent; + } + return current; + }; + + const groups = new Map(); + for (const issue of issues) { + const descriptor = groupDescriptor(groupSource(issue), options); + const group = groups.get(descriptor.key) ?? { + label: descriptor.label, + issues: [], + }; + group.issues.push(issue); + groups.set(descriptor.key, group); + } + + const entries = [...groups.entries()]; + if (options.grouping === "status") { + const rank = new Map( + ALL_STATUSES.map((status, index) => [`status:${status}`, index]), + ); + entries.sort( + ([a], [b]) => (rank.get(a) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b) ?? Number.MAX_SAFE_INTEGER), + ); + } + + const rows: IssueTableDisplayRow[] = []; + for (const [key, group] of entries) { + const collapsed = options.collapsedGroups.has(key); + rows.push({ + kind: "group", + key, + label: group.label, + count: group.issues.length, + collapsed, + }); + if (!collapsed) { + rows.push( + ...hierarchyRows( + group.issues, + options.collapsedParents, + applyHierarchy, + ), + ); + } + } + return rows; +} + +function columnValue( + issue: Issue, + columnKey: TableColumnKey, +): IssuePropertyValue | string | number | null | undefined { + const propertyId = propertyIdFromViewKey(columnKey); + if (propertyId) return issue.properties[propertyId]; + switch (columnKey) { + case "identifier": + return issue.identifier; + case "title": + return issue.title; + case "status": + return issue.status; + case "priority": + return issue.priority; + case "assignee": + return issue.assignee_id; + case "labels": + return issue.labels?.map((label) => label.name).join(", "); + case "project": + return issue.project_id; + case "start_date": + return issue.start_date; + case "due_date": + return issue.due_date; + case "created_at": + return issue.created_at; + case "updated_at": + return issue.updated_at; + case "child_progress": + return undefined; + case "creator": + return issue.creator_id; + } + return undefined; +} + +export function calculateIssueTableColumn( + issues: Issue[], + columnKey: TableColumnKey, + calculation: TableCalculation, +) { + if (calculation === "none") return null; + const values = issues + .map((issue) => columnValue(issue, columnKey)) + .filter((value) => value !== undefined && value !== null && value !== ""); + if (calculation === "count") return values.length; + const numbers = values.filter((value): value is number => typeof value === "number"); + if (numbers.length === 0) return null; + const sum = numbers.reduce((total, value) => total + value, 0); + return calculation === "sum" ? sum : sum / numbers.length; +} + +function escapeCsvCell(value: unknown) { + const raw = value == null ? "" : String(value); + // Spreadsheet apps execute string cells beginning with these characters as + // formulas. Prefix text (including headers) with an apostrophe so exported + // user-controlled titles/properties remain data. Preserve real numbers as + // numbers — a negative numeric value is not an injected formula string. + const text = + typeof value === "string" && /^[=+\-@\t\r]/.test(raw) + ? `'${raw}` + : raw; + return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export function buildIssueTableCsv( + headers: string[], + rows: unknown[][], +) { + return [headers, ...rows] + .map((row) => row.map(escapeCsvCell).join(",")) + .join("\r\n"); +} diff --git a/packages/views/issues/components/table-view.tsx b/packages/views/issues/components/table-view.tsx new file mode 100644 index 00000000000..f04c1c49116 --- /dev/null +++ b/packages/views/issues/components/table-view.tsx @@ -0,0 +1,1354 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + horizontalListSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, + type ColumnSizingState, + type OnChangeFn, +} from "@tanstack/react-table"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + ChevronRight, + Download, + EyeOff, + GripVertical, + Loader2, + Plus, + Search, + X, +} from "lucide-react"; +import { toast } from "sonner"; +import { DataTable } from "@multica/ui/components/ui/data-table"; +import { Button } from "@multica/ui/components/ui/button"; +import { Input } from "@multica/ui/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { + TableCell, + TableFooter, + TableRow, +} from "@multica/ui/components/ui/table"; +import { cn } from "@multica/ui/lib/utils"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { + TABLE_SYSTEM_COLUMNS, + propertyIdFromViewKey, + type SortField, + type TableColumnConfig, + type TableColumnKey, + type TableSystemColumnKey, +} from "@multica/core/issues/stores/view-store"; +import { useViewStore } from "@multica/core/issues/stores/view-store-context"; +import { propertyListOptions } from "@multica/core/properties"; +import { useWorkspacePaths } from "@multica/core/paths"; +import { buildActorNameResolver, useActorName } from "@multica/core/workspace/hooks"; +import { + agentListOptions, + memberListOptions, + squadListOptions, +} from "@multica/core/workspace/queries"; +import type { + Issue, + IssueProperty, + IssuePropertyValue, + Project, + UpdateIssueRequest, +} from "@multica/core/types"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ActorAvatar } from "../../common/actor-avatar"; +import { LabelChip } from "../../labels/label-chip"; +import { useNavigation } from "../../navigation"; +import { ProjectPicker } from "../../projects/components/project-picker"; +import { useT } from "../../i18n"; +import { useIssueSurfaceActionsOptional } from "../surface/actions-context"; +import { useIssueSurfaceSelection } from "../surface/selection-context"; +import { ProgressRing } from "./progress-ring"; +import { + AssigneePicker, + DueDatePicker, + LabelPicker, + PriorityPicker, + StartDatePicker, + StatusPicker, +} from "./pickers"; +import { CustomPropertyValueEditor } from "./pickers/custom-property-picker"; +import { + TABLE_STRUCTURE_MAX_WINDOW, + buildIssueTableCsv, + buildIssueTableRows, + getIssueTableSelectionRange, + isTableStructureSuspended, + shouldAutoLoadNextWindowPage, + type IssueTableDisplayRow, +} from "./table-view-model"; +import type { ChildProgress } from "./list-row"; +import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; + +const SELECT_COLUMN_ID = "__select"; +const ADD_COLUMN_ID = "__add"; + +type TableViewProps = { + issues: Issue[]; + childProgressMap: Map; + fetchNextPage: () => Promise; + hasNextPage: boolean; + isFetchingNextPage: boolean; + /** The window query is in error state — page auto-advance (structure loop + * AND scroll sentinel) must stop and hand control to the explicit Retry. */ + windowError: boolean; + total: number; + search: string; + onSearchChange: (query: string) => void; + exportIssues: () => Promise; + resolveExportLookups: (needs: { + projects: boolean; + childProgress: boolean; + }) => Promise<{ + projectMap: Map; + childProgressMap: Map; + }>; +}; + +type ColumnLabelKey = + | "title" + | "identifier" + | "status" + | "priority" + | "assignee" + | "labels" + | "project" + | "start_date" + | "due_date" + | "created_at" + | "updated_at" + | "child_progress" + | "creator"; + +const SORTABLE_COLUMNS: Partial> = { + title: "title", + status: "status", + priority: "priority", + start_date: "start_date", + due_date: "due_date", + created_at: "created_at", + updated_at: "updated_at", +}; + +function stopRowNavigation(event: React.SyntheticEvent) { + event.stopPropagation(); +} + +function SelectAllCheckbox({ + issueIds, + label, +}: { + issueIds: string[]; + label: string; +}) { + const selection = useIssueSurfaceSelection(); + const ref = useRef(null); + const selectedCount = issueIds.filter((id) => selection.selectedIds.has(id)).length; + const checked = issueIds.length > 0 && selectedCount === issueIds.length; + + useEffect(() => { + if (ref.current) { + ref.current.indeterminate = selectedCount > 0 && !checked; + } + }, [checked, selectedCount]); + + return ( + + checked ? selection.deselect(issueIds) : selection.select(issueIds) + } + className="size-3.5 cursor-pointer accent-primary" + /> + ); +} + +function IssueCheckbox({ + checked, + label, + onToggle, +}: { + checked: boolean; + label: string; + onToggle: (shiftKey: boolean) => void; +}) { + return ( + { + event.stopPropagation(); + onToggle(event.shiftKey); + }} + onChange={() => undefined} + className="size-3.5 cursor-pointer accent-primary" + /> + ); +} + +function SortableColumnHeader({ + columnKey, + label, + sortField, + sortBy, + sortDirection, + onSort, + onHide, + ascendingLabel, + descendingLabel, + hideLabel, + reorderLabel, +}: { + columnKey: TableColumnKey; + label: string; + sortField?: SortField; + sortBy: SortField; + sortDirection: "asc" | "desc"; + onSort: (field: SortField, direction: "asc" | "desc") => void; + onHide?: () => void; + ascendingLabel: string; + descendingLabel: string; + hideLabel: string; + reorderLabel: string; +}) { + const sortable = columnKey !== "title"; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: columnKey, disabled: !sortable }); + const active = sortField === sortBy; + + return ( +
+ {sortable && ( + + )} + + + {label} + {active && + (sortDirection === "asc" ? ( + + ) : ( + + ))} + + + {sortField && ( + <> + onSort(sortField, "asc")}> + + {ascendingLabel} + + onSort(sortField, "desc")}> + + {descendingLabel} + + + )} + {sortField && onHide && } + {onHide && ( + + + {hideLabel} + + )} + + +
+ ); +} + +export function TableColumnPicker({ + properties, + trigger, +}: { + properties: IssueProperty[]; + trigger: React.ReactElement; +}) { + const { t } = useT("issues"); + const [search, setSearch] = useState(""); + const tableColumns = useViewStore((state) => state.tableColumns); + const toggleTableColumn = useViewStore((state) => state.toggleTableColumn); + const selected = useMemo( + () => new Set(tableColumns.map((column) => column.key)), + [tableColumns], + ); + const query = search.trim().toLocaleLowerCase(); + const systemColumns = TABLE_SYSTEM_COLUMNS.filter((key) => + t(($) => $.table.columns[key as ColumnLabelKey]) + .toLocaleLowerCase() + .includes(query), + ); + const visibleProperties = properties.filter((property) => + property.name.toLocaleLowerCase().includes(query), + ); + + return ( + + + +
+ setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") event.stopPropagation(); + }} + placeholder={t(($) => $.table.columns.search_placeholder)} + className="h-7" + /> +
+
+ {systemColumns.length > 0 && ( + + + {t(($) => $.table.columns.system_section)} + + {systemColumns.map((key) => ( + { + event.preventDefault(); + toggleTableColumn(key); + }} + > + + {t(($) => $.table.columns[key as ColumnLabelKey])} + + ))} + + )} + {visibleProperties.length > 0 && ( + <> + {systemColumns.length > 0 && } + + + {t(($) => $.table.columns.property_section)} + + {visibleProperties.map((property) => { + const key = `property:${property.id}` as const; + return ( + { + event.preventDefault(); + toggleTableColumn(key); + }} + > + + {property.name} + + ); + })} + + + )} + {systemColumns.length === 0 && visibleProperties.length === 0 && ( +

+ {t(($) => $.table.columns.no_results)} +

+ )} +
+
+
+ ); +} + +export function TableIssueSearch({ + value, + onChange, + placeholder, + clearLabel, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; + clearLabel: string; +}) { + return ( +
+ + onChange(event.target.value)} + aria-label={placeholder} + placeholder={placeholder} + className="h-7 pl-7 pr-7 text-xs" + /> + {value && ( + + )} +
+ ); +} + +export function InlineTitle({ + row, + onUpdate, + onToggleParent, + toggleLabel, +}: { + row: Extract; + onUpdate: (updates: Partial) => void; + onToggleParent: () => void; + toggleLabel: string; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(row.issue.title); + const editingRef = useRef(editing); + editingRef.current = editing; + + useEffect(() => { + // Realtime/cache snapshots should refresh the passive label, but must not + // overwrite text the user is actively composing. + if (!editingRef.current) setDraft(row.issue.title); + }, [row.issue.title]); + + const commit = () => { + const title = draft.trim(); + setEditing(false); + if (title && title !== row.issue.title) onUpdate({ title }); + else setDraft(row.issue.title); + }; + + return ( +
+ {row.hasChildren ? ( + + ) : ( + + )} + + {row.issue.identifier} + + {editing ? ( + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") commit(); + if (event.key === "Escape") { + setDraft(row.issue.title); + setEditing(false); + } + }} + className="h-7 min-w-0 flex-1 px-2" + /> + ) : ( + + )} +
+ ); +} + +function LazyLabelCell({ issue }: { issue: Issue }) { + const { t } = useT("issues"); + const [editing, setEditing] = useState(false); + const labels = issue.labels ?? []; + if (editing) { + return ( +
+ { + if (!open) setEditing(false); + }} + triggerRender={
+ ); + } + return ( + + ); +} + +type IssueTableGroupRowProps = { + group: Extract; + colSpan: number; + onToggle: () => void; +}; + +export function IssueTableGroupRow({ + group, + colSpan, + onToggle, +}: IssueTableGroupRowProps) { + return ( + + + + + + ); +} + +function propertyDisplayValue( + property: IssueProperty, + value: IssuePropertyValue | undefined, +) { + if (value === undefined) return ""; + const options = property.config.options ?? []; + if (property.type === "select") { + return options.find((option) => option.id === value)?.name ?? ""; + } + if (property.type === "multi_select") { + const ids = Array.isArray(value) ? value : []; + return options + .filter((option) => ids.includes(option.id)) + .map((option) => option.name) + .join(", "); + } + return String(value); +} + +export function TableView({ + issues, + childProgressMap, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + windowError, + total, + search, + onSearchChange, + exportIssues, + resolveExportLookups, +}: TableViewProps) { + const { t, i18n } = useT("issues"); + const wsId = useWorkspaceId(); + const queryClient = useQueryClient(); + const navigation = useNavigation(); + const paths = useWorkspacePaths(); + const actions = useIssueSurfaceActionsOptional(); + const selection = useIssueSurfaceSelection(); + const { getActorName } = useActorName(); + const { data: properties = [] } = useQuery(propertyListOptions(wsId)); + const propertyById = useMemo( + () => new Map(properties.map((property) => [property.id, property])), + [properties], + ); + const activePropertyIds = useMemo( + () => new Set(properties.map((property) => property.id)), + [properties], + ); + const tableColumns = useViewStore((state) => state.tableColumns); + const toggleTableColumn = useViewStore((state) => state.toggleTableColumn); + const reorderTableColumn = useViewStore((state) => state.reorderTableColumn); + const setTableColumnWidth = useViewStore((state) => state.setTableColumnWidth); + const tableGrouping = useViewStore((state) => state.tableGrouping); + const tableCollapsedGroups = useViewStore((state) => state.tableCollapsedGroups); + const toggleTableGroupCollapsed = useViewStore( + (state) => state.toggleTableGroupCollapsed, + ); + const tableCollapsedParents = useViewStore((state) => state.tableCollapsedParents); + const toggleTableParentCollapsed = useViewStore( + (state) => state.toggleTableParentCollapsed, + ); + const tableHierarchy = useViewStore((state) => state.tableHierarchy); + const sortBy = useViewStore((state) => state.sortBy); + const setSortBy = useViewStore((state) => state.setSortBy); + const sortDirection = useViewStore((state) => state.sortDirection); + const setSortDirection = useViewStore((state) => state.setSortDirection); + const [exporting, setExporting] = useState<"all" | "selected" | null>(null); + const selectionAnchorRef = useRef(null); + + const groupingPropertyId = propertyIdFromViewKey(tableGrouping); + const effectiveTableGrouping = + groupingPropertyId && !activePropertyIds.has(groupingPropertyId) + ? "none" + : tableGrouping; + + // Grouping and hierarchy are whole-window statements, so the window must + // be materialized for them — but only under an explicit ceiling: grouping + // is persisted view state and hierarchy is on by default, so an unbounded + // loop would re-download entire large workspaces on every visit (round-3 + // review P1#1). Below the ceiling the remaining pages load sequentially + // (one per completed fetch — the toolbar's loaded-of-total line is the + // visible progress, and flipping the toggles off stops the loop), which + // also makes hierarchy apply without scrolling to the last page (round-3 + // P1#2). Above the ceiling both features suspend and the toolbar notice + // explains why; the window keeps the one-page-per-scroll sentinel. + // Advancement gates (error stop, hard loaded-count ceiling, fresh total) + // live in shouldAutoLoadNextWindowPage — see its doc for the failure + // modes each gate closes (round-4 review P1#1/P1#2). + const structureSuspended = isTableStructureSuspended(total); + const structureWanted = effectiveTableGrouping !== "none" || tableHierarchy; + const structureGrouping = structureSuspended ? "none" : effectiveTableGrouping; + const structureHierarchy = tableHierarchy && !structureSuspended; + const loadedCount = issues.length; + useEffect(() => { + if ( + shouldAutoLoadNextWindowPage({ + windowWanted: structureWanted, + total, + loadedCount, + hasNextPage, + isFetchingNextPage, + hasError: windowError, + }) + ) { + void fetchNextPage(); + } + }, [ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + loadedCount, + structureWanted, + total, + windowError, + ]); + + const visibleColumnConfigs = useMemo( + () => + tableColumns.filter((column) => { + const propertyId = propertyIdFromViewKey(column.key); + return !propertyId || activePropertyIds.has(propertyId); + }), + [activePropertyIds, tableColumns], + ); + + const displayRows = useMemo( + () => + buildIssueTableRows(issues, { + grouping: structureGrouping, + properties, + collapsedGroups: new Set(tableCollapsedGroups), + collapsedParents: new Set(tableCollapsedParents), + hierarchy: structureHierarchy, + windowComplete: !hasNextPage, + getActorName, + getStatusLabel: (status) => t(($) => $.status[status]), + noValueLabel: t(($) => $.table.no_value), + unassignedLabel: t(($) => $.table.unassigned), + trueLabel: t(($) => $.pickers.custom_property.true_label), + falseLabel: t(($) => $.pickers.custom_property.false_label), + }), + [ + getActorName, + hasNextPage, + issues, + properties, + t, + tableCollapsedGroups, + tableCollapsedParents, + structureGrouping, + structureHierarchy, + ], + ); + const visibleIssueIds = useMemo( + () => + displayRows + .filter((row): row is Extract => row.kind === "issue") + .map((row) => row.issue.id), + [displayRows], + ); + const selectedIssues = useMemo( + () => issues.filter((issue) => selection.selectedIds.has(issue.id)), + [issues, selection.selectedIds], + ); + const handleIssueSelection = useCallback( + (issueId: string, shiftKey: boolean) => { + const range = shiftKey + ? getIssueTableSelectionRange( + visibleIssueIds, + selectionAnchorRef.current, + issueId, + ) + : null; + + if (range) { + if (selection.selectedIds.has(issueId)) selection.deselect(range); + else selection.select(range); + return; + } + + selection.toggle(issueId); + selectionAnchorRef.current = issueId; + }, + [selection, visibleIssueIds], + ); + + useEffect(() => { + if (selection.selectedIds.size === 0) selectionAnchorRef.current = null; + }, [selection.selectedIds]); + + const columnLabel = useCallback( + (key: TableColumnKey) => { + const propertyId = propertyIdFromViewKey(key); + if (propertyId) return propertyById.get(propertyId)?.name ?? t(($) => $.table.no_value); + return t(($) => $.table.columns[key as ColumnLabelKey]); + }, + [propertyById, t], + ); + + const updateIssue = useCallback( + (issueId: string, updates: Partial) => + actions?.updateIssue(issueId, updates), + [actions], + ); + + const makeColumn = useCallback( + (config: TableColumnConfig): ColumnDef => { + const propertyId = propertyIdFromViewKey(config.key); + const property = propertyId ? propertyById.get(propertyId) : undefined; + const staticSort = propertyId + ? property && !["multi_select", "checkbox"].includes(property.type) + ? (`property:${propertyId}` as SortField) + : undefined + : SORTABLE_COLUMNS[config.key as TableSystemColumnKey]; + const definition: ColumnDef = { + id: config.key, + minSize: config.key === "title" ? 260 : 96, + maxSize: 640, + enableResizing: true, + header: () => ( + { + setSortBy(field); + setSortDirection(direction); + }} + onHide={ + config.key === "title" + ? undefined + : () => toggleTableColumn(config.key) + } + ascendingLabel={t(($) => $.table.sort_ascending)} + descendingLabel={t(($) => $.table.sort_descending)} + hideLabel={t(($) => $.table.columns.hide)} + reorderLabel={t(($) => $.table.columns.reorder, { + column: columnLabel(config.key), + })} + /> + ), + cell: ({ row }) => { + if (row.original.kind !== "issue") return null; + const issueRow = row.original; + const issue = issueRow.issue; + const onUpdate = (updates: Partial) => + updateIssue(issue.id, updates); + + if (property) { + return ( +
+ +
+ ); + } + switch (config.key) { + case "title": + return ( + + toggleTableParentCollapsed(issue.id) + } + toggleLabel={t(($) => $.table.toggle_sub_issues)} + /> + ); + case "identifier": + return {issue.identifier}; + case "status": + return ( +
+ +
+ ); + case "priority": + return ( +
+ +
+ ); + case "assignee": + return ( +
+ +
+ ); + case "labels": + return ; + case "project": + return ( +
+ } + /> +
+ ); + case "start_date": + return ( +
+ +
+ ); + case "due_date": + return ( +
+ +
+ ); + case "created_at": + case "updated_at": + return ( + + {new Intl.DateTimeFormat(i18n.language, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(issue[config.key]))} + + ); + case "child_progress": { + const progress = childProgressMap.get(issue.id); + return progress ? ( + + + {progress.done}/{progress.total} + + ) : ( + {t(($) => $.table.empty_value)} + ); + } + case "creator": + return ( + + + + {getActorName(issue.creator_type, issue.creator_id)} + + + ); + } + return null; + }, + }; + if (config.width !== undefined) definition.size = config.width; + return definition; + }, + [ + childProgressMap, + columnLabel, + getActorName, + i18n.language, + propertyById, + setSortBy, + setSortDirection, + sortBy, + sortDirection, + t, + toggleTableColumn, + toggleTableParentCollapsed, + updateIssue, + ], + ); + + const columns = useMemo[]>( + () => [ + { + id: SELECT_COLUMN_ID, + size: 44, + minSize: 44, + maxSize: 44, + enableResizing: false, + header: () => ( + $.table.select_all)} + /> + ), + cell: ({ row }) => { + if (row.original.kind !== "issue") return null; + const issue = row.original.issue; + return ( + $.table.select_issue, { + identifier: issue.identifier, + })} + onToggle={(shiftKey) => handleIssueSelection(issue.id, shiftKey)} + /> + ); + }, + }, + ...visibleColumnConfigs.map(makeColumn), + { + id: ADD_COLUMN_ID, + size: 48, + minSize: 48, + maxSize: 48, + enableResizing: false, + header: () => ( + $.table.columns.add)} + className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" + > + + + } + /> + ), + cell: () => null, + }, + ], + [ + handleIssueSelection, + makeColumn, + properties, + selection.selectedIds, + t, + visibleColumnConfigs, + visibleIssueIds, + ], + ); + + const columnSizing = useMemo( + () => + Object.fromEntries( + visibleColumnConfigs + .filter((column) => column.width !== undefined) + .map((column) => [column.key, column.width!]), + ), + [visibleColumnConfigs], + ); + const handleColumnSizingChange = useCallback>( + (updater) => { + const next = typeof updater === "function" ? updater(columnSizing) : updater; + for (const column of visibleColumnConfigs) { + const width = next[column.key]; + if (width !== column.width) setTableColumnWidth(column.key, width); + } + }, + [columnSizing, setTableColumnWidth, visibleColumnConfigs], + ); + + const table = useReactTable({ + data: displayRows, + columns, + getRowId: (row) => row.key, + getCoreRowModel: getCoreRowModel(), + state: { + columnSizing, + columnPinning: { left: [SELECT_COLUMN_ID, "title"], right: [] }, + }, + onColumnSizingChange: handleColumnSizingChange, + columnResizeMode: "onChange", + }); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + reorderTableColumn(active.id as TableColumnKey, over.id as TableColumnKey); + }, + [reorderTableColumn], + ); + + const handleExport = async (mode: "all" | "selected") => { + setExporting(mode); + try { + // Every lookup the file depends on is AWAITED here rather than read + // from render-time hook state — a cold or errored query must fail the + // export, not silently degrade it: with an unsettled catalog the + // user's property columns vanish from the file, and with unsettled + // directories every actor exports as "Unknown *" (round-2 review + // P2#4). fetchQuery throws on failure, which lands in the catch below. + const needsPropertyCatalog = tableColumns.some( + (column) => propertyIdFromViewKey(column.key) !== null, + ); + // fetchQuery bypasses the options' `select`, so unwrap the response. + const exportProperties = needsPropertyCatalog + ? (await queryClient.fetchQuery(propertyListOptions(wsId))).properties + : []; + const exportPropertyById = new Map( + exportProperties.map((property) => [property.id, property]), + ); + // Configured columns against the RESOLVED catalog — only a property + // definition that is genuinely gone (archived/deleted) drops out. + const csvColumns = tableColumns.filter((column) => { + const propertyId = propertyIdFromViewKey(column.key); + return !propertyId || exportPropertyById.has(propertyId); + }); + const needsActors = csvColumns.some( + (column) => column.key === "assignee" || column.key === "creator", + ); + const [rows, exportLookups, exportActorName] = await Promise.all([ + mode === "all" ? exportIssues() : Promise.resolve(selectedIssues), + resolveExportLookups({ + projects: csvColumns.some((column) => column.key === "project"), + childProgress: csvColumns.some( + (column) => column.key === "child_progress", + ), + }), + needsActors + ? Promise.all([ + queryClient.fetchQuery(memberListOptions(wsId)), + queryClient.fetchQuery(agentListOptions(wsId)), + queryClient.fetchQuery(squadListOptions(wsId)), + ]).then(([members, agents, squads]) => + buildActorNameResolver({ members, agents, squads }), + ) + : Promise.resolve(getActorName), + ]); + const headers = csvColumns.map((column) => { + const propertyId = propertyIdFromViewKey(column.key); + if (propertyId) return exportPropertyById.get(propertyId)?.name ?? ""; + return columnLabel(column.key); + }); + const csvRows = rows.map((issue) => + csvColumns.map((column) => { + const propertyId = propertyIdFromViewKey(column.key); + if (propertyId) { + const property = exportPropertyById.get(propertyId); + return property + ? propertyDisplayValue(property, issue.properties[propertyId]) + : ""; + } + switch (column.key) { + case "title": + return issue.title; + case "identifier": + return issue.identifier; + case "status": + return t(($) => $.status[issue.status]); + case "priority": + return t(($) => $.priority[issue.priority]); + case "assignee": + return issue.assignee_type && issue.assignee_id + ? exportActorName(issue.assignee_type, issue.assignee_id) + : ""; + case "labels": + return issue.labels?.map((label) => label.name).join(", ") ?? ""; + case "project": + return issue.project_id + ? exportLookups.projectMap.get(issue.project_id)?.title ?? "" + : ""; + case "start_date": + case "due_date": + return issue[column.key] ?? ""; + case "created_at": + case "updated_at": + return issue[column.key]; + case "child_progress": { + const progress = exportLookups.childProgressMap.get(issue.id); + return progress ? `${progress.done}/${progress.total}` : ""; + } + case "creator": + return exportActorName(issue.creator_type, issue.creator_id); + } + return ""; + }), + ); + const csv = buildIssueTableCsv(headers, csvRows); + const blob = new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + const filenamePrefix = mode === "all" ? "issues" : "issues-selected"; + anchor.download = `${filenamePrefix}-${new Date().toISOString().slice(0, 10)}.csv`; + anchor.click(); + URL.revokeObjectURL(url); + toast.success(t(($) => $.table.export_success, { count: rows.length })); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t(($) => $.table.export_failed), + ); + } finally { + setExporting(null); + } + }; + + const footer = ( + + + + { + // A failed window stops implicit loading too — otherwise every + // visibility transition retries into the same failure. The + // toolbar Retry is the explicit resume path. + if (hasNextPage && !isFetchingNextPage && !windowError) { + void fetchNextPage(); + } + }} + loading={false} + rootMargin="320px" + className="h-px w-px" + /> + + + + ); + + return ( +
+
+ $.table.search_placeholder)} + clearLabel={t(($) => $.table.search_clear)} + /> + + {t(($) => $.table.loaded_count, { count: issues.length, total })} + {windowError && hasNextPage && ( + + )} + {structureSuspended && structureWanted && ( + + {t(($) => $.table.structure_paused, { + total, + limit: TABLE_STRUCTURE_MAX_WINDOW, + })} + + )} + + + + {exporting ? ( + + ) : ( + + )} + {t(($) => $.table.export)} + + + } + /> + + void handleExport("all")}> + + {t(($) => $.table.export_all)} + + void handleExport("selected")} + > + + {t(($) => $.table.export_selected, { + count: selectedIssues.length, + })} + + + +
+ + column.key)} + strategy={horizontalListSortingStrategy} + > + $.table.empty)} + onRowClick={(row) => { + if (row.original.kind === "issue") { + navigation.push(paths.issueDetail(row.original.issue.id)); + } + }} + renderRow={(row) => { + if (row.original.kind !== "group") return null; + return ( + toggleTableGroupCollapsed(row.original.key)} + /> + ); + }} + className="min-h-0 flex-1" + /> + + +
+ ); +} diff --git a/packages/views/issues/components/workspace-agent-working-chip.test.tsx b/packages/views/issues/components/workspace-agent-working-chip.test.tsx index 4043ed1654d..d32ddf5c56a 100644 --- a/packages/views/issues/components/workspace-agent-working-chip.test.tsx +++ b/packages/views/issues/components/workspace-agent-working-chip.test.tsx @@ -202,6 +202,34 @@ describe("WorkspaceAgentWorkingChip", () => { ).toBeTruthy(); }); + it("renders an indeterminate state when the scope is unknown, never a number", () => { + // 3 agents ARE running workspace-wide, but the surface could not resolve + // which of them belong to the current window (table window resolving / + // failed / too large). Publishing any count here — 0 from an empty + // fallback or 3 from the workspace — would be a precise-looking wrong + // answer (round-5 review P2). The chip must say "unknown" instead. + mockState.snapshot = [ + makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }), + makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-2" }), + makeTask({ id: "t-3", agent_id: "agent-3", issue_id: "issue-3" }), + ]; + + renderWithI18n( + {}} + workingIssues={undefined} + />, + ); + + expect( + screen.getByRole("button", { name: "Agents working: —" }), + ).toBeTruthy(); + // No avatar stack: heads next to an unknown label would still read as a + // claim about who is in scope. + expect(mockState.avatarAgentIds).toBeUndefined(); + }); + it("shows 0 when nothing is running", () => { mockState.snapshot = []; diff --git a/packages/views/issues/components/workspace-agent-working-chip.tsx b/packages/views/issues/components/workspace-agent-working-chip.tsx index 8ef3a96e3d0..19e384f1989 100644 --- a/packages/views/issues/components/workspace-agent-working-chip.tsx +++ b/packages/views/issues/components/workspace-agent-working-chip.tsx @@ -29,7 +29,13 @@ interface WorkspaceAgentWorkingChipProps { // an issue this filter would hide is not part of the number. Taking scope // from the render pipeline instead of re-deriving it from the task snapshot // is what keeps that judgement in step with the list (MUL-4884). - workingIssues: readonly Issue[]; + // + // `undefined` means the scope is UNKNOWN — the table's ids-facet window is + // still resolving, failed, or is too large to materialize. The chip then + // shows an indeterminate "—" instead of a number: publishing a count from + // some other, incomplete window would be a precise-looking wrong answer + // (round-5 review P2). The toggle keeps working in that state. + workingIssues: readonly Issue[] | undefined; } export interface WorkingChipView { @@ -147,46 +153,55 @@ export function WorkspaceAgentWorkingChip({ const wsId = useWorkspaceId(); const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId)); + const scopeKnown = workingIssues !== undefined; const view = useMemo( - () => deriveWorkingChipView(snapshot, workingIssues), + () => deriveWorkingChipView(snapshot, workingIssues ?? []), [snapshot, workingIssues], ); // The number and the avatar stack are the same list, so they cannot // disagree. const agentCount = view.agentIds.length; - const hasAgents = agentCount > 0; + const hasAgents = scopeKnown && agentCount > 0; - const label = t(($) => $.agent_activity.chip_agents_working, { - count: agentCount, - }); + const label = scopeKnown + ? t(($) => $.agent_activity.chip_agents_working, { count: agentCount }) + : t(($) => $.agent_activity.chip_agents_working_unknown); // Three tiers: filter ON is the loud filled state, activity without the - // filter is a tint, nothing running is a plain control. + // filter is a tint, nothing running is a plain control. An unknown scope + // uses the plain tier — it makes no activity claim to colour by. const appearance = chipAppearance(value, hasAgents); + const trigger = ( + + ); + + // No hover card while the scope is unknown: there is no issue list to + // show, and an empty card would read as "nothing running" — the exact + // claim the unknown state exists to avoid. + if (!scopeKnown) return trigger; + return ( - - {hasAgents && ( - - )} - {agentCount} - {label} - - } - /> + ({ })); vi.mock("../../i18n", () => ({ - useT: () => ({ t: () => "translated" }), + // TableView also reads `i18n.language` for its date formatting. + useT: () => ({ t: () => "translated", i18n: { language: "en" } }), useTimeAgo: () => () => "now", })); @@ -245,3 +246,113 @@ describe("IssueSurface — scope switch loading semantics", () => { expect(screen.queryByText("WS1 issue")).not.toBeInTheDocument(); }); }); + +describe("IssueSurface — table pagination ownership", () => { + let qc: QueryClient; + let listIssues: ReturnType< + typeof vi.fn<(params?: ListIssuesParams) => Promise> + >; + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + listIssues = vi.fn(() => never()); + pruneIssueSurfaceViewStates([]); + mockWsId.current = "ws-1"; + // jsdom has no IntersectionObserver; the table footer sentinel constructs + // one on mount. A stub that never fires keeps the sentinel inert, so the + // structure loop is the only automatic pagination driver under test. + vi.stubGlobal( + "IntersectionObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + }); + + afterEach(() => { + cleanup(); + qc.clear(); + pruneIssueSurfaceViewStates([]); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("requests each offset exactly once while the agents-working filter is on", async () => { + // With the filter on, the working window IS the main table window (same + // query key). The data hook's chip loop and TableView's structure loop + // used to both answer the same render snapshot with fetchNextPage(), + // and the default cancel/restart semantics doubled every offset's HTTP + // request (round-6 review R1). Ownership now belongs to the structure + // loop alone, and all auto callers use cancelRefetch: false. + const { getIssueSurfaceViewStore } = await import( + "@multica/core/issues/stores/surface-view-store" + ); + const store = getIssueSurfaceViewStore("project:pt"); + store.getState().setViewMode("table"); + if (!store.getState().agentRunningFilter) { + store.getState().toggleAgentRunningFilter(); + } + + const runningIssues = Array.from({ length: 250 }, (_, index) => ({ + ...makeIssue(`run-${index}`, `Running ${index}`, "pt"), + status: "in_progress" as const, + })); + const idsOffsets: number[] = []; + listIssues.mockImplementation((params?: ListIssuesParams) => { + // A PRESENT-but-empty ids facet is the pre-snapshot state and returns + // an empty window (server semantics) — only the real running-set key's + // offsets count toward the uniqueness assertion. + if (params?.ids && params.ids.length > 0) { + const offset = params.offset ?? 0; + idsOffsets.push(offset); + return Promise.resolve({ + issues: runningIssues.slice(offset, offset + 100), + total: runningIssues.length, + }); + } + return Promise.resolve({ issues: [], total: 0 }); + }); + setApiInstance({ + listIssues, + listGroupedIssues: vi.fn(() => never()), + listProjects: vi.fn(() => never()), + getAgentTaskSnapshot: vi.fn(() => + Promise.resolve( + runningIssues.map((issue, index) => ({ + id: `task-${index}`, + agent_id: `agent-${index}`, + issue_id: issue.id, + status: "running", + })) as unknown as AgentTask[], + ), + ), + getChildIssueProgress: vi.fn(() => never()), + listProperties: vi.fn(() => never()), + listMembers: vi.fn(() => never()), + listAgents: vi.fn(() => never()), + listSquads: vi.fn(() => never()), + } as unknown as ApiClient); + + render( + + null} + renderLoading={() =>
} + batchToolbar="never" + /> + , + ); + + // The structure loop (hierarchy is on by default, 250 ≤ ceiling) + // materializes the window without user interaction. + await waitFor(() => expect(idsOffsets).toContain(200), { timeout: 5000 }); + // Let any straggling (buggy) second responder fire before asserting. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect([...idsOffsets].sort((a, b) => a - b)).toEqual([0, 100, 200]); + }); +}); diff --git a/packages/views/issues/surface/issue-surface.tsx b/packages/views/issues/surface/issue-surface.tsx index 5c058f69d6d..ea32f00648c 100644 --- a/packages/views/issues/surface/issue-surface.tsx +++ b/packages/views/issues/surface/issue-surface.tsx @@ -16,6 +16,7 @@ import { GanttView } from "../components/gantt-view"; import { IssuesHeader } from "../components/issues-header"; import { ListView } from "../components/list-view"; import { SwimLaneView } from "../components/swimlane-view"; +import { TableView } from "../components/table-view"; import { useT } from "../../i18n"; import { IssueContextMenuProvider } from "../actions"; import { IssueSurfaceActionsProvider } from "./actions-context"; @@ -31,8 +32,10 @@ export interface IssueSurfaceRenderContext { issues: Issue[]; /** The rows the agents-working filter would leave on screen, with this * surface's `clientFilter` applied — headers feed it to the working chip - * so the chip's count is the post-click row count (MUL-4884). */ - workingIssues: Issue[]; + * so the chip's count is the post-click row count (MUL-4884). Undefined + * means the set is UNKNOWN (table window resolving / failed / too large); + * the chip renders an indeterminate state instead of a number. */ + workingIssues: Issue[] | undefined; } interface IssueSurfaceComponentProps extends IssueSurfaceProps { @@ -121,6 +124,7 @@ function IssueSurfaceContent({ contentClassName, }: Omit) { const { t } = useT("projects"); + const { t: tIssues } = useT("issues"); const controller = useIssueSurfaceController({ scope, modes, @@ -142,9 +146,11 @@ function IssueSurfaceContent({ ); // Same clientFilter the rendered rows go through, so the chip's promise // survives on surfaces that narrow the list locally (e.g. a search box). + // An UNKNOWN scope (undefined) passes through untouched — there is nothing + // to filter and the chip must see it as unknown. const workingIssues = useMemo( () => - clientFilter + clientFilter && controller.workingScopeIssues ? controller.workingScopeIssues.filter((issue) => clientFilter(issue)) : controller.workingScopeIssues, [clientFilter, controller.workingScopeIssues], @@ -174,7 +180,9 @@ function IssueSurfaceContent({ (showClientEmpty ? showClientEmpty(renderContext) : true); const shouldShowBatchToolbar = batchToolbar !== "never" && - (batchToolbar === "always" || controller.viewMode === "list"); + (batchToolbar === "always" || + controller.viewMode === "list" || + controller.viewMode === "table"); return ( @@ -192,6 +200,9 @@ function IssueSurfaceContent({ workingIssues={workingIssues} allowGantt={controller.allowGantt} isRefreshing={controller.isRefreshing} + facetCountsExact={ + !(controller.viewMode === "table" && controller.hasNextFlatPage) + } /> )} {controller.isLoading ? ( @@ -200,6 +211,21 @@ function IssueSurfaceContent({ ) : ( ) + ) : controller.viewMode === "table" && controller.flatWindowColdError ? ( + // A cold-load failure is NOT an empty workspace: rendering the + // create-issue empty state here misreports a 5xx/offline as "no + // issues" and leaves no recovery path, since TableView (and its + // load-more Retry) never mounts without data (round-5 review P2). +
+

{tIssues(($) => $.table.load_failed)}

+ +
) : controller.isEmpty || shouldShowClientEmpty ? ( renderEmpty ? ( renderEmpty(renderContext) @@ -253,6 +279,21 @@ function IssueSurfaceContent({ onCreateIssue={openCreateIssue} /> )} + {controller.viewMode === "table" && ( + + )} {controller.viewMode === "gantt" && ( )} @@ -284,11 +325,18 @@ function IssueSurfaceContent({ } function IssueSurfaceSkeleton({ mode }: { mode: string }) { - if (mode === "list") { + if (mode === "list" || mode === "table") { return ( -
- {Array.from({ length: 4 }).map((_, i) => ( - +
+ {mode === "table" && } + {Array.from({ length: mode === "table" ? 8 : 4 }).map((_, i) => ( + ))}
); diff --git a/packages/views/issues/surface/selection-context.tsx b/packages/views/issues/surface/selection-context.tsx index 2090bf44f59..0f40ab067ab 100644 --- a/packages/views/issues/surface/selection-context.tsx +++ b/packages/views/issues/surface/selection-context.tsx @@ -4,7 +4,6 @@ import { createContext, useCallback, useContext, - useEffect, useMemo, useState, type ReactNode, @@ -27,13 +26,20 @@ export function useCreateIssueSurfaceSelection( resetKey = surfaceKey, ): IssueSurfaceSelection { const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [committedResetKey, setCommittedResetKey] = useState(resetKey); - useEffect(() => { - // Functional bail: on mount (and on resetKey changes where nothing was - // selected) the selection is already empty — swapping in a NEW empty Set - // here re-rendered the entire surface once per mount for nothing. - setSelectedIds((current) => (current.size === 0 ? current : new Set())); - }, [resetKey]); + // Render-phase reset (the React "adjusting state when props change" + // pattern), NOT an effect: an effect runs after commit, so one frame would + // ship the NEW membership (filters/search) with the OLD selection — batch + // consumers could act on rows the window no longer contains during that + // gap. Setting state during render re-renders synchronously before commit, + // so no such frame ever becomes visible. The size guard keeps the common + // nothing-selected key change from allocating a new Set and re-rendering + // the surface. + if (committedResetKey !== resetKey) { + setCommittedResetKey(resetKey); + if (selectedIds.size > 0) setSelectedIds(new Set()); + } const toggle = useCallback((id: string) => { setSelectedIds((current) => { diff --git a/packages/views/issues/surface/types.ts b/packages/views/issues/surface/types.ts index 38b15f4a47b..92ccb6eecb3 100644 --- a/packages/views/issues/surface/types.ts +++ b/packages/views/issues/surface/types.ts @@ -16,7 +16,7 @@ export type IssueCreateDefaults = Partial< export type IssueSurfaceMode = Extract< ViewMode, - "board" | "list" | "swimlane" | "gantt" + "board" | "list" | "table" | "swimlane" | "gantt" >; export interface IssueSurfaceProps { diff --git a/packages/views/issues/surface/use-issue-surface-controller.test.tsx b/packages/views/issues/surface/use-issue-surface-controller.test.tsx index fb00cc8e972..3ec50f2527f 100644 --- a/packages/views/issues/surface/use-issue-surface-controller.test.tsx +++ b/packages/views/issues/surface/use-issue-surface-controller.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { setApiInstance } from "@multica/core/api"; import type { ApiClient } from "@multica/core/api/client"; +import { agentTaskSnapshotOptions } from "@multica/core/agents"; import { issueKeys } from "@multica/core/issues/queries"; import { getIssueSurfaceViewStore, @@ -361,10 +362,11 @@ describe("useIssueSurfaceController", () => { store.getState().setViewMode("board"); }); - await waitFor(() => { - expect(result.current.viewMode).toBe("board"); - expect(result.current.selection.selectedIds).toEqual(new Set()); - }); + // Synchronous on purpose: the reset happens during render (not in an + // effect), so no committed frame pairs the new view with the old + // selection. + expect(result.current.viewMode).toBe("board"); + expect(result.current.selection.selectedIds).toEqual(new Set()); }); it("delegates movement through useUpdateIssue without rewriting the mutation path", () => { @@ -487,6 +489,384 @@ describe("useIssueSurfaceController", () => { await waitFor(() => expect(result.current.isRefreshing).toBe(false)); }); + it("debounces table search and sends it with the server-side flat window", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + listIssues.mockResolvedValue({ issues: [], total: 0 }); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + listIssues.mockClear(); + + act(() => result.current.setTableSearch(" Release train ")); + + expect(result.current.tableSearch).toBe(" Release train "); + expect(listIssues).not.toHaveBeenCalledWith( + expect.objectContaining({ q: "Release train" }), + ); + + await waitFor(() => + expect(listIssues).toHaveBeenCalledWith( + expect.objectContaining({ + project_id: "p1", + q: "Release train", + limit: 100, + offset: 0, + }), + ), + ); + expect(result.current.isEmpty).toBe(false); + }); + + it("sends the agents-working filter as a server ids facet so later pages can match", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + store.getState().toggleAgentRunningFilter(); + listIssues.mockResolvedValue({ issues: [], total: 0 }); + setApiInstance({ + listIssues, + listGroupedIssues: vi.fn(() => never()), + listProjects: vi.fn(() => never()), + getAgentTaskSnapshot: vi.fn(() => + Promise.resolve([ + { id: "task-1", issue_id: "issue-running", status: "running" }, + ] as unknown as AgentTask[]), + ), + getChildIssueProgress: vi.fn(() => never()), + } as unknown as ApiClient); + + renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + // Before the task snapshot lands the running set is empty — the facet + // must still be PRESENT (ids: []) so the server returns an empty window + // instead of every issue. + await waitFor(() => + expect(listIssues).toHaveBeenCalledWith( + expect.objectContaining({ project_id: "p1", ids: [] }), + ), + ); + // Once the snapshot resolves, the window re-keys to the running set. + await waitFor(() => + expect(listIssues).toHaveBeenCalledWith( + expect.objectContaining({ project_id: "p1", ids: ["issue-running"] }), + ), + ); + }); + + it("resolves the working-chip scope from the ids window even while the filter is off", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + const running = makeIssue({ id: "issue-running", status: "in_progress" }); + // The running issue lives beyond the loaded page (main window returns + // nothing); only the ids-facet query can see it. A chip scoped to loaded + // rows would report 0 here while the filter itself would find the issue. + listIssues.mockImplementation((params?: ListIssuesParams) => + Promise.resolve( + params?.ids + ? { issues: [running], total: 1 } + : { issues: [], total: 0 }, + ), + ); + setApiInstance({ + listIssues, + listGroupedIssues: vi.fn(() => never()), + listProjects: vi.fn(() => never()), + getAgentTaskSnapshot: vi.fn(() => + Promise.resolve([ + { id: "task-1", issue_id: "issue-running", status: "running" }, + ] as unknown as AgentTask[]), + ), + getChildIssueProgress: vi.fn(() => never()), + } as unknown as ApiClient); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + await waitFor(() => { + expect( + result.current.workingScopeIssues?.map((issue) => issue.id), + ).toEqual(["issue-running"]); + }); + // The main table window itself stayed unrestricted — the filter is off. + expect(listIssues).toHaveBeenCalledWith( + expect.objectContaining({ project_id: "p1", limit: 100, offset: 0 }), + ); + }); + + it("reports the LATEST page's total so a stale first page cannot re-open the structure ceiling", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + // page 1 claims a small window (under the ceiling); by page 2 the real + // window has grown far beyond it. The ceiling check must see the fresh + // total — pagination itself already advances on it. + listIssues.mockImplementation((params?: ListIssuesParams) => + Promise.resolve( + (params?.offset ?? 0) === 0 + ? { + issues: [ + makeIssue({ id: "i-1", status: "todo" }), + makeIssue({ id: "i-2", status: "todo" }), + ], + total: 900, + } + : { issues: [makeIssue({ id: "i-3", status: "todo" })], total: 50_000 }, + ), + ); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + await waitFor(() => expect(result.current.flatTotal).toBe(900)); + await act(async () => { + await result.current.fetchNextFlatPage(); + }); + await waitFor(() => expect(result.current.flatTotal).toBe(50_000)); + }); + + it("materializes the working window past page one so the chip scope is complete", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + // 101 running issues spread over two pages: presenting page 1 alone as + // the authoritative scope makes the chip under-count until the filter is + // toggled (round-4 review P2#3) — the bounded loop must fetch page 2 by + // itself, and only a COMPLETE window is treated as authoritative. + const runningIssues = Array.from({ length: 101 }, (_, index) => + makeIssue({ id: `run-${index}`, status: "in_progress" }), + ); + listIssues.mockImplementation((params?: ListIssuesParams) => { + if (params?.ids) { + const offset = params.offset ?? 0; + return Promise.resolve({ + issues: runningIssues.slice(offset, offset + 100), + total: runningIssues.length, + }); + } + return Promise.resolve({ issues: [], total: 0 }); + }); + setApiInstance({ + listIssues, + listGroupedIssues: vi.fn(() => never()), + listProjects: vi.fn(() => never()), + getAgentTaskSnapshot: vi.fn(() => + Promise.resolve( + runningIssues.map((issue, index) => ({ + id: `task-${index}`, + issue_id: issue.id, + status: "running", + })) as unknown as AgentTask[], + ), + ), + getChildIssueProgress: vi.fn(() => never()), + } as unknown as ApiClient); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + await waitFor(() => { + expect(result.current.workingScopeIssues).toHaveLength(101); + }); + }); + + it("never materializes an over-ceiling working window and presents its scope as unknown", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + // The working window shares the MAIN table cache key while the filter is + // on, so an uncapped chip-driven loop would re-open the very ceiling the + // structure loop enforces (round-5 review P1). An over-ceiling window + // must stop after page 1 — and the chip must see UNKNOWN, not a number + // built from one page. + const runningIssues = Array.from({ length: 100 }, (_, index) => + makeIssue({ id: `run-${index}`, status: "in_progress" }), + ); + const idsCalls: Array = []; + listIssues.mockImplementation((params?: ListIssuesParams) => { + if (params?.ids) { + idsCalls.push(params.offset); + return Promise.resolve({ issues: runningIssues, total: 5_000 }); + } + return Promise.resolve({ issues: [], total: 0 }); + }); + setApiInstance({ + listIssues, + listGroupedIssues: vi.fn(() => never()), + listProjects: vi.fn(() => never()), + getAgentTaskSnapshot: vi.fn(() => + Promise.resolve( + runningIssues.map((issue, index) => ({ + id: `task-${index}`, + issue_id: issue.id, + status: "running", + })) as unknown as AgentTask[], + ), + ), + getChildIssueProgress: vi.fn(() => never()), + } as unknown as ApiClient); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + await waitFor(() => expect(idsCalls.length).toBeGreaterThan(0)); + // Give any (buggy) auto-loop a chance to fire before asserting. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(idsCalls).toEqual([0]); + expect(result.current.workingScopeIssues).toBeUndefined(); + }); + + it("presents the scope as unknown while a re-keyed working window is still resolving", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + // Running set A resolves to a complete window; then the set changes to B + // and B's request stays pending. keepPreviousData leaves A's rows as + // PLACEHOLDER under the new key — publishing them (paired with the new + // snapshot) would be a precise-looking number for a scope nobody fetched + // (round-6 review P2#1). Until B resolves, the scope must be unknown. + const issueA = makeIssue({ id: "run-A", status: "in_progress" }); + let snapshotIssueId = "run-A"; + listIssues.mockImplementation((params?: ListIssuesParams) => { + if (params?.ids?.includes("run-A")) { + return Promise.resolve({ issues: [issueA], total: 1 }); + } + if (params?.ids) return never(); + return Promise.resolve({ issues: [], total: 0 }); + }); + setApiInstance({ + listIssues, + listGroupedIssues: vi.fn(() => never()), + listProjects: vi.fn(() => never()), + getAgentTaskSnapshot: vi.fn(() => + Promise.resolve([ + { + id: "task-1", + issue_id: snapshotIssueId, + status: "running", + }, + ] as unknown as AgentTask[]), + ), + getChildIssueProgress: vi.fn(() => never()), + } as unknown as ApiClient); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + await waitFor(() => { + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual([ + "run-A", + ]); + }); + + // The running set moves to B; B's ids window never resolves in this test. + snapshotIssueId = "run-B"; + await act(async () => { + await qc.invalidateQueries({ + queryKey: agentTaskSnapshotOptions("ws-1").queryKey, + }); + }); + + await waitFor(() => { + expect(result.current.workingScopeIssues).toBeUndefined(); + }); + }); + + it("treats a cold-load failure as an error state, not an empty workspace", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("table"); + listIssues.mockRejectedValue(new Error("boom")); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["table"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + await waitFor(() => expect(result.current.flatWindowColdError).toBe(true)); + // A failed fetch proves nothing about the window: claiming empty here + // swaps a 5xx/offline for a "create your first issue" screen (round-5 + // review P2). + expect(result.current.isEmpty).toBe(false); + expect(result.current.flatWindowError).toBe(true); + }); + + it("clears surface selection when the membership window changes (filters, search)", async () => { + const store = getIssueSurfaceViewStore("project:p1"); + store.getState().setViewMode("list"); + listIssues.mockResolvedValue({ issues: [], total: 0 }); + + const { result } = renderHook( + () => + useIssueSurfaceController({ + scope: { type: "project", projectId: "p1" }, + modes: ["list"], + }), + { wrapper: makeWrapper(qc, "project:p1") }, + ); + + act(() => { + result.current.selection.select(["issue-1"]); + }); + expect(result.current.selection.selectedIds).toEqual(new Set(["issue-1"])); + + // Batch actions mutate raw selected ids while export/common-field + // consumers intersect with visible rows — a selection surviving a + // membership change would let the same "1 selected" mean different sets. + // Asserted synchronously: the reset is render-phase, so not even one + // frame commits the new membership with the old selection. + act(() => { + store.getState().toggleStatusFilter("todo"); + }); + + expect(result.current.selection.selectedIds).toEqual(new Set()); + }); + it("still reports isEmpty for the full-window modes when the list is empty", async () => { listIssues.mockResolvedValue({ issues: [], total: 0 }); @@ -653,13 +1033,13 @@ describe("useIssueSurfaceController", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => - expect(result.current.workingScopeIssues.length).toBe(2), + expect(result.current.workingScopeIssues?.length).toBe(2), ); // The scope the chip counts agents within must be the rendered list // itself — that identity is what keeps the chip in step with the filter // (the chip's own number counts agents, not these rows). - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual( + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual( result.current.issues.map((i) => i.id), ); expect(result.current.issues.map((i) => i.id).sort()).toEqual([ @@ -690,13 +1070,13 @@ describe("useIssueSurfaceController", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => - expect(result.current.workingScopeIssues.length).toBe(1), + expect(result.current.workingScopeIssues?.length).toBe(1), ); // Filter off: the list still shows both rows, but the chip already // reports the one row a click would leave. expect(result.current.issues).toHaveLength(2); - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual([ + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual([ "todo-1", ]); }); @@ -727,12 +1107,12 @@ describe("useIssueSurfaceController", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => - expect(result.current.workingScopeIssues.length).toBe(1), + expect(result.current.workingScopeIssues?.length).toBe(1), ); // The regression: the chip used to say 2 here (both issues have running // agents) while clicking it produced a single row. - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual([ + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual([ "todo-1", ]); }); @@ -790,10 +1170,10 @@ describe("useIssueSurfaceController", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => - expect(result.current.workingScopeIssues.length).toBe(1), + expect(result.current.workingScopeIssues?.length).toBe(1), ); - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual([ + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual([ "todo-1", ]); }); @@ -828,10 +1208,10 @@ describe("useIssueSurfaceController", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => - expect(result.current.workingScopeIssues.length).toBe(1), + expect(result.current.workingScopeIssues?.length).toBe(1), ); - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual([ + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual([ "todo-1", ]); expect(result.current.issues.map((i) => i.id)).toEqual(["todo-1"]); @@ -903,10 +1283,10 @@ describe("useIssueSurfaceController", () => { // ganttShowCompleted defaults to false, so the done row and the undated // row are not drawn — the chip must not count them either. - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual([ + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual([ "gantt-open", ]); - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual( + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual( result.current.filteredGanttIssues.map((i) => i.id), ); }); @@ -940,11 +1320,11 @@ describe("useIssueSurfaceController", () => { // The done row is drawn now, so it counts. The undated one still cannot // be placed, so it still does not. - expect(result.current.workingScopeIssues.map((i) => i.id).sort()).toEqual([ + expect(result.current.workingScopeIssues?.map((i) => i.id).sort()).toEqual([ "gantt-done", "gantt-open", ]); - expect(result.current.workingScopeIssues.map((i) => i.id)).toEqual( + expect(result.current.workingScopeIssues?.map((i) => i.id)).toEqual( result.current.filteredGanttIssues.map((i) => i.id), ); }); diff --git a/packages/views/issues/surface/use-issue-surface-controller.ts b/packages/views/issues/surface/use-issue-surface-controller.ts index 5484e77b980..3690ca750c0 100644 --- a/packages/views/issues/surface/use-issue-surface-controller.ts +++ b/packages/views/issues/surface/use-issue-surface-controller.ts @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query"; import type { Issue, @@ -13,6 +13,7 @@ import { useWorkspaceId } from "@multica/core/hooks"; import { dateOnlyToLocalDate } from "@multica/core/issues/date"; import type { AssigneeGroupedIssuesFilter, + IssueFlatFilter, IssueSortParam, MyIssuesFilter, } from "@multica/core/issues/queries"; @@ -21,6 +22,7 @@ import { type IssueSurfaceQueryPlan, } from "@multica/core/issues/surface/query-plan"; import type { IssueScope } from "@multica/core/issues/surface/scope"; +import { issueSurfaceFlatExportOptions } from "@multica/core/issues/surface/repository"; import type { IssueDateFilter, SortField } from "@multica/core/issues/stores/view-store"; import { propertyListOptions } from "@multica/core/properties"; import { propertyIdFromViewKey } from "@multica/core/issues/stores/view-store"; @@ -28,7 +30,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context"; import type { IssueFilters } from "../utils/filter"; import type { ChildProgress } from "../components/list-row"; import type { IssueSurfaceMode } from "./types"; -import type { IssueSurfaceActivity } from "./activity"; +import { useIssueSurfaceActivity, type IssueSurfaceActivity } from "./activity"; import type { IssueSurfaceActions } from "./actions-context"; import { type IssueSurfaceSelection, @@ -59,7 +61,8 @@ export interface IssueSurfaceController { swimlaneIssues: Issue[]; /** The rows the agents-working filter would leave on screen. Feeds the * header chip so its count IS the post-click row count (MUL-4884). */ - workingScopeIssues: Issue[]; + /** See IssueSurfaceData.workingScopeIssues — undefined means UNKNOWN. */ + workingScopeIssues: Issue[] | undefined; filteredGanttIssues: Issue[]; assigneeGroups?: IssueAssigneeGroup[]; assigneeGroupQueryKey?: QueryKey; @@ -77,6 +80,26 @@ export interface IssueSurfaceController { selection: IssueSurfaceSelection; childProgressMap: Map; projectMap: Map; + resolveTableExportLookups: (needs: { + projects: boolean; + childProgress: boolean; + }) => Promise<{ + projectMap: Map; + childProgressMap: Map; + }>; + fetchNextFlatPage: () => Promise; + hasNextFlatPage: boolean; + isFetchingNextFlatPage: boolean; + flatTotal: number; + /** See IssueSurfaceData.flatWindowError. */ + flatWindowError: boolean; + /** See IssueSurfaceData.flatWindowColdError. */ + flatWindowColdError: boolean; + /** See IssueSurfaceData.refetchFlatWindow. */ + refetchFlatWindow: () => Promise; + tableSearch: string; + setTableSearch: (query: string) => void; + exportTableIssues: () => Promise; isLoading: boolean; /** See IssueSurfaceData.isRefreshing — placeholder-backed revalidation. */ isRefreshing: boolean; @@ -108,12 +131,27 @@ function issueDateFilterToApiParams(filter: IssueDateFilter | null) { }; } +function useDebouncedTableSearch(value: string, delayMs = 250) { + const [debouncedValue, setDebouncedValue] = useState(value.trim()); + + useEffect(() => { + const timer = window.setTimeout( + () => setDebouncedValue(value.trim()), + delayMs, + ); + return () => window.clearTimeout(timer); + }, [delayMs, value]); + + return debouncedValue; +} + export function useIssueSurfaceController({ scope, modes, createDefaults, }: UseIssueSurfaceControllerInput): IssueSurfaceController { const wsId = useWorkspaceId(); + const queryClient = useQueryClient(); const queryPlan = useMemo( () => buildIssueSurfaceQueryPlan(scope), [scope], @@ -141,6 +179,9 @@ export function useIssueSurfaceController({ const ganttShowCompleted = useViewStore((s) => s.ganttShowCompleted); const cardProperties = useViewStore((s) => s.cardProperties); const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping); + const tableColumns = useViewStore((s) => s.tableColumns); + const [tableSearch, setTableSearch] = useState(""); + const debouncedTableSearch = useDebouncedTableSearch(tableSearch); const allowedModes = useMemo(() => new Set(modes), [modes]); const fallbackMode = modes[0] ?? "list"; @@ -212,14 +253,10 @@ export function useIssueSurfaceController({ }; }, [dateParams, effectivePropertyFilters, propertySortId, rawPropertySortId, sortBy, sortDirection]); - const selection = useCreateIssueSurfaceSelection( - scopeKey, - `${scopeKey}:${effectiveViewMode}`, - ); - const usesAssigneeBoard = effectiveViewMode === "board" && grouping === "assignee"; const usesGantt = effectiveViewMode === "gantt" && !!projectId; + const usesTable = effectiveViewMode === "table"; const projectFilterState = useMemo( () => ({ @@ -231,14 +268,121 @@ export function useIssueSurfaceController({ const { projectFilters: viewProjectFilters, includeNoProject: viewIncludeNoProject } = projectFilterState; + // The agents-working filter is a live client-side signal (WS-driven task + // snapshot), but the table window is server-paginated — filtering loaded + // pages would permanently hide matches on unfetched pages (round-2 review + // P1#1). Send the running set as a server `ids` facet instead: total, + // pagination, and export all see the same window, and snapshot changes + // re-key the query. An EMPTY set is sent as an empty facet (empty window), + // never dropped. + const activity = useIssueSurfaceActivity(); + const sortedRunningIds = useMemo( + () => [...activity.runningIssueIds].sort(), + [activity.runningIssueIds], + ); + + const baseTableFacets = useMemo( + () => ({ + ...(debouncedTableSearch ? { q: debouncedTableSearch } : {}), + ...(statusFilters.length > 0 ? { statuses: statusFilters } : {}), + ...(priorityFilters.length > 0 ? { priorities: priorityFilters } : {}), + ...(assigneeFilters.length > 0 + ? { assignee_filters: assigneeFilters } + : {}), + ...(includeNoAssignee ? { include_no_assignee: true } : {}), + ...(creatorFilters.length > 0 + ? { creator_filters: creatorFilters } + : {}), + ...(viewProjectFilters.length > 0 + ? { project_ids: viewProjectFilters } + : {}), + ...(viewIncludeNoProject ? { include_no_project: true } : {}), + ...(labelFilters.length > 0 ? { label_ids: labelFilters } : {}), + ...(showSubIssues === false ? { top_level_only: true } : {}), + }), + [ + assigneeFilters, + creatorFilters, + debouncedTableSearch, + includeNoAssignee, + labelFilters, + priorityFilters, + showSubIssues, + statusFilters, + viewIncludeNoProject, + viewProjectFilters, + ], + ); + // The running-restricted variant of the window. It IS the table window + // while the filter is on; while the filter is off the data hook still + // subscribes to it (same query key, so toggling the filter hits a warm + // cache) to give the working chip its authoritative in-window count — + // deriving that count from loaded rows says "0 working" whenever the only + // running issue sits on an unfetched page (round-3 review P2#3). + const workingFacets = useMemo( + () => ({ ...baseTableFacets, ids: sortedRunningIds }), + [baseTableFacets, sortedRunningIds], + ); + const tableFacets = agentRunningFilter ? workingFacets : baseTableFacets; + + // Selection is only meaningful within the current membership window: batch + // actions act on selected ids while export/common-field consumers intersect + // with visible rows, so a selection that survives a membership change lets + // "1 selected" mean different sets to different consumers (round-2 review + // P1#2). Reset whenever any membership-affecting input changes. Sort is + // excluded on purpose — reordering does not change membership. The live + // running set is also excluded: while the agents-working filter is on, a + // task finishing should not wipe the user's selection mid-action. + const membershipKey = useMemo( + () => + JSON.stringify([ + statusFilters, + priorityFilters, + assigneeFilters, + includeNoAssignee, + creatorFilters, + viewProjectFilters, + viewIncludeNoProject, + labelFilters, + effectivePropertyFilters, + agentRunningFilter, + showSubIssues, + dateParams, + debouncedTableSearch, + ]), + [ + agentRunningFilter, + assigneeFilters, + creatorFilters, + dateParams, + debouncedTableSearch, + effectivePropertyFilters, + includeNoAssignee, + labelFilters, + priorityFilters, + showSubIssues, + statusFilters, + viewIncludeNoProject, + viewProjectFilters, + ], + ); + const selection = useCreateIssueSurfaceSelection( + scopeKey, + `${scopeKey}:${effectiveViewMode}:${membershipKey}`, + ); + const data = useIssueSurfaceData({ wsId, queryPlan, projectId, usesAssigneeBoard, usesGantt, + usesTable, ganttShowCompleted, sort, + tableFacets, + workingFacets, + activity, statusFilters, priorityFilters, assigneeFilters, @@ -252,24 +396,44 @@ export function useIssueSurfaceController({ showSubIssues, loadProjects: cardProperties.project || + (usesTable && tableColumns.some((column) => column.key === "project")) || (effectiveViewMode === "swimlane" && swimlaneGrouping === "project"), }); + const exportTableIssues = useCallback(async () => { + const exportIssues = await queryClient.fetchQuery( + issueSurfaceFlatExportOptions(wsId, queryPlan, sort, tableFacets), + ); + return data.filterIssuesForExport(exportIssues); + }, [data, queryClient, queryPlan, sort, tableFacets, wsId]); + const { actions, openCreateIssue, moveIssue } = useIssueSurfaceActions({ createDefaults: resolvedCreateDefaults, }); + const { filterIssuesForExport: _filterIssuesForExport, ...surfaceData } = data; + return { scopeKey, projectId, createDefaults: resolvedCreateDefaults, viewMode: effectiveViewMode, allowGantt: allowedModes.has("gantt") && !!projectId, - ...data, + ...surfaceData, + // Keep TableView mounted for an empty search result so its local search + // control remains available to refine or clear the query. Include the + // debounced value as well to avoid a brief empty-screen flash while a + // cleared query is waiting to re-fetch the unsearched window. + isEmpty: + surfaceData.isEmpty && + !(usesTable && (tableSearch.trim() || debouncedTableSearch)), sort, actions, selection, + tableSearch, + setTableSearch, openCreateIssue, moveIssue, + exportTableIssues, }; } diff --git a/packages/views/issues/surface/use-issue-surface-data.ts b/packages/views/issues/surface/use-issue-surface-data.ts index 5cb874cb237..44073703471 100644 --- a/packages/views/issues/surface/use-issue-surface-data.ts +++ b/packages/views/issues/surface/use-issue-surface-data.ts @@ -1,18 +1,24 @@ "use client"; -import { useMemo } from "react"; -import { useQuery, type QueryKey } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo } from "react"; +import { + useInfiniteQuery, + useQuery, + type QueryKey, +} from "@tanstack/react-query"; import type { Issue, IssueAssigneeGroup, Project } from "@multica/core/types"; import { ALL_STATUSES } from "@multica/core/issues/config"; import { projectListOptions } from "@multica/core/projects/queries"; import { childIssueProgressOptions, type AssigneeGroupedIssuesFilter, + type IssueFlatFilter, type IssueSortParam, type MyIssuesFilter, } from "@multica/core/issues/queries"; import { issueSurfaceAssigneeGroupsOptions, + issueSurfaceFlatOptions, issueSurfaceGanttOptions, issueSurfaceListOptions, } from "@multica/core/issues/surface/repository"; @@ -24,11 +30,9 @@ import { type IssueFilterState, type IssueFilters, } from "../utils/filter"; +import { shouldAutoLoadNextWindowPage } from "../components/table-view-model"; import type { ChildProgress } from "../components/list-row"; -import { - useIssueSurfaceActivity, - type IssueSurfaceActivity, -} from "./activity"; +import type { IssueSurfaceActivity } from "./activity"; const EMPTY_ISSUES: Issue[] = []; const EMPTY_CHILD_PROGRESS = new Map(); @@ -61,10 +65,14 @@ export interface IssueSurfaceData { projectIssues: Issue[]; issues: Issue[]; swimlaneIssues: Issue[]; - /** The rows the agents-working filter would leave on screen. See the - * `workingScopeIssues` memo for why this is a projection of the render - * pipeline rather than a second derivation from the task snapshot. */ - workingScopeIssues: Issue[]; + /** The rows the agents-working filter would leave on screen — or + * `undefined` when that set is genuinely UNKNOWN (table mode while the + * ids-facet window is still resolving, failed, or too large to + * materialize). Consumers must present unknown as unknown; substituting + * another incomplete window would publish a precise-looking wrong number + * (round-5 review P2). See the `workingScopeIssues` memo for why the known + * case is a projection of the render pipeline. */ + workingScopeIssues: Issue[] | undefined; filteredGanttIssues: Issue[]; assigneeGroups?: IssueAssigneeGroup[]; assigneeGroupQueryKey?: QueryKey; @@ -79,6 +87,30 @@ export interface IssueSurfaceData { activity: IssueSurfaceActivity; childProgressMap: Map; projectMap: Map; + resolveTableExportLookups: (needs: { + projects: boolean; + childProgress: boolean; + }) => Promise<{ + projectMap: Map; + childProgressMap: Map; + }>; + fetchNextFlatPage: () => Promise; + hasNextFlatPage: boolean; + isFetchingNextFlatPage: boolean; + flatTotal: number; + /** The flat window query is in error state (initial or next-page fetch, + * retries exhausted). Auto-advance loops MUST stop on this — re-firing + * after every failed attempt is a request storm — and surface an explicit + * Retry instead. */ + flatWindowError: boolean; + /** The flat window failed before producing ANY data (cold load, retries + * exhausted). This is NOT an empty workspace: isEmpty stays false and the + * surface must render an error state with a Retry instead of the + * create-issue empty state (round-5 review P2). */ + flatWindowColdError: boolean; + /** Explicit recovery for flatWindowColdError — refetches the flat window. */ + refetchFlatWindow: () => Promise; + filterIssuesForExport: (issues: Issue[]) => Issue[]; isLoading: boolean; /** The window's data is being revalidated while the previous snapshot is * shown as a placeholder (sort/date change, or any grouped-board filter @@ -94,8 +126,12 @@ export function useIssueSurfaceData({ projectId, usesAssigneeBoard, usesGantt, + usesTable, ganttShowCompleted, sort, + tableFacets, + workingFacets, + activity, statusFilters, priorityFilters, assigneeFilters, @@ -114,10 +150,19 @@ export function useIssueSurfaceData({ projectId?: string; usesAssigneeBoard: boolean; usesGantt: boolean; + usesTable: boolean; /** Gantt's "show completed" display toggle. The canvas hides done/cancelled * rows without it, so the working scope has to honour it too. */ ganttShowCompleted: boolean; sort: IssueSortParam; + tableFacets: IssueFlatFilter; + /** tableFacets restricted to the running set (ids facet) — the working + * chip's authoritative scope, and the table window itself while the + * agents-working filter is on (identical query key in that state). */ + workingFacets: IssueFlatFilter; + /** Owned by the controller so the agents-working facet and the client + * display filters read the same task snapshot. */ + activity: IssueSurfaceActivity; statusFilters: IssueStatus[]; priorityFilters: IssueFilterState["priorityFilters"]; assigneeFilters: IssueFilterState["assigneeFilters"]; @@ -131,7 +176,6 @@ export function useIssueSurfaceData({ showSubIssues: boolean; loadProjects: boolean; }): IssueSurfaceData { - const activity = useIssueSurfaceActivity(); const filterContext = useMemo( () => ({ activityByIssueId: activity.activityByIssueId }), [activity.activityByIssueId], @@ -171,7 +215,7 @@ export function useIssueSurfaceData({ const statusIssuesQuery = useQuery({ ...issueSurfaceListOptions(wsId, queryPlan, sort), - enabled: !usesAssigneeBoard && !usesGantt, + enabled: !usesAssigneeBoard && !usesGantt && !usesTable, }); const assigneeGroupsQuery = useQuery({ ...activeAssigneeGroupsOptions, @@ -181,7 +225,97 @@ export function useIssueSurfaceData({ ...issueSurfaceGanttOptions(wsId, projectId ?? ""), enabled: usesGantt, }); + const flatIssuesQuery = useInfiniteQuery({ + ...issueSurfaceFlatOptions(wsId, queryPlan, sort, tableFacets), + enabled: usesTable, + }); + + const flatIssues = useMemo( + () => + flatIssuesQuery.data?.pages.flatMap((page) => page.issues) ?? + EMPTY_ISSUES, + [flatIssuesQuery.data?.pages], + ); + const { fetchNextPage: fetchNextFlatPage } = flatIssuesQuery; + const fetchNextFlatPageNoCancel = useCallback( + () => fetchNextFlatPage({ cancelRefetch: false }), + [fetchNextFlatPage], + ); + // The LATEST page's total, not page 1's: totals drift while concurrent + // writes land, and the pagination protocol itself advances on the latest + // page's total — a consumer holding page 1's stale (smaller) total while + // hasNextPage kept advancing is how the structure ceiling stopped being a + // hard limit (round-4 review P1#2). + const flatPages = flatIssuesQuery.data?.pages; + const flatTotal = flatPages?.[flatPages.length - 1]?.total ?? 0; + // Running-restricted window — the table branch of the workingScopeIssues + // projection below. While the agents-working filter is on this observer + // shares the main flat query's key (one fetch); while it is off, it keeps + // the filter's window warm and gives the chip its authoritative scope. + const hasRunningIssues = activity.runningIssueIds.size > 0; + const workingWindowEnabled = usesTable && hasRunningIssues; + const workingWindowQuery = useInfiniteQuery({ + ...issueSurfaceFlatOptions(wsId, queryPlan, sort, workingFacets), + enabled: workingWindowEnabled, + }); + // Materialize the running window ONLY under the same hard gates as the + // structure loop — while the agents-working filter is on this query IS the + // main table window (shared key), so an ungated chip-driven loop would + // stuff the main cache past the very ceiling TableView just enforced, and + // the running set has no server-side size bound (round-5 review P1). Under + // the gates the loop is bounded: an over-ceiling window stops after page 1 + // (fresh total > ceiling) and presents as UNKNOWN instead of a number; an + // error stops the loop the same way. + // + // Ownership: this effect drives the query ONLY while the filter is OFF + // (background chip scope). Once the filter is on, the shared query already + // has pagination owners — TableView's structure loop and the scroll + // sentinel — and a second responder issuing fetchNextPage() from the same + // render snapshot cancel/restarts the first one's fetch. The abandoned + // HTTP request is not abortable (the queryFn does not thread AbortSignal), + // so every offset would be requested twice (round-6 review R1). + const { + hasNextPage: workingWindowHasNext, + isFetchingNextPage: workingWindowFetchingNext, + isError: workingWindowError, + isPlaceholderData: workingWindowIsPlaceholder, + fetchNextPage: fetchNextWorkingWindowPage, + } = workingWindowQuery; + const workingWindowPages = workingWindowQuery.data?.pages; + const workingWindowTotal = + workingWindowPages?.[workingWindowPages.length - 1]?.total ?? 0; + const workingWindowLoaded = useMemo( + () => + workingWindowPages?.reduce((count, page) => count + page.issues.length, 0) ?? + 0, + [workingWindowPages], + ); + useEffect(() => { + if ( + shouldAutoLoadNextWindowPage({ + windowWanted: workingWindowEnabled && !agentRunningFilter, + total: workingWindowTotal, + loadedCount: workingWindowLoaded, + hasNextPage: workingWindowHasNext, + isFetchingNextPage: workingWindowFetchingNext, + hasError: workingWindowError, + }) + ) { + // cancelRefetch: false — if some other observer already has a fetch in + // flight for this query, do nothing rather than cancel/restart it. + void fetchNextWorkingWindowPage({ cancelRefetch: false }); + } + }, [ + agentRunningFilter, + fetchNextWorkingWindowPage, + workingWindowEnabled, + workingWindowError, + workingWindowFetchingNext, + workingWindowHasNext, + workingWindowLoaded, + workingWindowTotal, + ]); const bucketedIssues = useMemo(() => { return usesAssigneeBoard ? (assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? []) @@ -194,7 +328,11 @@ export function useIssueSurfaceData({ // isEmpty check. The status filter narrows this set like any other status — // it no longer unlocks an otherwise-hidden bucket. const ganttIssues = ganttIssuesQuery.data ?? EMPTY_ISSUES; - const surfaceIssues = usesGantt ? ganttIssues : bucketedIssues; + const surfaceIssues = usesGantt + ? ganttIssues + : usesTable + ? flatIssues + : bucketedIssues; const baseFilterState = useMemo( () => ({ @@ -229,6 +367,11 @@ export function useIssueSurfaceData({ () => applyIssueFilters(surfaceIssues, baseFilterState, filterContext), [baseFilterState, filterContext, surfaceIssues], ); + const filterIssuesForExport = useCallback( + (exportIssues: Issue[]) => + applyIssueFilters(exportIssues, baseFilterState, filterContext), + [baseFilterState, filterContext], + ); const statuslessFilterState = useMemo( () => ({ @@ -294,6 +437,10 @@ export function useIssueSurfaceData({ // IssueSurface renders: // - gantt → the canvas set (scheduled + dated + showCompleted) // - assignee board → the grouped response, not the flat list + // - table → the ids-facet window (the query the filter itself + // runs) — the table's offset pages are only a SLICE of its window, so + // a loaded-rows projection says "nothing working" whenever the running + // issues sit on unfetched pages (round-3 review P2#3) // - board / list / swimlane → the flat filtered list // // Swimlane deliberately has no branch: SwimLaneView draws its cards from @@ -321,6 +468,35 @@ export function useIssueSurfaceData({ }) ?? [] ).flatMap((group) => group.issues); } + if (usesTable) { + // The table's loaded pages are only a SLICE of its window, so no + // fallback to them can honestly claim a precise working set. The scope + // is either the COMPLETE ids-facet window (fetched to the end for THIS + // key, no error), an empty running set (trivially complete without a + // request), or UNKNOWN — resolving, failed, or over the + // materialization ceiling (round-5 review P2). Consumers render + // unknown as unknown; they never get a number to mis-present. + // + // Placeholder data is explicitly EXCLUDED from completeness: on a + // re-key (running set or facet change) keepPreviousData shows the OLD + // key's window, and pairing those rows with the NEW task snapshot + // publishes a precise-looking number for a scope nobody fetched — + // including re-publishing a ceiling-capped old window as if it were + // complete (round-6 review P2#1). While the new key resolves, the + // scope is unknown. + if (!hasRunningIssues) return EMPTY_ISSUES; + const workingWindowComplete = + workingWindowQuery.data !== undefined && + !workingWindowIsPlaceholder && + !workingWindowHasNext && + !workingWindowError; + if (!workingWindowComplete) return undefined; + return applyIssueFilters( + workingWindowQuery.data.pages.flatMap((page) => page.issues), + { ...baseFilterState, workingOnly: true }, + filterContext, + ); + } return applyIssueFilters( surfaceIssues, { ...baseFilterState, workingOnly: true }, @@ -333,24 +509,67 @@ export function useIssueSurfaceData({ filterContext, ganttIssues, ganttShowCompleted, + hasRunningIssues, propertyFilters, showSubIssues, surfaceIssues, usesAssigneeBoard, usesGantt, + usesTable, + workingWindowError, + workingWindowHasNext, + workingWindowIsPlaceholder, + workingWindowQuery.data, ]); - const { data: childProgressMap = EMPTY_CHILD_PROGRESS } = useQuery( - childIssueProgressOptions(wsId), - ); - const { data: projects = EMPTY_PROJECTS } = useQuery({ + const { + data: childProgressData, + refetch: refetchChildProgress, + } = useQuery(childIssueProgressOptions(wsId)); + const childProgressMap = childProgressData ?? EMPTY_CHILD_PROGRESS; + const { + data: projectData, + refetch: refetchProjects, + } = useQuery({ ...projectListOptions(wsId), enabled: loadProjects, }); + const projects = projectData ?? EMPTY_PROJECTS; const projectMap = useMemo( () => new Map(projects.map((project) => [project.id, project])), [projects], ); + const resolveTableExportLookups = useCallback( + async (needs: { projects: boolean; childProgress: boolean }) => { + const [projectResult, progressResult] = await Promise.all([ + needs.projects ? refetchProjects() : Promise.resolve(null), + needs.childProgress + ? refetchChildProgress() + : Promise.resolve(null), + ]); + if (projectResult?.error) throw projectResult.error; + if (progressResult?.error) throw progressResult.error; + if (needs.projects && !projectResult?.data) { + throw new Error("Failed to load project data for export"); + } + if (needs.childProgress && !progressResult?.data) { + throw new Error("Failed to load child progress for export"); + } + const resolvedProjects = projectResult?.data ?? projects; + return { + projectMap: new Map( + resolvedProjects.map((project) => [project.id, project]), + ), + childProgressMap: progressResult?.data ?? childProgressMap, + }; + }, + [ + childProgressMap, + projects, + refetchChildProgress, + refetchProjects, + ], + ); const visibleStatuses = useMemo(() => { // Default view shows every lifecycle status, `cancelled` last (its @@ -401,7 +620,9 @@ export function useIssueSurfaceData({ ? assigneeGroupsQuery.isLoading : usesGantt ? ganttIssuesQuery.isLoading - : statusIssuesQuery.isLoading; + : usesTable + ? flatIssuesQuery.isLoading + : statusIssuesQuery.isLoading; // Placeholder-backed revalidation of the ACTIVE query only. First loads are // isLoading (no previous data to place-hold); gantt has no placeholder @@ -410,7 +631,9 @@ export function useIssueSurfaceData({ ? assigneeGroupsQuery.isPlaceholderData : usesGantt ? false - : statusIssuesQuery.isPlaceholderData; + : usesTable + ? flatIssuesQuery.isPlaceholderData + : statusIssuesQuery.isPlaceholderData; return { surfaceIssues, @@ -434,6 +657,20 @@ export function useIssueSurfaceData({ activity, childProgressMap, projectMap, + resolveTableExportLookups, + // cancelRefetch: false — the structure loop and the scroll sentinel are + // independent responders on this query; the default cancel/restart + // semantics turn a same-snapshot double call into a duplicated HTTP + // request, because the abandoned fetch is not abortable (the queryFn + // does not thread AbortSignal) (round-6 review R1). + fetchNextFlatPage: fetchNextFlatPageNoCancel, + hasNextFlatPage: flatIssuesQuery.hasNextPage ?? false, + isFetchingNextFlatPage: flatIssuesQuery.isFetchingNextPage, + flatTotal, + flatWindowError: flatIssuesQuery.isError, + flatWindowColdError: flatIssuesQuery.isError && flatIssuesQuery.data === undefined, + refetchFlatWindow: flatIssuesQuery.refetch, + filterIssuesForExport, isLoading, isRefreshing, // isEmpty asserts "this window has no issues". The board/list/swimlane @@ -442,7 +679,15 @@ export function useIssueSurfaceData({ // window is empty, so never claim it (same "uncertain → don't assert" // rule as surface membership). GanttView renders its own accurate // "no scheduled issues" empty state instead of the generic create-issue - // one. - isEmpty: !isLoading && !usesGantt && surfaceIssues.length === 0, + // one. A FAILED table fetch proves nothing either: total defaults to 0 + // after a cold-load error, and claiming empty there swaps a 5xx/offline + // for a "create your first issue" screen with no recovery path (round-5 + // review P2) — only a successful zero-result window is empty. + isEmpty: + !isLoading && + !usesGantt && + (usesTable + ? !flatIssuesQuery.isError && flatTotal === 0 + : surfaceIssues.length === 0), }; } diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index b07cf682b3a..14820bdf6aa 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -78,10 +78,12 @@ "group_parent": "Parent issue", "group_project": "Project", "sort_manual": "Manual", + "sort_status": "Status", "sort_priority": "Priority", "sort_start_date": "Start date", "sort_due_date": "Due date", "sort_created": "Created date", + "sort_updated": "Updated date", "sort_title": "Title", "card_priority": "Priority", "card_description": "Description", @@ -96,14 +98,77 @@ "view": { "tooltip_board": "Board view", "tooltip_list": "List view", + "tooltip_table": "Table view", "tooltip_gantt": "Gantt view", "tooltip_swimlane": "Swimlane view", "section": "View", "board": "Board", "list": "List", + "table": "Table", "gantt": "Gantt", "swimlane": "Swimlane" }, + "table": { + "select_all": "Select all visible issues", + "select_issue": "Select {{identifier}}", + "sort_ascending": "Ascending", + "sort_descending": "Descending", + "toggle_sub_issues": "Toggle sub-issues", + "empty_value": "Empty", + "quick_create_placeholder": "Add an issue…", + "quick_create_submit": "Add", + "quick_create_failed": "Failed to create issue", + "no_value": "No value", + "unassigned": "Unassigned", + "empty": "No issues match this view.", + "search_placeholder": "Search title or issue ID…", + "search_clear": "Clear search", + "loaded_count": "Loaded {{count}} of {{total}}", + "structure_paused": "Grouping and hierarchy are paused — {{total}} issues exceed the {{limit}}-issue limit. Narrow filters to re-enable.", + "load_failed": "Failed to load issues.", + "load_failed_retry": "Retry", + "load_more_failed_retry": "Loading more failed — Retry", + "hierarchy": "Hierarchy", + "hierarchy_description": "Nest sub-issues beneath their parents", + "group_label": "Group", + "group_none": "No grouping", + "export": "Export", + "export_all": "Export all", + "export_selected": "Export selected ({{count}})", + "export_success": "Exported {{count}} issues", + "export_failed": "Failed to export issues", + "columns": { + "section": "Columns", + "system_section": "Issue properties", + "property_section": "Custom properties", + "search_placeholder": "Search columns…", + "no_results": "No matching columns", + "add": "Add column", + "hide": "Hide column", + "reorder": "Reorder {{column}} column", + "title": "Issue", + "identifier": "Identifier", + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "labels": "Labels", + "project": "Project", + "start_date": "Start date", + "due_date": "Due date", + "created_at": "Created", + "updated_at": "Updated", + "child_progress": "Sub-issue progress", + "creator": "Created by" + }, + "calculation": { + "label": "Calculate", + "description": "Show count, sum, or average in the footer", + "none": "None", + "sum": "Sum", + "average": "Average", + "count": "Count" + } + }, "gantt": { "header_issue": "Issue", "zoom_day": "Day", @@ -366,7 +431,8 @@ "chip_agents_working_one": "{{count}} agent working", "chip_agents_working_other": "{{count}} agents working", "issues_count_one": "{{count}} issue", - "issues_count_other": "{{count}} issues" + "issues_count_other": "{{count}} issues", + "chip_agents_working_unknown": "Agents working: —" }, "agent_live": { "is_working": "{{name}} is working", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index 5ef5f63ebef..52ca58ef65e 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -76,10 +76,12 @@ "group_parent": "親イシュー", "group_project": "プロジェクト", "sort_manual": "手動", + "sort_status": "ステータス", "sort_priority": "優先度", "sort_start_date": "開始日", "sort_due_date": "期限", "sort_created": "作成日", + "sort_updated": "更新日", "sort_title": "タイトル", "card_priority": "優先度", "card_description": "説明", @@ -94,14 +96,77 @@ "view": { "tooltip_board": "ボード表示", "tooltip_list": "リスト表示", + "tooltip_table": "テーブル表示", "tooltip_gantt": "ガント表示", "tooltip_swimlane": "スイムレーン表示", "section": "表示", "board": "ボード", "list": "リスト", + "table": "テーブル", "gantt": "ガント", "swimlane": "スイムレーン" }, + "table": { + "select_all": "表示中のイシューをすべて選択", + "select_issue": "{{identifier}} を選択", + "sort_ascending": "昇順", + "sort_descending": "降順", + "toggle_sub_issues": "サブイシューを展開または折りたたむ", + "empty_value": "空", + "quick_create_placeholder": "イシューを追加…", + "quick_create_submit": "追加", + "quick_create_failed": "イシューを作成できませんでした", + "no_value": "値なし", + "unassigned": "未割り当て", + "empty": "この表示に一致するイシューはありません。", + "search_placeholder": "タイトルまたは Issue ID を検索…", + "search_clear": "検索をクリア", + "loaded_count": "{{total}} 件中 {{count}} 件を読み込み済み", + "structure_paused": "グループと階層は一時停止中です — {{total}} 件が上限 {{limit}} 件を超えています。フィルターで絞り込むと再び有効になります。", + "load_failed": "イシューを読み込めませんでした。", + "load_failed_retry": "再試行", + "load_more_failed_retry": "続きの読み込みに失敗しました — 再試行", + "hierarchy": "階層", + "hierarchy_description": "サブイシューを親イシューの下に入れ子で表示", + "group_label": "グループ", + "group_none": "グループ化なし", + "export": "エクスポート", + "export_all": "すべてエクスポート", + "export_selected": "選択項目をエクスポート({{count}})", + "export_success": "{{count}} 件のイシューをエクスポートしました", + "export_failed": "イシューをエクスポートできませんでした", + "columns": { + "section": "列", + "system_section": "イシューのプロパティ", + "property_section": "カスタムプロパティ", + "search_placeholder": "列を検索…", + "no_results": "一致する列がありません", + "add": "列を追加", + "hide": "列を非表示", + "reorder": "{{column}} 列を並べ替え", + "title": "イシュー", + "identifier": "ID", + "status": "ステータス", + "priority": "優先度", + "assignee": "担当者", + "labels": "ラベル", + "project": "プロジェクト", + "start_date": "開始日", + "due_date": "期限", + "created_at": "作成日", + "updated_at": "更新日", + "child_progress": "サブイシューの進捗", + "creator": "作成者" + }, + "calculation": { + "label": "計算", + "description": "フッターに件数、合計、または平均を表示", + "none": "なし", + "sum": "合計", + "average": "平均", + "count": "件数" + } + }, "gantt": { "header_issue": "イシュー", "zoom_day": "日", @@ -347,7 +412,8 @@ "filter_active_label": "エージェントが作業中の issue を表示中", "tasks_count_other": "task {{count}} 件", "chip_agents_working_other": "作業中のエージェント {{count}} 件", - "issues_count_other": "issue {{count}} 件" + "issues_count_other": "issue {{count}} 件", + "chip_agents_working_unknown": "作業中のエージェント: —" }, "agent_live": { "is_working": "{{name}} が作業中", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 64661030f5e..3ad35284d69 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -76,10 +76,12 @@ "group_parent": "상위 이슈", "group_project": "프로젝트", "sort_manual": "수동", + "sort_status": "상태", "sort_priority": "우선순위", "sort_start_date": "시작일", "sort_due_date": "마감일", "sort_created": "생성일", + "sort_updated": "수정일", "sort_title": "제목", "card_priority": "우선순위", "card_description": "설명", @@ -94,14 +96,77 @@ "view": { "tooltip_board": "보드 보기", "tooltip_list": "리스트 보기", + "tooltip_table": "테이블 보기", "tooltip_gantt": "간트 보기", "tooltip_swimlane": "스윔레인 보기", "section": "보기", "board": "보드", "list": "리스트", + "table": "테이블", "gantt": "간트", "swimlane": "스윔레인" }, + "table": { + "select_all": "표시된 모든 이슈 선택", + "select_issue": "{{identifier}} 선택", + "sort_ascending": "오름차순", + "sort_descending": "내림차순", + "toggle_sub_issues": "하위 이슈 펼치기 또는 접기", + "empty_value": "비어 있음", + "quick_create_placeholder": "이슈 추가…", + "quick_create_submit": "추가", + "quick_create_failed": "이슈를 만들지 못했습니다", + "no_value": "값 없음", + "unassigned": "담당자 없음", + "empty": "이 보기에 일치하는 이슈가 없습니다.", + "search_placeholder": "제목 또는 이슈 ID 검색…", + "search_clear": "검색 지우기", + "loaded_count": "{{total}}개 중 {{count}}개 불러옴", + "structure_paused": "그룹화와 계층이 일시 중지되었습니다 — 이슈 {{total}}개가 상한 {{limit}}개를 초과했습니다. 필터로 좁히면 다시 활성화됩니다.", + "load_failed": "이슈를 불러오지 못했습니다.", + "load_failed_retry": "다시 시도", + "load_more_failed_retry": "추가 항목을 불러오지 못했습니다 — 다시 시도", + "hierarchy": "계층", + "hierarchy_description": "하위 이슈를 상위 이슈 아래에 중첩해 표시", + "group_label": "그룹", + "group_none": "그룹화 안 함", + "export": "내보내기", + "export_all": "모두 내보내기", + "export_selected": "선택 항목 내보내기({{count}})", + "export_success": "이슈 {{count}}개를 내보냈습니다", + "export_failed": "이슈를 내보내지 못했습니다", + "columns": { + "section": "열", + "system_section": "이슈 속성", + "property_section": "사용자 지정 속성", + "search_placeholder": "열 검색…", + "no_results": "일치하는 열이 없습니다", + "add": "열 추가", + "hide": "열 숨기기", + "reorder": "{{column}} 열 순서 변경", + "title": "이슈", + "identifier": "식별자", + "status": "상태", + "priority": "우선순위", + "assignee": "담당자", + "labels": "라벨", + "project": "프로젝트", + "start_date": "시작일", + "due_date": "마감일", + "created_at": "생성일", + "updated_at": "수정일", + "child_progress": "하위 이슈 진행률", + "creator": "작성자" + }, + "calculation": { + "label": "계산", + "description": "바닥글에 개수, 합계 또는 평균 표시", + "none": "없음", + "sum": "합계", + "average": "평균", + "count": "개수" + } + }, "gantt": { "header_issue": "이슈", "zoom_day": "일", @@ -347,7 +412,8 @@ "filter_active_label": "에이전트가 작업 중인 issue만 보는 중", "tasks_count_other": "task {{count}}개", "chip_agents_working_other": "작업 중인 에이전트 {{count}}개", - "issues_count_other": "issue {{count}}개" + "issues_count_other": "issue {{count}}개", + "chip_agents_working_unknown": "작업 중인 에이전트: —" }, "agent_live": { "is_working": "{{name}} 작업 중", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 23f73cfd141..ad4d447587f 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -76,10 +76,12 @@ "group_parent": "父级 issue", "group_project": "项目", "sort_manual": "手动", + "sort_status": "状态", "sort_priority": "优先级", "sort_start_date": "开始日期", "sort_due_date": "截止日期", "sort_created": "创建时间", + "sort_updated": "更新时间", "sort_title": "标题", "card_priority": "优先级", "card_description": "描述", @@ -94,14 +96,77 @@ "view": { "tooltip_board": "看板视图", "tooltip_list": "列表视图", + "tooltip_table": "表格视图", "tooltip_gantt": "甘特图", "tooltip_swimlane": "泳道视图", "section": "视图", "board": "看板", "list": "列表", + "table": "表格", "gantt": "甘特图", "swimlane": "泳道" }, + "table": { + "select_all": "选择所有可见 issue", + "select_issue": "选择 {{identifier}}", + "sort_ascending": "升序", + "sort_descending": "降序", + "toggle_sub_issues": "展开或折叠子 issue", + "empty_value": "空", + "quick_create_placeholder": "添加 issue…", + "quick_create_submit": "添加", + "quick_create_failed": "创建 issue 失败", + "no_value": "无值", + "unassigned": "未分配", + "empty": "当前视图没有匹配的 issue。", + "search_placeholder": "搜索标题或 issue 编号…", + "search_clear": "清除搜索", + "loaded_count": "已载入 {{count}} / {{total}}", + "structure_paused": "分组和层级已暂停——{{total}} 个 issue 超过 {{limit}} 个上限,收窄筛选后自动恢复。", + "load_failed": "加载 issue 失败。", + "load_failed_retry": "重试", + "load_more_failed_retry": "加载更多失败——重试", + "hierarchy": "层级", + "hierarchy_description": "将子 issue 嵌套显示在父 issue 下方", + "group_label": "分组", + "group_none": "不分组", + "export": "导出", + "export_all": "导出全部", + "export_selected": "导出已选择({{count}})", + "export_success": "已导出 {{count}} 个 issue", + "export_failed": "导出 issue 失败", + "columns": { + "section": "列", + "system_section": "Issue 属性", + "property_section": "自定义属性", + "search_placeholder": "搜索列…", + "no_results": "没有匹配的列", + "add": "添加列", + "hide": "隐藏列", + "reorder": "重新排列 {{column}} 列", + "title": "Issue", + "identifier": "编号", + "status": "状态", + "priority": "优先级", + "assignee": "负责人", + "labels": "标签", + "project": "项目", + "start_date": "开始日期", + "due_date": "截止日期", + "created_at": "创建时间", + "updated_at": "更新时间", + "child_progress": "子 issue 进度", + "creator": "创建者" + }, + "calculation": { + "label": "计算", + "description": "在表尾显示计数、总和或平均值", + "none": "无", + "sum": "总和", + "average": "平均值", + "count": "计数" + } + }, "gantt": { "header_issue": "Issue", "zoom_day": "日", @@ -347,7 +412,8 @@ "filter_active_label": "正在查看有智能体在工作的 issue", "tasks_count_other": "{{count}} 个 task", "chip_agents_working_other": "{{count}} 个智能体工作中", - "issues_count_other": "{{count}} 个 issue" + "issues_count_other": "{{count}} 个 issue", + "chip_agents_working_unknown": "工作中的智能体:—" }, "agent_live": { "is_working": "{{name}} 在工作", diff --git a/packages/views/my-issues/components/my-issues-header.tsx b/packages/views/my-issues/components/my-issues-header.tsx index 7763c1cb14b..4da0c40d032 100644 --- a/packages/views/my-issues/components/my-issues-header.tsx +++ b/packages/views/my-issues/components/my-issues-header.tsx @@ -26,14 +26,18 @@ export function MyIssuesHeader({ scope, onScopeChange, isRefreshing = false, + facetCountsExact = true, }: { allIssues: Issue[]; - /** The rows the agents-working filter would leave on screen. Scopes the - * chip: it counts the agents working on these rows. */ - workingIssues: Issue[]; + /** The rows the agents-working filter would leave on screen — undefined + * when the set is unknown (chip renders indeterminate). Scopes the chip: + * it counts the agents working on these rows. */ + workingIssues: Issue[] | undefined; scope: MyIssuesScope; onScopeChange: (scope: MyIssuesScope) => void; isRefreshing?: boolean; + /** See IssueDisplayControls.facetCountsExact. */ + facetCountsExact?: boolean; }) { const { t } = useT("my-issues"); const { t: tIssues } = useT("issues"); @@ -114,7 +118,10 @@ export function MyIssuesHeader({ onToggle={toggleAgentRunningFilter} workingIssues={workingIssues} /> - +
diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx index 6f7414a4b38..a1055131fe2 100644 --- a/packages/views/my-issues/components/my-issues-page.tsx +++ b/packages/views/my-issues/components/my-issues-page.tsx @@ -36,7 +36,7 @@ export function MyIssuesPage() { userId: user.id, relation: relationFromScope(scope), }} - modes={["board", "list", "swimlane"]} + modes={["board", "list", "table", "swimlane"]} batchToolbar="list" renderHeader={({ controller, workingIssues }) => ( )} renderEmpty={() => ( diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 2b19149332c..2a9b81466b9 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -544,7 +544,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82fd0602561..a6d2c011fec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -841,6 +841,9 @@ importers: '@tanstack/react-table': specifier: 'catalog:' version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': + specifier: 'catalog:' + version: 3.14.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: specifier: 'catalog:' version: 0.7.1 diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 6cb4d95e6c4..5bfc093e555 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -1038,6 +1038,9 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Get("/children", h.ListChildrenByParents) r.Get("/grouped", h.ListGroupedIssues) r.Get("/", h.ListIssues) + // POST twin of GET /api/issues for oversized filter sets + // (agents-working ids facet) — see QueryIssues. + r.Post("/query", h.QueryIssues) r.Post("/", h.CreateIssue) r.Post("/quick-create", h.QuickCreateIssue) r.Post("/preview-trigger", h.PreviewIssueTrigger) diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 799bc101e24..82ca84338fc 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -755,6 +755,26 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { }) } +// QueryIssues is the POST twin of ListIssues for filter sets too large for a +// GET request line — the table's agents-working facet can carry hundreds of +// issue ids, and common reverse proxies cap request lines around 8 KB. The +// body is a flat JSON object with EXACTLY the same keys and string encodings +// as ListIssues' query parameters; the handler rebuilds the query string and +// delegates, so the two transports cannot drift. +func (h *Handler) QueryIssues(w http.ResponseWriter, r *http.Request) { + var params map[string]string + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(¶ms); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + values := make(url.Values, len(params)) + for key, value := range params { + values.Set(key, value) + } + r.URL.RawQuery = values.Encode() + h.ListIssues(w, r) +} + func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -902,9 +922,13 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } } - var statusFilter pgtype.Text - if s := r.URL.Query().Get("status"); s != "" { - statusFilter = pgtype.Text{String: s, Valid: true} + statusesFilter := splitCommaParam(r.URL.Query().Get("statuses")) + if len(statusesFilter) == 0 { + statusesFilter = splitCommaParam(r.URL.Query().Get("status")) + } + prioritiesFilter := splitCommaParam(r.URL.Query().Get("priorities")) + if len(prioritiesFilter) == 0 { + prioritiesFilter = splitCommaParam(r.URL.Query().Get("priority")) } // assignee_types narrows the list to issues assigned to the given actor @@ -936,8 +960,11 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { sortIsProperty := false if s := r.URL.Query().Get("sort"); s != "" { switch s { - case "position", "title", "created_at", "start_date", "due_date": + case "position", "title", "created_at", "updated_at", "start_date", "due_date": sortCol = s + case "status": + sortCol = "CASE i.status WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'in_review' THEN 3 WHEN 'done' THEN 4 WHEN 'blocked' THEN 5 WHEN 'cancelled' THEN 6 ELSE 7 END" + sortIsExpr = true case "priority": sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" sortIsExpr = true @@ -989,11 +1016,11 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return "$" + strconv.Itoa(len(args)) } - if statusFilter.Valid { - where = append(where, fmt.Sprintf("i.status = %s", addArg(statusFilter.String))) + if len(statusesFilter) > 0 { + where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(statusesFilter))) } - if priorityFilter.Valid { - where = append(where, fmt.Sprintf("i.priority = %s", addArg(priorityFilter.String))) + if len(prioritiesFilter) > 0 { + where = append(where, fmt.Sprintf("i.priority = ANY(%s::text[])", addArg(prioritiesFilter))) } if assigneeFilter.Valid { where = append(where, fmt.Sprintf("i.assignee_id = %s::uuid", addArg(assigneeFilter))) @@ -1010,6 +1037,90 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if projectFilter.Valid { where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(projectFilter))) } + + // Table facets must be part of the server window. Applying them after + // LIMIT/OFFSET hides matches that live on later pages and makes `total` + // disagree with the rows the user sees/exports. + assigneeFilters, ok := parseActorFilterList(w, r.URL.Query().Get("assignee_filters"), "assignee_filters") + if !ok { + return + } + includeNoAssignee := r.URL.Query().Get("include_no_assignee") == "true" + if len(assigneeFilters) > 0 || includeNoAssignee { + ors := make([]string, 0, len(assigneeFilters)+1) + for _, filter := range assigneeFilters { + ors = append(ors, fmt.Sprintf( + "(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)", + addArg(filter.actorType), + addArg(filter.actorID), + )) + } + if includeNoAssignee { + ors = append(ors, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)") + } + where = append(where, "("+strings.Join(ors, " OR ")+")") + } + + creatorFilters, ok := parseActorFilterList(w, r.URL.Query().Get("creator_filters"), "creator_filters") + if !ok { + return + } + if len(creatorFilters) > 0 { + ors := make([]string, 0, len(creatorFilters)) + for _, filter := range creatorFilters { + ors = append(ors, fmt.Sprintf( + "(i.creator_type = %s::text AND i.creator_id = %s::uuid)", + addArg(filter.actorType), + addArg(filter.actorID), + )) + } + where = append(where, "("+strings.Join(ors, " OR ")+")") + } + + projectIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("project_ids"), "project_ids") + if !ok { + return + } + includeNoProject := r.URL.Query().Get("include_no_project") == "true" + if len(projectIDs) > 0 || includeNoProject { + ors := make([]string, 0, 2) + if len(projectIDs) > 0 { + ors = append(ors, fmt.Sprintf("i.project_id = ANY(%s::uuid[])", addArg(projectIDs))) + } + if includeNoProject { + ors = append(ors, "i.project_id IS NULL") + } + where = append(where, "("+strings.Join(ors, " OR ")+")") + } + + labelIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("label_ids"), "label_ids") + if !ok { + return + } + if len(labelIDs) > 0 { + where = append(where, fmt.Sprintf( + "EXISTS (SELECT 1 FROM issue_to_label itl WHERE itl.issue_id = i.id AND itl.label_id = ANY(%s::uuid[]))", + addArg(labelIDs), + )) + } + // ids restricts the window to an explicit id set (the table's + // agents-working facet sends the live running-issue ids). Presence with an + // EMPTY list is meaningful — it must yield an empty window, not degrade to + // the unrestricted one, so gate on Has() rather than the parsed length. + if r.URL.Query().Has("ids") { + idsFilter, ok := parseUUIDParamList(w, r.URL.Query().Get("ids"), "ids") + if !ok { + return + } + if idsFilter == nil { + idsFilter = []pgtype.UUID{} + } + where = append(where, fmt.Sprintf("i.id = ANY(%s::uuid[])", addArg(idsFilter))) + } + if r.URL.Query().Get("top_level_only") == "true" { + where = append(where, "i.parent_issue_id IS NULL") + } + where = appendIssueTableSearchFilter(where, addArg, r.URL.Query().Get("q")) if scheduledFilter.Valid { where = append(where, "(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)") } @@ -1068,7 +1179,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // directions (mirrors the client comparator). orderBy += " NULLS LAST" } - orderBy += ", i.created_at DESC" + // created_at alone is not unique (bulk imports share timestamps); without + // a unique final key the database may reorder ties between two + // LIMIT/OFFSET requests, duplicating or dropping rows at page boundaries. + orderBy += ", i.created_at DESC, i.id DESC" offsetRef := addArg(int64(offset)) limitRef := addArg(int64(limit)) @@ -1225,6 +1339,36 @@ func appendIssueDateFilter(where []string, addArg func(any) string, filter *issu )) } +// appendIssueTableSearchFilter adds a quick identity search to the ordinary +// ListIssues window. Unlike the ranked global search endpoint, this predicate +// preserves the table's active filters, explicit sort, total, and pagination. +// Every word must appear in the title; a complete identifier (or bare issue +// number) also matches the immutable numeric issue number. +func appendIssueTableSearchFilter(where []string, addArg func(any) string, raw string) []string { + query := strings.TrimSpace(raw) + if query == "" { + return where + } + + words := splitSearchTerms(strings.ToLower(query)) + ors := make([]string, 0, 2) + if len(words) > 0 { + titleMatches := make([]string, 0, len(words)) + for _, word := range words { + pattern := "%" + escapeLike(word) + "%" + titleMatches = append(titleMatches, fmt.Sprintf("LOWER(i.title) LIKE %s", addArg(pattern))) + } + ors = append(ors, "("+strings.Join(titleMatches, " AND ")+")") + } + if number, ok := parseQueryNumber(query); ok { + ors = append(ors, fmt.Sprintf("i.number = %s", addArg(number))) + } + if len(ors) == 0 { + return where + } + return append(where, "("+strings.Join(ors, " OR ")+")") +} + func splitCommaParam(raw string) []string { if raw == "" { return nil @@ -1539,8 +1683,11 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { sortIsProperty := false if s := r.URL.Query().Get("sort"); s != "" { switch s { - case "position", "title", "created_at", "start_date", "due_date": + case "position", "title", "created_at", "updated_at", "start_date", "due_date": sortCol = s + case "status": + sortCol = "CASE i.status WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'in_review' THEN 3 WHEN 'done' THEN 4 WHEN 'blocked' THEN 5 WHEN 'cancelled' THEN 6 ELSE 7 END" + sortIsExpr = true case "priority": sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" sortIsExpr = true @@ -1592,7 +1739,9 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { if sortCol == "start_date" || sortCol == "due_date" || sortIsProperty { intraGroupOrder += " NULLS LAST" } - intraGroupOrder += ", i.created_at DESC" + // Unique final key — see ListIssues: created_at ties would otherwise make + // ROW_NUMBER() unstable across per-group offset pages. + intraGroupOrder += ", i.created_at DESC, i.id DESC" offsetRef := addArg(int64(offset)) limitRef := addArg(int64(limit)) diff --git a/server/internal/handler/issue_sort_test.go b/server/internal/handler/issue_sort_test.go new file mode 100644 index 00000000000..c5b4f23d38d --- /dev/null +++ b/server/internal/handler/issue_sort_test.go @@ -0,0 +1,111 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestListIssuesSortsByStatusAndUpdatedAt(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + + var projectID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO project (workspace_id, title) VALUES ($1, $2) RETURNING id + `, testWorkspaceID, fmt.Sprintf("Issue table sort %d", suffix)).Scan(&projectID); err != nil { + t.Fatalf("create project: %v", err) + } + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID) + }) + + type fixture struct { + title string + status string + updatedAt time.Time + } + fixtures := []fixture{ + {"sort-done", "done", time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)}, + {"sort-backlog", "backlog", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"sort-progress", "in_progress", time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, + } + for index, item := range fixtures { + var number int + if err := testPool.QueryRow(ctx, ` + UPDATE workspace + SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1 + WHERE id = $1 RETURNING issue_counter + `, testWorkspaceID).Scan(&number); err != nil { + t.Fatalf("next issue number: %v", err) + } + if _, err := testPool.Exec(ctx, ` + INSERT INTO issue ( + workspace_id, title, status, priority, creator_type, creator_id, + position, number, project_id, created_at, updated_at + ) + VALUES ($1, $2, $3, 'none', 'member', $4, $5, $6, $7, $8, $8) + `, testWorkspaceID, item.title, item.status, testUserID, index, number, projectID, item.updatedAt); err != nil { + t.Fatalf("create issue %q: %v", item.title, err) + } + } + + listTitles := func(sort, direction string) []string { + t.Helper() + path := fmt.Sprintf( + "/api/issues?workspace_id=%s&project_id=%s&limit=50&sort=%s&direction=%s", + testWorkspaceID, + projectID, + sort, + direction, + ) + w := httptest.NewRecorder() + testHandler.ListIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var response struct { + Issues []IssueResponse `json:"issues"` + } + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + titles := make([]string, 0, len(response.Issues)) + for _, issue := range response.Issues { + titles = append(titles, issue.Title) + } + return titles + } + + assertTitles := func(got, want []string) { + t.Helper() + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("order = %v, want %v", got, want) + } + } + + assertTitles(listTitles("status", "asc"), []string{ + "sort-backlog", + "sort-progress", + "sort-done", + }) + assertTitles(listTitles("status", "desc"), []string{ + "sort-done", + "sort-progress", + "sort-backlog", + }) + assertTitles(listTitles("updated_at", "asc"), []string{ + "sort-backlog", + "sort-progress", + "sort-done", + }) + assertTitles(listTitles("updated_at", "desc"), []string{ + "sort-done", + "sort-progress", + "sort-backlog", + }) +} diff --git a/server/internal/handler/issue_table_filters_test.go b/server/internal/handler/issue_table_filters_test.go new file mode 100644 index 00000000000..2a81bbd7cbf --- /dev/null +++ b/server/internal/handler/issue_table_filters_test.go @@ -0,0 +1,368 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "testing" + "time" +) + +// Flat table facets must be evaluated before LIMIT/OFFSET and COUNT. This +// exercises the same multi-value/nullable filters the table query sends. +func TestListIssues_TableFacetsAreServerSide(t *testing.T) { + ctx := context.Background() + token := fmt.Sprintf("table-filter-%d", time.Now().UnixNano()) + metadata := fmt.Sprintf(`{"table_filter_test":%q}`, token) + + createProject := func(title string) string { + var id string + if err := testPool.QueryRow(ctx, ` + INSERT INTO project (workspace_id, title) VALUES ($1, $2) RETURNING id + `, testWorkspaceID, title).Scan(&id); err != nil { + t.Fatalf("create project: %v", err) + } + return id + } + projectA := createProject(token + " A") + projectB := createProject(token + " B") + + var labelA, labelB string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue_label (workspace_id, name, color) + VALUES ($1, $2, '#ef4444') RETURNING id + `, testWorkspaceID, token+" A").Scan(&labelA); err != nil { + t.Fatalf("create label A: %v", err) + } + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue_label (workspace_id, name, color) + VALUES ($1, $2, '#22c55e') RETURNING id + `, testWorkspaceID, token+" B").Scan(&labelB); err != nil { + t.Fatalf("create label B: %v", err) + } + + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE metadata @> $1::jsonb`, metadata) + _, _ = testPool.Exec(context.Background(), `DELETE FROM issue_label WHERE id IN ($1, $2)`, labelA, labelB) + _, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id IN ($1, $2)`, projectA, projectB) + }) + + nextNumber := func() int { + var number int + if err := testPool.QueryRow(ctx, ` + UPDATE workspace + SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1 + WHERE id = $1 RETURNING issue_counter + `, testWorkspaceID).Scan(&number); err != nil { + t.Fatalf("next issue number: %v", err) + } + return number + } + + insertIssue := func(title, status, priority string, assigned bool, projectID, parentID *string) string { + var assigneeType *string + var assigneeID *string + if assigned { + member := "member" + assigneeType = &member + assigneeID = &testUserID + } + var id string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue ( + workspace_id, title, status, priority, assignee_type, assignee_id, + creator_type, creator_id, parent_issue_id, position, number, + project_id, metadata + ) VALUES ($1, $2, $3, $4, $5, $6, 'member', $7, $8, 0, $9, $10, $11::jsonb) + RETURNING id + `, testWorkspaceID, title, status, priority, assigneeType, assigneeID, + testUserID, parentID, nextNumber(), projectID, metadata).Scan(&id); err != nil { + t.Fatalf("create issue %q: %v", title, err) + } + return id + } + + issueA := insertIssue(token+" todo", "todo", "high", false, nil, nil) + issueB := insertIssue(token+" progress", "in_progress", "low", true, &projectA, nil) + issueC := insertIssue(token+" child", "done", "high", true, &projectB, &issueB) + if _, err := testPool.Exec(ctx, ` + INSERT INTO issue_to_label (issue_id, label_id) VALUES ($1, $2), ($3, $4), ($5, $2) + `, issueA, labelA, issueB, labelB, issueC); err != nil { + t.Fatalf("attach labels: %v", err) + } + + list := func(query string) ([]string, int64) { + t.Helper() + path := fmt.Sprintf( + "/api/issues?workspace_id=%s&limit=100&metadata=%s%s", + testWorkspaceID, + url.QueryEscape(metadata), + query, + ) + w := httptest.NewRecorder() + testHandler.ListIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var response struct { + Issues []IssueResponse `json:"issues"` + Total int64 `json:"total"` + } + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + ids := make([]string, 0, len(response.Issues)) + for _, issue := range response.Issues { + ids = append(ids, issue.ID) + } + sort.Strings(ids) + return ids, response.Total + } + + assertList := func(query string, want ...string) { + t.Helper() + got, total := list(query) + sort.Strings(want) + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("query %q ids = %v, want %v", query, got, want) + } + if total != int64(len(want)) { + t.Fatalf("query %q total = %d, want %d", query, total, len(want)) + } + } + + assertList("&statuses=todo,in_progress", issueA, issueB) + assertList("&priorities=high", issueA, issueC) + assertList("&assignee_filters="+url.QueryEscape("member:"+testUserID), issueB, issueC) + assertList("&project_ids="+projectA+"&include_no_project=true", issueA, issueB) + assertList("&label_ids="+labelA, issueA, issueC) + assertList("&top_level_only=true", issueA, issueB) + assertList("&q="+url.QueryEscape("progress "+token), issueB) + assertList("&q="+url.QueryEscape("progress")+"&statuses=in_progress", issueB) + var issueBNumber int + if err := testPool.QueryRow(ctx, `SELECT number FROM issue WHERE id = $1`, issueB).Scan(&issueBNumber); err != nil { + t.Fatalf("read issue number: %v", err) + } + assertList("&q="+url.QueryEscape(fmt.Sprintf("MUL-%d", issueBNumber)), issueB) + assertList( + "&statuses=todo&priorities=high&include_no_assignee=true&label_ids="+labelA+"&top_level_only=true", + issueA, + ) + + // The ids facet restricts the window to an explicit id set (the table's + // agents-working filter). It must compose with other facets, and a + // PRESENT-but-EMPTY list must yield an empty window — the "nothing is + // running" state — not fall back to the unrestricted one. + assertList("&ids="+issueA+","+issueC, issueA, issueC) + assertList("&ids="+issueA+","+issueC+"&statuses=done", issueC) + assertList("&ids=") +} + +// POST /api/issues/query is the body-transport twin of GET /api/issues for +// filter sets too large for a request line (the agents-working ids facet can +// carry hundreds of UUIDs; proxies cap request lines around 8 KB). It must +// return exactly what the GET returns for the same parameters, including at +// id-list sizes that would overflow a GET. +func TestQueryIssues_PostTwinMatchesGet(t *testing.T) { + ctx := context.Background() + token := fmt.Sprintf("post-query-%d", time.Now().UnixNano()) + metadata := fmt.Sprintf(`{"post_query_test":%q}`, token) + + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE metadata @> $1::jsonb`, metadata) + }) + + nextNumber := func() int { + var number int + if err := testPool.QueryRow(ctx, ` + UPDATE workspace + SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1 + WHERE id = $1 RETURNING issue_counter + `, testWorkspaceID).Scan(&number); err != nil { + t.Fatalf("next issue number: %v", err) + } + return number + } + insertIssue := func(title string) string { + var id string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue ( + workspace_id, title, status, priority, creator_type, creator_id, + position, number, metadata + ) VALUES ($1, $2, 'todo', 'none', 'member', $3, 0, $4, $5::jsonb) + RETURNING id + `, testWorkspaceID, title, testUserID, nextNumber(), metadata).Scan(&id); err != nil { + t.Fatalf("create issue %q: %v", title, err) + } + return id + } + issueA := insertIssue(token + " A") + issueB := insertIssue(token + " B") + insertIssue(token + " C") + + decode := func(w *httptest.ResponseRecorder, transport string) ([]string, int64) { + t.Helper() + if w.Code != http.StatusOK { + t.Fatalf("%s: expected 200, got %d: %s", transport, w.Code, w.Body.String()) + } + var response struct { + Issues []IssueResponse `json:"issues"` + Total int64 `json:"total"` + } + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("%s decode: %v", transport, err) + } + ids := make([]string, 0, len(response.Issues)) + for _, issue := range response.Issues { + ids = append(ids, issue.ID) + } + sort.Strings(ids) + return ids, response.Total + } + + // Pad the target ids with 300 extra UUIDs — a body far beyond any GET + // request-line budget. The extras match nothing; the result must still be + // exactly A and B. + padded := []string{issueA, issueB} + for i := 0; i < 300; i++ { + var extra string + if err := testPool.QueryRow(ctx, `SELECT gen_random_uuid()::text`).Scan(&extra); err != nil { + t.Fatalf("generate uuid: %v", err) + } + padded = append(padded, extra) + } + + postBody := map[string]string{ + "workspace_id": testWorkspaceID, + "metadata": metadata, + "limit": "100", + "ids": strings.Join(padded, ","), + } + postRecorder := httptest.NewRecorder() + testHandler.QueryIssues(postRecorder, newRequest("POST", "/api/issues/query", postBody)) + postIDs, postTotal := decode(postRecorder, "POST") + + wantIDs := []string{issueA, issueB} + sort.Strings(wantIDs) + if fmt.Sprint(postIDs) != fmt.Sprint(wantIDs) || postTotal != 2 { + t.Fatalf("POST ids = %v total = %d, want %v total 2", postIDs, postTotal, wantIDs) + } + + getPath := fmt.Sprintf( + "/api/issues?workspace_id=%s&limit=100&metadata=%s&ids=%s", + testWorkspaceID, url.QueryEscape(metadata), + url.QueryEscape(issueA+","+issueB), + ) + getRecorder := httptest.NewRecorder() + testHandler.ListIssues(getRecorder, newRequest("GET", getPath, nil)) + getIDs, getTotal := decode(getRecorder, "GET") + + if fmt.Sprint(postIDs) != fmt.Sprint(getIDs) || postTotal != getTotal { + t.Fatalf("transport mismatch: POST %v/%d vs GET %v/%d", postIDs, postTotal, getIDs, getTotal) + } + + // Malformed body fails closed. + badRecorder := httptest.NewRecorder() + badRequest := httptest.NewRequest("POST", "/api/issues/query", strings.NewReader("not json")) + badRequest.Header.Set("X-User-ID", testUserID) + badRequest.Header.Set("X-Workspace-ID", testWorkspaceID) + testHandler.QueryIssues(badRecorder, badRequest) + if badRecorder.Code != http.StatusBadRequest { + t.Fatalf("malformed body: expected 400, got %d", badRecorder.Code) + } +} + +// Offset pages are only stable when the full ORDER BY is deterministic. All +// rows here share status, priority, AND created_at, so ordering falls +// entirely to the unique id tie-break — without it the database may reorder +// ties between two LIMIT/OFFSET requests, duplicating or dropping rows at +// page boundaries. +func TestListIssues_OffsetPaginationStableOnCreatedAtTies(t *testing.T) { + ctx := context.Background() + token := fmt.Sprintf("tie-page-%d", time.Now().UnixNano()) + metadata := fmt.Sprintf(`{"tie_page_test":%q}`, token) + + t.Cleanup(func() { + _, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE metadata @> $1::jsonb`, metadata) + }) + + nextNumber := func() int { + var number int + if err := testPool.QueryRow(ctx, ` + UPDATE workspace + SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1 + WHERE id = $1 RETURNING issue_counter + `, testWorkspaceID).Scan(&number); err != nil { + t.Fatalf("next issue number: %v", err) + } + return number + } + + const totalIssues = 5 + want := make(map[string]bool, totalIssues) + for i := 0; i < totalIssues; i++ { + var id string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue ( + workspace_id, title, status, priority, creator_type, creator_id, + position, number, metadata, created_at, updated_at + ) VALUES ($1, $2, 'todo', 'none', 'member', $3, 0, $4, $5::jsonb, + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z') + RETURNING id + `, testWorkspaceID, fmt.Sprintf("%s %d", token, i), testUserID, + nextNumber(), metadata).Scan(&id); err != nil { + t.Fatalf("create issue %d: %v", i, err) + } + want[id] = true + } + + page := func(offset int) []string { + t.Helper() + path := fmt.Sprintf( + "/api/issues?workspace_id=%s&metadata=%s&sort=status&direction=asc&limit=2&offset=%d", + testWorkspaceID, url.QueryEscape(metadata), offset, + ) + w := httptest.NewRecorder() + testHandler.ListIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListIssues offset=%d: expected 200, got %d: %s", offset, w.Code, w.Body.String()) + } + var response struct { + Issues []IssueResponse `json:"issues"` + Total int64 `json:"total"` + } + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Total != totalIssues { + t.Fatalf("offset=%d total = %d, want %d", offset, response.Total, totalIssues) + } + ids := make([]string, 0, len(response.Issues)) + for _, issue := range response.Issues { + ids = append(ids, issue.ID) + } + return ids + } + + seen := make(map[string]int, totalIssues) + var walked []string + for offset := 0; offset < totalIssues; offset += 2 { + for _, id := range page(offset) { + seen[id]++ + walked = append(walked, id) + } + } + if len(walked) != totalIssues { + t.Fatalf("walked %d rows across pages, want %d (%v)", len(walked), totalIssues, walked) + } + for id := range want { + if seen[id] != 1 { + t.Fatalf("issue %s appeared %d times across pages, want exactly once", id, seen[id]) + } + } +}