Files
multica/packages/views/editor/title-editor.test.tsx
Naiyuan Qing fae7b046f2 feat(views): support the send shortcut in manual issue create (MUL-4931) (#5583)
* feat(views): support the send shortcut in manual issue create (MUL-4931)

Agent create has had Cmd/Ctrl+Enter all along; manual create had no submit
shortcut at all, in either the title or the description.

Reuse the configurable `send` action rather than hardcoding the chord, so a
rebound or unbound shortcut follows the user's setting and the IME guards and
Shift+Enter replay come along for free:

- Description: pass `onSubmit` to ContentEditor, which already carries the
  extension.
- Title: add an opt-in `onSubmitShortcut` to the shared TitleEditor. Plain
  Enter stays inert there — #5532 removed that trigger a day ago because it
  created from half-typed titles — and a single-line title has no newline to
  trade Enter against, so the chord path refuses a plain-Enter `send` binding.
  Hosts relying on plain-Enter submit (create-project, autopilot-dialog) keep
  `onSubmit` and are untouched.

Also fixes a real double-create: `submitting` is state, so two chord presses in
one tick both read the stale value and fired two creates. A ref flips
synchronously and single-flights both create panels.

Accessibility: the empty-title Create button moves from native `disabled` to
`aria-disabled`, so it stays focusable and keyboard/SR users can finally reach
the "Enter a title to create" tooltip. Keycaps are decorative, keeping the
accessible name "Create Issue". Empty-title chord submits now focus the title
instead of silently no-oping.

Widen the `send` setting description across all four locales — it claimed to
cover only chat, comments, replies, feedback and prompts.

Tests: real-ProseMirror coverage for the title chord (every other editor test
mocks Tiptap, so nothing verified extension ordering), plus chord/plain-Enter/
empty-title/upload-gate/single-flight/keycaps/aria-disabled cases. The
single-flight test dispatches both presses inside one act(); it fails against
the old state-based guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(views): cover quick-create single-flight + aria-disabled visuals (MUL-4931)

Review follow-up on #5583.

The quick-create ref guard shipped without a regression test, so the claim that
both create panels were covered was only true of manual create. That path files
a real issue, so a double-fire is a duplicate issue rather than a glitch. Add
the same-tick regression: both presses dispatch inside one act(), and it fails
against the old state-based guard (expected 1 call, got 2).

The Button base only styles native `disabled` (disabled:opacity-50 /
disabled:pointer-events-none), so the aria-disabled empty-title button stayed a
fully lit, pressable-looking primary. Add local aria-disabled opacity, cursor,
and press-animation styles, with no pointer-events-none — that would break the
tooltip hover and the click that focuses the title. Verified against compiled
Tailwind output: aria-disabled:active:translate-y-0 and the base's
active:not-aria-[haspopup]:translate-y-px have equal specificity and the
override emits later, so it wins without !important.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 12:27:12 +08:00

160 lines
5.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
const mockFocus = vi.hoisted(() => vi.fn());
const mockSetContent = vi.hoisted(() => vi.fn());
const mockBlur = vi.hoisted(() => vi.fn());
const editorState = vi.hoisted(() => ({
isFocused: false,
isDestroyed: false,
text: "",
}));
vi.mock("../i18n", () => ({
useT: () => ({ t: (fn: unknown) => (typeof fn === "function" ? "" : "") }),
}));
const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
vi.mock("@tiptap/react", () => ({
useEditor: () => {
if (!editorRef.current) {
editorRef.current = {
get isFocused() {
return editorState.isFocused;
},
get isDestroyed() {
return editorState.isDestroyed;
},
commands: {
focus: mockFocus,
blur: mockBlur,
setContent: mockSetContent,
},
getText: () => editorState.text,
};
}
return editorRef.current;
},
EditorContent: () => <div data-testid="editor-content" />,
}));
import { createShortcutChord } from "@multica/core/shortcuts";
import { TitleEditor, titleShortcutSubmitAllowed } from "./title-editor";
describe("TitleEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
editorState.isFocused = false;
editorState.isDestroyed = false;
editorState.text = "";
editorRef.current = null;
});
it("syncs editor content when defaultValue changes externally and editor is unfocused", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
expect(mockSetContent).not.toHaveBeenCalled();
rerender(<TitleEditor defaultValue="new title from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
{
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "new title from server" }],
},
],
},
{ emitUpdate: false },
);
});
it("does not overwrite the user's in-flight edits when the editor is focused and dirty", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
editorState.isFocused = true;
editorState.text = "user typed but not yet blurred";
rerender(<TitleEditor defaultValue="external update" />);
expect(mockSetContent).not.toHaveBeenCalled();
});
// Regression: a focused but clean editor (user clicked in but never typed)
// must still accept external updates, otherwise the subsequent blur would
// compare stale editor text to the new server value and silently roll the
// external update back.
it("syncs to new defaultValue when editor is focused but clean", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
// User clicked into the title field but has not typed anything yet:
// editor text still equals the previous defaultValue.
editorState.isFocused = true;
editorState.text = "old title";
rerender(<TitleEditor defaultValue="new title from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
{
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "new title from server" }],
},
],
},
{ emitUpdate: false },
);
});
it("short-circuits when editor text already equals incoming defaultValue", () => {
editorState.text = "same title";
const { rerender } = render(<TitleEditor defaultValue="same title" />);
// Force the effect to re-run by rendering with a different prop, then
// back to the same value. Even an identity-equal prop should be skipped.
rerender(<TitleEditor defaultValue="same title" />);
expect(mockSetContent).not.toHaveBeenCalled();
});
it("clears the editor when defaultValue transitions to empty", () => {
editorState.text = "old title";
const { rerender } = render(<TitleEditor defaultValue="old title" />);
rerender(<TitleEditor defaultValue="" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith("", { emitUpdate: false });
});
});
// MUL-4931 — which `send` bindings the title's shortcut-submit path honors.
describe("titleShortcutSubmitAllowed", () => {
it("allows the default Mod+Enter chord", () => {
expect(
titleShortcutSubmitAllowed(createShortcutChord("Enter", { primary: true })),
).toBe(true);
});
it("refuses plain Enter, which the single-line keymap already owns", () => {
// A user may bind `send` to plain Enter for chat. The title must not
// inherit it — Enter there means "done", and creating from a half-typed
// title is the misfire #5532 removed.
expect(titleShortcutSubmitAllowed(createShortcutChord("Enter"))).toBe(false);
});
it("refuses an unbound send action", () => {
expect(titleShortcutSubmitAllowed(null)).toBe(false);
});
});