Adds a text-selection comment flow that opens New Task with source context. - Add a reusable selection comment hook and popover for editor/document text selections. - Thread selected-file and line-range context into the New Task modal. - Enable comment-on-selection entry points in file editor, browser, documents, and memory surfaces. - Cover the selection flow with focused component and hook tests, plus docs and copy updates. Files changed: docs/dashboard-guide.md | 6 +- packages/dashboard/app/App.tsx | 7 +- packages/dashboard/app/components/AppModals.tsx | 2 + .../dashboard/app/components/DocumentsView.tsx | 25 ++- .../dashboard/app/components/FileBrowserModal.tsx | 3 + packages/dashboard/app/components/FileEditor.tsx | 35 +++- packages/dashboard/app/components/MemoryView.tsx | 5 +- packages/dashboard/app/components/NewTaskModal.tsx | 15 +- .../app/components/SelectionCommentPopover.css | 73 +++++++++ .../app/components/SelectionCommentPopover.tsx | 181 +++++++++++++++++++++ .../components/__tests__/DocumentsView.test.tsx | 59 +++++++ .../components/__tests__/FileBrowserModal.test.tsx | 79 +++++++++ .../app/components/__tests__/FileEditor.test.tsx | 60 +++++++ .../app/components/__tests__/MemoryView.test.tsx | 48 +++++- .../app/components/__tests__/NewTaskModal.test.tsx | 17 ++ .../__tests__/SelectionCommentPopover.test.tsx | 75 +++++++++ .../app/hooks/__tests__/useModalManager.test.ts | 29 ++++ .../hooks/__tests__/useSelectionComment.test.ts | 140 ++++++++++++++++ packages/dashboard/app/hooks/useModalManager.ts | 19 ++- .../dashboard/app/hooks/useSelectionComment.ts | 95 +++++++++++ packages/i18n/locales/en/app.json | 11 ++ 21 files changed, 973 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-6528 Fusion-Task-Lineage: ef79e450-e69f-497b-97cd-f82d3c832fdf
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { createRef } from "react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useSelectionComment } from "../useSelectionComment";
|
|
|
|
function mockRangeRect() {
|
|
const rect = new DOMRect(10, 20, 80, 12);
|
|
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
|
configurable: true,
|
|
value: vi.fn(() => rect),
|
|
});
|
|
Object.defineProperty(Range.prototype, "getClientRects", {
|
|
configurable: true,
|
|
value: vi.fn(() => ({ 0: rect, length: 1, item: () => rect, [Symbol.iterator]: function* () { yield rect; } }) as DOMRectList),
|
|
});
|
|
}
|
|
|
|
function selectText(node: Node) {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(node);
|
|
const selection = document.getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
document.dispatchEvent(new Event("selectionchange"));
|
|
}
|
|
|
|
describe("useSelectionComment", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
vi.restoreAllMocks();
|
|
mockRangeRect();
|
|
});
|
|
|
|
it("reports selected text and anchor rect inside the container", async () => {
|
|
const container = document.createElement("div");
|
|
const text = document.createTextNode("selected snippet");
|
|
container.append(text);
|
|
document.body.append(container);
|
|
const ref = createRef<HTMLElement>();
|
|
ref.current = container;
|
|
|
|
const { result } = renderHook(() => useSelectionComment(ref));
|
|
|
|
act(() => selectText(text));
|
|
|
|
await waitFor(() => expect(result.current?.selectedText).toBe("selected snippet"));
|
|
expect(result.current?.anchorRect.left).toBe(10);
|
|
});
|
|
|
|
it("clears selection state when the selection is outside the container", async () => {
|
|
const container = document.createElement("div");
|
|
container.textContent = "inside";
|
|
const outside = document.createElement("div");
|
|
outside.textContent = "outside";
|
|
document.body.append(container, outside);
|
|
const ref = createRef<HTMLElement>();
|
|
ref.current = container;
|
|
|
|
const { result } = renderHook(() => useSelectionComment(ref));
|
|
|
|
act(() => selectText(outside.firstChild as Node));
|
|
|
|
await waitFor(() => expect(result.current).toBeNull());
|
|
});
|
|
|
|
it("clears selection state when the selection is collapsed", async () => {
|
|
const container = document.createElement("div");
|
|
const text = document.createTextNode("selected snippet");
|
|
container.append(text);
|
|
document.body.append(container);
|
|
const ref = createRef<HTMLElement>();
|
|
ref.current = container;
|
|
|
|
const { result } = renderHook(() => useSelectionComment(ref));
|
|
act(() => selectText(text));
|
|
await waitFor(() => expect(result.current?.selectedText).toBe("selected snippet"));
|
|
|
|
act(() => {
|
|
const selection = document.getSelection();
|
|
selection?.collapse(text, 0);
|
|
document.dispatchEvent(new Event("selectionchange"));
|
|
});
|
|
|
|
await waitFor(() => expect(result.current).toBeNull());
|
|
});
|
|
|
|
it("keeps the existing selection state while locked", async () => {
|
|
const container = document.createElement("div");
|
|
const text = document.createTextNode("selected snippet");
|
|
container.append(text);
|
|
document.body.append(container);
|
|
const ref = createRef<HTMLElement>();
|
|
ref.current = container;
|
|
|
|
let locked = false;
|
|
const { result, rerender } = renderHook(() => useSelectionComment(ref, { locked }));
|
|
act(() => selectText(text));
|
|
await waitFor(() => expect(result.current?.selectedText).toBe("selected snippet"));
|
|
|
|
locked = true;
|
|
rerender();
|
|
act(() => {
|
|
const selection = document.getSelection();
|
|
selection?.collapse(text, 0);
|
|
document.dispatchEvent(new Event("selectionchange"));
|
|
});
|
|
|
|
expect(result.current?.selectedText).toBe("selected snippet");
|
|
});
|
|
|
|
it("propagates a line range from the optional mapper", async () => {
|
|
const container = document.createElement("div");
|
|
const text = document.createTextNode("selected snippet");
|
|
container.append(text);
|
|
document.body.append(container);
|
|
const ref = createRef<HTMLElement>();
|
|
ref.current = container;
|
|
|
|
const { result } = renderHook(() => useSelectionComment(ref, { getLineRange: () => ({ start: 2, end: 5 }) }));
|
|
|
|
act(() => selectText(text));
|
|
|
|
await waitFor(() => expect(result.current?.lineRange).toEqual({ start: 2, end: 5 }));
|
|
});
|
|
|
|
it("ignores whitespace-only selections", async () => {
|
|
const container = document.createElement("div");
|
|
const text = document.createTextNode(" ");
|
|
container.append(text);
|
|
document.body.append(container);
|
|
const ref = createRef<HTMLElement>();
|
|
ref.current = container;
|
|
|
|
const { result } = renderHook(() => useSelectionComment(ref));
|
|
|
|
act(() => selectText(text));
|
|
|
|
await waitFor(() => expect(result.current).toBeNull());
|
|
});
|
|
});
|