Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
a13b649a4c fix(issues): preserve cached labels and refresh on label edit/delete
Three fixes from gpt-boy's review of #1741:

1. IssueResponse.Labels was a non-omitempty slice, so paths that didn't
   load labels (UpdateIssue, batch updates, the issue:updated WS broadcast)
   serialized labels:null. onIssueUpdated then merged that null into the
   list/detail caches, wiping chips on every other tab whenever any non-
   label field changed. Switched to *[]LabelResponse + omitempty: nil =
   field absent (client merge keeps existing labels); non-nil (incl. empty
   slice) = authoritative.

2. issue.labels is a denormalized snapshot, but useUpdateLabel /
   useDeleteLabel and the WS label:* prefix only touched labelKeys, leaving
   stale chips in list/board after rename/recolor/delete. Mutations now
   also invalidate issueKeys.all(wsId), and the realtime refreshMap maps
   the label prefix to both labels and issues invalidation for cross-tab.

3. Persisted cardProperties from before this branch lacks the new `labels`
   key. Render fell back to `?? true` but the dropdown switch read it raw
   and showed unchecked. Added a custom Zustand merge that deep-merges
   cardProperties so newly added toggles inherit defaults for existing
   users; dropped the `?? true` fallbacks now that the store guarantees
   the key.
2026-04-27 16:26:46 +08:00
Jiang Bohan
d90c37a0a0 feat(issues): render labels on list/board with bulk server-side fetch
ListIssues / ListOpenIssues / GetIssue now bulk-fetch labels per response
via a new ListLabelsForIssues query so the client gets labels in a single
round-trip instead of N requests per visible issue. List-row and board-card
read issue.labels directly; an issue_labels:changed WS handler patches the
list and detail caches in place so chips stay live across tabs, and
attach/detach mutations mirror their result into the same caches for
immediate same-tab feedback.

Adds a "Labels" toggle to the card properties dropdown (defaults on).
2026-04-27 15:52:14 +08:00
12 changed files with 246 additions and 9 deletions

View File

@@ -20,6 +20,7 @@ export interface CardProperties {
dueDate: boolean;
project: boolean;
childProgress: boolean;
labels: boolean;
}
export interface ActorFilterValue {
@@ -41,6 +42,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "labels", label: "Labels" },
{ key: "childProgress", label: "Sub-issue progress" },
];
@@ -92,6 +94,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
dueDate: true,
project: true,
childProgress: true,
labels: true,
},
listCollapsedStatuses: [],
@@ -204,6 +207,22 @@ export const viewStorePersistOptions = (name: string) => ({
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is
// missing — the dropdown switch then reads `undefined` and renders unchecked
// even though defaults treat it as on. Deep-merge `cardProperties` so newly
// added toggles inherit their default value for existing users.
merge: (persisted: unknown, current: IssueViewState): IssueViewState => {
const p = (persisted ?? {}) as Partial<IssueViewState>;
return {
...current,
...p,
cardProperties: {
...current.cardProperties,
...(p.cardProperties ?? {}),
},
};
},
});
/** Factory: creates a vanilla StoreApi for use with React Context. */

View File

@@ -6,7 +6,7 @@ import {
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import type { Issue } from "../types";
import type { Issue, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
@@ -72,6 +72,26 @@ 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.
*/
export function onIssueLabelsChanged(
qc: QueryClient,
wsId: string,
issueId: string,
labels: Label[],
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issueId, { labels }) : old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
export function onIssueDeleted(
qc: QueryClient,
wsId: string,

View File

@@ -2,6 +2,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { labelKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import { issueKeys } from "../issues/queries";
import { onIssueLabelsChanged } from "../issues/ws-updaters";
import type {
Label,
CreateLabelRequest,
@@ -60,6 +62,9 @@ export function useUpdateLabel() {
// stale copy of this label is refetched. The list cache is the source
// of truth; byIssue views will re-render with the fresh data.
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
// Issues now embed labels (denormalized snapshot), so a rename/recolor
// also has to refresh the issues caches that hold those snapshots.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
},
});
}
@@ -84,6 +89,9 @@ export function useDeleteLabel() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
// A deleted label still lives in cached issue.labels arrays until we
// refetch — invalidate so list/board chips drop the orphan.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
},
});
}
@@ -100,6 +108,9 @@ export function useAttachLabel(issueId: string) {
// invalidation to refetch.
if (data && Array.isArray(data.labels)) {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), data);
// Mirror into the issues list / detail caches so list/board chips
// update immediately for the actor without waiting for the WS event.
onIssueLabelsChanged(qc, wsId, issueId, data.labels);
}
},
onSettled: () => {
@@ -116,13 +127,20 @@ export function useDetachLabel(issueId: string) {
onMutate: async (labelId) => {
await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
const prev = qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId));
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
old ? { ...old, labels: old.labels.filter((l: Label) => l.id !== labelId) } : old,
);
const next = prev
? { ...prev, labels: prev.labels.filter((l: Label) => l.id !== labelId) }
: undefined;
if (next) {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), next);
onIssueLabelsChanged(qc, wsId, issueId, next.labels);
}
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
if (ctx?.prev) {
qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });

View File

@@ -18,6 +18,7 @@ import {
onIssueCreated,
onIssueUpdated,
onIssueDeleted,
onIssueLabelsChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
@@ -31,6 +32,7 @@ import type {
IssueUpdatedPayload,
IssueCreatedPayload,
IssueDeletedPayload,
IssueLabelsChangedPayload,
InboxNewPayload,
CommentCreatedPayload,
CommentUpdatedPayload,
@@ -117,6 +119,17 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
label: () => {
// label:created/updated/deleted — also refresh issues, since each
// issue carries a denormalized snapshot of its labels (rename/recolor
// /delete on a label needs to flush the chips on every issue showing
// it).
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: ["labels", wsId] });
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
}
},
pin: () => {
const wsId = getCurrentWsId();
const userId = authStore.getState().user?.id;
@@ -147,7 +160,7 @@ export function useRealtimeSync(
// Event types handled by specific handlers below -- skip generic refresh
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"comment:created", "comment:updated", "comment:deleted",
"activity:created",
"reaction:added", "reaction:removed",
@@ -200,6 +213,13 @@ export function useRealtimeSync(
}
});
const unsubIssueLabelsChanged = ws.on("issue_labels:changed", (p) => {
const { issue_id, labels } = p as IssueLabelsChangedPayload;
if (!issue_id) return;
const wsId = getCurrentWsId();
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (!item) return;
@@ -464,6 +484,7 @@ export function useRealtimeSync(
unsubIssueUpdated();
unsubIssueCreated();
unsubIssueDeleted();
unsubIssueLabelsChanged();
unsubInboxNew();
unsubCommentCreated();
unsubCommentUpdated();

View File

@@ -5,6 +5,7 @@ import type { Comment, Reaction } from "./comment";
import type { TimelineEntry } from "./activity";
import type { Workspace, MemberWithUser, Invitation } from "./workspace";
import type { Project } from "./project";
import type { Label } from "./label";
// WebSocket event types (matching Go server protocol/events.go)
export type WSEventType =
@@ -82,6 +83,11 @@ export interface IssueDeletedPayload {
issue_id: string;
}
export interface IssueLabelsChangedPayload {
issue_id: string;
labels: Label[];
}
export interface AgentStatusPayload {
agent: Agent;
}

View File

@@ -1,3 +1,5 @@
import type { Label } from "./label";
export type IssueStatus =
| "backlog"
| "todo"
@@ -38,6 +40,7 @@ export interface Issue {
position: number;
due_date: string | null;
reactions?: IssueReaction[];
labels?: Label[];
created_at: string;
updated_at: string;
}

View File

@@ -21,6 +21,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { ProgressRing } from "./progress-ring";
import type { ChildProgress } from "./list-row";
import { IssueActionsContextMenu } from "../actions";
import { LabelChip } from "../../labels/label-chip";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@@ -59,6 +60,7 @@ export const BoardCardContent = memo(function BoardCardContent({
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((p) => p.id === issue.project_id) : undefined;
const labels = issue.labels ?? [];
const updateIssueMutation = useUpdateIssue();
const handleUpdate = useCallback(
@@ -77,6 +79,7 @@ export const BoardCardContent = memo(function BoardCardContent({
const showDueDate = storeProperties.dueDate && issue.due_date;
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
const showLabels = storeProperties.labels && labels.length > 0;
return (
<div className="rounded-lg border-[0.5px] border-border bg-card py-3 px-2.5 shadow-[0_3px_6px_-2px_rgba(0,0,0,0.02),0_1px_1px_0_rgba(0,0,0,0.04)] transition-colors group-hover/card:border-accent group-hover/card:bg-accent group-data-[popup-open]/card:border-accent group-data-[popup-open]/card:bg-accent">
@@ -88,8 +91,8 @@ export const BoardCardContent = memo(function BoardCardContent({
{issue.title}
</p>
{/* Sub-issue progress + project */}
{(showChildProgress || showProject) && (
{/* Sub-issue progress + project + labels */}
{(showChildProgress || showProject || showLabels) && (
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
{showChildProgress && (
<div className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
@@ -105,6 +108,9 @@ export const BoardCardContent = memo(function BoardCardContent({
<span className="truncate">{project!.title}</span>
</span>
)}
{showLabels && labels.map((label) => (
<LabelChip key={label.id} label={label} />
))}
</div>
)}

View File

@@ -108,7 +108,7 @@ const mockViewState = {
includeNoProject: false,
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true },
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
toggleStatusFilter: vi.fn(),

View File

@@ -13,6 +13,7 @@ import { projectListOptions } from "@multica/core/projects/queries";
import { PriorityIcon } from "./priority-icon";
import { ProgressRing } from "./progress-ring";
import { IssueActionsContextMenu } from "../actions";
import { LabelChip } from "../../labels/label-chip";
export interface ChildProgress {
done: number;
@@ -43,11 +44,13 @@ export const ListRow = memo(function ListRow({
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((pr) => pr.id === issue.project_id) : undefined;
const labels = issue.labels ?? [];
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = storeProperties.dueDate && issue.due_date;
const showLabels = storeProperties.labels && labels.length > 0;
return (
<IssueActionsContextMenu issue={issue}>
@@ -88,6 +91,18 @@ export const ListRow = memo(function ListRow({
</span>
)}
</span>
{showLabels && (
<span className="hidden md:inline-flex shrink-0 items-center gap-1 max-w-[260px] overflow-hidden">
{labels.slice(0, 3).map((label) => (
<LabelChip key={label.id} label={label} />
))}
{labels.length > 3 && (
<span className="text-[11px] text-muted-foreground">
+{labels.length - 3}
</span>
)}
</span>
)}
{showProject && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground max-w-[140px]">
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>

View File

@@ -42,6 +42,13 @@ type IssueResponse struct {
UpdatedAt string `json:"updated_at"`
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
// Labels are bulk-attached by list/detail endpoints so the client can render
// chips without an N+1 round-trip per row. Pointer + omitempty so paths that
// don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated
// WS broadcast) emit no `labels` field at all — the client merge then
// preserves whatever labels are already in cache. nil pointer = "field
// absent, do not touch"; non-nil (incl. empty slice) = authoritative list.
Labels *[]LabelResponse `json:"labels,omitempty"`
}
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
@@ -93,6 +100,37 @@ func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueRespons
}
}
// labelsByIssue bulk-loads labels for the given issue IDs and returns a map
// keyed by issue UUID string. On error or empty input, returns an empty map —
// label rendering is non-critical and we'd rather serve issues without labels
// than fail the whole list call.
func (h *Handler) labelsByIssue(ctx context.Context, wsUUID pgtype.UUID, issueIDs []pgtype.UUID) map[string][]LabelResponse {
out := map[string][]LabelResponse{}
if len(issueIDs) == 0 {
return out
}
rows, err := h.Queries.ListLabelsForIssues(ctx, db.ListLabelsForIssuesParams{
IssueIds: issueIDs,
WorkspaceID: wsUUID,
})
if err != nil {
slog.Warn("ListLabelsForIssues failed", "error", err)
return out
}
for _, r := range rows {
issueID := uuidToString(r.IssueID)
out[issueID] = append(out[issueID], LabelResponse{
ID: uuidToString(r.ID),
WorkspaceID: uuidToString(r.WorkspaceID),
Name: r.Name,
Color: r.Color,
CreatedAt: timestampToString(r.CreatedAt),
UpdatedAt: timestampToString(r.UpdatedAt),
})
}
return out
}
func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse {
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
return IssueResponse{
@@ -603,9 +641,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
prefix := h.getIssuePrefix(ctx, wsUUID)
ids := make([]pgtype.UUID, len(issues))
for i, issue := range issues {
ids[i] = issue.ID
}
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = openIssueRowToResponse(issue, prefix)
labels := labelsMap[resp[i].ID]
if labels == nil {
labels = []LabelResponse{}
}
resp[i].Labels = &labels
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -664,9 +712,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
prefix := h.getIssuePrefix(ctx, wsUUID)
ids := make([]pgtype.UUID, len(issues))
for i, issue := range issues {
ids[i] = issue.ID
}
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = issueListRowToResponse(issue, prefix)
labels := labelsMap[resp[i].ID]
if labels == nil {
labels = []LabelResponse{}
}
resp[i].Labels = &labels
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -683,6 +741,11 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
detailLabels := h.labelsByIssue(r.Context(), issue.WorkspaceID, []pgtype.UUID{issue.ID})[uuidToString(issue.ID)]
if detailLabels == nil {
detailLabels = []LabelResponse{}
}
resp.Labels = &detailLabels
// Fetch issue reactions.
reactions, err := h.Queries.ListIssueReactions(r.Context(), issue.ID)

View File

@@ -211,6 +211,61 @@ func (q *Queries) ListLabelsByIssue(ctx context.Context, arg ListLabelsByIssuePa
return items, nil
}
const listLabelsForIssues = `-- name: ListLabelsForIssues :many
SELECT il.issue_id, l.id, l.workspace_id, l.name, l.color, l.created_at, l.updated_at
FROM issue_label l
JOIN issue_to_label il ON il.label_id = l.id
WHERE il.issue_id = ANY($1::uuid[])
AND l.workspace_id = $2::uuid
ORDER BY il.issue_id, LOWER(l.name) ASC
`
type ListLabelsForIssuesParams struct {
IssueIds []pgtype.UUID `json:"issue_ids"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type ListLabelsForIssuesRow struct {
IssueID pgtype.UUID `json:"issue_id"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Color string `json:"color"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
// Bulk variant: fetch labels for many issues in one round-trip so the issue
// list endpoints can fold labels into each row without N+1 queries from the
// client. Workspace-guarded the same way as ListLabelsByIssue.
func (q *Queries) ListLabelsForIssues(ctx context.Context, arg ListLabelsForIssuesParams) ([]ListLabelsForIssuesRow, error) {
rows, err := q.db.Query(ctx, listLabelsForIssues, arg.IssueIds, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListLabelsForIssuesRow{}
for rows.Next() {
var i ListLabelsForIssuesRow
if err := rows.Scan(
&i.IssueID,
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Color,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateLabel = `-- name: UpdateLabel :one
UPDATE issue_label SET
name = COALESCE($3, name),

View File

@@ -66,3 +66,14 @@ JOIN issue_to_label il ON il.label_id = l.id
WHERE il.issue_id = sqlc.arg('issue_id')::uuid
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
ORDER BY LOWER(l.name) ASC;
-- name: ListLabelsForIssues :many
-- Bulk variant: fetch labels for many issues in one round-trip so the issue
-- list endpoints can fold labels into each row without N+1 queries from the
-- client. Workspace-guarded the same way as ListLabelsByIssue.
SELECT il.issue_id, l.*
FROM issue_label l
JOIN issue_to_label il ON il.label_id = l.id
WHERE il.issue_id = ANY(sqlc.arg('issue_ids')::uuid[])
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
ORDER BY il.issue_id, LOWER(l.name) ASC;