mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e3a9b8e5 | ||
|
|
c337b7d095 | ||
|
|
fe5d541fea |
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user