mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 23:49:22 +02:00
Compare commits
2 Commits
agent/lamb
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a13b649a4c | ||
|
|
d90c37a0a0 |
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user