From abd69890a8fa350ea92ba533dc2ea459dfb7c806 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:29:42 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"feat(issues):=20server-side=20filters?= =?UTF-8?q?=20incl.=20label,=20fixing=20pagination=20drop=E2=80=A6"=20(#17?= =?UTF-8?q?79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 246fcd4ce44900238329ee384fc5ff88b093db62. --- packages/core/api/client.ts | 11 +- packages/core/issues/mutations.ts | 135 ++++-------- packages/core/issues/queries.ts | 59 ++---- packages/core/issues/stores/view-store.ts | 11 - packages/core/issues/ws-updaters.ts | 51 +---- packages/core/types/api.ts | 15 +- .../extensions/mention-suggestion.test.tsx | 36 +--- .../editor/extensions/mention-suggestion.tsx | 18 +- .../views/issues/components/board-view.tsx | 43 ++-- .../views/issues/components/issues-header.tsx | 104 +-------- .../issues/components/issues-page.test.tsx | 2 - .../views/issues/components/issues-page.tsx | 52 ++--- .../views/issues/components/list-view.tsx | 27 +-- packages/views/issues/utils/filter.test.ts | 199 ++++++++++++------ packages/views/issues/utils/filter.ts | 101 +++++---- .../my-issues/components/my-issues-page.tsx | 49 +++-- .../projects/components/project-detail.tsx | 46 ++-- server/cmd/multica/cmd_issue.go | 4 - server/internal/handler/handler.go | 28 --- server/internal/handler/issue.go | 139 +++++------- server/pkg/db/generated/issue.sql.go | 155 +++++--------- server/pkg/db/queries/issue.sql | 69 ++---- 22 files changed, 470 insertions(+), 884 deletions(-) diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 8ba3b241f..8822d4f91 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -363,14 +363,11 @@ export class ApiClient { if (params?.offset) search.set("offset", String(params.offset)); if (params?.workspace_id) search.set("workspace_id", params.workspace_id); if (params?.status) search.set("status", params.status); - if (params?.priorities?.length) search.set("priorities", params.priorities.join(",")); - if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); + if (params?.priority) search.set("priority", params.priority); + 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?.include_no_assignee) search.set("include_no_assignee", "true"); - if (params?.creator_ids?.length) search.set("creator_ids", params.creator_ids.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?.creator_id) search.set("creator_id", params.creator_id); + if (params?.project_id) search.set("project_id", params.project_id); if (params?.open_only) search.set("open_only", "true"); return this.fetch(`/api/issues?${search}`); } diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index 0e7544071..f9bddbd3a 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -1,10 +1,9 @@ import { useState, useCallback } from "react"; -import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api"; import { issueKeys, ISSUE_PAGE_SIZE, - type IssueListFilter, type MyIssuesFilter, } from "./queries"; import { @@ -25,60 +24,6 @@ import type { } from "../types"; import type { TimelineEntry, IssueSubscriber, Reaction } from "../types"; -// --------------------------------------------------------------------------- -// Cache helpers — apply an updater to every filter-keyed list cache. -// --------------------------------------------------------------------------- - -/** - * Calls `updater` against every workspace issue-list cache (all filter - * combinations under `["issues", wsId, "list", *]`). Filter-keyed caches mean - * the same workspace can have many list entries; mutations need to update or - * snapshot all of them so the UI stays consistent regardless of which filter - * the user has active. - */ -function updateAllListCaches( - qc: QueryClient, - wsId: string, - updater: (old: ListIssuesCache | undefined) => ListIssuesCache | undefined, -) { - return qc.setQueriesData( - { queryKey: issueKeys.listPrefix(wsId) }, - updater, - ); -} - -/** Snapshot every workspace list cache so mutations can roll back on error. */ -function snapshotAllListCaches(qc: QueryClient, wsId: string) { - return qc - .getQueriesData({ queryKey: issueKeys.listPrefix(wsId) }) - .map(([key, data]) => ({ key, data })); -} - -function restoreListCacheSnapshots( - qc: QueryClient, - snapshots: { key: readonly unknown[]; data: ListIssuesCache | undefined }[], -) { - for (const { key, data } of snapshots) { - if (data !== undefined) qc.setQueryData(key, data); - } -} - -function findIssueAcrossListCaches( - qc: QueryClient, - wsId: string, - issueId: string, -) { - const entries = qc.getQueriesData({ - queryKey: issueKeys.listPrefix(wsId), - }); - for (const [, cache] of entries) { - if (!cache) continue; - const loc = findIssueLocation(cache, issueId); - if (loc) return loc.issue; - } - return undefined; -} - // --------------------------------------------------------------------------- // Shared mutation variable types — used by both mutation hooks and // useMutationState consumers to keep the type assertion in sync. @@ -106,17 +51,15 @@ export type ToggleIssueReactionVars = { */ export function useLoadMoreByStatus( status: IssueStatus, - options: { filter?: IssueListFilter; myIssues?: { scope: string; filter: MyIssuesFilter } } = {}, + myIssues?: { scope: string; filter: MyIssuesFilter }, ) { - const { filter, myIssues } = options; const qc = useQueryClient(); const wsId = useWorkspaceId(); const [isLoading, setIsLoading] = useState(false); const queryKey = myIssues ? issueKeys.myList(wsId, myIssues.scope, myIssues.filter) - : issueKeys.list(wsId, filter); - const requestFilter = myIssues?.filter ?? filter ?? {}; + : issueKeys.list(wsId); const cache = qc.getQueryData(queryKey); const bucket = cache?.byStatus[status]; const loaded = bucket?.issues.length ?? 0; @@ -131,7 +74,7 @@ export function useLoadMoreByStatus( status, limit: ISSUE_PAGE_SIZE, offset: loaded, - ...requestFilter, + ...myIssues?.filter, }); qc.setQueryData(queryKey, (old) => { if (!old) return old; @@ -146,7 +89,7 @@ export function useLoadMoreByStatus( } finally { setIsLoading(false); } - }, [qc, queryKey, status, loaded, hasMore, isLoading, requestFilter]); + }, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]); return { loadMore, hasMore, isLoading, total }; } @@ -161,11 +104,7 @@ export function useCreateIssue() { return useMutation({ mutationFn: (data: CreateIssueRequest) => api.createIssue(data), onSuccess: (newIssue) => { - // Insert the new issue into every active list cache. Filter-keyed - // caches are invalidated on settled below, so a momentary inclusion - // in a cache that "shouldn't" contain the issue is corrected within - // the same tick. - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? addIssueToBuckets(old, newIssue) : old, ); // Surface the just-created issue in cmd+k's Recent list without @@ -178,7 +117,7 @@ export function useCreateIssue() { } }, onSettled: () => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); }, }); } @@ -194,8 +133,8 @@ export function useUpdateIssue() { // cache update happens in the same tick as mutate(). Awaiting would // yield to the event loop, letting @dnd-kit reset its visual state // before the optimistic update lands. - qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); + qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); // Resolve parent_issue_id from the freshest source so we can keep the @@ -203,13 +142,13 @@ export function useUpdateIssue() { // sub-issues list). const parentId = prevDetail?.parent_issue_id ?? - findIssueAcrossListCaches(qc, wsId, id)?.parent_issue_id ?? + (prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ?? null; const prevChildren = parentId ? qc.getQueryData(issueKeys.children(wsId, parentId)) : undefined; - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? patchIssueInBuckets(old, id, data) : old, ); qc.setQueryData(issueKeys.detail(wsId, id), (old) => @@ -222,10 +161,10 @@ export function useUpdateIssue() { old?.map((c) => (c.id === id ? { ...c, ...data } : c)), ); } - return { listSnapshots, prevDetail, prevChildren, parentId, id }; + return { prevList, prevDetail, prevChildren, parentId, id }; }, onError: (_err, _vars, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); if (ctx?.prevDetail) qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); if (ctx?.parentId && ctx.prevChildren !== undefined) { @@ -237,7 +176,7 @@ export function useUpdateIssue() { }, onSettled: (_data, _err, vars, ctx) => { qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) }); - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); // Invalidate old parent's children cache if (ctx?.parentId) { qc.invalidateQueries({ @@ -263,20 +202,20 @@ export function useDeleteIssue() { return useMutation({ mutationFn: (id: string) => api.deleteIssue(id), onMutate: async (id) => { - await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); - const deleted = findIssueAcrossListCaches(qc, wsId, id); - updateAllListCaches(qc, wsId, (old) => + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined; + qc.setQueryData(issueKeys.list(wsId), (old) => old ? removeIssueFromBuckets(old, id) : old, ); qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) }); - return { listSnapshots, parentIssueId: deleted?.parent_issue_id }; + return { prevList, parentIssueId: deleted?.parent_issue_id }; }, onError: (_err, _id, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); }, onSettled: (_data, _err, _id, ctx) => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); if (ctx?.parentIssueId) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) }); qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) }); @@ -297,21 +236,21 @@ export function useBatchUpdateIssues() { updates: UpdateIssueRequest; }) => api.batchUpdateIssues(ids, updates), onMutate: async ({ ids, updates }) => { - await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); - updateAllListCaches(qc, wsId, (old) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => { if (!old) return old; let next = old; for (const id of ids) next = patchIssueInBuckets(next, id, updates); return next; }); - return { listSnapshots }; + return { prevList }; }, onError: (_err, _vars, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); }, onSettled: () => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); }, }); } @@ -322,26 +261,28 @@ export function useBatchDeleteIssues() { return useMutation({ mutationFn: (ids: string[]) => api.batchDeleteIssues(ids), onMutate: async (ids) => { - await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); const parentIssueIds = new Set(); - for (const id of ids) { - const found = findIssueAcrossListCaches(qc, wsId, id); - if (found?.parent_issue_id) parentIssueIds.add(found.parent_issue_id); + if (prevList) { + for (const id of ids) { + const loc = findIssueLocation(prevList, id); + if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id); + } } - updateAllListCaches(qc, wsId, (old) => { + qc.setQueryData(issueKeys.list(wsId), (old) => { if (!old) return old; let next = old; for (const id of ids) next = removeIssueFromBuckets(next, id); return next; }); - return { listSnapshots, parentIssueIds }; + return { prevList, parentIssueIds }; }, onError: (_err, _ids, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); }, onSettled: (_data, _err, _ids, ctx) => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) { for (const parentId of ctx.parentIssueIds) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) }); diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index f84e331fd..dd1a35679 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -5,14 +5,12 @@ import { BOARD_STATUSES } from "./config"; export const issueKeys = { all: (wsId: string) => ["issues", wsId] as const, - /** Filter-keyed list. Empty filter is the canonical workspace-wide list. */ - list: (wsId: string, filter: IssueListFilter = EMPTY_FILTER) => - [...issueKeys.all(wsId), "list", normalizeFilter(filter)] as const, + list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, /** All "my issues" queries — use for bulk invalidation. */ myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const, /** Per-scope "my issues" list with filter identity baked into the key. */ - myList: (wsId: string, scope: string, filter: IssueListFilter) => - [...issueKeys.myAll(wsId), scope, normalizeFilter(filter)] as const, + myList: (wsId: string, scope: string, filter: MyIssuesFilter) => + [...issueKeys.myAll(wsId), scope, filter] as const, detail: (wsId: string, id: string) => [...issueKeys.all(wsId), "detail", id] as const, children: (wsId: string, id: string) => @@ -24,44 +22,13 @@ export const issueKeys = { subscribers: (issueId: string) => ["issues", "subscribers", issueId] as const, usage: (issueId: string) => ["issues", "usage", issueId] as const, - /** Prefix used by mutations to broadcast cache updates across all filters. */ - listPrefix: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, }; -/** - * Server-side filter passed to `GET /api/issues`. Status is excluded because - * the cache buckets per-status — each bucket is fetched with its own status - * query param (see {@link fetchFirstPages}). - */ -export type IssueListFilter = Omit< +export type MyIssuesFilter = Pick< ListIssuesParams, - "limit" | "offset" | "workspace_id" | "status" | "open_only" + "assignee_id" | "assignee_ids" | "creator_id" | "project_id" >; -/** Backwards-compat alias — old name was specific to the My Issues page. */ -export type MyIssuesFilter = IssueListFilter; - -const EMPTY_FILTER: IssueListFilter = {}; - -/** - * Normalizes a filter object so semantically-identical filters produce - * identical query keys. Sorts arrays (so `[a, b]` and `[b, a]` cache to the - * same key) and drops empty/falsy entries (so `{}` and `{ priorities: [] }` - * are equivalent). - */ -function normalizeFilter(filter: IssueListFilter): IssueListFilter { - const out: IssueListFilter = {}; - if (filter.priorities?.length) out.priorities = [...filter.priorities].sort(); - if (filter.assignee_types?.length) out.assignee_types = [...filter.assignee_types].sort(); - if (filter.assignee_ids?.length) out.assignee_ids = [...filter.assignee_ids].sort(); - if (filter.include_no_assignee) out.include_no_assignee = true; - if (filter.creator_ids?.length) out.creator_ids = [...filter.creator_ids].sort(); - if (filter.project_ids?.length) out.project_ids = [...filter.project_ids].sort(); - if (filter.include_no_project) out.include_no_project = true; - if (filter.label_ids?.length) out.label_ids = [...filter.label_ids].sort(); - return out; -} - /** Page size per status column. */ export const ISSUE_PAGE_SIZE = 50; @@ -78,7 +45,7 @@ export function flattenIssueBuckets(data: ListIssuesCache) { return out; } -async function fetchFirstPages(filter: IssueListFilter = {}): Promise { +async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise { const responses = await Promise.all( PAGINATED_STATUSES.map((status) => api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }), @@ -98,15 +65,13 @@ async function fetchFirstPages(filter: IssueListFilter = {}): Promise(...)` and preserve the byStatus shape. * - * Fetches the first page of each paginated status in parallel. Filter goes - * into both the cache key and the request, so filter changes trigger a - * fresh server-side fetch and don't share cache with other filters. Use - * {@link useLoadMoreByStatus} to paginate a specific status. + * Fetches the first page of each paginated status in parallel. Use + * {@link useLoadMoreByStatus} to paginate a specific status into the cache. */ -export function issueListOptions(wsId: string, filter: IssueListFilter = EMPTY_FILTER) { +export function issueListOptions(wsId: string) { return queryOptions({ - queryKey: issueKeys.list(wsId, filter), - queryFn: () => fetchFirstPages(filter), + queryKey: issueKeys.list(wsId), + queryFn: () => fetchFirstPages(), select: flattenIssueBuckets, }); } @@ -118,7 +83,7 @@ export function issueListOptions(wsId: string, filter: IssueListFilter = EMPTY_F export function myIssueListOptions( wsId: string, scope: string, - filter: IssueListFilter, + filter: MyIssuesFilter, ) { return queryOptions({ queryKey: issueKeys.myList(wsId, scope, filter), diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index e61b8f15e..c704ba62b 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -55,7 +55,6 @@ export interface IssueViewState { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; - labelFilters: string[]; sortBy: SortField; sortDirection: SortDirection; cardProperties: CardProperties; @@ -68,7 +67,6 @@ export interface IssueViewState { toggleCreatorFilter: (value: ActorFilterValue) => void; toggleProjectFilter: (projectId: string) => void; toggleNoProject: () => void; - toggleLabelFilter: (labelId: string) => void; hideStatus: (status: IssueStatus) => void; showStatus: (status: IssueStatus) => void; clearFilters: () => void; @@ -87,7 +85,6 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, - labelFilters: [], sortBy: "position", sortDirection: "asc", cardProperties: { @@ -150,12 +147,6 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue })), toggleNoProject: () => set((state) => ({ includeNoProject: !state.includeNoProject })), - toggleLabelFilter: (labelId) => - set((state) => ({ - labelFilters: state.labelFilters.includes(labelId) - ? state.labelFilters.filter((id) => id !== labelId) - : [...state.labelFilters, labelId], - })), hideStatus: (status) => set((state) => { // If no filter active, activate filter with all EXCEPT this one @@ -181,7 +172,6 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, - labelFilters: [], }), setSortBy: (field) => set({ sortBy: field }), setSortDirection: (dir) => set({ sortDirection: dir }), @@ -212,7 +202,6 @@ export const viewStorePersistOptions = (name: string) => ({ creatorFilters: state.creatorFilters, projectFilters: state.projectFilters, includeNoProject: state.includeNoProject, - labelFilters: state.labelFilters, sortBy: state.sortBy, sortDirection: state.sortDirection, cardProperties: state.cardProperties, diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index 1e137135c..299019fb1 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -9,45 +9,14 @@ import { import type { Issue, Label } from "../types"; import type { ListIssuesCache } from "../types"; -/** Apply an updater to every workspace list cache (all filter combinations). */ -function updateAllListCaches( - qc: QueryClient, - wsId: string, - updater: (old: ListIssuesCache | undefined) => ListIssuesCache | undefined, -) { - qc.setQueriesData( - { queryKey: issueKeys.listPrefix(wsId) }, - updater, - ); -} - -function findIssueAcrossListCaches( - qc: QueryClient, - wsId: string, - issueId: string, -) { - const entries = qc.getQueriesData({ - queryKey: issueKeys.listPrefix(wsId), - }); - for (const [, cache] of entries) { - if (!cache) continue; - const loc = findIssueLocation(cache, issueId); - if (loc) return loc.issue; - } - return undefined; -} - export function onIssueCreated( qc: QueryClient, wsId: string, issue: Issue, ) { - // Insert into every active list cache. Filter mismatches are corrected - // by the trailing `invalidateQueries` on the listPrefix. - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? addIssueToBuckets(old, issue) : old, ); - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); if (issue.parent_issue_id) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) }); @@ -63,17 +32,18 @@ export function onIssueUpdated( // Look up the OLD parent before mutating list state, so we can keep // the parent's children cache in sync (powers the sub-issues list // shown on the parent issue page). + const listData = qc.getQueryData(issueKeys.list(wsId)); const detailData = qc.getQueryData(issueKeys.detail(wsId, issue.id)); const oldParentId = detailData?.parent_issue_id ?? - findIssueAcrossListCaches(qc, wsId, issue.id)?.parent_issue_id ?? + (listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ?? null; // The NEW parent comes from the WS payload when parent_issue_id changed const newParentId = issue.parent_issue_id ?? null; const parentChanged = issue.parent_issue_id !== undefined && newParentId !== oldParentId; - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? patchIssueInBuckets(old, issue.id, issue) : old, ); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); @@ -106,9 +76,6 @@ export function onIssueUpdated( * Patch an issue's `labels` field in-place across the list cache, my-issues * caches, and the detail cache. Triggered by the `issue_labels:changed` WS * event after attach/detach so list/board chips update without a refetch. - * - * Also invalidates the listPrefix because a label change can move issues in - * or out of an active label filter. */ export function onIssueLabelsChanged( qc: QueryClient, @@ -116,13 +83,12 @@ export function onIssueLabelsChanged( issueId: string, labels: Label[], ) { - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? patchIssueInBuckets(old, issueId, { labels }) : old, ); qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => old ? { ...old, labels } : old, ); - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); } @@ -131,10 +97,11 @@ export function onIssueDeleted( wsId: string, issueId: string, ) { - // Look up the issue before removing it to check for parent_issue_id. - const deleted = findIssueAcrossListCaches(qc, wsId, issueId); + // Look up the issue before removing it to check for parent_issue_id + const listData = qc.getQueryData(issueKeys.list(wsId)); + const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined; - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? removeIssueFromBuckets(old, issueId) : old, ); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 80aa6e923..aa6e0bca9 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -34,18 +34,11 @@ export interface ListIssuesParams { offset?: number; workspace_id?: string; status?: IssueStatus; - priorities?: IssuePriority[]; - /** Match issues whose assignee is one of these polymorphic types ("member"/"agent"). */ - assignee_types?: IssueAssigneeType[]; + priority?: IssuePriority; + assignee_id?: string; assignee_ids?: string[]; - /** When true, also include issues with no assignee (OR'd with assignee_ids). */ - include_no_assignee?: boolean; - creator_ids?: string[]; - project_ids?: string[]; - /** When true, also include issues with no project (OR'd with project_ids). */ - include_no_project?: boolean; - /** Match issues that have at least one of these labels. */ - label_ids?: string[]; + creator_id?: string; + project_id?: string; open_only?: boolean; } diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx index 0a39f6415..ef60322fe 100644 --- a/packages/views/editor/extensions/mention-suggestion.test.tsx +++ b/packages/views/editor/extensions/mention-suggestion.test.tsx @@ -26,40 +26,20 @@ function fakeQc(data: { agents?: Array<{ id: string; name: string; archived_at: string | null }>; issues?: Array<{ id: string; identifier: string; title: string; status: string }>; }): QueryClient { - const map = new Map(); - map.set(JSON.stringify(workspaceKeys.members("ws-1")), { - key: workspaceKeys.members("ws-1"), - data: data.members ?? [], - }); - map.set(JSON.stringify(workspaceKeys.agents("ws-1")), { - key: workspaceKeys.agents("ws-1"), - data: data.agents ?? [], - }); + const map = new Map(); + map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []); + map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []); const byStatus: ListIssuesCache["byStatus"] = {}; for (const status of PAGINATED_STATUSES) { const bucket = (data.issues ?? []).filter((i) => i.status === status); byStatus[status as IssueStatus] = { issues: bucket as never, total: bucket.length }; } - const listKey = issueKeys.list("ws-1"); - map.set(JSON.stringify(listKey), { - key: listKey, - data: { byStatus } satisfies ListIssuesCache, - }); + map.set( + JSON.stringify(issueKeys.list("ws-1")), + { byStatus } satisfies ListIssuesCache, + ); return { - getQueryData: (key: readonly unknown[]) => - map.get(JSON.stringify(key))?.data, - // Prefix-match implementation: any cached key whose prefix equals the - // filter's queryKey is returned. Mirrors TanStack's behavior closely - // enough for our cache-read paths. - getQueriesData: ({ queryKey }: { queryKey: readonly unknown[] }) => { - const prefix = JSON.stringify(queryKey); - const trimmed = prefix.slice(0, -1); // strip trailing ']' - const out: Array = []; - for (const [k, entry] of map.entries()) { - if (k.startsWith(trimmed)) out.push([entry.key, entry.data] as const); - } - return out; - }, + getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)), } as unknown as QueryClient; } diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index 659e8ca79..000cad1f4 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -254,22 +254,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit< const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? []; const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? []; - // List caches are filter-keyed, so a single workspace can have multiple - // entries. Pull from whichever caches exist and dedupe — the goal is just - // an instant first paint; the server search below fills in misses. - const cachedListEntries = qc.getQueriesData({ - queryKey: issueKeys.listPrefix(wsId), - }); - const seen = new Set(); - const cachedIssues: Issue[] = []; - for (const [, cache] of cachedListEntries) { - if (!cache) continue; - for (const issue of flattenIssueBuckets(cache)) { - if (seen.has(issue.id)) continue; - seen.add(issue.id); - cachedIssues.push(issue); - } - } + const cachedResponse = qc.getQueryData(issueKeys.list(wsId)); + const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : []; const q = query.toLowerCase(); diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index 4093e9371..4cf96b1e6 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -19,17 +19,7 @@ import { Eye, MoreHorizontal } from "lucide-react"; import type { Issue, IssueStatus } from "@multica/core/types"; import { Button } from "@multica/ui/components/ui/button"; import { useLoadMoreByStatus } from "@multica/core/issues/mutations"; -import type { IssueListFilter, MyIssuesFilter } from "@multica/core/issues/queries"; - -/** - * Threaded between BoardView and its inner components — selects which list - * cache `useLoadMoreByStatus` paginates. The workspace list path keys on - * `filter`; the My Issues path keys on `(scope, filter)` because each scope - * has its own cache entry. - */ -type LoadMoreOptions = - | { filter?: IssueListFilter; myIssues?: never } - | { filter?: never; myIssues: { scope: string; filter: MyIssuesFilter } }; +import type { MyIssuesFilter } from "@multica/core/issues/queries"; import { DropdownMenu, DropdownMenuTrigger, @@ -113,7 +103,6 @@ export function BoardView({ hiddenStatuses, onMoveIssue, childProgressMap = EMPTY_PROGRESS_MAP, - listFilter, myIssuesScope, myIssuesFilter, }: { @@ -126,17 +115,15 @@ export function BoardView({ newPosition?: number ) => void; childProgressMap?: Map; - /** Filter that keys the workspace list cache. Threaded into useLoadMoreByStatus so pagination targets the same filtered bucket the page is rendering. */ - listFilter?: IssueListFilter; /** When set, per-status load-more targets the scoped cache instead of the workspace one. */ myIssuesScope?: string; myIssuesFilter?: MyIssuesFilter; }) { const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); - const loadMoreOptions = myIssuesScope - ? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } } - : { filter: listFilter }; + const myIssuesOpts = myIssuesScope + ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } + : undefined; // --- Drag state --- const [activeIssue, setActiveIssue] = useState(null); @@ -300,14 +287,14 @@ export function BoardView({ issueIds={columns[status] ?? []} issueMap={issueMapRef.current} childProgressMap={childProgressMap} - loadMoreOptions={loadMoreOptions} + myIssuesOpts={myIssuesOpts} /> ))} {hiddenStatuses.length > 0 && ( )} @@ -328,17 +315,17 @@ function PaginatedBoardColumn({ issueIds, issueMap, childProgressMap, - loadMoreOptions, + myIssuesOpts, }: { status: IssueStatus; issueIds: string[]; issueMap: Map; childProgressMap?: Map; - loadMoreOptions: LoadMoreOptions; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus( status, - loadMoreOptions, + myIssuesOpts, ); return ( @@ -375,7 +362,7 @@ function HiddenColumnsPanel({ ))} @@ -385,14 +372,14 @@ function HiddenColumnsPanel({ function HiddenColumnRow({ status, - loadMoreOptions, + myIssuesOpts, }: { status: IssueStatus; - loadMoreOptions: LoadMoreOptions; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { const cfg = STATUS_CONFIG[status]; const viewStoreApi = useViewStoreApi(); - const { total } = useLoadMoreByStatus(status, loadMoreOptions); + const { total } = useLoadMoreByStatus(status, myIssuesOpts); return (
diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index c9e955d84..878b6e948 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -14,7 +14,6 @@ import { List, SignalHigh, SlidersHorizontal, - Tag, User, UserMinus, UserPen, @@ -50,10 +49,8 @@ import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; -import { labelListOptions } from "@multica/core/labels/queries"; import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; -import { LabelChip } from "../../labels/label-chip"; import { SORT_OPTIONS, CARD_PROPERTY_OPTIONS, @@ -97,7 +94,6 @@ function getActiveFilterCount(state: { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; - labelFilters: string[]; }) { let count = 0; if (state.statusFilters.length > 0) count++; @@ -105,7 +101,6 @@ function getActiveFilterCount(state: { if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++; if (state.creatorFilters.length > 0) count++; if (state.projectFilters.length > 0 || state.includeNoProject) count++; - if (state.labelFilters.length > 0) count++; return count; } @@ -116,7 +111,6 @@ function useIssueCounts(allIssues: Issue[]) { const assignee = new Map(); const creator = new Map(); const project = new Map(); - const label = new Map(); let noAssignee = 0; let noProject = 0; @@ -139,15 +133,9 @@ function useIssueCounts(allIssues: Issue[]) { } else { project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1); } - - if (issue.labels) { - for (const l of issue.labels) { - label.set(l.id, (label.get(l.id) ?? 0) + 1); - } - } } - return { status, priority, assignee, creator, noAssignee, project, noProject, label }; + return { status, priority, assignee, creator, noAssignee, project, noProject }; }, [allIssues]); } @@ -387,75 +375,11 @@ function ProjectSubContent({ ); } -// --------------------------------------------------------------------------- -// Label sub-menu content -// --------------------------------------------------------------------------- - -function LabelSubContent({ - counts, - selected, - onToggle, -}: { - counts: Map; - selected: string[]; - onToggle: (labelId: string) => void; -}) { - const [search, setSearch] = useState(""); - const wsId = useWorkspaceId(); - const { data: labels = [] } = useQuery(labelListOptions(wsId)); - const query = search.trim().toLowerCase(); - const filtered = labels.filter((l) => l.name.toLowerCase().includes(query)); - - return ( - <> -
- setSearch(e.target.value)} - placeholder="Filter..." - className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" - autoFocus - /> -
- -
- {filtered.map((l) => { - const checked = selected.includes(l.id); - const count = counts.get(l.id) ?? 0; - return ( - onToggle(l.id)} - className={FILTER_ITEM_CLASS} - > - - - {count > 0 && ( - - {count} - - )} - - ); - })} - - {filtered.length === 0 && ( -
- {search ? "No results" : "No labels yet"} -
- )} -
- - ); -} - // --------------------------------------------------------------------------- // IssuesHeader // --------------------------------------------------------------------------- -export function IssuesHeader({ issues }: { issues: Issue[] }) { +export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { const scope = useIssuesScopeStore((s) => s.scope); const setScope = useIssuesScopeStore((s) => s.setScope); @@ -467,13 +391,12 @@ export function IssuesHeader({ issues }: { issues: Issue[] }) { const creatorFilters = useViewStore((s) => s.creatorFilters); const projectFilters = useViewStore((s) => s.projectFilters); const includeNoProject = useViewStore((s) => s.includeNoProject); - const labelFilters = useViewStore((s) => s.labelFilters); const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); const cardProperties = useViewStore((s) => s.cardProperties); const act = useViewStoreApi().getState(); - const counts = useIssueCounts(issues); + const counts = useIssueCounts(scopedIssues); const hasActiveFilters = getActiveFilterCount({ @@ -484,7 +407,6 @@ export function IssuesHeader({ issues }: { issues: Issue[] }) { creatorFilters, projectFilters, includeNoProject, - labelFilters, }) > 0; const sortLabel = @@ -678,26 +600,6 @@ export function IssuesHeader({ issues }: { issues: Issue[] }) { - {/* Label */} - - - - Label - {labelFilters.length > 0 && ( - - {labelFilters.length} - - )} - - - - - - {/* Reset */} {hasActiveFilters && ( <> diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index 54db9e80b..d543443c0 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -106,7 +106,6 @@ const mockViewState = { creatorFilters: [] as { type: string; id: string }[], projectFilters: [] as string[], includeNoProject: false, - labelFilters: [] as string[], sortBy: "position" as const, sortDirection: "asc" as const, cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true }, @@ -119,7 +118,6 @@ const mockViewState = { toggleCreatorFilter: vi.fn(), toggleProjectFilter: vi.fn(), toggleNoProject: vi.fn(), - toggleLabelFilter: vi.fn(), hideStatus: vi.fn(), showStatus: vi.fn(), clearFilters: vi.fn(), diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx index 5f283800d..dc2901b27 100644 --- a/packages/views/issues/components/issues-page.tsx +++ b/packages/views/issues/components/issues-page.tsx @@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"; import { useIssueViewStore, useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store"; import { useIssuesScopeStore } from "@multica/core/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; -import { buildIssueListFilter } from "../utils/filter"; +import { filterIssues } from "../utils/filter"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { useCurrentWorkspace } from "@multica/core/paths"; import { WorkspaceAvatar } from "../../workspace/workspace-avatar"; @@ -25,6 +25,7 @@ import { BatchActionToolbar } from "./batch-action-toolbar"; export function IssuesPage() { const wsId = useWorkspaceId(); + const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); const workspace = useCurrentWorkspace(); const scope = useIssuesScopeStore((s) => s.scope); @@ -36,7 +37,6 @@ export function IssuesPage() { const creatorFilters = useIssueViewStore((s) => s.creatorFilters); const projectFilters = useIssueViewStore((s) => s.projectFilters); const includeNoProject = useIssueViewStore((s) => s.includeNoProject); - const labelFilters = useIssueViewStore((s) => s.labelFilters); // Clear filter state when switching between workspaces (URL-driven). useClearFiltersOnWorkspaceChange(useIssueViewStore, wsId); @@ -45,30 +45,18 @@ export function IssuesPage() { useIssueSelectionStore.getState().clear(); }, [viewMode, scope]); - // Server-side filter: every active filter goes into the GET /api/issues - // query string so each status bucket fetches the *correct first 50*. With - // client-side filtering issues outside the first page silently fell out of - // view (#1491). Status is intentionally not part of the wire filter — each - // bucket fetches its own status, and we hide buckets via `visibleStatuses`. - // Scope (Members/Agents) is mapped to assignee_types so it routes through - // the same SQL filter — applying scope client-side reproduced the same - // pagination-blind bug for the scope tabs. - const listFilter = useMemo(() => { - const base = buildIssueListFilter({ - priorityFilters, - assigneeFilters, - includeNoAssignee, - creatorFilters, - projectFilters, - includeNoProject, - labelFilters, - }); - if (scope === "members") return { ...base, assignee_types: ["member" as const] }; - if (scope === "agents") return { ...base, assignee_types: ["agent" as const] }; - return base; - }, [scope, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters]); - const { data: issues = [], isLoading: loading } = useQuery( - issueListOptions(wsId, listFilter), + // Scope pre-filter: narrow by assignee type + const scopedIssues = useMemo(() => { + if (scope === "members") + return allIssues.filter((i) => i.assignee_type === "member"); + if (scope === "agents") + return allIssues.filter((i) => i.assignee_type === "agent"); + return allIssues; + }, [allIssues, scope]); + + const issues = useMemo( + () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }), + [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject], ); // Fetch sub-issue progress from the backend so counts are accurate @@ -162,10 +150,10 @@ export function IssuesPage() { {/* Header 2: Scope tabs + filters */} - + {/* Content: scrollable */} - {issues.length === 0 ? ( + {scopedIssues.length === 0 ? (

No issues yet

@@ -180,15 +168,9 @@ export function IssuesPage() { hiddenStatuses={hiddenStatuses} onMoveIssue={handleMoveIssue} childProgressMap={childProgressMap} - listFilter={listFilter} /> ) : ( - + )}
)} diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 81b47b194..0cef6e4ce 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -7,7 +7,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ import { Button } from "@multica/ui/components/ui/button"; import type { Issue, IssueStatus } from "@multica/core/types"; import { useLoadMoreByStatus } from "@multica/core/issues/mutations"; -import type { IssueListFilter, MyIssuesFilter } from "@multica/core/issues/queries"; +import type { MyIssuesFilter } from "@multica/core/issues/queries"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { useModalStore } from "@multica/core/modals"; import { useViewStore } from "@multica/core/issues/stores/view-store-context"; @@ -19,27 +19,16 @@ import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; const EMPTY_PROGRESS_MAP = new Map(); -/** - * Threaded into useLoadMoreByStatus — keeps pagination requests on the same - * filter (or My Issues scope) as the page is currently rendering. - */ -type LoadMoreOptions = - | { filter?: IssueListFilter; myIssues?: never } - | { filter?: never; myIssues: { scope: string; filter: MyIssuesFilter } }; - export function ListView({ issues, visibleStatuses, childProgressMap = EMPTY_PROGRESS_MAP, - listFilter, myIssuesScope, myIssuesFilter, }: { issues: Issue[]; visibleStatuses: IssueStatus[]; childProgressMap?: Map; - /** Workspace list filter — pagination targets the matching filter cache. */ - listFilter?: IssueListFilter; /** When set, per-status load-more targets the scoped cache instead of the workspace one. */ myIssuesScope?: string; myIssuesFilter?: MyIssuesFilter; @@ -70,9 +59,9 @@ export function ListView({ [visibleStatuses, listCollapsedStatuses] ); - const loadMoreOptions: LoadMoreOptions = myIssuesScope - ? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } } - : { filter: listFilter }; + const myIssuesOpts = myIssuesScope + ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } + : undefined; return (
@@ -96,7 +85,7 @@ export function ListView({ status={status} issues={issuesByStatus.get(status) ?? []} childProgressMap={childProgressMap} - loadMoreOptions={loadMoreOptions} + myIssuesOpts={myIssuesOpts} /> ))} @@ -108,12 +97,12 @@ function StatusAccordionItem({ status, issues, childProgressMap, - loadMoreOptions, + myIssuesOpts, }: { status: IssueStatus; issues: Issue[]; childProgressMap: Map; - loadMoreOptions: LoadMoreOptions; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { const cfg = STATUS_CONFIG[status]; const selectedIds = useIssueSelectionStore((s) => s.selectedIds); @@ -121,7 +110,7 @@ function StatusAccordionItem({ const deselect = useIssueSelectionStore((s) => s.deselect); const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus( status, - loadMoreOptions, + myIssuesOpts, ); const issueIds = issues.map((i) => i.id); diff --git a/packages/views/issues/utils/filter.test.ts b/packages/views/issues/utils/filter.test.ts index e93be4069..adbb4faae 100644 --- a/packages/views/issues/utils/filter.test.ts +++ b/packages/views/issues/utils/filter.test.ts @@ -1,93 +1,164 @@ import { describe, it, expect } from "vitest"; -import { buildIssueListFilter, type IssueViewFilters } from "./filter"; +import type { Issue } from "@multica/core/types"; +import { filterIssues, type IssueFilters } from "./filter"; -const NO_FILTER: IssueViewFilters = { +const NO_FILTER: IssueFilters = { + statusFilters: [], priorityFilters: [], assigneeFilters: [], includeNoAssignee: false, creatorFilters: [], projectFilters: [], includeNoProject: false, - labelFilters: [], }; -describe("buildIssueListFilter", () => { - it("returns an empty object when nothing is selected", () => { - expect(buildIssueListFilter(NO_FILTER)).toEqual({}); +function makeIssue(overrides: Partial = {}): Issue { + return { + id: "i-1", + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title: "Test", + description: null, + status: "todo", + priority: "medium", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "u-1", + parent_issue_id: null, + project_id: null, + position: 0, + due_date: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + ...overrides, + }; +} + +const issues: Issue[] = [ + makeIssue({ id: "1", status: "todo", priority: "high", assignee_type: "member", assignee_id: "u-1", creator_type: "member", creator_id: "u-1", project_id: "p-1" }), + makeIssue({ id: "2", status: "in_progress", priority: "medium", assignee_type: "agent", assignee_id: "a-1", creator_type: "agent", creator_id: "a-1", project_id: "p-2" }), + makeIssue({ id: "3", status: "done", priority: "low", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "u-2", project_id: null }), + makeIssue({ id: "4", status: "todo", priority: "urgent", assignee_type: "member", assignee_id: "u-2", creator_type: "member", creator_id: "u-1", project_id: "p-1" }), +]; + +describe("filterIssues", () => { + it("returns all issues when no filters are active", () => { + expect(filterIssues(issues, NO_FILTER)).toHaveLength(4); }); - it("encodes priority filter as an array", () => { - expect( - buildIssueListFilter({ ...NO_FILTER, priorityFilters: ["high", "urgent"] }), - ).toEqual({ priorities: ["high", "urgent"] }); + // --- Status --- + it("filters by status", () => { + const result = filterIssues(issues, { ...NO_FILTER, statusFilters: ["todo"] }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); - it("encodes assignee filters as id-only arrays", () => { - expect( - buildIssueListFilter({ - ...NO_FILTER, - assigneeFilters: [ - { type: "member", id: "u-1" }, - { type: "agent", id: "a-1" }, - ], - }), - ).toEqual({ assignee_ids: ["u-1", "a-1"] }); + // --- Priority --- + it("filters by priority", () => { + const result = filterIssues(issues, { ...NO_FILTER, priorityFilters: ["high", "urgent"] }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); - it("encodes 'no assignee' as include_no_assignee", () => { - expect(buildIssueListFilter({ ...NO_FILTER, includeNoAssignee: true })).toEqual({ - include_no_assignee: true, + // --- Assignee --- + it("filters by specific assignee", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + assigneeFilters: [{ type: "member", id: "u-1" }], }); + expect(result.map((i) => i.id)).toEqual(["1"]); }); - it("combines assignee ids with the no-assignee toggle", () => { - expect( - buildIssueListFilter({ - ...NO_FILTER, - assigneeFilters: [{ type: "member", id: "u-1" }], - includeNoAssignee: true, - }), - ).toEqual({ assignee_ids: ["u-1"], include_no_assignee: true }); + it("filters by 'No assignee' only", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true }); + expect(result.map((i) => i.id)).toEqual(["3"]); }); - it("encodes creator and project filters", () => { - expect( - buildIssueListFilter({ - ...NO_FILTER, - creatorFilters: [{ type: "member", id: "u-2" }], - projectFilters: ["p-1", "p-2"], - includeNoProject: true, - }), - ).toEqual({ - creator_ids: ["u-2"], - project_ids: ["p-1", "p-2"], - include_no_project: true, + it("filters by assignee + No assignee combined", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + assigneeFilters: [{ type: "agent", id: "a-1" }], + includeNoAssignee: true, }); + expect(result.map((i) => i.id)).toEqual(["2", "3"]); }); - it("encodes label filters", () => { - expect( - buildIssueListFilter({ ...NO_FILTER, labelFilters: ["l-1", "l-2"] }), - ).toEqual({ label_ids: ["l-1", "l-2"] }); + it("hides assigned issues when only 'No assignee' is selected", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true }); + expect(result.every((i) => !i.assignee_id)).toBe(true); }); - it("combines every filter dimension", () => { - expect( - buildIssueListFilter({ - priorityFilters: ["high"], - assigneeFilters: [{ type: "member", id: "u-1" }], - includeNoAssignee: false, - creatorFilters: [{ type: "agent", id: "a-1" }], - projectFilters: ["p-1"], - includeNoProject: false, - labelFilters: ["l-1"], - }), - ).toEqual({ - priorities: ["high"], - assignee_ids: ["u-1"], - creator_ids: ["a-1"], - project_ids: ["p-1"], - label_ids: ["l-1"], + // --- Creator --- + it("filters by creator", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + creatorFilters: [{ type: "agent", id: "a-1" }], }); + expect(result.map((i) => i.id)).toEqual(["2"]); + }); + + // --- Combinations --- + it("applies status + assignee filters together", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + statusFilters: ["todo"], + assigneeFilters: [{ type: "member", id: "u-1" }], + }); + expect(result.map((i) => i.id)).toEqual(["1"]); + }); + + it("applies status + priority + creator filters together", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + statusFilters: ["todo"], + priorityFilters: ["urgent"], + creatorFilters: [{ type: "member", id: "u-1" }], + }); + expect(result.map((i) => i.id)).toEqual(["4"]); + }); + + // --- Project --- + it("filters by specific project", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + projectFilters: ["p-1"], + }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); + }); + + it("filters by multiple projects", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + projectFilters: ["p-1", "p-2"], + }); + expect(result.map((i) => i.id)).toEqual(["1", "2", "4"]); + }); + + it("filters by 'No project' only", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true }); + expect(result.map((i) => i.id)).toEqual(["3"]); + }); + + it("filters by project + No project combined", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + projectFilters: ["p-2"], + includeNoProject: true, + }); + expect(result.map((i) => i.id)).toEqual(["2", "3"]); + }); + + it("hides project issues when only 'No project' is selected", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true }); + expect(result.every((i) => !i.project_id)).toBe(true); + }); + + it("applies status + project filters together", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + statusFilters: ["todo"], + projectFilters: ["p-1"], + }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); }); diff --git a/packages/views/issues/utils/filter.ts b/packages/views/issues/utils/filter.ts index 3d5c164f1..7779318ce 100644 --- a/packages/views/issues/utils/filter.ts +++ b/packages/views/issues/utils/filter.ts @@ -1,57 +1,72 @@ -import type { IssuePriority } from "@multica/core/types"; -import type { IssueListFilter } from "@multica/core/issues/queries"; +import type { Issue, IssueStatus, IssuePriority } from "@multica/core/types"; import type { ActorFilterValue } from "@multica/core/issues/stores/view-store"; -/** - * Shape of the filter state held in the view store. Status is excluded — it - * controls which buckets are visible on the page, not which buckets are - * fetched (see {@link buildIssueListFilter} below). - */ -export interface IssueViewFilters { +export interface IssueFilters { + statusFilters: IssueStatus[]; priorityFilters: IssuePriority[]; assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; - labelFilters: string[]; } /** - * Translate the view-store filter state into the wire-shape filter accepted - * by `GET /api/issues`. Each filter type is OR'd within itself and AND'd - * with the others — same semantics the SQL layer enforces. + * Filter issues using positive selection model. + * Empty arrays = no filter (show all). Non-empty = show only matching. * - * Assignee and project filters can mix actor IDs with the "no assignee / - * project" toggle. The toggle is encoded as `include_no_*: true`, which the - * backend OR's against the id-list match. - * - * Returns an empty object when nothing is selected — callers can pass this - * to {@link issueListOptions} as a no-op filter. + * Assignee has a special "No assignee" toggle (includeNoAssignee): + * - When only includeNoAssignee is true → show only unassigned issues + * - When assigneeFilters has items → show only those assignees' issues + * - When both → show matching assignees + unassigned */ -export function buildIssueListFilter( - filters: IssueViewFilters, -): IssueListFilter { - const out: IssueListFilter = {}; - if (filters.priorityFilters.length > 0) { - out.priorities = [...filters.priorityFilters]; - } - // Assignee + creator filters carry an actor type alongside the id, but the - // backend keys assignment by id alone (member/agent ids never collide), so - // we send only the ids. - if (filters.assigneeFilters.length > 0) { - out.assignee_ids = filters.assigneeFilters.map((f) => f.id); - } - if (filters.includeNoAssignee) out.include_no_assignee = true; - if (filters.creatorFilters.length > 0) { - out.creator_ids = filters.creatorFilters.map((f) => f.id); - } - if (filters.projectFilters.length > 0) { - out.project_ids = [...filters.projectFilters]; - } - if (filters.includeNoProject) out.include_no_project = true; - if (filters.labelFilters.length > 0) { - out.label_ids = [...filters.labelFilters]; - } - return out; +export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] { + const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters; + const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee; + const hasProjectFilter = projectFilters.length > 0 || includeNoProject; + + return issues.filter((issue) => { + if (statusFilters.length > 0 && !statusFilters.includes(issue.status)) + return false; + + if (priorityFilters.length > 0 && !priorityFilters.includes(issue.priority)) + return false; + + if (hasAssigneeFilter) { + if (!issue.assignee_id) { + // Unassigned issue — show only if "No assignee" is checked + if (!includeNoAssignee) return false; + } else if (assigneeFilters.length > 0) { + // Assigned issue — show only if assignee is in the filter list + if (!assigneeFilters.some( + (f) => f.type === issue.assignee_type && f.id === issue.assignee_id, + )) return false; + } else { + // Only "No assignee" is checked, no specific assignees → hide assigned issues + return false; + } + } + + if ( + creatorFilters.length > 0 && + !creatorFilters.some( + (f) => f.type === issue.creator_type && f.id === issue.creator_id, + ) + ) { + return false; + } + + if (hasProjectFilter) { + if (!issue.project_id) { + if (!includeNoProject) return false; + } else if (projectFilters.length > 0) { + if (!projectFilters.includes(issue.project_id)) return false; + } else { + // Only "No project" is checked → hide issues that have a project + return false; + } + } + + return true; + }); } diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx index 546700720..03c9b2af1 100644 --- a/packages/views/my-issues/components/my-issues-page.tsx +++ b/packages/views/my-issues/components/my-issues-page.tsx @@ -11,6 +11,7 @@ import { useCurrentWorkspace } from "@multica/core/paths"; import { WorkspaceAvatar } from "../../workspace/workspace-avatar"; import { useQuery } from "@tanstack/react-query"; import { agentListOptions } from "@multica/core/workspace/queries"; +import { filterIssues } from "../../issues/utils/filter"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store"; @@ -54,38 +55,36 @@ export function MyIssuesPage() { const filter: MyIssuesFilter = useMemo(() => { if (!user) return {}; - // Server-side filtering — priority filters are added so paginated buckets - // reflect the user's selection even when matches sit past the first page. - // Status filtering stays client-side via `visibleStatuses`: each status - // bucket is fetched independently anyway, so hiding columns is a render - // concern, not a data-fetching one. - const base: MyIssuesFilter = - priorityFilters.length > 0 ? { priorities: [...priorityFilters] } : {}; switch (scope) { case "assigned": - return { ...base, assignee_ids: [user.id] }; + return { assignee_id: user.id }; case "created": - return { ...base, creator_ids: [user.id] }; + return { creator_id: user.id }; case "agents": - return { ...base, assignee_ids: myAgentIds }; + return { assignee_ids: myAgentIds }; default: - return { ...base, assignee_ids: [user.id] }; + return { assignee_id: user.id }; } - }, [scope, user, myAgentIds, priorityFilters]); + }, [scope, user, myAgentIds]); - // The "My Agents" tab is empty by definition when the user owns no agents. - // Without this guard the filter would resolve to `assignee_ids: []`, which - // the API client and query-key normalizer drop as falsy — the request would - // then go out unfiltered and surface other people's issues under "My Agents". - const myAgentsEmpty = scope === "agents" && myAgentIds.length === 0; - const { data: myIssues = [], isLoading: loading } = useQuery({ - ...myIssueListOptions(wsId, scope, filter), - enabled: !myAgentsEmpty, - }); - // Server-side filtering means `myIssues` already reflects the active scope - // + priority filter; rendering this directly avoids the pagination bug - // that the old client-side `filterIssues` step caused. - const issues = myIssues; + const { data: myIssues = [], isLoading: loading } = useQuery( + myIssueListOptions(wsId, scope, filter), + ); + + // Apply status/priority filters from view store + const issues = useMemo( + () => + filterIssues(myIssues, { + statusFilters, + priorityFilters, + assigneeFilters: [], + includeNoAssignee: false, + creatorFilters: [], + projectFilters: [], + includeNoProject: false, + }), + [myIssues, statusFilters, priorityFilters], + ); const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 08d2be818..24db446fb 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo, useState, useCallback, useRef, useEffect } from "react"; -import { useStore } from "zustand"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Trash2, UserMinus } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -23,7 +22,7 @@ import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG, PROJECT_PRIORITY_ORDER, PR import { BOARD_STATUSES } from "@multica/core/issues/config"; import { createIssueViewStore } from "@multica/core/issues/stores/view-store"; import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context"; -import { buildIssueListFilter } from "../../issues/utils/filter"; +import { filterIssues } from "../../issues/utils/filter"; import { getProjectIssueMetrics } from "./project-issue-metrics"; import { ActorAvatar } from "../../common/actor-avatar"; import { AppLink, useNavigation } from "../../navigation"; @@ -100,7 +99,6 @@ function ProjectIssuesContent({ scope, filter, }: { - /** Issues already fetched server-side with this project + view-store filter applied. */ projectIssues: Issue[]; scope: string; filter: MyIssuesFilter; @@ -108,12 +106,15 @@ function ProjectIssuesContent({ const wsId = useWorkspaceId(); const viewMode = useViewStore((s) => s.viewMode); const statusFilters = useViewStore((s) => s.statusFilters); + const priorityFilters = useViewStore((s) => s.priorityFilters); + const assigneeFilters = useViewStore((s) => s.assigneeFilters); + const includeNoAssignee = useViewStore((s) => s.includeNoAssignee); + const creatorFilters = useViewStore((s) => s.creatorFilters); - // Server-side filtering means `projectIssues` already reflects priority / - // assignee / creator / label filters. Status filtering remains a render - // concern (each status bucket is fetched independently anyway, see - // visibleStatuses below). - const issues = projectIssues; + const issues = useMemo( + () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }), + [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], + ); const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); @@ -194,32 +195,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) { const workspaceName = workspace?.name; const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId)); const projectScope = `project:${projectId}`; - // Read view-store filter state directly from the project's own view store - // (lives outside the ViewStoreProvider here). Merging it into the query - // filter means filter changes trigger a refetch under a fresh cache key - // — fixing the pagination bug where client-side filtering hid matches - // sitting past the first page (#1491). - const priorityFilters = useStore(projectViewStore, (s) => s.priorityFilters); - const assigneeFilters = useStore(projectViewStore, (s) => s.assigneeFilters); - const includeNoAssignee = useStore(projectViewStore, (s) => s.includeNoAssignee); - const creatorFilters = useStore(projectViewStore, (s) => s.creatorFilters); - const labelFilters = useStore(projectViewStore, (s) => s.labelFilters); const projectFilter = useMemo( - () => ({ - project_ids: [projectId], - ...buildIssueListFilter({ - priorityFilters, - assigneeFilters, - includeNoAssignee, - creatorFilters, - // Project filter is fixed by the page route — view-store project - // selections don't apply here. - projectFilters: [], - includeNoProject: false, - labelFilters, - }), - }), - [projectId, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters], + () => ({ project_id: projectId }), + [projectId], ); const { data: projectIssues = [] } = useQuery( myIssueListOptions(wsId, projectScope, projectFilter), @@ -601,7 +579,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) { - + 0 { - params.Set("label_ids", strings.Join(labels, ",")) - } path := "/api/issues" if len(params) > 0 { diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index 6634ee4be..039e3cce2 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -8,7 +8,6 @@ import ( "errors" "log/slog" "net/http" - "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" @@ -154,14 +153,7 @@ func parseUUIDOrBadRequest(w http.ResponseWriter, s, fieldName string) (pgtype.U return u, true } -// parseUUIDSliceOrBadRequest mirrors parseUUIDOrBadRequest for slice inputs. -// An empty input slice returns nil (not a zero-length slice) so callers can -// distinguish "not provided" from "explicitly empty" — both look the same on -// the wire, but the SQL-layer narg check expects NULL for "no filter". func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName string) ([]pgtype.UUID, bool) { - if len(ids) == 0 { - return nil, true - } uuids := make([]pgtype.UUID, len(ids)) for i, id := range ids { u, err := util.ParseUUID(id) @@ -174,26 +166,6 @@ func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName s return uuids, true } -// splitCSV parses a comma-separated query-string value into a slice, trimming -// whitespace and dropping empty entries. Returns nil for an empty input so -// callers can distinguish "absent" from "present but empty". -func splitCSV(s string) []string { - if s == "" { - return nil - } - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { - out = append(out, t) - } - } - if len(out) == 0 { - return nil - } - return out -} - // publish sends a domain event through the event bus. func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) { h.Bus.Publish(events.Event{ diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 55d433c6e..10eee7d73 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -609,75 +609,56 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // Parse optional filter params. Malformed UUIDs in filters return 400 — // silently coercing them to a zero UUID would mask a client bug and let // the query return an empty result set (or worse, match a NULL row). - q := r.URL.Query() - - priorities := splitCSV(q.Get("priorities")) - if priorities == nil { - // Backwards compat: accept legacy single-value `priority`. - if p := q.Get("priority"); p != "" { - priorities = []string{p} + var priorityFilter pgtype.Text + if p := r.URL.Query().Get("priority"); p != "" { + priorityFilter = pgtype.Text{String: p, Valid: true} + } + var assigneeFilter pgtype.UUID + if a := r.URL.Query().Get("assignee_id"); a != "" { + id, ok := parseUUIDOrBadRequest(w, a, "assignee_id") + if !ok { + return } + assigneeFilter = id } - assigneeTypes := splitCSV(q.Get("assignee_types")) - assigneeIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("assignee_ids")), "assignee_ids") - if !ok { - return - } - if assigneeIds == nil { - // Backwards compat: accept legacy single-value `assignee_id`. - if a := q.Get("assignee_id"); a != "" { - id, ok := parseUUIDOrBadRequest(w, a, "assignee_id") - if !ok { - return + var assigneeIdsFilter []pgtype.UUID + if ids := r.URL.Query().Get("assignee_ids"); ids != "" { + for _, raw := range strings.Split(ids, ",") { + if s := strings.TrimSpace(raw); s != "" { + id, ok := parseUUIDOrBadRequest(w, s, "assignee_ids") + if !ok { + return + } + assigneeIdsFilter = append(assigneeIdsFilter, id) } - assigneeIds = []pgtype.UUID{id} } } - creatorIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("creator_ids")), "creator_ids") - if !ok { - return - } - if creatorIds == nil { - if c := q.Get("creator_id"); c != "" { - id, ok := parseUUIDOrBadRequest(w, c, "creator_id") - if !ok { - return - } - creatorIds = []pgtype.UUID{id} + var creatorFilter pgtype.UUID + if c := r.URL.Query().Get("creator_id"); c != "" { + id, ok := parseUUIDOrBadRequest(w, c, "creator_id") + if !ok { + return } + creatorFilter = id } - projectIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("project_ids")), "project_ids") - if !ok { - return - } - if projectIds == nil { - if p := q.Get("project_id"); p != "" { - id, ok := parseUUIDOrBadRequest(w, p, "project_id") - if !ok { - return - } - projectIds = []pgtype.UUID{id} + var projectFilter pgtype.UUID + if p := r.URL.Query().Get("project_id"); p != "" { + id, ok := parseUUIDOrBadRequest(w, p, "project_id") + if !ok { + return } + projectFilter = id } - labelIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("label_ids")), "label_ids") - if !ok { - return - } - includeNoAssignee := pgtype.Bool{Bool: q.Get("include_no_assignee") == "true", Valid: true} - includeNoProject := pgtype.Bool{Bool: q.Get("include_no_project") == "true", Valid: true} // open_only=true returns all non-done/cancelled issues (no limit). - if q.Get("open_only") == "true" { + if r.URL.Query().Get("open_only") == "true" { issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ - WorkspaceID: wsUUID, - Priorities: priorities, - AssigneeTypes: assigneeTypes, - AssigneeIds: assigneeIds, - IncludeNoAssignee: includeNoAssignee, - CreatorIds: creatorIds, - ProjectIds: projectIds, - IncludeNoProject: includeNoProject, - LabelIds: labelIds, + WorkspaceID: wsUUID, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -709,35 +690,32 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { limit := 100 offset := 0 - if l := q.Get("limit"); l != "" { + if l := r.URL.Query().Get("limit"); l != "" { if v, err := strconv.Atoi(l); err == nil { limit = v } } - if o := q.Get("offset"); o != "" { + if o := r.URL.Query().Get("offset"); o != "" { if v, err := strconv.Atoi(o); err == nil { offset = v } } var statusFilter pgtype.Text - if s := q.Get("status"); s != "" { + if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ - WorkspaceID: wsUUID, - Limit: int32(limit), - Offset: int32(offset), - Status: statusFilter, - Priorities: priorities, - AssigneeTypes: assigneeTypes, - AssigneeIds: assigneeIds, - IncludeNoAssignee: includeNoAssignee, - CreatorIds: creatorIds, - ProjectIds: projectIds, - IncludeNoProject: includeNoProject, - LabelIds: labelIds, + WorkspaceID: wsUUID, + Limit: int32(limit), + Offset: int32(offset), + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -746,16 +724,13 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // Get the true total count for pagination awareness. total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{ - WorkspaceID: wsUUID, - Status: statusFilter, - Priorities: priorities, - AssigneeTypes: assigneeTypes, - AssigneeIds: assigneeIds, - IncludeNoAssignee: includeNoAssignee, - CreatorIds: creatorIds, - ProjectIds: projectIds, - IncludeNoProject: includeNoProject, - LabelIds: labelIds, + WorkspaceID: wsUUID, + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, }) if err != nil { total = int64(len(issues)) diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index cf5936555..d33dc7eb4 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -97,51 +97,32 @@ const countIssues = `-- name: CountIssues :one SELECT count(*) FROM issue WHERE workspace_id = $1 AND ($2::text IS NULL OR status = $2) - AND ($3::text[] IS NULL OR priority = ANY($3::text[])) - AND ($4::text[] IS NULL OR assignee_type = ANY($4::text[])) - AND ( - ($5::uuid[] IS NULL AND NOT COALESCE($6::bool, false)) - OR assignee_id = ANY($5::uuid[]) - OR (COALESCE($6::bool, false) AND assignee_id IS NULL) - ) - AND ($7::uuid[] IS NULL OR creator_id = ANY($7::uuid[])) - AND ( - ($8::uuid[] IS NULL AND NOT COALESCE($9::bool, false)) - OR project_id = ANY($8::uuid[]) - OR (COALESCE($9::bool, false) AND project_id IS NULL) - ) - AND ($10::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY($10::uuid[]) - )) + AND ($3::text IS NULL OR priority = $3) + AND ($4::uuid IS NULL OR assignee_id = $4) + AND ($5::uuid[] IS NULL OR assignee_id = ANY($5::uuid[])) + AND ($6::uuid IS NULL OR creator_id = $6) + AND ($7::uuid IS NULL OR project_id = $7) ` type CountIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Status pgtype.Text `json:"status"` - Priorities []string `json:"priorities"` - AssigneeTypes []string `json:"assignee_types"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` - CreatorIds []pgtype.UUID `json:"creator_ids"` - ProjectIds []pgtype.UUID `json:"project_ids"` - IncludeNoProject pgtype.Bool `json:"include_no_project"` - LabelIds []pgtype.UUID `json:"label_ids"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` } func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) { row := q.db.QueryRow(ctx, countIssues, arg.WorkspaceID, arg.Status, - arg.Priorities, - arg.AssigneeTypes, + arg.Priority, + arg.AssigneeID, arg.AssigneeIds, - arg.IncludeNoAssignee, - arg.CreatorIds, - arg.ProjectIds, - arg.IncludeNoProject, - arg.LabelIds, + arg.CreatorID, + arg.ProjectID, ) var count int64 err := row.Scan(&count) @@ -478,41 +459,25 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) - AND ($5::text[] IS NULL OR priority = ANY($5::text[])) - AND ($6::text[] IS NULL OR assignee_type = ANY($6::text[])) - AND ( - ($7::uuid[] IS NULL AND NOT COALESCE($8::bool, false)) - OR assignee_id = ANY($7::uuid[]) - OR (COALESCE($8::bool, false) AND assignee_id IS NULL) - ) - AND ($9::uuid[] IS NULL OR creator_id = ANY($9::uuid[])) - AND ( - ($10::uuid[] IS NULL AND NOT COALESCE($11::bool, false)) - OR project_id = ANY($10::uuid[]) - OR (COALESCE($11::bool, false) AND project_id IS NULL) - ) - AND ($12::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY($12::uuid[]) - )) + AND ($5::text IS NULL OR priority = $5) + AND ($6::uuid IS NULL OR assignee_id = $6) + AND ($7::uuid[] IS NULL OR assignee_id = ANY($7::uuid[])) + AND ($8::uuid IS NULL OR creator_id = $8) + AND ($9::uuid IS NULL OR project_id = $9) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3 ` type ListIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - Status pgtype.Text `json:"status"` - Priorities []string `json:"priorities"` - AssigneeTypes []string `json:"assignee_types"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` - CreatorIds []pgtype.UUID `json:"creator_ids"` - ProjectIds []pgtype.UUID `json:"project_ids"` - IncludeNoProject pgtype.Bool `json:"include_no_project"` - LabelIds []pgtype.UUID `json:"label_ids"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` } type ListIssuesRow struct { @@ -541,14 +506,11 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI arg.Limit, arg.Offset, arg.Status, - arg.Priorities, - arg.AssigneeTypes, + arg.Priority, + arg.AssigneeID, arg.AssigneeIds, - arg.IncludeNoAssignee, - arg.CreatorIds, - arg.ProjectIds, - arg.IncludeNoProject, - arg.LabelIds, + arg.CreatorID, + arg.ProjectID, ) if err != nil { return nil, err @@ -593,37 +555,21 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') - AND ($2::text[] IS NULL OR priority = ANY($2::text[])) - AND ($3::text[] IS NULL OR assignee_type = ANY($3::text[])) - AND ( - ($4::uuid[] IS NULL AND NOT COALESCE($5::bool, false)) - OR assignee_id = ANY($4::uuid[]) - OR (COALESCE($5::bool, false) AND assignee_id IS NULL) - ) - AND ($6::uuid[] IS NULL OR creator_id = ANY($6::uuid[])) - AND ( - ($7::uuid[] IS NULL AND NOT COALESCE($8::bool, false)) - OR project_id = ANY($7::uuid[]) - OR (COALESCE($8::bool, false) AND project_id IS NULL) - ) - AND ($9::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY($9::uuid[]) - )) + AND ($2::text IS NULL OR priority = $2) + AND ($3::uuid IS NULL OR assignee_id = $3) + AND ($4::uuid[] IS NULL OR assignee_id = ANY($4::uuid[])) + AND ($5::uuid IS NULL OR creator_id = $5) + AND ($6::uuid IS NULL OR project_id = $6) ORDER BY position ASC, created_at DESC ` type ListOpenIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Priorities []string `json:"priorities"` - AssigneeTypes []string `json:"assignee_types"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` - CreatorIds []pgtype.UUID `json:"creator_ids"` - ProjectIds []pgtype.UUID `json:"project_ids"` - IncludeNoProject pgtype.Bool `json:"include_no_project"` - LabelIds []pgtype.UUID `json:"label_ids"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` } type ListOpenIssuesRow struct { @@ -649,14 +595,11 @@ type ListOpenIssuesRow struct { func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, - arg.Priorities, - arg.AssigneeTypes, + arg.Priority, + arg.AssigneeID, arg.AssigneeIds, - arg.IncludeNoAssignee, - arg.CreatorIds, - arg.ProjectIds, - arg.IncludeNoProject, - arg.LabelIds, + arg.CreatorID, + arg.ProjectID, ) if err != nil { return nil, err diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 26308b8cb..617e7fab8 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -5,24 +5,11 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) - AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) - AND ( - (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) - OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) - ) - AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) - AND ( - (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) - OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) - ) - AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) - )) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3; @@ -91,48 +78,22 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') - AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) - AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) - AND ( - (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) - OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) - ) - AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) - AND ( - (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) - OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) - ) - AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) - )) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) ORDER BY position ASC, created_at DESC; -- name: CountIssues :one SELECT count(*) FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) - AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) - AND ( - (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) - OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) - ) - AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) - AND ( - (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) - OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) - ) - AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) - )); + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')); -- name: ListChildIssues :many SELECT * FROM issue