mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +02:00
* 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>
160 lines
5.1 KiB
TypeScript
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);
|
|
});
|
|
});
|