Merge develop into main
All checks were successful
All checks were successful
Consolidate all feature and fix branches into main: - feat: orchestrator observability + mosaic rails integration (#422) - fix: post-422 CI and compose env follow-up (#423) - fix: orchestrator startup provider-key requirements (#425) - fix: BetterAuth OAuth2 flow and compose wiring (#426) - fix: BetterAuth UUID ID generation (#427) - test: web vitest localStorage/file warnings (#428) - fix: auth frontend remediation + review hardening (#421) - Plus numerous Docker, deploy, and auth fixes from develop Lockfile conflict resolved by regenerating from merged package.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,15 @@ export function LinkAutocomplete({
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// Refs for event handler to avoid stale closures when effects re-attach listeners
|
||||
const stateRef = useRef(state);
|
||||
const resultsRef = useRef(results);
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
const insertLinkRef = useRef<((result: SearchResult) => void) | null>(null);
|
||||
stateRef.current = state;
|
||||
resultsRef.current = results;
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
/**
|
||||
* Search for knowledge entries matching the query.
|
||||
* Accepts an AbortSignal to allow cancellation of in-flight requests,
|
||||
@@ -254,47 +263,48 @@ export function LinkAutocomplete({
|
||||
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in the dropdown
|
||||
* Handle keyboard navigation in the dropdown.
|
||||
* Reads from refs to avoid stale closures when the effect
|
||||
* that attaches this listener hasn't re-run yet.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent): void => {
|
||||
if (!state.isOpen) return;
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent): void => {
|
||||
if (!stateRef.current.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % results.length);
|
||||
break;
|
||||
const currentResults = resultsRef.current;
|
||||
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
|
||||
break;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % currentResults.length);
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (results.length > 0 && selectedIndex >= 0) {
|
||||
const selected = results[selectedIndex];
|
||||
if (selected) {
|
||||
insertLink(selected);
|
||||
}
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + currentResults.length) % currentResults.length);
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (currentResults.length > 0 && selectedIndexRef.current >= 0) {
|
||||
const selected = currentResults[selectedIndexRef.current];
|
||||
if (selected) {
|
||||
insertLinkRef.current?.(selected);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setState({
|
||||
isOpen: false,
|
||||
query: "",
|
||||
position: { top: 0, left: 0 },
|
||||
triggerIndex: -1,
|
||||
});
|
||||
setResults([]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[state.isOpen, results, selectedIndex]
|
||||
);
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setState({
|
||||
isOpen: false,
|
||||
query: "",
|
||||
position: { top: 0, left: 0 },
|
||||
triggerIndex: -1,
|
||||
});
|
||||
setResults([]);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Insert the selected link into the textarea
|
||||
@@ -330,6 +340,7 @@ export function LinkAutocomplete({
|
||||
},
|
||||
[textareaRef, state.triggerIndex, onInsert]
|
||||
);
|
||||
insertLinkRef.current = insertLink;
|
||||
|
||||
/**
|
||||
* Handle click on a result
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
import React from "react";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
@@ -352,10 +351,7 @@ describe("LinkAutocomplete", (): void => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should perform debounced search when typing query", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should perform debounced search when typing query", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -395,11 +391,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
// Should not call API immediately
|
||||
expect(mockApiRequest).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward 300ms and let promises resolve
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
"/api/knowledge/search?q=test&limit=10",
|
||||
@@ -411,14 +402,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should navigate results with arrow keys", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should navigate results with arrow keys", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -471,10 +457,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Entry One")).toBeInTheDocument();
|
||||
});
|
||||
@@ -484,7 +466,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
expect(firstItem).toHaveClass("bg-blue-50");
|
||||
|
||||
// Press ArrowDown
|
||||
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||
act(() => {
|
||||
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||
});
|
||||
|
||||
// Second item should now be selected
|
||||
await waitFor(() => {
|
||||
@@ -493,21 +477,18 @@ describe("LinkAutocomplete", (): void => {
|
||||
});
|
||||
|
||||
// Press ArrowUp
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
act(() => {
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
});
|
||||
|
||||
// First item should be selected again
|
||||
await waitFor(() => {
|
||||
const firstItem = screen.getByText("Entry One").closest("li");
|
||||
expect(firstItem).toHaveClass("bg-blue-50");
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should insert link on Enter key", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should insert link on Enter key", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -544,10 +525,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||
});
|
||||
@@ -558,14 +535,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should insert link on click", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should insert link on click", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -602,10 +574,6 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||
});
|
||||
@@ -616,14 +584,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should close dropdown on Escape key", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should close dropdown on Escape key", async (): Promise<void> => {
|
||||
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
@@ -636,28 +599,19 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
|
||||
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should close dropdown when closing brackets are typed", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should close dropdown when closing brackets are typed", async (): Promise<void> => {
|
||||
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
@@ -670,12 +624,8 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
|
||||
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Type closing brackets
|
||||
@@ -686,16 +636,11 @@ describe("LinkAutocomplete", (): void => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should show 'No entries found' when search returns no results", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should show 'No entries found' when search returns no results", async (): Promise<void> => {
|
||||
mockApiRequest.mockResolvedValue({
|
||||
data: [],
|
||||
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
|
||||
@@ -713,32 +658,24 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No entries found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should show loading state while searching", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should show loading state while searching", async (): Promise<void> => {
|
||||
// Mock a slow API response
|
||||
let resolveSearch: (value: unknown) => void;
|
||||
const searchPromise = new Promise((resolve) => {
|
||||
let resolveSearch: (value: {
|
||||
data: unknown[];
|
||||
meta: { total: number; page: number; limit: number; totalPages: number };
|
||||
}) => void = () => undefined;
|
||||
const searchPromise = new Promise<{
|
||||
data: unknown[];
|
||||
meta: { total: number; page: number; limit: number; totalPages: number };
|
||||
}>((resolve) => {
|
||||
resolveSearch = resolve;
|
||||
});
|
||||
mockApiRequest.mockReturnValue(
|
||||
searchPromise as Promise<{
|
||||
data: unknown[];
|
||||
meta: { total: number; page: number; limit: number; totalPages: number };
|
||||
}>
|
||||
);
|
||||
mockApiRequest.mockReturnValue(searchPromise);
|
||||
|
||||
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||
|
||||
@@ -752,16 +689,12 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Searching...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Resolve the search
|
||||
resolveSearch!({
|
||||
resolveSearch({
|
||||
data: [],
|
||||
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
|
||||
});
|
||||
@@ -769,14 +702,9 @@ describe("LinkAutocomplete", (): void => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Searching...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
|
||||
it.skip("should display summary preview for entries", async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
it("should display summary preview for entries", async (): Promise<void> => {
|
||||
const mockResults = {
|
||||
data: [
|
||||
{
|
||||
@@ -813,14 +741,8 @@ describe("LinkAutocomplete", (): void => {
|
||||
fireEvent.input(textarea);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("This is a helpful summary")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user