Revert "feat(issues): server-side filters incl. label, fixing pagination drop…" (#1779)

This reverts commit 246fcd4ce4.
This commit is contained in:
Bohan Jiang
2026-04-28 16:29:42 +08:00
committed by GitHub
parent 246fcd4ce4
commit abd69890a8
22 changed files with 470 additions and 884 deletions
+4 -7
View File
@@ -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}`);
}
+38 -97
View File
@@ -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<ListIssuesCache>(
{ 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<ListIssuesCache>({ 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<ListIssuesCache>({
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<ListIssuesCache>(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<ListIssuesCache>(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<ListIssuesCache>(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<ListIssuesCache>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(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<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
updateAllListCaches(qc, wsId, (old) =>
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, id, data) : old,
);
qc.setQueryData<Issue>(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<ListIssuesCache>(issueKeys.list(wsId));
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
qc.setQueryData<ListIssuesCache>(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<ListIssuesCache>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesCache>(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<ListIssuesCache>(issueKeys.list(wsId));
const parentIssueIds = new Set<string>();
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<ListIssuesCache>(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) });
+12 -47
View File
@@ -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<ListIssuesCache> {
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
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<ListIssues
* `Issue[]` for consumers. Mutations and ws-updaters must use
* `setQueryData<ListIssuesCache>(...)` 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),
-11
View File
@@ -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<IssueViewState>["setState"]): Issue
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
@@ -150,12 +147,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["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<IssueViewState>["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,
+9 -42
View File
@@ -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<ListIssuesCache>(
{ queryKey: issueKeys.listPrefix(wsId) },
updater,
);
}
function findIssueAcrossListCaches(
qc: QueryClient,
wsId: string,
issueId: string,
) {
const entries = qc.getQueriesData<ListIssuesCache>({
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<ListIssuesCache>(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<ListIssuesCache>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(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<ListIssuesCache>(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<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issueId, { labels }) : old,
);
qc.setQueryData<Issue>(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<ListIssuesCache>(issueKeys.list(wsId));
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
updateAllListCaches(qc, wsId, (old) =>
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
+4 -11
View File
@@ -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;
}
@@ -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<string, { key: readonly unknown[]; data: unknown }>();
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<string, unknown>();
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<readonly [readonly unknown[], unknown]> = [];
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;
}
@@ -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<ListIssuesCache>({
queryKey: issueKeys.listPrefix(wsId),
});
const seen = new Set<string>();
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<ListIssuesCache>(issueKeys.list(wsId));
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
const q = query.toLowerCase();
+15 -28
View File
@@ -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<string, ChildProgress>;
/** 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<Issue | null>(null);
@@ -300,14 +287,14 @@ export function BoardView({
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
loadMoreOptions={loadMoreOptions}
myIssuesOpts={myIssuesOpts}
/>
))}
{hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
loadMoreOptions={loadMoreOptions}
myIssuesOpts={myIssuesOpts}
/>
)}
</div>
@@ -328,17 +315,17 @@ function PaginatedBoardColumn({
issueIds,
issueMap,
childProgressMap,
loadMoreOptions,
myIssuesOpts,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
loadMoreOptions: LoadMoreOptions;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
status,
loadMoreOptions,
myIssuesOpts,
);
return (
<BoardColumn
@@ -358,10 +345,10 @@ function PaginatedBoardColumn({
function HiddenColumnsPanel({
hiddenStatuses,
loadMoreOptions,
myIssuesOpts,
}: {
hiddenStatuses: IssueStatus[];
loadMoreOptions: LoadMoreOptions;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
return (
<div className="flex w-[240px] shrink-0 flex-col">
@@ -375,7 +362,7 @@ function HiddenColumnsPanel({
<HiddenColumnRow
key={status}
status={status}
loadMoreOptions={loadMoreOptions}
myIssuesOpts={myIssuesOpts}
/>
))}
</div>
@@ -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 (
<div className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50">
<div className="flex items-center gap-2">
@@ -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<string, number>();
const creator = new Map<string, number>();
const project = new Map<string, number>();
const label = new Map<string, number>();
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<string, number>;
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 (
<>
<div className="px-2 py-1.5 border-b border-foreground/5">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
autoFocus
/>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{filtered.map((l) => {
const checked = selected.includes(l.id);
const count = counts.get(l.id) ?? 0;
return (
<DropdownMenuCheckboxItem
key={l.id}
checked={checked}
onCheckedChange={() => onToggle(l.id)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<LabelChip label={l} />
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{count}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
{filtered.length === 0 && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
{search ? "No results" : "No labels yet"}
</div>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// 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[] }) {
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Label */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Tag className="size-3.5" />
<span className="flex-1">Label</span>
{labelFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{labelFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
<LabelSubContent
counts={counts.label}
selected={labelFilters}
onToggle={act.toggleLabelFilter}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Reset */}
{hasActiveFilters && (
<>
@@ -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(),
@@ -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() {
<ViewStoreProvider store={useIssueViewStore}>
{/* Header 2: Scope tabs + filters */}
<IssuesHeader issues={issues} />
<IssuesHeader scopedIssues={scopedIssues} />
{/* Content: scrollable */}
{issues.length === 0 ? (
{scopedIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">No issues yet</p>
@@ -180,15 +168,9 @@ export function IssuesPage() {
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
listFilter={listFilter}
/>
) : (
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
childProgressMap={childProgressMap}
listFilter={listFilter}
/>
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
)}
</div>
)}
+8 -19
View File
@@ -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<string, ChildProgress>();
/**
* 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<string, ChildProgress>;
/** 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 (
<div className="flex-1 min-h-0 overflow-y-auto p-2">
@@ -96,7 +85,7 @@ export function ListView({
status={status}
issues={issuesByStatus.get(status) ?? []}
childProgressMap={childProgressMap}
loadMoreOptions={loadMoreOptions}
myIssuesOpts={myIssuesOpts}
/>
))}
</Accordion.Root>
@@ -108,12 +97,12 @@ function StatusAccordionItem({
status,
issues,
childProgressMap,
loadMoreOptions,
myIssuesOpts,
}: {
status: IssueStatus;
issues: Issue[];
childProgressMap: Map<string, ChildProgress>;
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);
+135 -64
View File
@@ -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> = {}): 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"]);
});
});
+58 -43
View File
@@ -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;
});
}
@@ -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));
@@ -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<MyIssuesFilter>(
() => ({
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 }) {
</PageHeader>
<ViewStoreProvider store={projectViewStore}>
<IssuesHeader issues={projectIssues} />
<IssuesHeader scopedIssues={projectIssues} />
<ProjectIssuesContent
projectIssues={projectIssues}
scope={projectScope}
-4
View File
@@ -251,7 +251,6 @@ func init() {
issueListCmd.Flags().String("priority", "", "Filter by priority")
issueListCmd.Flags().String("assignee", "", "Filter by assignee name")
issueListCmd.Flags().String("project", "", "Filter by project ID")
issueListCmd.Flags().StringSlice("label", nil, "Filter by label ID (repeatable; matches issues with any of the given labels)")
issueListCmd.Flags().Int("limit", 50, "Maximum number of issues to return")
issueListCmd.Flags().Int("offset", 0, "Number of issues to skip (for pagination)")
@@ -374,9 +373,6 @@ func runIssueList(cmd *cobra.Command, _ []string) error {
if v, _ := cmd.Flags().GetString("project"); v != "" {
params.Set("project_id", v)
}
if labels, _ := cmd.Flags().GetStringSlice("label"); len(labels) > 0 {
params.Set("label_ids", strings.Join(labels, ","))
}
path := "/api/issues"
if len(params) > 0 {
-28
View File
@@ -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{
+57 -82
View File
@@ -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))
+49 -106
View File
@@ -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
+15 -54
View File
@@ -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