Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
c0e3a9b8e5 fix(issues): route Members/Agents scope through server-side filter
The Members/Agents scope tabs on the workspace issues page were still
narrowing client-side via `assignee_type === 'member'`. That hits the
exact pagination-blind bug this PR is meant to fix: if the first 50
issues per status don't include the right assignee type, the tab
shows "No issues" while later pages have matches.

Adds an `assignee_types text[]` filter to ListIssues / ListOpenIssues /
CountIssues, threads it through the API client, normalizer and view
filter, and maps the scope tab to it. Each scope now keys its own
list cache and refetches with the correct first page.

Also disables the My Issues "My Agents" query when the user owns no
agents — `assignee_ids: []` was getting dropped by both the API client
and the query-key normalizer, so the request went out unfiltered and
surfaced unrelated issues under "My Agents".
2026-04-28 16:00:40 +08:00
Jiang Bohan
c337b7d095 feat(issues): drive workspace + my-issues filters from the server
issueListOptions and myIssueListOptions now key the React Query cache
on a normalized filter object, so each filter combination has its own
cache entry and a filter change re-fetches with the wire-shape filter
applied server-side. Drops the client-side filterIssues step on the
issues page, my-issues page, and project detail — that step silently
hid matches that lived past the first paginated page (#1491).

Adds a Label submenu to the workspace issues filter dropdown, plus
labelFilters in the view store. Mutations and ws-updaters fan their
optimistic patches across every filter-keyed list cache via
qc.setQueriesData on issueKeys.listPrefix(wsId), and the editor's
mention-suggestion reads from any matching list cache for instant
first paint regardless of which filter is active.
2026-04-28 15:32:28 +08:00
Jiang Bohan
fe5d541fea feat(issues): server-side label + filter querying for issue list
Extends GET /api/issues with label_ids, priorities, creator_ids,
project_ids, include_no_assignee, and include_no_project params, and
moves the existing single-value filters onto array-form. Each filter
becomes part of the SQL WHERE clause so paginated buckets reflect the
user's selection — fixes the bug where client-side filtering hid
matches sitting past the first page (#1491).

CLI gains a repeatable --label flag; legacy --priority/--assignee/
--project keep working via the single-value compatibility paths.
2026-04-28 15:32:17 +08:00
22 changed files with 885 additions and 471 deletions

View File

@@ -363,11 +363,14 @@ 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?.priority) search.set("priority", params.priority);
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
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?.assignee_ids?.length) search.set("assignee_ids", params.assignee_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?.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?.open_only) search.set("open_only", "true");
return this.fetch(`/api/issues?${search}`);
}

View File

@@ -1,9 +1,10 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { api } from "../api";
import {
issueKeys,
ISSUE_PAGE_SIZE,
type IssueListFilter,
type MyIssuesFilter,
} from "./queries";
import {
@@ -24,6 +25,60 @@ 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.
@@ -51,15 +106,17 @@ export type ToggleIssueReactionVars = {
*/
export function useLoadMoreByStatus(
status: IssueStatus,
myIssues?: { scope: string; filter: MyIssuesFilter },
options: { filter?: IssueListFilter; 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);
: issueKeys.list(wsId, filter);
const requestFilter = myIssues?.filter ?? filter ?? {};
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
const bucket = cache?.byStatus[status];
const loaded = bucket?.issues.length ?? 0;
@@ -74,7 +131,7 @@ export function useLoadMoreByStatus(
status,
limit: ISSUE_PAGE_SIZE,
offset: loaded,
...myIssues?.filter,
...requestFilter,
});
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
if (!old) return old;
@@ -89,7 +146,7 @@ export function useLoadMoreByStatus(
} finally {
setIsLoading(false);
}
}, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
}, [qc, queryKey, status, loaded, hasMore, isLoading, requestFilter]);
return { loadMore, hasMore, isLoading, total };
}
@@ -104,7 +161,11 @@ export function useCreateIssue() {
return useMutation({
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
// 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) =>
old ? addIssueToBuckets(old, newIssue) : old,
);
// Surface the just-created issue in cmd+k's Recent list without
@@ -117,7 +178,7 @@ export function useCreateIssue() {
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) });
},
});
}
@@ -133,8 +194,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.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) });
const listSnapshots = snapshotAllListCaches(qc, wsId);
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
// Resolve parent_issue_id from the freshest source so we can keep the
@@ -142,13 +203,13 @@ export function useUpdateIssue() {
// sub-issues list).
const parentId =
prevDetail?.parent_issue_id ??
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
findIssueAcrossListCaches(qc, wsId, id)?.parent_issue_id ??
null;
const prevChildren = parentId
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
updateAllListCaches(qc, wsId, (old) =>
old ? patchIssueInBuckets(old, id, data) : old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
@@ -161,10 +222,10 @@ export function useUpdateIssue() {
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
);
}
return { prevList, prevDetail, prevChildren, parentId, id };
return { listSnapshots, prevDetail, prevChildren, parentId, id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots);
if (ctx?.prevDetail)
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
if (ctx?.parentId && ctx.prevChildren !== undefined) {
@@ -176,7 +237,7 @@ export function useUpdateIssue() {
},
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) });
// Invalidate old parent's children cache
if (ctx?.parentId) {
qc.invalidateQueries({
@@ -202,20 +263,20 @@ export function useDeleteIssue() {
return useMutation({
mutationFn: (id: string) => api.deleteIssue(id),
onMutate: async (id) => {
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) =>
await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) });
const listSnapshots = snapshotAllListCaches(qc, wsId);
const deleted = findIssueAcrossListCaches(qc, wsId, id);
updateAllListCaches(qc, wsId, (old) =>
old ? removeIssueFromBuckets(old, id) : old,
);
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList, parentIssueId: deleted?.parent_issue_id };
return { listSnapshots, parentIssueId: deleted?.parent_issue_id };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots);
},
onSettled: (_data, _err, _id, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) });
if (ctx?.parentIssueId) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
@@ -236,21 +297,21 @@ export function useBatchUpdateIssues() {
updates: UpdateIssueRequest;
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) });
const listSnapshots = snapshotAllListCaches(qc, wsId);
updateAllListCaches(qc, wsId, (old) => {
if (!old) return old;
let next = old;
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
return next;
});
return { prevList };
return { listSnapshots };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) });
},
});
}
@@ -261,28 +322,26 @@ export function useBatchDeleteIssues() {
return useMutation({
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) });
const listSnapshots = snapshotAllListCaches(qc, wsId);
const parentIssueIds = new Set<string>();
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);
}
for (const id of ids) {
const found = findIssueAcrossListCaches(qc, wsId, id);
if (found?.parent_issue_id) parentIssueIds.add(found.parent_issue_id);
}
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
updateAllListCaches(qc, wsId, (old) => {
if (!old) return old;
let next = old;
for (const id of ids) next = removeIssueFromBuckets(next, id);
return next;
});
return { prevList, parentIssueIds };
return { listSnapshots, parentIssueIds };
},
onError: (_err, _ids, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots);
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) });
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
for (const parentId of ctx.parentIssueIds) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });

View File

@@ -5,12 +5,14 @@ import { BOARD_STATUSES } from "./config";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] 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,
/** 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: MyIssuesFilter) =>
[...issueKeys.myAll(wsId), scope, filter] as const,
myList: (wsId: string, scope: string, filter: IssueListFilter) =>
[...issueKeys.myAll(wsId), scope, normalizeFilter(filter)] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
@@ -22,13 +24,44 @@ 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,
};
export type MyIssuesFilter = Pick<
/**
* 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<
ListIssuesParams,
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
"limit" | "offset" | "workspace_id" | "status" | "open_only"
>;
/** 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;
@@ -45,7 +78,7 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
return out;
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
async function fetchFirstPages(filter: IssueListFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
@@ -65,13 +98,15 @@ async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesC
* `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. Use
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
* 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.
*/
export function issueListOptions(wsId: string) {
export function issueListOptions(wsId: string, filter: IssueListFilter = EMPTY_FILTER) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: () => fetchFirstPages(),
queryKey: issueKeys.list(wsId, filter),
queryFn: () => fetchFirstPages(filter),
select: flattenIssueBuckets,
});
}
@@ -83,7 +118,7 @@ export function issueListOptions(wsId: string) {
export function myIssueListOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
filter: IssueListFilter,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),

View File

@@ -55,6 +55,7 @@ export interface IssueViewState {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
@@ -67,6 +68,7 @@ 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;
@@ -85,6 +87,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
@@ -147,6 +150,12 @@ 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
@@ -172,6 +181,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
}),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
@@ -202,6 +212,7 @@ 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,

View File

@@ -9,14 +9,45 @@ 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,
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
// Insert into every active list cache. Filter mismatches are corrected
// by the trailing `invalidateQueries` on the listPrefix.
updateAllListCaches(qc, 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) });
@@ -32,18 +63,17 @@ 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 ??
(listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ??
findIssueAcrossListCaches(qc, wsId, issue.id)?.parent_issue_id ??
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;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
updateAllListCaches(qc, wsId, (old) =>
old ? patchIssueInBuckets(old, issue.id, issue) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
@@ -76,6 +106,9 @@ 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,
@@ -83,12 +116,13 @@ export function onIssueLabelsChanged(
issueId: string,
labels: Label[],
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
updateAllListCaches(qc, 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) });
}
@@ -97,11 +131,10 @@ export function onIssueDeleted(
wsId: string,
issueId: string,
) {
// 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;
// Look up the issue before removing it to check for parent_issue_id.
const deleted = findIssueAcrossListCaches(qc, wsId, issueId);
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
updateAllListCaches(qc, wsId, (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });

View File

@@ -34,11 +34,18 @@ export interface ListIssuesParams {
offset?: number;
workspace_id?: string;
status?: IssueStatus;
priority?: IssuePriority;
assignee_id?: string;
priorities?: IssuePriority[];
/** Match issues whose assignee is one of these polymorphic types ("member"/"agent"). */
assignee_types?: IssueAssigneeType[];
assignee_ids?: string[];
creator_id?: string;
project_id?: 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[];
open_only?: boolean;
}

View File

@@ -26,20 +26,40 @@ 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, unknown>();
map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []);
map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []);
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 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 };
}
map.set(
JSON.stringify(issueKeys.list("ws-1")),
{ byStatus } satisfies ListIssuesCache,
);
const listKey = issueKeys.list("ws-1");
map.set(JSON.stringify(listKey), {
key: listKey,
data: { byStatus } satisfies ListIssuesCache,
});
return {
getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)),
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;
},
} as unknown as QueryClient;
}

View File

@@ -254,8 +254,22 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
const cachedResponse = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
// 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 q = query.toLowerCase();

View File

@@ -19,7 +19,17 @@ 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 { MyIssuesFilter } from "@multica/core/issues/queries";
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 {
DropdownMenu,
DropdownMenuTrigger,
@@ -103,6 +113,7 @@ export function BoardView({
hiddenStatuses,
onMoveIssue,
childProgressMap = EMPTY_PROGRESS_MAP,
listFilter,
myIssuesScope,
myIssuesFilter,
}: {
@@ -115,15 +126,17 @@ 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 myIssuesOpts = myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
const loadMoreOptions = myIssuesScope
? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } }
: { filter: listFilter };
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
@@ -287,14 +300,14 @@ export function BoardView({
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
loadMoreOptions={loadMoreOptions}
/>
))}
{hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
myIssuesOpts={myIssuesOpts}
loadMoreOptions={loadMoreOptions}
/>
)}
</div>
@@ -315,17 +328,17 @@ function PaginatedBoardColumn({
issueIds,
issueMap,
childProgressMap,
myIssuesOpts,
loadMoreOptions,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
loadMoreOptions: LoadMoreOptions;
}) {
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
status,
myIssuesOpts,
loadMoreOptions,
);
return (
<BoardColumn
@@ -345,10 +358,10 @@ function PaginatedBoardColumn({
function HiddenColumnsPanel({
hiddenStatuses,
myIssuesOpts,
loadMoreOptions,
}: {
hiddenStatuses: IssueStatus[];
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
loadMoreOptions: LoadMoreOptions;
}) {
return (
<div className="flex w-[240px] shrink-0 flex-col">
@@ -362,7 +375,7 @@ function HiddenColumnsPanel({
<HiddenColumnRow
key={status}
status={status}
myIssuesOpts={myIssuesOpts}
loadMoreOptions={loadMoreOptions}
/>
))}
</div>
@@ -372,14 +385,14 @@ function HiddenColumnsPanel({
function HiddenColumnRow({
status,
myIssuesOpts,
loadMoreOptions,
}: {
status: IssueStatus;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
loadMoreOptions: LoadMoreOptions;
}) {
const cfg = STATUS_CONFIG[status];
const viewStoreApi = useViewStoreApi();
const { total } = useLoadMoreByStatus(status, myIssuesOpts);
const { total } = useLoadMoreByStatus(status, loadMoreOptions);
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">

View File

@@ -14,6 +14,7 @@ import {
List,
SignalHigh,
SlidersHorizontal,
Tag,
User,
UserMinus,
UserPen,
@@ -49,8 +50,10 @@ 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,
@@ -94,6 +97,7 @@ function getActiveFilterCount(state: {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
}) {
let count = 0;
if (state.statusFilters.length > 0) count++;
@@ -101,6 +105,7 @@ 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;
}
@@ -111,6 +116,7 @@ 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;
@@ -133,9 +139,15 @@ 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 };
return { status, priority, assignee, creator, noAssignee, project, noProject, label };
}, [allIssues]);
}
@@ -375,11 +387,75 @@ 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({ scopedIssues }: { scopedIssues: Issue[] }) {
export function IssuesHeader({ issues }: { issues: Issue[] }) {
const scope = useIssuesScopeStore((s) => s.scope);
const setScope = useIssuesScopeStore((s) => s.setScope);
@@ -391,12 +467,13 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: 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(scopedIssues);
const counts = useIssueCounts(issues);
const hasActiveFilters =
getActiveFilterCount({
@@ -407,6 +484,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
}) > 0;
const sortLabel =
@@ -600,6 +678,26 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: 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 && (
<>

View File

@@ -106,6 +106,7 @@ 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 },
@@ -118,6 +119,7 @@ const mockViewState = {
toggleCreatorFilter: vi.fn(),
toggleProjectFilter: vi.fn(),
toggleNoProject: vi.fn(),
toggleLabelFilter: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),

View File

@@ -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 { filterIssues } from "../utils/filter";
import { buildIssueListFilter } from "../utils/filter";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import { useCurrentWorkspace } from "@multica/core/paths";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
@@ -25,7 +25,6 @@ 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);
@@ -37,6 +36,7 @@ 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,18 +45,30 @@ export function IssuesPage() {
useIssueSelectionStore.getState().clear();
}, [viewMode, scope]);
// 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],
// 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),
);
// Fetch sub-issue progress from the backend so counts are accurate
@@ -150,10 +162,10 @@ export function IssuesPage() {
<ViewStoreProvider store={useIssueViewStore}>
{/* Header 2: Scope tabs + filters */}
<IssuesHeader scopedIssues={scopedIssues} />
<IssuesHeader issues={issues} />
{/* Content: scrollable */}
{scopedIssues.length === 0 ? (
{issues.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>
@@ -168,9 +180,15 @@ export function IssuesPage() {
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
listFilter={listFilter}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
childProgressMap={childProgressMap}
listFilter={listFilter}
/>
)}
</div>
)}

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 { MyIssuesFilter } from "@multica/core/issues/queries";
import type { IssueListFilter, 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,16 +19,27 @@ 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;
@@ -59,9 +70,9 @@ export function ListView({
[visibleStatuses, listCollapsedStatuses]
);
const myIssuesOpts = myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
const loadMoreOptions: LoadMoreOptions = myIssuesScope
? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } }
: { filter: listFilter };
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2">
@@ -85,7 +96,7 @@ export function ListView({
status={status}
issues={issuesByStatus.get(status) ?? []}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
loadMoreOptions={loadMoreOptions}
/>
))}
</Accordion.Root>
@@ -97,12 +108,12 @@ function StatusAccordionItem({
status,
issues,
childProgressMap,
myIssuesOpts,
loadMoreOptions,
}: {
status: IssueStatus;
issues: Issue[];
childProgressMap: Map<string, ChildProgress>;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
loadMoreOptions: LoadMoreOptions;
}) {
const cfg = STATUS_CONFIG[status];
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@@ -110,7 +121,7 @@ function StatusAccordionItem({
const deselect = useIssueSelectionStore((s) => s.deselect);
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
status,
myIssuesOpts,
loadMoreOptions,
);
const issueIds = issues.map((i) => i.id);

View File

@@ -1,164 +1,93 @@
import { describe, it, expect } from "vitest";
import type { Issue } from "@multica/core/types";
import { filterIssues, type IssueFilters } from "./filter";
import { buildIssueListFilter, type IssueViewFilters } from "./filter";
const NO_FILTER: IssueFilters = {
statusFilters: [],
const NO_FILTER: IssueViewFilters = {
priorityFilters: [],
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
};
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);
describe("buildIssueListFilter", () => {
it("returns an empty object when nothing is selected", () => {
expect(buildIssueListFilter(NO_FILTER)).toEqual({});
});
// --- Status ---
it("filters by status", () => {
const result = filterIssues(issues, { ...NO_FILTER, statusFilters: ["todo"] });
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
it("encodes priority filter as an array", () => {
expect(
buildIssueListFilter({ ...NO_FILTER, priorityFilters: ["high", "urgent"] }),
).toEqual({ priorities: ["high", "urgent"] });
});
// --- 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 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"] });
});
// --- Assignee ---
it("filters by specific assignee", () => {
const result = filterIssues(issues, {
...NO_FILTER,
assigneeFilters: [{ type: "member", id: "u-1" }],
it("encodes 'no assignee' as include_no_assignee", () => {
expect(buildIssueListFilter({ ...NO_FILTER, includeNoAssignee: true })).toEqual({
include_no_assignee: true,
});
expect(result.map((i) => i.id)).toEqual(["1"]);
});
it("filters by 'No assignee' only", () => {
const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true });
expect(result.map((i) => i.id)).toEqual(["3"]);
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 assignee + No assignee combined", () => {
const result = filterIssues(issues, {
...NO_FILTER,
assigneeFilters: [{ type: "agent", id: "a-1" }],
includeNoAssignee: true,
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,
});
expect(result.map((i) => i.id)).toEqual(["2", "3"]);
});
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("encodes label filters", () => {
expect(
buildIssueListFilter({ ...NO_FILTER, labelFilters: ["l-1", "l-2"] }),
).toEqual({ label_ids: ["l-1", "l-2"] });
});
// --- Creator ---
it("filters by creator", () => {
const result = filterIssues(issues, {
...NO_FILTER,
creatorFilters: [{ type: "agent", id: "a-1" }],
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"],
});
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"]);
});
});

View File

@@ -1,72 +1,57 @@
import type { Issue, IssueStatus, IssuePriority } from "@multica/core/types";
import type { IssuePriority } from "@multica/core/types";
import type { IssueListFilter } from "@multica/core/issues/queries";
import type { ActorFilterValue } from "@multica/core/issues/stores/view-store";
export interface IssueFilters {
statusFilters: IssueStatus[];
/**
* 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 {
priorityFilters: IssuePriority[];
assigneeFilters: ActorFilterValue[];
includeNoAssignee: boolean;
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
}
/**
* Filter issues using positive selection model.
* Empty arrays = no filter (show all). Non-empty = show only matching.
* 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.
*
* 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
* 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.
*/
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;
});
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;
}

View File

@@ -11,7 +11,6 @@ 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";
@@ -55,36 +54,38 @@ 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 { assignee_id: user.id };
return { ...base, assignee_ids: [user.id] };
case "created":
return { creator_id: user.id };
return { ...base, creator_ids: [user.id] };
case "agents":
return { assignee_ids: myAgentIds };
return { ...base, assignee_ids: myAgentIds };
default:
return { assignee_id: user.id };
return { ...base, assignee_ids: [user.id] };
}
}, [scope, user, myAgentIds]);
}, [scope, user, myAgentIds, priorityFilters]);
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],
);
// 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: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));

View File

@@ -1,6 +1,7 @@
"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";
@@ -22,7 +23,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 { filterIssues } from "../../issues/utils/filter";
import { buildIssueListFilter } from "../../issues/utils/filter";
import { getProjectIssueMetrics } from "./project-issue-metrics";
import { ActorAvatar } from "../../common/actor-avatar";
import { AppLink, useNavigation } from "../../navigation";
@@ -99,6 +100,7 @@ function ProjectIssuesContent({
scope,
filter,
}: {
/** Issues already fetched server-side with this project + view-store filter applied. */
projectIssues: Issue[];
scope: string;
filter: MyIssuesFilter;
@@ -106,15 +108,12 @@ 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);
const issues = useMemo(
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }),
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, 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 { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
@@ -195,9 +194,32 @@ 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_id: projectId }),
[projectId],
() => ({
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],
);
const { data: projectIssues = [] } = useQuery(
myIssueListOptions(wsId, projectScope, projectFilter),
@@ -579,7 +601,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
</PageHeader>
<ViewStoreProvider store={projectViewStore}>
<IssuesHeader scopedIssues={projectIssues} />
<IssuesHeader issues={projectIssues} />
<ProjectIssuesContent
projectIssues={projectIssues}
scope={projectScope}

View File

@@ -251,6 +251,7 @@ 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)")
@@ -373,6 +374,9 @@ 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 {

View File

@@ -8,6 +8,7 @@ import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
@@ -145,7 +146,14 @@ 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)
@@ -158,6 +166,26 @@ 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{

View File

@@ -609,56 +609,75 @@ 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).
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
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}
}
assigneeFilter = id
}
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)
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
}
assigneeIds = []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
creatorIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("creator_ids")), "creator_ids")
if !ok {
return
}
var projectFilter pgtype.UUID
if p := r.URL.Query().Get("project_id"); p != "" {
id, ok := parseUUIDOrBadRequest(w, p, "project_id")
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}
}
projectFilter = 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}
}
}
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 r.URL.Query().Get("open_only") == "true" {
if q.Get("open_only") == "true" {
issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{
WorkspaceID: wsUUID,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
AssigneeIds: assigneeIdsFilter,
CreatorID: creatorFilter,
ProjectID: projectFilter,
WorkspaceID: wsUUID,
Priorities: priorities,
AssigneeTypes: assigneeTypes,
AssigneeIds: assigneeIds,
IncludeNoAssignee: includeNoAssignee,
CreatorIds: creatorIds,
ProjectIds: projectIds,
IncludeNoProject: includeNoProject,
LabelIds: labelIds,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
@@ -690,32 +709,35 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
limit := 100
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if l := q.Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if o := q.Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil {
offset = v
}
}
var statusFilter pgtype.Text
if s := r.URL.Query().Get("status"); s != "" {
if s := q.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,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
AssigneeIds: assigneeIdsFilter,
CreatorID: creatorFilter,
ProjectID: projectFilter,
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,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
@@ -724,13 +746,16 @@ 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,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
AssigneeIds: assigneeIdsFilter,
CreatorID: creatorFilter,
ProjectID: projectFilter,
WorkspaceID: wsUUID,
Status: statusFilter,
Priorities: priorities,
AssigneeTypes: assigneeTypes,
AssigneeIds: assigneeIds,
IncludeNoAssignee: includeNoAssignee,
CreatorIds: creatorIds,
ProjectIds: projectIds,
IncludeNoProject: includeNoProject,
LabelIds: labelIds,
})
if err != nil {
total = int64(len(issues))

View File

@@ -97,32 +97,51 @@ 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 = $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)
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[])
))
`
type CountIssuesParams struct {
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"`
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"`
}
func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) {
row := q.db.QueryRow(ctx, countIssues,
arg.WorkspaceID,
arg.Status,
arg.Priority,
arg.AssigneeID,
arg.Priorities,
arg.AssigneeTypes,
arg.AssigneeIds,
arg.CreatorID,
arg.ProjectID,
arg.IncludeNoAssignee,
arg.CreatorIds,
arg.ProjectIds,
arg.IncludeNoProject,
arg.LabelIds,
)
var count int64
err := row.Scan(&count)
@@ -459,25 +478,41 @@ 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 = $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)
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[])
))
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"`
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"`
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"`
}
type ListIssuesRow struct {
@@ -506,11 +541,14 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI
arg.Limit,
arg.Offset,
arg.Status,
arg.Priority,
arg.AssigneeID,
arg.Priorities,
arg.AssigneeTypes,
arg.AssigneeIds,
arg.CreatorID,
arg.ProjectID,
arg.IncludeNoAssignee,
arg.CreatorIds,
arg.ProjectIds,
arg.IncludeNoProject,
arg.LabelIds,
)
if err != nil {
return nil, err
@@ -555,21 +593,37 @@ 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 = $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)
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[])
))
ORDER BY position ASC, created_at DESC
`
type ListOpenIssuesParams struct {
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"`
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"`
}
type ListOpenIssuesRow struct {
@@ -595,11 +649,14 @@ type ListOpenIssuesRow struct {
func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) {
rows, err := q.db.Query(ctx, listOpenIssues,
arg.WorkspaceID,
arg.Priority,
arg.AssigneeID,
arg.Priorities,
arg.AssigneeTypes,
arg.AssigneeIds,
arg.CreatorID,
arg.ProjectID,
arg.IncludeNoAssignee,
arg.CreatorIds,
arg.ProjectIds,
arg.IncludeNoProject,
arg.LabelIds,
)
if err != nil {
return nil, err

View File

@@ -5,11 +5,24 @@ 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('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'))
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[])
))
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;
@@ -78,22 +91,48 @@ SELECT id, workspace_id, title, description, status, priority,
FROM issue
WHERE workspace_id = $1
AND status NOT IN ('done', 'cancelled')
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'))
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[])
))
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('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'));
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[])
));
-- name: ListChildIssues :many
SELECT * FROM issue