Files
fusion/packages/dashboard/app/hooks/__tests__/useSelectionComment.test.ts
gsxdsm 4bfb6b46ad FN-6528: add selection comments for new tasks
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
2026-06-17 03:29:39 -07:00

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());
});
});