fix(issues): offer quick actions in the reply composer's / menu (MUL-5588)

The thread reply composer enabled the built-in `/` command menu but never
passed `quickActionMenu` to its ContentEditor, so the menu listed only
`/note` while the top-level comment composer listed the workspace's quick
actions. A reply posts to the same issue, so `/` must offer the same
catalog in both.

Wires ReplyInput to the same `useQuickActionMenu(issueId)` hook CommentInput
already uses — picking an action inserts the server-rendered body for the
user to edit, exactly as in the top-level composer.

Adds a parametrized test over both composers so the two cannot drift apart
again.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Lambda
2026-07-31 15:53:47 +08:00
parent 98766cc06a
commit 7cff2bdbcc
2 changed files with 112 additions and 1 deletions

View File

@@ -5,18 +5,34 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import type { Attachment } from "@multica/core/types";
import { useCommentComposerStore, useCommentDraftStore } from "@multica/core/issues/stores";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { renderWithI18n } from "../../test/i18n";
import { CommentInput } from "./comment-input";
import { ReplyInput } from "./reply-input";
/** Shape of ContentEditor's `quickActionMenu` prop, as the composers pass it. */
type QuickActionMenuProp = {
getQuickActions?: () => { id: string; name: string; description?: string }[];
renderQuickAction?: (quickActionId: string) => Promise<string>;
onRenderError?: (error: unknown) => void;
};
// Uploads now flow through the module-level coordinator, which calls
// `api.uploadFile(file, ctx, signal)` (MUL-5181). Tests drive uploads by
// mocking that call directly rather than the old `uploadWithToast` hook.
const apiUploadFile = vi.hoisted(() => vi.fn());
const apiListWorkspaces = vi.hoisted(() => vi.fn());
const apiListQuickActions = vi.hoisted(() => vi.fn());
const apiRenderQuickAction = vi.hoisted(() => vi.fn());
const uploadWithToast = vi.hoisted(() => vi.fn());
const editorDefaultValues = vi.hoisted(() => ({
values: [] as Array<string | undefined>,
}));
// The `/` quick-action menu is wired through a ContentEditor prop, so the mock
// editor records it — that prop being absent is exactly the MUL-5588 bug.
const editorQuickActionMenu = vi.hoisted(() => ({
last: undefined as QuickActionMenuProp | undefined,
}));
// Observability + failure control for the write-back insert path (MUL-5181):
// `insertMarkdownAtEnd` returns false while the (simulated) Tiptap instance
// doesn't exist yet, exactly like the real handle.
@@ -38,7 +54,12 @@ const editorUploadSignal = vi.hoisted(
let mockUploadIdSeq = 0;
vi.mock("@multica/core/api", () => ({
api: { uploadFile: apiUploadFile },
api: {
uploadFile: apiUploadFile,
listWorkspaces: apiListWorkspaces,
listQuickActions: apiListQuickActions,
renderQuickAction: apiRenderQuickAction,
},
}));
vi.mock("@multica/core/hooks/use-file-upload", async () => ({
@@ -87,6 +108,7 @@ vi.mock("../../editor", async () => ({
onUploadingChange,
onSubmit,
onReady,
quickActionMenu,
}: {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
@@ -95,10 +117,12 @@ vi.mock("../../editor", async () => ({
onUploadingChange?: (uploading: boolean) => void;
onSubmit?: () => void;
onReady?: () => void;
quickActionMenu?: QuickActionMenuProp;
},
ref: Ref<unknown>,
) {
editorDefaultValues.values.push(defaultValue);
editorQuickActionMenu.last = quickActionMenu;
editorUploadSignal.notify = onUploadingChange;
const valueRef = useRef(defaultValue ?? "");
// Mirrors the real editor's `uploading` node attrs: the placeholder exists
@@ -233,6 +257,9 @@ function getSubmitButton(container: HTMLElement): HTMLButtonElement {
beforeEach(() => {
uploadWithToast.mockReset();
apiUploadFile.mockReset();
apiListWorkspaces.mockReset();
apiListQuickActions.mockReset();
apiRenderQuickAction.mockReset();
insertMarkdownSpy.mockReset();
insertPlaceholderSpy.mockReset();
insertMarkdownBehavior.succeed = true;
@@ -243,10 +270,88 @@ beforeEach(() => {
// path and hide the shell the next test expects.
useCommentDraftStore.setState({ drafts: {} });
editorDefaultValues.values = [];
editorQuickActionMenu.last = undefined;
focusCalls.focused = 0;
focusCalls.blurred = 0;
});
// ---------------------------------------------------------------------------
// Quick action `/` menu (MUL-5588)
// ---------------------------------------------------------------------------
describe("quick action `/` menu", () => {
const workspace = { id: "ws-1", slug: "acme", name: "Acme" };
function renderInWorkspace(ui: ReactNode) {
apiListWorkspaces.mockResolvedValue([workspace]);
apiListQuickActions.mockResolvedValue({
quick_actions: [
{ id: "qa-1", name: "review", description: "Ask for a review", status: "active" },
{ id: "qa-2", name: "retired", description: "", status: "archived" },
],
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderWithI18n(
<QueryClientProvider client={queryClient}>
{/* useQuickActionMenu resolves the workspace from the slug context; without
it the catalog query stays disabled and every menu reads as empty. */}
<WorkspaceSlugProvider slug="acme">{ui}</WorkspaceSlugProvider>
</QueryClientProvider>,
);
}
// Both composers post to the same issue, so `/` must offer the same catalog in
// both. The reply box shipped without the prop entirely (MUL-5588): its menu
// listed only the built-in `/note` while the top-level composer listed the
// workspace's quick actions.
const composers = [
{
name: "top-level comment composer",
shell: "comment-composer-shell" as const,
element: <CommentInput issueId="issue-1" onSubmit={vi.fn().mockResolvedValue(true)} />,
},
{
name: "thread reply composer",
shell: "reply-composer-shell" as const,
element: (
<ReplyInput
issueId="issue-1"
parentId="comment-1"
avatarType="member"
avatarId="user-1"
onSubmit={vi.fn().mockResolvedValue(true)}
/>
),
},
];
for (const composer of composers) {
it(`offers the workspace's active quick actions in the ${composer.name}`, async () => {
renderInWorkspace(composer.element);
activateComposer(composer.shell);
await waitFor(() => {
expect(editorQuickActionMenu.last?.getQuickActions?.()).toEqual([
{ id: "qa-1", name: "review", description: "Ask for a review" },
]);
});
});
it(`binds quick action rendering to this issue in the ${composer.name}`, async () => {
apiRenderQuickAction.mockResolvedValue("rendered body");
renderInWorkspace(composer.element);
activateComposer(composer.shell);
await waitFor(() => {
expect(editorQuickActionMenu.last?.renderQuickAction).toBeTypeOf("function");
});
await editorQuickActionMenu.last?.renderQuickAction?.("qa-1");
expect(apiRenderQuickAction).toHaveBeenCalledWith("issue-1", "qa-1");
});
}
});
describe("comment composers", () => {
it("renders the main comment composer without a manual expand control", () => {
const { container } = renderCommentInput();

View File

@@ -14,6 +14,7 @@ import { useT } from "../../i18n";
import { CommentTriggerChips } from "./comment-trigger-chips";
import { useCommentTriggerPreview } from "../hooks/use-comment-trigger-preview";
import { useCommentUploads } from "./use-comment-uploads";
import { useQuickActionMenu } from "../hooks/use-quick-action-menu";
// ---------------------------------------------------------------------------
// Types
@@ -57,6 +58,10 @@ function ReplyInput({
const composerRef = useRef<HTMLDivElement>(null);
// See CommentInput — replying mid-upload posts without the file.
const uploadGate = useUploadGate(editorRef);
// Quick actions in the `/` menu — same catalog and same insert-don't-run
// behavior as the top-level composer. A reply posts to the same issue, so
// `/` has to offer the same thing here (MUL-5588).
const quickActionMenu = useQuickActionMenu(issueId);
// If a draft key is provided, hydrate from store on mount (defaultValue is
// the only injection point on ContentEditorRef) and flush on every onUpdate.
const [initialDraft] = useState(() =>
@@ -246,6 +251,7 @@ function ReplyInput({
attachments={pendingAttachments}
enableSlashCommands
slashCommandMode="command"
quickActionMenu={quickActionMenu}
/>
</div>
)}