feat(FN-4735): complete Step 4 — add useIssueMention hook
Fusion-Task-Id: FN-4735 Fusion-Task-Lineage: e171d3ca-d415-4f4d-8ec0-d94ca97f1aab
This commit is contained in:
committed by
gsxdsm
parent
0f67447fcc
commit
aa3b2cfb4c
114
packages/dashboard/app/hooks/__tests__/useIssueMention.test.ts
Normal file
114
packages/dashboard/app/hooks/__tests__/useIssueMention.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useIssueMention } from "../useIssueMention";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchRecentIssues: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchRecentIssues } from "../../api";
|
||||
|
||||
const mockFetchRecentIssues = fetchRecentIssues as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("useIssueMention", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
mockFetchRecentIssues.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("detects trigger at start, after space, and punctuation", () => {
|
||||
const { result } = renderHook(() => useIssueMention());
|
||||
|
||||
act(() => result.current.detectMention("#", 1));
|
||||
expect(result.current.mentionActive).toBe(true);
|
||||
|
||||
act(() => result.current.detectMention("hey #1", 6));
|
||||
expect(result.current.mentionActive).toBe(true);
|
||||
|
||||
act(() => result.current.detectMention("hey, #2", 7));
|
||||
expect(result.current.mentionActive).toBe(true);
|
||||
});
|
||||
|
||||
it("deactivates on path-like query and stays active on digits", () => {
|
||||
const { result } = renderHook(() => useIssueMention());
|
||||
|
||||
act(() => result.current.detectMention("#123", 4));
|
||||
expect(result.current.mentionActive).toBe(true);
|
||||
|
||||
act(() => result.current.detectMention("#src/foo.ts", 11));
|
||||
expect(result.current.mentionActive).toBe(false);
|
||||
});
|
||||
|
||||
it("debounces fetch", async () => {
|
||||
const { result } = renderHook(() => useIssueMention({ projectId: "p1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.detectMention("#12", 3);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(199);
|
||||
});
|
||||
expect(mockFetchRecentIssues).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchRecentIssues).toHaveBeenCalledWith("p1", "12");
|
||||
});
|
||||
|
||||
it("selectIssue inserts trailing space and cursor position", () => {
|
||||
const { result } = renderHook(() => useIssueMention());
|
||||
|
||||
act(() => result.current.detectMention("Fix #4 now", 6));
|
||||
|
||||
const selection = result.current.selectIssue(
|
||||
{ number: 42, title: "Bug", state: "open", htmlUrl: "https://x", repository: "o/r" },
|
||||
"Fix #4 now",
|
||||
);
|
||||
|
||||
expect(selection.text).toBe("Fix #42 now");
|
||||
expect(selection.cursorPosition).toBe(8);
|
||||
});
|
||||
|
||||
it("handles keyboard navigation and dismiss", async () => {
|
||||
mockFetchRecentIssues.mockResolvedValueOnce([
|
||||
{ number: 1, title: "a", state: "open", htmlUrl: "https://x/1", repository: "o/r" },
|
||||
{ number: 2, title: "b", state: "open", htmlUrl: "https://x/2", repository: "o/r" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useIssueMention());
|
||||
|
||||
act(() => {
|
||||
result.current.detectMention("#", 1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
act(() => {
|
||||
result.current.handleKeyDown({ key: "ArrowDown", preventDefault } as unknown as React.KeyboardEvent<HTMLElement>);
|
||||
});
|
||||
|
||||
expect(result.current.selectedIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown({ key: "Escape", preventDefault } as unknown as React.KeyboardEvent<HTMLElement>);
|
||||
});
|
||||
|
||||
expect(result.current.mentionActive).toBe(false);
|
||||
});
|
||||
});
|
||||
211
packages/dashboard/app/hooks/useIssueMention.ts
Normal file
211
packages/dashboard/app/hooks/useIssueMention.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { fetchRecentIssues, type IssueMentionItem } from "../api";
|
||||
|
||||
export interface UseIssueMentionOptions {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface IssueSelectResult {
|
||||
text: string;
|
||||
cursorPosition: number;
|
||||
}
|
||||
|
||||
export interface UseIssueMentionReturn {
|
||||
mentionActive: boolean;
|
||||
issues: IssueMentionItem[];
|
||||
loading: boolean;
|
||||
mentionQuery: string;
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
detectMention: (text: string, cursorPosition: number) => boolean;
|
||||
dismissMention: () => void;
|
||||
selectIssue: (issue: IssueMentionItem, currentText: string) => IssueSelectResult;
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLElement>) => boolean;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
/**
|
||||
* Hook to manage # issue mention interactions.
|
||||
*
|
||||
* Disambiguation contract:
|
||||
* - treat `#` as issue mention when query is empty, only digits, or has neither `/` nor `.`
|
||||
* - if query contains `/` or `.`, issue mention deactivates so file mention can take over
|
||||
*/
|
||||
export function useIssueMention(options: UseIssueMentionOptions = {}): UseIssueMentionReturn {
|
||||
const { projectId } = options;
|
||||
const [mentionActive, setMentionActive] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
||||
const [issues, setIssues] = useState<IssueMentionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortController = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
abortController.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dismissMention = useCallback(() => {
|
||||
setMentionActive(false);
|
||||
setMentionQuery("");
|
||||
setMentionStartIndex(-1);
|
||||
setIssues([]);
|
||||
setSelectedIndex(0);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const detectMention = useCallback((text: string, cursorPosition: number): boolean => {
|
||||
if (cursorPosition < 0 || cursorPosition > text.length) {
|
||||
setMentionActive(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isPathChar = (char: string): boolean => /[a-zA-Z0-9/_.-]/.test(char);
|
||||
|
||||
for (let i = cursorPosition - 1; i >= 0; i--) {
|
||||
if (text[i] === "#") {
|
||||
const validTrigger = i === 0 || /[\s,.;:!?'"()[\]{}]/.test(text[i - 1] ?? "");
|
||||
if (!validTrigger) {
|
||||
setMentionActive(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const query = text.slice(i + 1, cursorPosition);
|
||||
if (query.includes("/") || query.includes(".")) {
|
||||
setMentionActive(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.length > 0 && !/^\d*$/.test(query)) {
|
||||
// active for textual title search when not path-like
|
||||
}
|
||||
|
||||
setMentionStartIndex(i);
|
||||
setMentionQuery(query);
|
||||
setSelectedIndex(0);
|
||||
setMentionActive(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isPathChar(text[i])) {
|
||||
setMentionActive(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setMentionActive(false);
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const performSearch = useCallback(
|
||||
async (query: string) => {
|
||||
abortController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortController.current = controller;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const items = await fetchRecentIssues(projectId, query.trim() ? query : undefined);
|
||||
if (!controller.signal.aborted) {
|
||||
setIssues(items);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
setIssues([]);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mentionActive) return;
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
void performSearch(mentionQuery);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
};
|
||||
}, [mentionActive, mentionQuery, performSearch]);
|
||||
|
||||
/**
|
||||
* Replaces `#<partial>` with `#<number> ` (trailing space), so users can continue typing naturally.
|
||||
*/
|
||||
const selectIssue = useCallback(
|
||||
(issue: IssueMentionItem, currentText: string): IssueSelectResult => {
|
||||
if (!mentionActive || mentionStartIndex < 0) {
|
||||
return { text: currentText, cursorPosition: currentText.length };
|
||||
}
|
||||
|
||||
const beforeMention = currentText.slice(0, mentionStartIndex);
|
||||
const afterMention = currentText.slice(mentionStartIndex + 1);
|
||||
const mentionEndMatch = afterMention.match(/[\s]|$/);
|
||||
const mentionEndIndex = mentionEndMatch ? mentionEndMatch.index ?? afterMention.length : afterMention.length;
|
||||
const afterCurrentMention = afterMention.slice(mentionEndIndex);
|
||||
const replacement = `#${issue.number} `;
|
||||
const text = `${beforeMention}${replacement}${afterCurrentMention}`;
|
||||
const cursorPosition = beforeMention.length + replacement.length;
|
||||
|
||||
return { text, cursorPosition };
|
||||
},
|
||||
[mentionActive, mentionStartIndex],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>): boolean => {
|
||||
if (!mentionActive || issues.length === 0) return false;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, issues.length - 1));
|
||||
return true;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
return true;
|
||||
case "Enter":
|
||||
case "Tab":
|
||||
if (issues[selectedIndex]) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
dismissMention();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[dismissMention, mentionActive, issues, selectedIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
mentionActive,
|
||||
issues,
|
||||
loading,
|
||||
mentionQuery,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
detectMention,
|
||||
dismissMention,
|
||||
selectIssue,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user