mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 04:38:50 +02:00
Compare commits
5 Commits
agent/lamb
...
codex/comm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa312c3789 | ||
|
|
7d24a8594a | ||
|
|
730fb61f4a | ||
|
|
e55f050b84 | ||
|
|
fa2a0e57ec |
@@ -12,9 +12,12 @@ import { defaultStorage } from "../../platform/storage";
|
||||
export type ViewMode = "board" | "list" | "gantt" | "swimlane";
|
||||
export type GanttZoom = "day" | "week" | "month";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SwimlaneGrouping = "parent" | "project" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export const SWIMLANE_GROUPINGS: SwimlaneGrouping[] = ["parent", "project", "assignee"];
|
||||
|
||||
export interface CardProperties {
|
||||
priority: boolean;
|
||||
description: boolean;
|
||||
@@ -79,8 +82,15 @@ export interface IssueViewState {
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
ganttZoom: GanttZoom;
|
||||
ganttShowCompleted: boolean;
|
||||
swimlaneOrder: string[];
|
||||
collapsedSwimlanes: string[];
|
||||
/** Active swimlane grouping dimension. */
|
||||
swimlaneGrouping: SwimlaneGrouping;
|
||||
/** Persisted lane order, keyed by grouping. Entries are raw lane ids
|
||||
* (parent issue id, project id, or `<assigneeType>:<assigneeId>`). */
|
||||
swimlaneOrders: Record<SwimlaneGrouping, string[]>;
|
||||
/** Persisted collapsed lanes, keyed by grouping. Same id space as
|
||||
* `swimlaneOrders`, plus the sentinel `"none"` for the pinned
|
||||
* no-X lane and `"__orphans__"` for the parent-grouping fallback. */
|
||||
collapsedSwimlanes: Record<SwimlaneGrouping, string[]>;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGanttZoom: (zoom: GanttZoom) => void;
|
||||
toggleGanttShowCompleted: () => void;
|
||||
@@ -101,7 +111,10 @@ export interface IssueViewState {
|
||||
setSortDirection: (dir: SortDirection) => void;
|
||||
toggleCardProperty: (key: keyof CardProperties) => void;
|
||||
toggleListCollapsed: (status: IssueStatus) => void;
|
||||
setSwimlaneGrouping: (grouping: SwimlaneGrouping) => void;
|
||||
/** Update the lane order for the currently active swimlane grouping. */
|
||||
setSwimlaneOrder: (order: string[]) => void;
|
||||
/** Toggle a lane key in the currently active swimlane grouping. */
|
||||
toggleSwimlaneCollapsed: (key: string) => void;
|
||||
}
|
||||
|
||||
@@ -132,8 +145,9 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
listCollapsedStatuses: [],
|
||||
ganttZoom: "week",
|
||||
ganttShowCompleted: false,
|
||||
swimlaneOrder: [],
|
||||
collapsedSwimlanes: [],
|
||||
swimlaneGrouping: "assignee",
|
||||
swimlaneOrders: { parent: [], project: [], assignee: [] },
|
||||
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
|
||||
@@ -239,13 +253,22 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
setSwimlaneOrder: (order) => set({ swimlaneOrder: order }),
|
||||
toggleSwimlaneCollapsed: (key) =>
|
||||
setSwimlaneGrouping: (grouping) => set({ swimlaneGrouping: grouping }),
|
||||
setSwimlaneOrder: (order) =>
|
||||
set((state) => ({
|
||||
collapsedSwimlanes: state.collapsedSwimlanes.includes(key)
|
||||
? state.collapsedSwimlanes.filter((k) => k !== key)
|
||||
: [...state.collapsedSwimlanes, key],
|
||||
swimlaneOrders: { ...state.swimlaneOrders, [state.swimlaneGrouping]: order },
|
||||
})),
|
||||
toggleSwimlaneCollapsed: (key) =>
|
||||
set((state) => {
|
||||
const grouping = state.swimlaneGrouping;
|
||||
const current = state.collapsedSwimlanes[grouping];
|
||||
const next = current.includes(key)
|
||||
? current.filter((k) => k !== key)
|
||||
: [...current, key];
|
||||
return {
|
||||
collapsedSwimlanes: { ...state.collapsedSwimlanes, [grouping]: next },
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const viewStorePersistOptions = (name: string) => ({
|
||||
@@ -272,7 +295,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
ganttZoom: state.ganttZoom,
|
||||
ganttShowCompleted: state.ganttShowCompleted,
|
||||
swimlaneOrder: state.swimlaneOrder,
|
||||
swimlaneGrouping: state.swimlaneGrouping,
|
||||
swimlaneOrders: state.swimlaneOrders,
|
||||
collapsedSwimlanes: state.collapsedSwimlanes,
|
||||
}),
|
||||
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
|
||||
@@ -293,6 +317,13 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
|
||||
current: T,
|
||||
): T {
|
||||
const p = (persisted ?? {}) as Partial<T>;
|
||||
// `collapsedSwimlanes` changed shape from `string[]` to
|
||||
// `Record<SwimlaneGrouping, string[]>`. A snapshot saved in the old
|
||||
// shape would otherwise overwrite the default record with an array
|
||||
// and crash on first read — fall back to the default when the
|
||||
// persisted value isn't a plain object.
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
@@ -300,6 +331,12 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
|
||||
...current.cardProperties,
|
||||
...(p.cardProperties ?? {}),
|
||||
},
|
||||
swimlaneOrders: isRecord(p.swimlaneOrders)
|
||||
? { ...current.swimlaneOrders, ...p.swimlaneOrders }
|
||||
: current.swimlaneOrders,
|
||||
collapsedSwimlanes: isRecord(p.collapsedSwimlanes)
|
||||
? { ...current.collapsedSwimlanes, ...p.collapsedSwimlanes }
|
||||
: current.collapsedSwimlanes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface FileUploadButtonProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: "sm" | "default";
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
function FileUploadButton({
|
||||
@@ -18,16 +19,17 @@ function FileUploadButton({
|
||||
disabled,
|
||||
className,
|
||||
size = "default",
|
||||
multiple = false,
|
||||
}: FileUploadButtonProps) {
|
||||
const { t } = useTranslation("ui");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const attachLabel = t(($) => $.attach_file);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
e.target.value = "";
|
||||
onSelect(file);
|
||||
for (const file of files) onSelect(file);
|
||||
};
|
||||
|
||||
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
@@ -52,6 +54,7 @@ function FileUploadButton({
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* that decision out of this file so this stays a single-purpose row UI.
|
||||
*/
|
||||
|
||||
import { Download, Eye, FileText, Loader2 } from "lucide-react";
|
||||
import { Download, Eye, FileText, Loader2, Trash2 } from "lucide-react";
|
||||
import { useT } from "../i18n";
|
||||
import { getPreviewKind } from "./utils/preview";
|
||||
|
||||
@@ -18,8 +18,10 @@ interface AttachmentCardChromeProps {
|
||||
uploading?: boolean;
|
||||
canPreview: boolean;
|
||||
canDownload: boolean;
|
||||
canDelete?: boolean;
|
||||
onPreview: () => void;
|
||||
onDownload: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function AttachmentCardChrome({
|
||||
@@ -27,8 +29,10 @@ function AttachmentCardChrome({
|
||||
uploading,
|
||||
canPreview,
|
||||
canDownload,
|
||||
canDelete,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: AttachmentCardChromeProps) {
|
||||
const { t } = useT("editor");
|
||||
return (
|
||||
@@ -78,6 +82,21 @@ function AttachmentCardChrome({
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!uploading && canDelete && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title={t(($) => $.attachment.remove)}
|
||||
aria-label={t(($) => $.attachment.remove)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +120,8 @@ export interface AttachmentCardProps {
|
||||
onPreview: () => void;
|
||||
/** Pressed when the Download button is clicked. */
|
||||
onDownload: () => void;
|
||||
/** Optional remove button, used by editable comment/file-card surfaces. */
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function AttachmentCard({
|
||||
@@ -111,6 +132,7 @@ export function AttachmentCard({
|
||||
uploading,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: AttachmentCardProps) {
|
||||
const kind = filename ? getPreviewKind(contentType, filename) : null;
|
||||
// Media kinds (pdf/video/audio) are previewable from a URL alone — the
|
||||
@@ -130,8 +152,10 @@ export function AttachmentCard({
|
||||
uploading={uploading}
|
||||
canPreview={canPreview}
|
||||
canDownload={!!href}
|
||||
canDelete={!!onDelete}
|
||||
onPreview={onPreview}
|
||||
onDownload={onDownload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -190,6 +190,7 @@ export function Attachment({
|
||||
filename={state.filename}
|
||||
onPreview={openPreview}
|
||||
onDownload={handleDownload}
|
||||
onDelete={editable ? onDelete : undefined}
|
||||
/>
|
||||
{preview.modal}
|
||||
</>
|
||||
@@ -206,6 +207,7 @@ export function Attachment({
|
||||
uploading={state.uploading}
|
||||
onPreview={openPreview}
|
||||
onDownload={handleDownload}
|
||||
onDelete={editable ? onDelete : undefined}
|
||||
/>
|
||||
{preview.modal}
|
||||
</>
|
||||
|
||||
@@ -29,16 +29,19 @@ const FILE_CARD_MARKDOWN_RE = new RegExp(
|
||||
// React NodeView — thin wrapper, all rendering lives in <Attachment>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FileCardView({ node }: NodeViewProps) {
|
||||
export function FileCardView({ node, editor, deleteNode }: NodeViewProps) {
|
||||
const href = (node.attrs.href as string) || "";
|
||||
const filename = (node.attrs.filename as string) || "";
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
const editable = editor?.isEditable ?? false;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
|
||||
<div contentEditable={false}>
|
||||
<Attachment
|
||||
attachment={{ kind: "url", url: href, filename, uploading }}
|
||||
editable={editable}
|
||||
onDelete={editable ? deleteNode : undefined}
|
||||
/>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* 80px placeholder and the toolbar pins itself open with all actions enabled.
|
||||
*/
|
||||
|
||||
import { Download, ExternalLink, Maximize2 } from "lucide-react";
|
||||
import { Download, ExternalLink, Maximize2, Trash2 } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { paths, useWorkspaceSlug } from "@multica/core/paths";
|
||||
import { useT } from "../i18n";
|
||||
@@ -42,6 +42,7 @@ interface HtmlAttachmentPreviewProps {
|
||||
filename: string;
|
||||
onPreview: () => void;
|
||||
onDownload: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function HtmlAttachmentPreview({
|
||||
@@ -49,6 +50,7 @@ export function HtmlAttachmentPreview({
|
||||
filename,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: HtmlAttachmentPreviewProps) {
|
||||
const { t } = useT("editor");
|
||||
// Subscribe to the same React Query cache key the body consumes so the
|
||||
@@ -143,6 +145,21 @@ export function HtmlAttachmentPreview({
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title={t(($) => $.attachment.remove)}
|
||||
aria-label={t(($) => $.attachment.remove)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,16 +123,7 @@ export const BoardColumn = memo(function BoardColumn({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`relative min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver && sortLabel
|
||||
? "ring-2 ring-brand/25 bg-accent/15"
|
||||
: isOver
|
||||
? "bg-accent/60"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative min-h-[200px] flex-1 rounded-lg">
|
||||
{isOver && sortLabel && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/40">
|
||||
<span className="rounded-md bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-sm border border-border">
|
||||
@@ -140,17 +131,28 @@ export const BoardColumn = memo(function BoardColumn({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} childProgress={childProgressMap?.get(issue.id)} disableSorting={!!sortLabel} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.board.empty_column)}
|
||||
</p>
|
||||
)}
|
||||
{footer}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`absolute inset-0 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver && sortLabel
|
||||
? "ring-2 ring-brand/25 bg-accent/15"
|
||||
: isOver
|
||||
? "bg-accent/60"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} childProgress={childProgressMap?.get(issue.id)} disableSorting={!!sortLabel} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.board.empty_column)}
|
||||
</p>
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -121,7 +121,17 @@ function DeleteCommentDialog({
|
||||
// Standalone attachment list — renders attachments not already in the markdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
||||
export function AttachmentList({
|
||||
attachments,
|
||||
content,
|
||||
className,
|
||||
onRemove,
|
||||
}: {
|
||||
attachments?: Attachment[];
|
||||
content?: string;
|
||||
className?: string;
|
||||
onRemove?: (attachmentId: string) => void;
|
||||
}) {
|
||||
if (!attachments?.length) return null;
|
||||
// Skip attachments whose URL is already referenced in the markdown content,
|
||||
// and duplicates of the same file (same name/type/size) that are referenced.
|
||||
@@ -151,6 +161,8 @@ export function AttachmentList({ attachments, content, className }: { attachment
|
||||
<AttachmentRenderer
|
||||
key={a.id}
|
||||
attachment={{ kind: "record", attachment: a }}
|
||||
editable={!!onRemove}
|
||||
onDelete={onRemove ? () => onRemove(a.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -158,6 +170,34 @@ export function AttachmentList({ attachments, content, className }: { attachment
|
||||
);
|
||||
}
|
||||
|
||||
function collectActiveAttachmentIds(
|
||||
content: string,
|
||||
attachments: Attachment[],
|
||||
retainedStandaloneIds?: Set<string> | null,
|
||||
): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const attachment of attachments) {
|
||||
if (content.includes(attachment.url)) ids.add(attachment.id);
|
||||
}
|
||||
for (const id of retainedStandaloneIds ?? []) ids.add(id);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function sameIdSet(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const set = new Set(a);
|
||||
return b.every((id) => set.has(id));
|
||||
}
|
||||
|
||||
function initialStandaloneAttachmentIds(entry: TimelineEntry): Set<string> {
|
||||
const content = entry.content ?? "";
|
||||
return new Set(
|
||||
(entry.attachments ?? [])
|
||||
.filter((attachment) => !content.includes(attachment.url))
|
||||
.map((attachment) => attachment.id),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single comment row (used for both parent and replies within the same Card)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,6 +232,7 @@ function CommentRow({
|
||||
// them to the comment (otherwise they'd remain orphaned at the issue level
|
||||
// and disappear after refresh).
|
||||
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
|
||||
const [retainedStandaloneIds, setRetainedStandaloneIds] = useState<Set<string> | null>(null);
|
||||
const editorAttachments = pendingAttachments.length > 0
|
||||
? [...(entry.attachments ?? []), ...pendingAttachments]
|
||||
: entry.attachments;
|
||||
@@ -229,6 +270,7 @@ function CommentRow({
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
@@ -236,6 +278,7 @@ function CommentRow({
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
setRetainedStandaloneIds(null);
|
||||
clearEditDraft(editDraftKey);
|
||||
};
|
||||
|
||||
@@ -245,19 +288,25 @@ function CommentRow({
|
||||
?.getMarkdown()
|
||||
?.replace(/(\n\s*)+$/, "")
|
||||
.trim();
|
||||
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
||||
if (!trimmed) return;
|
||||
const activeIds = collectActiveAttachmentIds(
|
||||
trimmed,
|
||||
[...(entry.attachments ?? []), ...pendingAttachments],
|
||||
retainedStandaloneIds,
|
||||
);
|
||||
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
|
||||
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
setRetainedStandaloneIds(null);
|
||||
clearEditDraft(editDraftKey);
|
||||
return;
|
||||
}
|
||||
const activeIds = pendingAttachments
|
||||
.filter((a) => trimmed.includes(a.url))
|
||||
.map((a) => a.id);
|
||||
try {
|
||||
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
|
||||
await onEdit(entry.id, trimmed, activeIds);
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
setRetainedStandaloneIds(null);
|
||||
clearEditDraft(editDraftKey);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
@@ -271,6 +320,9 @@ function CommentRow({
|
||||
const reactions = entry.reactions ?? [];
|
||||
const contentText = entry.content ?? "";
|
||||
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
||||
const standaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
|
||||
retainedStandaloneIds?.has(attachment.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
|
||||
@@ -366,10 +418,26 @@ function CommentRow({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{standaloneEditAttachments.length > 0 && (
|
||||
<AttachmentList
|
||||
attachments={standaloneEditAttachments}
|
||||
className="max-w-full"
|
||||
onRemove={(attachmentId) =>
|
||||
setRetainedStandaloneIds((ids) => {
|
||||
const next = new Set(ids ?? []);
|
||||
next.delete(attachmentId);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
|
||||
@@ -430,6 +498,7 @@ function CommentCardImpl({
|
||||
const cancelledRef = useRef(false);
|
||||
// Pending uploads from the root-comment edit pass — same rationale as CommentRow.
|
||||
const [parentPendingAttachments, setParentPendingAttachments] = useState<Attachment[]>([]);
|
||||
const [parentRetainedStandaloneIds, setParentRetainedStandaloneIds] = useState<Set<string> | null>(null);
|
||||
const parentEditorAttachments = parentPendingAttachments.length > 0
|
||||
? [...(entry.attachments ?? []), ...parentPendingAttachments]
|
||||
: entry.attachments;
|
||||
@@ -464,6 +533,7 @@ function CommentCardImpl({
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
setParentRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
@@ -471,6 +541,7 @@ function CommentCardImpl({
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
setParentRetainedStandaloneIds(null);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
};
|
||||
|
||||
@@ -480,19 +551,25 @@ function CommentCardImpl({
|
||||
?.getMarkdown()
|
||||
?.replace(/(\n\s*)+$/, "")
|
||||
.trim();
|
||||
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
||||
if (!trimmed) return;
|
||||
const activeIds = collectActiveAttachmentIds(
|
||||
trimmed,
|
||||
[...(entry.attachments ?? []), ...parentPendingAttachments],
|
||||
parentRetainedStandaloneIds,
|
||||
);
|
||||
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
|
||||
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
setParentRetainedStandaloneIds(null);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
return;
|
||||
}
|
||||
const activeIds = parentPendingAttachments
|
||||
.filter((a) => trimmed.includes(a.url))
|
||||
.map((a) => a.id);
|
||||
try {
|
||||
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
|
||||
await onEdit(entry.id, trimmed, activeIds);
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
setParentRetainedStandaloneIds(null);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
@@ -513,6 +590,9 @@ function CommentCardImpl({
|
||||
const reactions = entry.reactions ?? [];
|
||||
const contentText = entry.content ?? "";
|
||||
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
||||
const parentStandaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
|
||||
parentRetainedStandaloneIds?.has(attachment.id),
|
||||
);
|
||||
|
||||
const isHighlighted = highlightedCommentId === entry.id;
|
||||
|
||||
@@ -665,10 +745,26 @@ function CommentCardImpl({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{parentStandaloneEditAttachments.length > 0 && (
|
||||
<AttachmentList
|
||||
attachments={parentStandaloneEditAttachments}
|
||||
className="max-w-full"
|
||||
onRemove={(attachmentId) =>
|
||||
setParentRetainedStandaloneIds((ids) => {
|
||||
const next = new Set(ids ?? []);
|
||||
next.delete(attachmentId);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
|
||||
|
||||
@@ -135,6 +135,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
</Tooltip>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<SubmitButton
|
||||
|
||||
@@ -59,11 +59,12 @@ import { LabelChip } from "../../labels/label-chip";
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
GROUPING_OPTIONS,
|
||||
SWIMLANE_GROUPINGS,
|
||||
CARD_PROPERTY_OPTIONS,
|
||||
type ActorFilterValue,
|
||||
} from "@multica/core/issues/stores/view-store";
|
||||
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
|
||||
import type { SortField, IssueGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
|
||||
import type { SortField, IssueGrouping, SwimlaneGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
|
||||
import {
|
||||
useIssuesScopeStore,
|
||||
type IssuesScope,
|
||||
@@ -602,6 +603,7 @@ export function IssueDisplayControls({
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const grouping = useViewStore((s) => s.grouping);
|
||||
const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping);
|
||||
const cardProperties = useViewStore((s) => s.cardProperties);
|
||||
const act = useViewStoreApi().getState();
|
||||
|
||||
@@ -631,6 +633,11 @@ export function IssueDisplayControls({
|
||||
status: "group_status",
|
||||
assignee: "group_assignee",
|
||||
};
|
||||
const SWIMLANE_GROUPING_LABEL_KEY: Record<SwimlaneGrouping, "group_parent" | "group_project" | "group_assignee"> = {
|
||||
parent: "group_parent",
|
||||
project: "group_project",
|
||||
assignee: "group_assignee",
|
||||
};
|
||||
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_start_date" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
|
||||
priority: "card_priority",
|
||||
description: "card_description",
|
||||
@@ -643,6 +650,7 @@ export function IssueDisplayControls({
|
||||
};
|
||||
const sortLabel = t(($) => $.display[SORT_LABEL_KEY[sortBy]]);
|
||||
const groupingLabel = t(($) => $.display[GROUPING_LABEL_KEY[grouping]]);
|
||||
const swimlaneGroupingLabel = t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[swimlaneGrouping]]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -911,6 +919,41 @@ export function IssueDisplayControls({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "swimlane" && (
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.display.grouping_section)}
|
||||
</span>
|
||||
<div className="mt-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-between text-xs"
|
||||
>
|
||||
{swimlaneGroupingLabel}
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuRadioGroup
|
||||
value={swimlaneGrouping}
|
||||
onValueChange={(v) => act.setSwimlaneGrouping(v as SwimlaneGrouping)}
|
||||
>
|
||||
{SWIMLANE_GROUPINGS.map((value) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
{t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[value]])}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -166,6 +166,7 @@ function ReplyInput({
|
||||
</Tooltip>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -27,6 +27,32 @@ vi.mock("@multica/core/paths", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Stub backend-bound queries that the swimlane invokes for project /
|
||||
// assignee groupings. The hook MUST return a stable reference each call
|
||||
// — production `useActorName` wraps its returns in `useMemo`, and the
|
||||
// swimlane feeds the result into a `useMemo(..., [getActorName, ...])`
|
||||
// that then drives a `useEffect(setLocalCells, [cells])` chain. A fresh
|
||||
// object per render therefore loops the effect indefinitely.
|
||||
vi.mock("@multica/core/projects/queries", () => ({
|
||||
projectListOptions: (_wsId: string) => ({
|
||||
queryKey: ["projects", _wsId, "list"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
}));
|
||||
const { mockActorNameResult } = vi.hoisted(() => ({
|
||||
mockActorNameResult: {
|
||||
getActorName: (_type: string, _id: string) => "Mock Actor",
|
||||
getActorInitials: () => "MA",
|
||||
getActorAvatarUrl: () => null,
|
||||
getMemberName: () => "Mock Member",
|
||||
getAgentName: () => "Mock Agent",
|
||||
getSquadName: () => "Mock Squad",
|
||||
},
|
||||
}));
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => mockActorNameResult,
|
||||
}));
|
||||
|
||||
// Mock @multica/core/auth
|
||||
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
@@ -97,15 +123,22 @@ vi.mock("@multica/core/issues/mutations", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock view store. `swimlaneOrder` is mutable on the captured object so
|
||||
// tests can simulate persisted lane order and assert that
|
||||
// `setSwimlaneOrder` was called by drag-end handlers.
|
||||
type SwimlaneGroupingMock = "parent" | "project" | "assignee";
|
||||
|
||||
// Mock view store. The lane order and collapsed-lane fields are mutable
|
||||
// records on the captured object so tests can simulate persisted state
|
||||
// (per grouping) and assert that `setSwimlaneOrder` was called by drag-end
|
||||
// handlers. The store actions operate on `swimlaneGrouping` — tests that
|
||||
// flip grouping must set both `swimlaneGrouping` and the matching slice
|
||||
// in `swimlaneOrders` / `collapsedSwimlanes`.
|
||||
const mockViewState: {
|
||||
sortBy: "position";
|
||||
sortDirection: "asc";
|
||||
cardProperties: Record<string, boolean>;
|
||||
swimlaneOrder: string[];
|
||||
collapsedSwimlanes: string[];
|
||||
swimlaneGrouping: SwimlaneGroupingMock;
|
||||
swimlaneOrders: Record<SwimlaneGroupingMock, string[]>;
|
||||
collapsedSwimlanes: Record<SwimlaneGroupingMock, string[]>;
|
||||
setSwimlaneGrouping: (g: SwimlaneGroupingMock) => void;
|
||||
setSwimlaneOrder: (order: string[]) => void;
|
||||
toggleSwimlaneCollapsed: (key: string) => void;
|
||||
hideStatus: (s: string) => void;
|
||||
@@ -114,8 +147,10 @@ const mockViewState: {
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
|
||||
swimlaneOrder: [],
|
||||
collapsedSwimlanes: [],
|
||||
swimlaneGrouping: "parent",
|
||||
swimlaneOrders: { parent: [], project: [], assignee: [] },
|
||||
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
|
||||
setSwimlaneGrouping: vi.fn(),
|
||||
setSwimlaneOrder: vi.fn(),
|
||||
toggleSwimlaneCollapsed: vi.fn(),
|
||||
hideStatus: vi.fn(),
|
||||
@@ -269,8 +304,9 @@ function renderWithI18n(ui: React.ReactNode) {
|
||||
describe("SwimLaneView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockViewState.swimlaneOrder = [];
|
||||
mockViewState.collapsedSwimlanes = [];
|
||||
mockViewState.swimlaneGrouping = "parent";
|
||||
mockViewState.swimlaneOrders = { parent: [], project: [], assignee: [] };
|
||||
mockViewState.collapsedSwimlanes = { parent: [], project: [], assignee: [] };
|
||||
useLoadMoreByStatusMock.mockImplementation(() => ({
|
||||
total: 0,
|
||||
loaded: 0,
|
||||
@@ -727,8 +763,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -736,7 +772,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("appends newly-visible parents to the persisted order on first reorder", () => {
|
||||
mockViewState.swimlaneOrder = ["parent-1"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -744,8 +783,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -753,7 +792,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("preserves persisted entries that aren't currently visible during a reorder", () => {
|
||||
mockViewState.swimlaneOrder = ["filtered-a", "parent-1", "filtered-b", "parent-2"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["filtered-a", "parent-1", "filtered-b", "parent-2"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -761,8 +803,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -781,8 +823,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-1" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-1" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -797,8 +839,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -806,7 +848,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("renders parent lanes in stored swimlaneOrder when set", () => {
|
||||
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["parent-2", "parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -820,7 +865,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("keeps 'No parent' lane pinned at top regardless of stored order", () => {
|
||||
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["parent-2", "parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -836,7 +884,10 @@ describe("SwimLaneView", () => {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
it("collapses a parent lane when its id is in stored collapsedSwimlanes", () => {
|
||||
mockViewState.collapsedSwimlanes = ["parent-1"];
|
||||
mockViewState.collapsedSwimlanes = {
|
||||
...mockViewState.collapsedSwimlanes,
|
||||
parent: ["parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -851,7 +902,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("collapses the 'No parent' lane when 'none' is in stored collapsedSwimlanes", () => {
|
||||
mockViewState.collapsedSwimlanes = ["none"];
|
||||
mockViewState.collapsedSwimlanes = {
|
||||
...mockViewState.collapsedSwimlanes,
|
||||
parent: ["none"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={mockIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -887,4 +941,209 @@ describe("SwimLaneView", () => {
|
||||
|
||||
expect(mockToggleSwimlaneCollapsed).toHaveBeenCalledWith("none");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Project grouping
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const projectIssues: Issue[] = [
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-a",
|
||||
identifier: "PROJ-100",
|
||||
title: "Issue A",
|
||||
project_id: "proj-1",
|
||||
parent_issue_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-b",
|
||||
identifier: "PROJ-101",
|
||||
title: "Issue B",
|
||||
project_id: "proj-2",
|
||||
parent_issue_id: null,
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-c",
|
||||
identifier: "PROJ-102",
|
||||
title: "Issue C",
|
||||
project_id: null,
|
||||
parent_issue_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
];
|
||||
|
||||
it("groups by project when swimlaneGrouping is 'project'", () => {
|
||||
mockViewState.swimlaneGrouping = "project";
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={projectIssues} onMoveIssue={vi.fn()} />,
|
||||
);
|
||||
|
||||
// No-project pinned lane is always present.
|
||||
expect(screen.getAllByText("No project").length).toBeGreaterThanOrEqual(1);
|
||||
// Both issue cards from real projects render — production fetches
|
||||
// project titles from the API; in tests the mocked listProjects
|
||||
// returns [] so the lane headers fall back to an empty title and
|
||||
// we assert on card visibility, not lane title text.
|
||||
expect(screen.getByText("Issue A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits project_id when a card is dropped into a project lane", () => {
|
||||
mockViewState.swimlaneGrouping = "project";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={projectIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
// Drop "issue-c" (no project) into proj-1's todo cell.
|
||||
const target = "swim:project:proj-1:todo";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-c" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-c" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-c",
|
||||
expect.objectContaining({ project_id: "proj-1", status: "todo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits null project_id when a card is dropped into the 'No project' lane", () => {
|
||||
mockViewState.swimlaneGrouping = "project";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={projectIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
const target = "swim:project:none:in_review";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-a" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-a" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-a",
|
||||
expect.objectContaining({ project_id: null, status: "in_review" }),
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Assignee grouping
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const assigneeIssues: Issue[] = [
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-x",
|
||||
identifier: "PROJ-200",
|
||||
title: "Issue X",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-y",
|
||||
identifier: "PROJ-201",
|
||||
title: "Issue Y",
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-z",
|
||||
identifier: "PROJ-202",
|
||||
title: "Issue Z",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
];
|
||||
|
||||
it("groups by assignee when swimlaneGrouping is 'assignee'", () => {
|
||||
mockViewState.swimlaneGrouping = "assignee";
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={assigneeIssues} onMoveIssue={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Unassigned pinned lane is always rendered.
|
||||
expect(screen.getAllByText("Unassigned").length).toBeGreaterThanOrEqual(1);
|
||||
// Mock actor name fallback for both member and agent.
|
||||
expect(screen.getAllByText("Mock Actor").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText("Issue X")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue Y")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue Z")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits assignee_type + assignee_id when a card is dropped into an actor lane", () => {
|
||||
mockViewState.swimlaneGrouping = "assignee";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={assigneeIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
const target = "swim:assignee:member:user-1:in_review";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-z" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-z" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-z",
|
||||
expect.objectContaining({
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
status: "in_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits null assignee when a card is dropped into the 'Unassigned' lane", () => {
|
||||
mockViewState.swimlaneGrouping = "assignee";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={assigneeIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
const target = "swim:assignee:none:done";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-x" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-x" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-x",
|
||||
expect.objectContaining({
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
status: "done",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,8 @@
|
||||
"preview_too_large": "File is too large to preview. Please download.",
|
||||
"preview_unsupported": "This file type can't be previewed.",
|
||||
"close": "Close",
|
||||
"open_in_new_tab": "Open in new tab"
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"remove": "Remove attachment"
|
||||
},
|
||||
"link_hover": {
|
||||
"copy_link": "Copy link",
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
"descending_title": "Descending",
|
||||
"group_status": "Status",
|
||||
"group_assignee": "Assignee",
|
||||
"group_parent": "Parent issue",
|
||||
"group_project": "Project",
|
||||
"sort_manual": "Manual",
|
||||
"sort_priority": "Priority",
|
||||
"sort_start_date": "Start date",
|
||||
@@ -127,6 +129,8 @@
|
||||
"swimlane": {
|
||||
"no_parent": "No parent",
|
||||
"other_parents": "Other parents",
|
||||
"no_project": "No project",
|
||||
"no_assignee": "Unassigned",
|
||||
"open_parent": "Open parent issue",
|
||||
"toggle_collapse": "Toggle lane"
|
||||
},
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
"inspector": {
|
||||
"section_properties": "属性",
|
||||
"section_details": "详情",
|
||||
"section_skills": "skill",
|
||||
"section_skills": "Skills",
|
||||
"prop_runtime": "运行时",
|
||||
"prop_model": "模型",
|
||||
"prop_thinking": "思考",
|
||||
@@ -186,9 +186,9 @@
|
||||
},
|
||||
"tabs": {
|
||||
"activity": "动态",
|
||||
"tasks": "task",
|
||||
"tasks": "Tasks",
|
||||
"instructions": "指令",
|
||||
"skills": "skill",
|
||||
"skills": "Skills",
|
||||
"environment": "环境变量",
|
||||
"custom_args": "自定义参数",
|
||||
"discard_dialog_title": "放弃未保存的修改?",
|
||||
@@ -229,7 +229,7 @@
|
||||
"editor_placeholder": "写下这个智能体该做什么、关注什么、要避开什么…"
|
||||
},
|
||||
"skills_section": {
|
||||
"label": "Skill",
|
||||
"label": "Skills",
|
||||
"placeholder": "从工作区添加 skill",
|
||||
"selected_other": "已选 {{count}} 个 —— 点击修改",
|
||||
"collapse": "收起",
|
||||
@@ -324,7 +324,7 @@
|
||||
"success_pct": "{{percent}}% 成功",
|
||||
"avg_duration": "平均 {{value}}",
|
||||
"failed_count": "{{count}} 次失败",
|
||||
"source_issue": "issue",
|
||||
"source_issue": "Issue",
|
||||
"source_chat": "聊天",
|
||||
"source_autopilot": "自动化",
|
||||
"source_untracked": "未追踪",
|
||||
@@ -332,7 +332,7 @@
|
||||
"source_creating_issue": "正在创建 issue",
|
||||
"source_chat_session": "聊天会话",
|
||||
"source_autopilot_run": "自动化运行",
|
||||
"issue_short_fallback": "issue {{prefix}}...",
|
||||
"issue_short_fallback": "Issue {{prefix}}...",
|
||||
"triggered_by": "触发来源",
|
||||
"open_issue_aria": "打开 issue",
|
||||
"open_issue_tooltip": "打开 issue",
|
||||
@@ -366,7 +366,7 @@
|
||||
"unavailable": "智能体不可用",
|
||||
"detail_link": "详情 →",
|
||||
"runtime_label": "运行时",
|
||||
"skills_label": "skill",
|
||||
"skills_label": "Skills",
|
||||
"owner_label": "所有者",
|
||||
"unknown_runtime": "未知运行时"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"preview_too_large": "文件太大,无法在线预览,请下载查看。",
|
||||
"preview_unsupported": "该文件类型暂不支持预览。",
|
||||
"close": "关闭",
|
||||
"open_in_new_tab": "在新标签页打开"
|
||||
"open_in_new_tab": "在新标签页打开",
|
||||
"remove": "移除附件"
|
||||
},
|
||||
"link_hover": {
|
||||
"copy_link": "复制链接",
|
||||
@@ -51,7 +52,7 @@
|
||||
},
|
||||
"mention": {
|
||||
"group_users": "用户",
|
||||
"group_issues": "issue",
|
||||
"group_issues": "Issues",
|
||||
"all_members": "所有成员",
|
||||
"searching": "搜索中...",
|
||||
"no_results": "无结果"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"page": {
|
||||
"breadcrumb_title": "issue",
|
||||
"breadcrumb_title": "Issues",
|
||||
"breadcrumb_workspace_fallback": "工作区",
|
||||
"empty_title": "还没有 issue",
|
||||
"empty_hint": "创建一个 issue 开始使用。",
|
||||
@@ -59,6 +59,8 @@
|
||||
"descending_title": "降序",
|
||||
"group_status": "状态",
|
||||
"group_assignee": "负责人",
|
||||
"group_parent": "父级 issue",
|
||||
"group_project": "项目",
|
||||
"sort_manual": "手动",
|
||||
"sort_priority": "优先级",
|
||||
"sort_start_date": "开始日期",
|
||||
@@ -125,6 +127,8 @@
|
||||
"swimlane": {
|
||||
"no_parent": "无父级",
|
||||
"other_parents": "其他父级",
|
||||
"no_project": "无项目",
|
||||
"no_assignee": "未指派",
|
||||
"open_parent": "打开父级 issue",
|
||||
"toggle_collapse": "切换泳道"
|
||||
},
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"nav": {
|
||||
"inbox": "收件箱",
|
||||
"my_issues": "我的 Issue",
|
||||
"issues": "Issue",
|
||||
"my_issues": "我的 issue",
|
||||
"issues": "Issues",
|
||||
"projects": "项目",
|
||||
"autopilots": "自动化",
|
||||
"agents": "智能体",
|
||||
"squads": "小队",
|
||||
"usage": "用量",
|
||||
"runtimes": "运行时",
|
||||
"skills": "Skill",
|
||||
"skills": "Skills",
|
||||
"settings": "设置"
|
||||
},
|
||||
"help": {
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"preview": {
|
||||
"inbox_label": "收件箱",
|
||||
"inbox_meta": "你的通知",
|
||||
"issues_label": "issue",
|
||||
"issues_label": "Issues",
|
||||
"issues_meta": "共享任务面板",
|
||||
"agents_label": "智能体",
|
||||
"agents_meta": "你的 AI 队友",
|
||||
@@ -292,7 +292,7 @@
|
||||
"autopilot_meta": "定时自动化",
|
||||
"runtimes_label": "运行时",
|
||||
"runtimes_meta": "智能体跑的地方",
|
||||
"skills_label": "skill",
|
||||
"skills_label": "Skills",
|
||||
"skills_meta": "可复用的剧本",
|
||||
"more_label": "更多",
|
||||
"more_meta": "等等"
|
||||
|
||||
@@ -177,22 +177,22 @@
|
||||
"create_agent": "创建智能体"
|
||||
},
|
||||
"cloud_runtime": {
|
||||
"action": "Cloud Runtime",
|
||||
"title": "Cloud Runtime",
|
||||
"action": "云端运行时",
|
||||
"title": "云端运行时",
|
||||
"description": "创建托管云端节点,并查看它的 Fleet 状态。",
|
||||
"create_title": "新节点",
|
||||
"create_hint": "只需要填写名称、实例规格和磁盘大小,其余配置由 Fleet 默认处理。",
|
||||
"nodes_title": "Fleet 节点",
|
||||
"refresh": "刷新",
|
||||
"nodes_empty": "还没有云端节点",
|
||||
"nodes_failed": "Cloud Runtime 不可用",
|
||||
"nodes_failed": "云端运行时不可用",
|
||||
"nodes_failed_hint": "Fleet 未返回节点列表。",
|
||||
"node_fallback_name": "云端节点",
|
||||
"create": "创建节点",
|
||||
"creating": "创建中...",
|
||||
"cancel": "取消",
|
||||
"toast_created": "Cloud Runtime 节点已创建",
|
||||
"toast_create_failed": "创建 Cloud Runtime 节点失败",
|
||||
"toast_created": "云端节点已创建",
|
||||
"toast_create_failed": "创建云端节点失败",
|
||||
"delete": "删除节点",
|
||||
"delete_confirm": "确定要删除此云节点吗?此操作无法撤销。",
|
||||
"toast_deleted": "云节点已删除",
|
||||
|
||||
@@ -7,17 +7,17 @@
|
||||
"commands": "命令",
|
||||
"members": "成员",
|
||||
"projects": "项目",
|
||||
"issues": "issue",
|
||||
"issues": "Issues",
|
||||
"recent": "最近"
|
||||
},
|
||||
"pages": {
|
||||
"inbox": "收件箱",
|
||||
"my_issues": "我的 issue",
|
||||
"issues": "issue",
|
||||
"issues": "Issues",
|
||||
"projects": "项目",
|
||||
"agents": "智能体",
|
||||
"runtimes": "运行时",
|
||||
"skills": "skill",
|
||||
"skills": "Skills",
|
||||
"settings": "设置"
|
||||
},
|
||||
"commands": {
|
||||
|
||||
@@ -179,11 +179,11 @@
|
||||
"section_master": "启用 GitHub 功能",
|
||||
"master_description_on": "关闭后,所有 GitHub 入口都会被隐藏,也不再产生新的副作用。后台已有数据不会被删除。团队不使用 GitHub?在这里直接关掉即可。",
|
||||
"master_description_off": "GitHub 功能已关闭。PR 侧栏、Co-authored-by trailer 与自动关联都已暂停。后台数据未删除。",
|
||||
"section_connection": "Connection",
|
||||
"section_connection": "连接",
|
||||
"connection_title": "GitHub App",
|
||||
"connection_description_prefix": "自动把 issue 关联到 Pull Request。当 PR 的分支、标题或正文中包含",
|
||||
"connection_description_suffix": "并被合并时,对应的 issue 会自动转为",
|
||||
"connection_description_done": "Done",
|
||||
"connection_description_done": "已完成",
|
||||
"connection_identifier_example": "MUL-123",
|
||||
"connected_to": "已连接到 {{login}}",
|
||||
"connect_github": "连接 GitHub",
|
||||
@@ -204,7 +204,7 @@
|
||||
"toast_open_failed": "打开 GitHub 安装页失败",
|
||||
"toast_disconnect_failed": "断开 GitHub App 失败",
|
||||
"toast_disconnected": "已断开 GitHub App",
|
||||
"section_features": "Features",
|
||||
"section_features": "功能",
|
||||
"feature_pr_sidebar_label": "Pull Request 侧栏",
|
||||
"feature_pr_sidebar_description": "在 Issue 详情侧栏中展示关联的 Pull Request。",
|
||||
"feature_co_author_label": "Co-authored-by trailer",
|
||||
@@ -212,8 +212,8 @@
|
||||
"feature_co_author_description_suffix": "。",
|
||||
"feature_auto_link_label": "Issue ↔ PR 自动关联",
|
||||
"feature_auto_link_description": "根据 PR 标题、正文和分支名匹配 issue 编号并自动建立链接。",
|
||||
"section_repositories": "Repositories",
|
||||
"repositories_shortcut_label": "仓库 URL 仍在 Repositories 标签页中管理",
|
||||
"section_repositories": "代码仓库",
|
||||
"repositories_shortcut_label": "仓库 URL 仍在「代码仓库」标签页中管理",
|
||||
"repositories_shortcut_link": "前往管理 →",
|
||||
"toast_failed": "更新 GitHub 设置失败"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "skill",
|
||||
"title": "Skills",
|
||||
"tagline": "工作区里任何智能体都能使用的指令。",
|
||||
"learn_more": "了解更多 →",
|
||||
"new_skill": "新建 skill",
|
||||
@@ -198,7 +198,7 @@
|
||||
"toast_created": "已创建 skill"
|
||||
},
|
||||
"url": {
|
||||
"url_label": "skill URL",
|
||||
"url_label": "Skill URL",
|
||||
"supported_sources": "支持的来源",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
|
||||
@@ -1024,8 +1024,8 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
Content string `json:"content"`
|
||||
AttachmentIDs *[]string `json:"attachment_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
@@ -1036,9 +1036,14 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
||||
if !ok {
|
||||
return
|
||||
var attachmentIDs []pgtype.UUID
|
||||
replaceAttachments := req.AttachmentIDs != nil
|
||||
if replaceAttachments {
|
||||
var ok bool
|
||||
attachmentIDs, ok = parseUUIDSliceOrBadRequest(w, *req.AttachmentIDs, "attachment_ids")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
|
||||
@@ -1053,12 +1058,17 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bind any newly uploaded attachments referenced in the edited content so
|
||||
// they appear in the timeline's comment.attachments after refresh. Existing
|
||||
// attachments already point at this comment via the upload flow; passing
|
||||
// them again is a no-op at the SQL level.
|
||||
if len(attachmentIDs) > 0 {
|
||||
h.linkAttachmentsByIDs(r.Context(), comment.ID, existing.IssueID, attachmentIDs)
|
||||
// Replace the comment attachment set when a modern client sends
|
||||
// attachment_ids. Older clients omit the field; in that case preserve the
|
||||
// existing attachment links rather than unlinking everything.
|
||||
if replaceAttachments {
|
||||
if err := h.Queries.ReplaceCommentAttachments(r.Context(), db.ReplaceCommentAttachmentsParams{
|
||||
CommentID: comment.ID,
|
||||
IssueID: existing.IssueID,
|
||||
Column3: attachmentIDs,
|
||||
}); err != nil {
|
||||
slog.Error("failed to replace comment attachments", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch reactions and attachments for the updated comment.
|
||||
|
||||
@@ -447,3 +447,27 @@ func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachment
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const replaceCommentAttachments = `-- name: ReplaceCommentAttachments :exec
|
||||
UPDATE attachment
|
||||
SET comment_id = CASE
|
||||
WHEN id = ANY($3::uuid[]) THEN $1
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE issue_id = $2
|
||||
AND (
|
||||
comment_id = $1
|
||||
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
|
||||
)
|
||||
`
|
||||
|
||||
type ReplaceCommentAttachmentsParams struct {
|
||||
CommentID pgtype.UUID `json:"comment_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Column3 []pgtype.UUID `json:"column_3"`
|
||||
}
|
||||
|
||||
func (q *Queries) ReplaceCommentAttachments(ctx context.Context, arg ReplaceCommentAttachmentsParams) error {
|
||||
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.Column3)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,6 +44,18 @@ WHERE issue_id = $2
|
||||
AND comment_id IS NULL
|
||||
AND id = ANY($3::uuid[]);
|
||||
|
||||
-- name: ReplaceCommentAttachments :exec
|
||||
UPDATE attachment
|
||||
SET comment_id = CASE
|
||||
WHEN id = ANY($3::uuid[]) THEN $1
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE issue_id = $2
|
||||
AND (
|
||||
comment_id = $1
|
||||
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
|
||||
);
|
||||
|
||||
-- name: LinkAttachmentsToChatMessage :exec
|
||||
UPDATE attachment
|
||||
SET chat_message_id = $1
|
||||
|
||||
Reference in New Issue
Block a user