mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
2 Commits
feature/ru
...
agent/walt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1621000b5b | ||
|
|
33c84238c3 |
174
packages/views/issues/components/comment-composers.test.tsx
Normal file
174
packages/views/issues/components/comment-composers.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, type Ref } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
|
||||
const uploadWithToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
||||
useFileUpload: () => ({ uploadWithToast }),
|
||||
}));
|
||||
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorType, actorId }: { actorType: string; actorId: string }) => (
|
||||
<span data-testid="actor-avatar">
|
||||
{actorType}:{actorId}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../editor", () => ({
|
||||
useFileDropZone: () => ({
|
||||
isDragOver: false,
|
||||
dropZoneProps: { "data-testid": "drop-zone" },
|
||||
}),
|
||||
FileDropOverlay: () => null,
|
||||
ContentEditor: forwardRef(function MockContentEditor(
|
||||
{
|
||||
defaultValue,
|
||||
onUpdate,
|
||||
placeholder,
|
||||
onUploadFile,
|
||||
}: {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
},
|
||||
ref: Ref<unknown>,
|
||||
) {
|
||||
const valueRef = useRef(defaultValue ?? "");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => {
|
||||
valueRef.current = "";
|
||||
},
|
||||
focus: () => {},
|
||||
blur: () => {},
|
||||
uploadFile: async (file: File) => {
|
||||
const result = await onUploadFile?.(file);
|
||||
if (!result) return;
|
||||
valueRef.current = `${valueRef.current}\n${result.url}`.trim();
|
||||
onUpdate?.(valueRef.current);
|
||||
},
|
||||
hasActiveUploads: () => false,
|
||||
}));
|
||||
|
||||
return (
|
||||
<textarea
|
||||
data-testid="editor"
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
valueRef.current = event.target.value;
|
||||
onUpdate?.(event.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(undefined)) {
|
||||
const view = renderWithI18n(<CommentInput issueId="issue-1" onSubmit={onSubmit} />);
|
||||
return { ...view, onSubmit };
|
||||
}
|
||||
|
||||
function renderReplyInput({
|
||||
onSubmit = vi.fn().mockResolvedValue(undefined),
|
||||
size = "sm",
|
||||
}: {
|
||||
onSubmit?: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
} = {}) {
|
||||
const view = renderWithI18n(
|
||||
<ReplyInput
|
||||
issueId="issue-1"
|
||||
avatarType="member"
|
||||
avatarId="user-1"
|
||||
onSubmit={onSubmit}
|
||||
size={size}
|
||||
/>,
|
||||
);
|
||||
return { ...view, onSubmit };
|
||||
}
|
||||
|
||||
function getSubmitButton(container: HTMLElement): HTMLButtonElement {
|
||||
const button = container.querySelectorAll("button")[1];
|
||||
if (!button) throw new Error("Expected submit button to render");
|
||||
return button;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
uploadWithToast.mockReset();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("comment composers", () => {
|
||||
it("renders the main comment composer without a manual expand control", () => {
|
||||
const { container } = renderCommentInput();
|
||||
|
||||
expect(screen.getByPlaceholderText("Leave a comment...")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Attach file" })).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("button")).toHaveLength(2);
|
||||
|
||||
const shell = screen.getByTestId("drop-zone");
|
||||
expect(shell.className).not.toMatch(/max-h-/);
|
||||
expect(shell.className).not.toContain("h-[70vh]");
|
||||
});
|
||||
|
||||
it("renders reply composer without a manual expand control", () => {
|
||||
const { container } = renderReplyInput();
|
||||
|
||||
expect(screen.getByPlaceholderText("Leave a reply...")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Attach file" })).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("button")).toHaveLength(2);
|
||||
|
||||
const shell = screen.getByTestId("drop-zone");
|
||||
expect(shell.className).not.toMatch(/max-h-/);
|
||||
expect(shell.className).not.toContain("h-[60vh]");
|
||||
});
|
||||
|
||||
it("lets default-size replies grow without a height cap", () => {
|
||||
const { container } = renderReplyInput({ size: "default" });
|
||||
|
||||
expect(screen.getByPlaceholderText("Leave a reply...")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("button")).toHaveLength(2);
|
||||
|
||||
const shell = screen.getByTestId("drop-zone");
|
||||
expect(shell.className).not.toMatch(/max-h-/);
|
||||
});
|
||||
|
||||
it("keeps main comment submission wired after removing expand", async () => {
|
||||
const { container, onSubmit } = renderCommentInput();
|
||||
|
||||
fireEvent.change(screen.getByTestId("editor"), {
|
||||
target: { value: "hello from composer" },
|
||||
});
|
||||
fireEvent.click(getSubmitButton(container));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith("hello from composer", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps reply submission wired after removing expand", async () => {
|
||||
const { container, onSubmit } = renderReplyInput();
|
||||
|
||||
fireEvent.change(screen.getByTestId("editor"), {
|
||||
target: { value: "thread reply" },
|
||||
});
|
||||
fireEvent.click(getSubmitButton(container));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith("thread reply", undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
@@ -30,7 +27,6 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const initialDraft = useCommentDraftStore.getState().getDraft(draftKey);
|
||||
const [isEmpty, setIsEmpty] = useState(() => !initialDraft?.trim());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// Attachments uploaded in this composer session. Drives both:
|
||||
// - submit-time `attachment_ids` payload (filtered to URLs still in markdown)
|
||||
// - the editor's AttachmentDownloadProvider, so file-card Eye buttons can
|
||||
@@ -91,10 +87,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
return (
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border",
|
||||
isExpanded ? "h-[70vh]" : "max-h-56",
|
||||
)}
|
||||
className="relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
@@ -116,23 +109,6 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => !v);
|
||||
editorRef.current?.focus();
|
||||
}}
|
||||
className="rounded-sm p-1.5 text-muted-foreground opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{isExpanded ? t(($) => $.comment.collapse_tooltip) : t(($) => $.comment.expand_tooltip)}</TooltipContent>
|
||||
</Tooltip>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { ArrowUp, Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -54,7 +53,6 @@ function ReplyInput({
|
||||
const setDraft = useCommentDraftStore((s) => s.setDraft);
|
||||
const clearDraft = useCommentDraftStore((s) => s.clearDraft);
|
||||
const [isEmpty, setIsEmpty] = useState(!initialDraft?.trim());
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Attachments uploaded in this composer session — see CommentInput for the
|
||||
// rationale (drives both submit-time attachment_ids and editor previews).
|
||||
@@ -121,10 +119,7 @@ function ReplyInput({
|
||||
{...dropZoneProps}
|
||||
className={cn(
|
||||
"relative min-w-0 flex-1 flex flex-col",
|
||||
isExpanded
|
||||
? "h-[60vh]"
|
||||
: size === "sm" ? "max-h-40" : "max-h-56",
|
||||
(!isEmpty || isExpanded) && "pb-7",
|
||||
!isEmpty && "pb-7",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
@@ -147,23 +142,6 @@ function ReplyInput({
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => !v);
|
||||
editorRef.current?.focus();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{isExpanded ? t(($) => $.reply.collapse_tooltip) : t(($) => $.reply.expand_tooltip)}</TooltipContent>
|
||||
</Tooltip>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
@@ -173,7 +151,12 @@ function ReplyInput({
|
||||
type="button"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 items-center justify-center rounded-full transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
isEmpty
|
||||
? "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
)}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
@@ -275,8 +275,6 @@
|
||||
"reply_count_other": "{{count}} replies",
|
||||
"leave_comment_placeholder": "Leave a comment...",
|
||||
"send_tooltip": "Send",
|
||||
"expand_tooltip": "Expand",
|
||||
"collapse_tooltip": "Collapse",
|
||||
"resolve": {
|
||||
"resolve_action": "Resolve thread",
|
||||
"unresolve_action": "Unresolve thread",
|
||||
@@ -290,9 +288,7 @@
|
||||
}
|
||||
},
|
||||
"reply": {
|
||||
"placeholder": "Leave a reply...",
|
||||
"expand_tooltip": "Expand",
|
||||
"collapse_tooltip": "Collapse"
|
||||
"placeholder": "Leave a reply..."
|
||||
},
|
||||
"agent_activity": {
|
||||
"hover_header_one": "{{count}} agent working",
|
||||
|
||||
@@ -267,8 +267,6 @@
|
||||
"reply_count_other": "返信 {{count}} 件",
|
||||
"leave_comment_placeholder": "コメントを残す...",
|
||||
"send_tooltip": "送信",
|
||||
"expand_tooltip": "展開",
|
||||
"collapse_tooltip": "折りたたむ",
|
||||
"resolve": {
|
||||
"resolve_action": "スレッドを解決",
|
||||
"unresolve_action": "スレッドの解決を取り消す",
|
||||
@@ -280,9 +278,7 @@
|
||||
}
|
||||
},
|
||||
"reply": {
|
||||
"placeholder": "返信を残す...",
|
||||
"expand_tooltip": "展開",
|
||||
"collapse_tooltip": "折りたたむ"
|
||||
"placeholder": "返信を残す..."
|
||||
},
|
||||
"agent_activity": {
|
||||
"hover_header_other": "作業中のエージェント {{count}} 件",
|
||||
|
||||
@@ -275,8 +275,6 @@
|
||||
"reply_count_other": "답글 {{count}}개",
|
||||
"leave_comment_placeholder": "댓글 남기기...",
|
||||
"send_tooltip": "보내기",
|
||||
"expand_tooltip": "펼치기",
|
||||
"collapse_tooltip": "접기",
|
||||
"resolve": {
|
||||
"resolve_action": "스레드 해결",
|
||||
"unresolve_action": "스레드 해결 취소",
|
||||
@@ -290,9 +288,7 @@
|
||||
}
|
||||
},
|
||||
"reply": {
|
||||
"placeholder": "답글 남기기...",
|
||||
"expand_tooltip": "펼치기",
|
||||
"collapse_tooltip": "접기"
|
||||
"placeholder": "답글 남기기..."
|
||||
},
|
||||
"agent_activity": {
|
||||
"hover_header_one": "작업 중인 에이전트 {{count}}개",
|
||||
|
||||
@@ -272,8 +272,6 @@
|
||||
"reply_count_other": "{{count}} 条回复",
|
||||
"leave_comment_placeholder": "留下评论...",
|
||||
"send_tooltip": "发送",
|
||||
"expand_tooltip": "展开",
|
||||
"collapse_tooltip": "收起",
|
||||
"resolve": {
|
||||
"resolve_action": "标记为已解决",
|
||||
"unresolve_action": "重新打开",
|
||||
@@ -285,9 +283,7 @@
|
||||
}
|
||||
},
|
||||
"reply": {
|
||||
"placeholder": "回复...",
|
||||
"expand_tooltip": "展开",
|
||||
"collapse_tooltip": "收起"
|
||||
"placeholder": "回复..."
|
||||
},
|
||||
"agent_activity": {
|
||||
"hover_header_other": "{{count}} 个智能体正在工作",
|
||||
|
||||
Reference in New Issue
Block a user