@databiosphere/findable-ui 49.1.0 → 49.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +21 -0
- package/lib/views/ExploreView/entityList/filters/components/ToggleButtonGroup/toggleButtonGroup.d.ts +2 -3
- package/lib/views/ExploreView/entityList/filters/components/ToggleButtonGroup/toggleButtonGroup.js +3 -4
- package/lib/views/ResearchView/assistant/assistant.js +3 -5
- package/lib/views/ResearchView/assistant/components/Form/form.d.ts +1 -2
- package/lib/views/ResearchView/assistant/components/Form/form.js +5 -18
- package/lib/views/ResearchView/assistant/components/Form/types.d.ts +1 -2
- package/lib/views/ResearchView/assistant/components/Form/utils.d.ts +1 -1
- package/lib/views/ResearchView/assistant/components/Messages/hooks/UseScroll/hook.d.ts +2 -1
- package/lib/views/ResearchView/assistant/components/Messages/hooks/UseScroll/hook.js +5 -2
- package/lib/views/ResearchView/assistant/components/ToggleButtonGroup/toggleButtonGroup.d.ts +2 -3
- package/lib/views/ResearchView/assistant/components/ToggleButtonGroup/toggleButtonGroup.js +3 -4
- package/lib/views/ResearchView/assistant/stories/assistant.stories.js +1 -1
- package/lib/views/ResearchView/state/provider.d.ts +4 -1
- package/lib/views/ResearchView/state/provider.js +5 -2
- package/lib/views/ResearchView/state/query/context.d.ts +5 -0
- package/lib/views/ResearchView/state/query/context.js +7 -0
- package/lib/views/ResearchView/state/query/hooks/UseQuery/hook.d.ts +6 -0
- package/lib/views/ResearchView/state/query/hooks/UseQuery/hook.js +9 -0
- package/lib/views/ResearchView/state/query/hooks/UseSubmit/hook.d.ts +7 -0
- package/lib/views/ResearchView/state/query/hooks/UseSubmit/hook.js +46 -0
- package/lib/views/ResearchView/state/query/provider.d.ts +13 -0
- package/lib/views/ResearchView/state/query/provider.js +15 -0
- package/lib/views/ResearchView/{query → state/query}/types.d.ts +5 -12
- package/package.json +1 -1
- package/src/views/ExploreView/entityList/filters/components/ToggleButtonGroup/toggleButtonGroup.tsx +10 -6
- package/src/views/ResearchView/assistant/assistant.tsx +3 -5
- package/src/views/ResearchView/assistant/components/Form/form.tsx +4 -19
- package/src/views/ResearchView/assistant/components/Form/types.ts +0 -2
- package/src/views/ResearchView/assistant/components/Form/utils.ts +1 -1
- package/src/views/ResearchView/assistant/components/Messages/hooks/UseScroll/hook.ts +5 -2
- package/src/views/ResearchView/assistant/components/ToggleButtonGroup/toggleButtonGroup.tsx +10 -6
- package/src/views/ResearchView/assistant/stories/assistant.stories.tsx +1 -1
- package/src/views/ResearchView/state/provider.tsx +8 -1
- package/src/views/ResearchView/state/query/context.ts +9 -0
- package/src/views/ResearchView/state/query/hooks/UseQuery/hook.ts +11 -0
- package/src/views/ResearchView/state/query/hooks/UseSubmit/hook.ts +66 -0
- package/src/views/ResearchView/state/query/provider.tsx +27 -0
- package/src/views/ResearchView/{query → state/query}/types.ts +9 -15
- package/tests/research.queryProvider.test.ts +321 -0
- package/lib/views/ResearchView/adapter/useAdapter.d.ts +0 -6
- package/lib/views/ResearchView/adapter/useAdapter.js +0 -15
- package/lib/views/ResearchView/query/useQuery.d.ts +0 -7
- package/lib/views/ResearchView/query/useQuery.js +0 -44
- package/src/views/ResearchView/adapter/useAdapter.ts +0 -19
- package/src/views/ResearchView/query/useQuery.ts +0 -60
- package/tests/research.useQuery.test.ts +0 -165
- /package/lib/views/ResearchView/{query → state/query}/types.js +0 -0
|
@@ -6,9 +6,8 @@ import { useAiRoutes } from "../../../../../hooks/ai/useAiRoutes/hook";
|
|
|
6
6
|
import { Beta } from "../../../../../components/common/Chip/components/Beta/beta";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* ToggleButtonGroup component for navigating
|
|
10
|
-
*
|
|
11
|
-
* @returns ToggleButtonGroup JSX element.
|
|
9
|
+
* ToggleButtonGroup component for navigating between ResearchView and ExploreView.
|
|
10
|
+
* @returns ToggleButtonGroup JSX element, or null if routes are not configured.
|
|
12
11
|
*/
|
|
13
12
|
export const ToggleButtonGroup = (): JSX.Element | null => {
|
|
14
13
|
const { routes } = useAiRoutes() || {};
|
|
@@ -18,12 +17,17 @@ export const ToggleButtonGroup = (): JSX.Element | null => {
|
|
|
18
17
|
return (
|
|
19
18
|
<StyledBox>
|
|
20
19
|
<StyledToggleButtonGroup exclusive>
|
|
21
|
-
<ToggleButton selected value="research">
|
|
22
|
-
Research <Beta />
|
|
23
|
-
</ToggleButton>
|
|
24
20
|
<ToggleButton component={Link} href={routes.search} value="search">
|
|
25
21
|
Search
|
|
26
22
|
</ToggleButton>
|
|
23
|
+
<ToggleButton
|
|
24
|
+
component={Link}
|
|
25
|
+
href={routes.research}
|
|
26
|
+
selected
|
|
27
|
+
value="research"
|
|
28
|
+
>
|
|
29
|
+
Research <Beta />
|
|
30
|
+
</ToggleButton>
|
|
27
31
|
</StyledToggleButtonGroup>
|
|
28
32
|
</StyledBox>
|
|
29
33
|
);
|
|
@@ -12,7 +12,7 @@ const meta: Meta<typeof Assistant> = {
|
|
|
12
12
|
decorators: [
|
|
13
13
|
(Story): JSX.Element => (
|
|
14
14
|
<ConfigProvider config={INITIAL_CONFIG}>
|
|
15
|
-
<ChatProvider initialArgs={INITIAL_ARGS}>
|
|
15
|
+
<ChatProvider initialArgs={INITIAL_ARGS} url="https://api.example.com">
|
|
16
16
|
<Box
|
|
17
17
|
sx={{
|
|
18
18
|
backgroundColor: PALETTE.COMMON_WHITE,
|
|
@@ -2,26 +2,33 @@ import { JSX, ReactNode } from "react";
|
|
|
2
2
|
import { ChatContext } from "./context";
|
|
3
3
|
import { useChatReducer } from "./hooks/UseChatReducer/hook";
|
|
4
4
|
import { InitialArgs } from "./initializer/types";
|
|
5
|
+
import { QueryProvider } from "./query/provider";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Provider for Chat state.
|
|
8
9
|
* Manages chat state such as query and response data.
|
|
10
|
+
* Nests QueryProvider to own the fetch lifecycle for query submission.
|
|
9
11
|
*
|
|
10
12
|
* @param props - Props.
|
|
11
13
|
* @param props.children - Children.
|
|
12
14
|
* @param props.initialArgs - Initial arguments.
|
|
15
|
+
* @param props.url - URL for the query endpoint.
|
|
13
16
|
*
|
|
14
17
|
* @returns A context provider wrapping the given children.
|
|
15
18
|
*/
|
|
16
19
|
export function ChatProvider({
|
|
17
20
|
children,
|
|
18
21
|
initialArgs,
|
|
22
|
+
url,
|
|
19
23
|
}: {
|
|
20
24
|
children: ReactNode;
|
|
21
25
|
initialArgs?: InitialArgs;
|
|
26
|
+
url: string;
|
|
22
27
|
}): JSX.Element {
|
|
23
28
|
const reducer = useChatReducer(initialArgs);
|
|
24
29
|
return (
|
|
25
|
-
<ChatContext.Provider value={reducer}>
|
|
30
|
+
<ChatContext.Provider value={reducer}>
|
|
31
|
+
<QueryProvider url={url}>{children}</QueryProvider>
|
|
32
|
+
</ChatContext.Provider>
|
|
26
33
|
);
|
|
27
34
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import { QueryContext } from "../../context";
|
|
3
|
+
import { QueryContextValue } from "../../types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to access query submission from the QueryProvider.
|
|
7
|
+
* @returns Query context value with onSubmit.
|
|
8
|
+
*/
|
|
9
|
+
export const useQuery = (): QueryContextValue => {
|
|
10
|
+
return useContext(QueryContext);
|
|
11
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { FormEvent, useCallback, useRef } from "react";
|
|
2
|
+
import { fetchResponse } from "../../../../query/fetch";
|
|
3
|
+
import { useChatDispatch } from "../../../hooks/UseChatDispatch/hook";
|
|
4
|
+
import { MessageResponse } from "../../../types";
|
|
5
|
+
import {
|
|
6
|
+
OnSubmitOptions,
|
|
7
|
+
OnSubmitPayload,
|
|
8
|
+
QueryContextValue,
|
|
9
|
+
} from "../../types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hook that manages query submission and abort lifecycle.
|
|
13
|
+
* @param url - The API URL to send queries to.
|
|
14
|
+
* @returns Object containing the onSubmit handler.
|
|
15
|
+
*/
|
|
16
|
+
export const useSubmit = (url: string): Pick<QueryContextValue, "onSubmit"> => {
|
|
17
|
+
const abortRef = useRef<AbortController>(null);
|
|
18
|
+
const dispatch = useChatDispatch();
|
|
19
|
+
|
|
20
|
+
const onSubmit = useCallback(
|
|
21
|
+
async (
|
|
22
|
+
e: FormEvent<HTMLFormElement>,
|
|
23
|
+
payload: OnSubmitPayload,
|
|
24
|
+
options: OnSubmitOptions,
|
|
25
|
+
): Promise<void> => {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
|
|
28
|
+
if (options.status.loading) return;
|
|
29
|
+
|
|
30
|
+
const { query } = payload;
|
|
31
|
+
if (!query) return;
|
|
32
|
+
|
|
33
|
+
const form = e.currentTarget;
|
|
34
|
+
|
|
35
|
+
// Dispatch query and loading state.
|
|
36
|
+
dispatch.onSetQuery(query);
|
|
37
|
+
dispatch.onSetStatus(true);
|
|
38
|
+
form.reset();
|
|
39
|
+
options.onMutate?.(form, query);
|
|
40
|
+
|
|
41
|
+
// Abort any in-flight request.
|
|
42
|
+
abortRef.current?.abort();
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
abortRef.current = controller;
|
|
45
|
+
|
|
46
|
+
await fetchResponse(url, query, {
|
|
47
|
+
controller,
|
|
48
|
+
onError: (error) => {
|
|
49
|
+
dispatch.onSetError(error.message);
|
|
50
|
+
options.onError?.(error);
|
|
51
|
+
},
|
|
52
|
+
onSettled: () => {
|
|
53
|
+
dispatch.onSetStatus(false);
|
|
54
|
+
options.onSettled?.(form);
|
|
55
|
+
},
|
|
56
|
+
onSuccess: (data) => {
|
|
57
|
+
dispatch.onSetMessage(data as MessageResponse);
|
|
58
|
+
options.onSuccess?.(data);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
[dispatch, url],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return { onSubmit };
|
|
66
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { JSX, ReactNode } from "react";
|
|
2
|
+
import { QueryContext } from "./context";
|
|
3
|
+
import { useSubmit } from "./hooks/UseSubmit/hook";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provider that owns the fetch lifecycle for query submission.
|
|
7
|
+
* Persists across page navigation so in-flight requests are not aborted.
|
|
8
|
+
* @param props - Props.
|
|
9
|
+
* @param props.children - Children.
|
|
10
|
+
* @param props.url - URL for the query endpoint.
|
|
11
|
+
* @returns A context provider wrapping the given children.
|
|
12
|
+
*/
|
|
13
|
+
export function QueryProvider({
|
|
14
|
+
children,
|
|
15
|
+
url,
|
|
16
|
+
}: {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
url: string;
|
|
19
|
+
}): JSX.Element {
|
|
20
|
+
const { onSubmit } = useSubmit(url);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<QueryContext.Provider value={{ onSubmit }}>
|
|
24
|
+
{children}
|
|
25
|
+
</QueryContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
import { FormEvent } from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Actions returned by the useQuery hook.
|
|
5
|
-
*/
|
|
6
|
-
export interface Actions {
|
|
7
|
-
onSubmit: (
|
|
8
|
-
e: FormEvent<HTMLFormElement>,
|
|
9
|
-
payload: OnSubmitPayload,
|
|
10
|
-
options?: OnSubmitOptions,
|
|
11
|
-
) => Promise<void>;
|
|
12
|
-
}
|
|
2
|
+
import { Status } from "../types";
|
|
13
3
|
|
|
14
4
|
/**
|
|
15
5
|
* Options for the onSubmit action.
|
|
@@ -19,7 +9,7 @@ export interface OnSubmitOptions {
|
|
|
19
9
|
onMutate?: (form: HTMLFormElement, query: string) => void;
|
|
20
10
|
onSettled?: (form: HTMLFormElement) => void;
|
|
21
11
|
onSuccess?: (data: unknown) => void;
|
|
22
|
-
status
|
|
12
|
+
status: Status;
|
|
23
13
|
}
|
|
24
14
|
|
|
25
15
|
/**
|
|
@@ -30,8 +20,12 @@ export interface OnSubmitPayload {
|
|
|
30
20
|
}
|
|
31
21
|
|
|
32
22
|
/**
|
|
33
|
-
*
|
|
23
|
+
* Context value for the QueryProvider.
|
|
34
24
|
*/
|
|
35
|
-
export interface
|
|
36
|
-
|
|
25
|
+
export interface QueryContextValue {
|
|
26
|
+
onSubmit: (
|
|
27
|
+
e: FormEvent<HTMLFormElement>,
|
|
28
|
+
payload: OnSubmitPayload,
|
|
29
|
+
options: OnSubmitOptions,
|
|
30
|
+
) => Promise<void>;
|
|
37
31
|
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { FormEvent, ReactNode } from "react";
|
|
4
|
+
import React from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetch callbacks passed to fetchResponse.
|
|
8
|
+
*/
|
|
9
|
+
interface FetchCallbacks {
|
|
10
|
+
controller: AbortController;
|
|
11
|
+
onError: (error: Error) => void;
|
|
12
|
+
onSettled: () => void;
|
|
13
|
+
onSuccess: (data: unknown) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Mock fetchResponse
|
|
17
|
+
const mockFetchResponse = jest.fn();
|
|
18
|
+
|
|
19
|
+
jest.unstable_mockModule("../src/views/ResearchView/query/fetch", () => ({
|
|
20
|
+
fetchResponse: mockFetchResponse,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { useQuery } =
|
|
24
|
+
await import("../src/views/ResearchView/state/query/hooks/UseQuery/hook");
|
|
25
|
+
const { ChatProvider } =
|
|
26
|
+
await import("../src/views/ResearchView/state/provider");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a mock form event for testing.
|
|
30
|
+
* @returns Mock FormEvent.
|
|
31
|
+
*/
|
|
32
|
+
function createMockFormEvent(): FormEvent<HTMLFormElement> {
|
|
33
|
+
const mockForm = document.createElement("form");
|
|
34
|
+
|
|
35
|
+
// Mock reset
|
|
36
|
+
mockForm.reset = jest.fn();
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
currentTarget: mockForm,
|
|
40
|
+
preventDefault: jest.fn(),
|
|
41
|
+
} as unknown as FormEvent<HTMLFormElement>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a wrapper component that provides ChatProvider with a URL.
|
|
46
|
+
* @param url - The query endpoint URL.
|
|
47
|
+
* @returns A wrapper component for renderHook.
|
|
48
|
+
*/
|
|
49
|
+
function createWrapper(
|
|
50
|
+
url = "https://api.example.com",
|
|
51
|
+
): ({ children }: { children: ReactNode }) => ReactNode {
|
|
52
|
+
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
|
|
53
|
+
return React.createElement(ChatProvider, { url }, children);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("QueryProvider", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
mockFetchResponse.mockReset();
|
|
60
|
+
mockFetchResponse.mockImplementation(
|
|
61
|
+
async (_url: unknown, _query: unknown, callbacks: unknown) => {
|
|
62
|
+
(callbacks as FetchCallbacks).onSettled();
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("initial state", () => {
|
|
68
|
+
it("should return onSubmit function", () => {
|
|
69
|
+
const { result } = renderHook(() => useQuery(), {
|
|
70
|
+
wrapper: createWrapper(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(typeof result.current.onSubmit).toBe("function");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("submit guards", () => {
|
|
78
|
+
it("should not submit if status is loading", async () => {
|
|
79
|
+
const { result } = renderHook(() => useQuery(), {
|
|
80
|
+
wrapper: createWrapper(),
|
|
81
|
+
});
|
|
82
|
+
const event = createMockFormEvent();
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
await result.current.onSubmit(
|
|
86
|
+
event,
|
|
87
|
+
{ query: "valid query" },
|
|
88
|
+
{ status: { loading: true } },
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(mockFetchResponse).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should not submit if query is empty", async () => {
|
|
96
|
+
const { result } = renderHook(() => useQuery(), {
|
|
97
|
+
wrapper: createWrapper(),
|
|
98
|
+
});
|
|
99
|
+
const event = createMockFormEvent();
|
|
100
|
+
|
|
101
|
+
await act(async () => {
|
|
102
|
+
await result.current.onSubmit(
|
|
103
|
+
event,
|
|
104
|
+
{ query: "" },
|
|
105
|
+
{ status: { loading: false } },
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(mockFetchResponse).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should submit if query is provided", async () => {
|
|
113
|
+
const { result } = renderHook(() => useQuery(), {
|
|
114
|
+
wrapper: createWrapper(),
|
|
115
|
+
});
|
|
116
|
+
const event = createMockFormEvent();
|
|
117
|
+
|
|
118
|
+
await act(async () => {
|
|
119
|
+
await result.current.onSubmit(
|
|
120
|
+
event,
|
|
121
|
+
{ query: "valid query" },
|
|
122
|
+
{ status: { loading: false } },
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(mockFetchResponse).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("submit behavior", () => {
|
|
131
|
+
it("should call preventDefault on form event", async () => {
|
|
132
|
+
const { result } = renderHook(() => useQuery(), {
|
|
133
|
+
wrapper: createWrapper(),
|
|
134
|
+
});
|
|
135
|
+
const event = createMockFormEvent();
|
|
136
|
+
|
|
137
|
+
await act(async () => {
|
|
138
|
+
await result.current.onSubmit(
|
|
139
|
+
event,
|
|
140
|
+
{ query: "diabetes studies" },
|
|
141
|
+
{ status: { loading: false } },
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should call fetchResponse with correct arguments", async () => {
|
|
149
|
+
const { result } = renderHook(() => useQuery(), {
|
|
150
|
+
wrapper: createWrapper(),
|
|
151
|
+
});
|
|
152
|
+
const event = createMockFormEvent();
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await result.current.onSubmit(
|
|
156
|
+
event,
|
|
157
|
+
{ query: "diabetes studies" },
|
|
158
|
+
{ status: { loading: false } },
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(mockFetchResponse).toHaveBeenCalledWith(
|
|
163
|
+
"https://api.example.com",
|
|
164
|
+
"diabetes studies",
|
|
165
|
+
expect.objectContaining({
|
|
166
|
+
controller: expect.any(AbortController),
|
|
167
|
+
onError: expect.any(Function),
|
|
168
|
+
onSettled: expect.any(Function),
|
|
169
|
+
onSuccess: expect.any(Function),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should pass url to fetchResponse", async () => {
|
|
175
|
+
const testUrl = "https://custom-api.example.com/search";
|
|
176
|
+
const { result } = renderHook(() => useQuery(), {
|
|
177
|
+
wrapper: createWrapper(testUrl),
|
|
178
|
+
});
|
|
179
|
+
const event = createMockFormEvent();
|
|
180
|
+
|
|
181
|
+
await act(async () => {
|
|
182
|
+
await result.current.onSubmit(
|
|
183
|
+
event,
|
|
184
|
+
{ query: "cancer studies" },
|
|
185
|
+
{ status: { loading: false } },
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(mockFetchResponse).toHaveBeenCalledWith(
|
|
190
|
+
testUrl,
|
|
191
|
+
expect.any(String),
|
|
192
|
+
expect.any(Object),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("option callbacks", () => {
|
|
198
|
+
it("should call onMutate after dispatching query", async () => {
|
|
199
|
+
const onMutate = jest.fn();
|
|
200
|
+
const { result } = renderHook(() => useQuery(), {
|
|
201
|
+
wrapper: createWrapper(),
|
|
202
|
+
});
|
|
203
|
+
const event = createMockFormEvent();
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
await result.current.onSubmit(
|
|
207
|
+
event,
|
|
208
|
+
{ query: "test query" },
|
|
209
|
+
{ onMutate, status: { loading: false } },
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(onMutate).toHaveBeenCalledWith(event.currentTarget, "test query");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("should call onSettled after fetch completes", async () => {
|
|
217
|
+
const onSettled = jest.fn();
|
|
218
|
+
const { result } = renderHook(() => useQuery(), {
|
|
219
|
+
wrapper: createWrapper(),
|
|
220
|
+
});
|
|
221
|
+
const event = createMockFormEvent();
|
|
222
|
+
|
|
223
|
+
await act(async () => {
|
|
224
|
+
await result.current.onSubmit(
|
|
225
|
+
event,
|
|
226
|
+
{ query: "test query" },
|
|
227
|
+
{ onSettled, status: { loading: false } },
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(onSettled).toHaveBeenCalledWith(event.currentTarget);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("should call onSuccess after successful fetch", async () => {
|
|
235
|
+
const mockData = { message: "success" };
|
|
236
|
+
mockFetchResponse.mockImplementation(
|
|
237
|
+
async (_url: unknown, _query: unknown, callbacks: unknown) => {
|
|
238
|
+
(callbacks as FetchCallbacks).onSuccess(mockData);
|
|
239
|
+
(callbacks as FetchCallbacks).onSettled();
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const onSuccess = jest.fn();
|
|
244
|
+
const { result } = renderHook(() => useQuery(), {
|
|
245
|
+
wrapper: createWrapper(),
|
|
246
|
+
});
|
|
247
|
+
const event = createMockFormEvent();
|
|
248
|
+
|
|
249
|
+
await act(async () => {
|
|
250
|
+
await result.current.onSubmit(
|
|
251
|
+
event,
|
|
252
|
+
{ query: "test query" },
|
|
253
|
+
{ onSuccess, status: { loading: false } },
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(onSuccess).toHaveBeenCalledWith(mockData);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("should call onError after failed fetch", async () => {
|
|
261
|
+
const mockError = new Error("Network error");
|
|
262
|
+
mockFetchResponse.mockImplementation(
|
|
263
|
+
async (_url: unknown, _query: unknown, callbacks: unknown) => {
|
|
264
|
+
(callbacks as FetchCallbacks).onError(mockError);
|
|
265
|
+
(callbacks as FetchCallbacks).onSettled();
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const onError = jest.fn();
|
|
270
|
+
const { result } = renderHook(() => useQuery(), {
|
|
271
|
+
wrapper: createWrapper(),
|
|
272
|
+
});
|
|
273
|
+
const event = createMockFormEvent();
|
|
274
|
+
|
|
275
|
+
await act(async () => {
|
|
276
|
+
await result.current.onSubmit(
|
|
277
|
+
event,
|
|
278
|
+
{ query: "test query" },
|
|
279
|
+
{ onError, status: { loading: false } },
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(onError).toHaveBeenCalledWith(mockError);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("abort handling", () => {
|
|
288
|
+
it("should create new AbortController for each submit", async () => {
|
|
289
|
+
const controllers: AbortController[] = [];
|
|
290
|
+
mockFetchResponse.mockImplementation(
|
|
291
|
+
async (_url: unknown, _query: unknown, callbacks: unknown) => {
|
|
292
|
+
controllers.push((callbacks as FetchCallbacks).controller);
|
|
293
|
+
(callbacks as FetchCallbacks).onSettled();
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const { result } = renderHook(() => useQuery(), {
|
|
298
|
+
wrapper: createWrapper(),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await act(async () => {
|
|
302
|
+
await result.current.onSubmit(
|
|
303
|
+
createMockFormEvent(),
|
|
304
|
+
{ query: "query 1" },
|
|
305
|
+
{ status: { loading: false } },
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await act(async () => {
|
|
310
|
+
await result.current.onSubmit(
|
|
311
|
+
createMockFormEvent(),
|
|
312
|
+
{ query: "query 2" },
|
|
313
|
+
{ status: { loading: false } },
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(controllers).toHaveLength(2);
|
|
318
|
+
expect(controllers[0]).not.toBe(controllers[1]);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { useConfig } from "../../../hooks/useConfig";
|
|
2
|
-
import { useQuery } from "../query/useQuery";
|
|
3
|
-
/**
|
|
4
|
-
* Adapter hook that wires AI query to app config.
|
|
5
|
-
* @returns AI query interface with actions.
|
|
6
|
-
*/
|
|
7
|
-
export function useAdapter() {
|
|
8
|
-
const { config } = useConfig();
|
|
9
|
-
const { ai } = config;
|
|
10
|
-
const { url } = ai || {};
|
|
11
|
-
if (!url) {
|
|
12
|
-
throw new Error("Chat URL is not configured");
|
|
13
|
-
}
|
|
14
|
-
return useQuery(url);
|
|
15
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { UseQuery } from "./types";
|
|
2
|
-
/**
|
|
3
|
-
* Custom hook for managing the actions of a chat query form.
|
|
4
|
-
* @param url - The URL to send the query to.
|
|
5
|
-
* @returns An object containing the actions of the query.
|
|
6
|
-
*/
|
|
7
|
-
export declare const useQuery: (url: string) => UseQuery;
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
-
import { fetchResponse } from "./fetch";
|
|
3
|
-
/**
|
|
4
|
-
* Custom hook for managing the actions of a chat query form.
|
|
5
|
-
* @param url - The URL to send the query to.
|
|
6
|
-
* @returns An object containing the actions of the query.
|
|
7
|
-
*/
|
|
8
|
-
export const useQuery = (url) => {
|
|
9
|
-
const abortRef = useRef(null);
|
|
10
|
-
const onSubmit = useCallback(async (e, payload, options) => {
|
|
11
|
-
e.preventDefault();
|
|
12
|
-
if (options?.status?.loading)
|
|
13
|
-
return;
|
|
14
|
-
const form = e.currentTarget;
|
|
15
|
-
const { query } = payload;
|
|
16
|
-
if (!query)
|
|
17
|
-
return;
|
|
18
|
-
// Call onMutate callback
|
|
19
|
-
options?.onMutate?.(form, query);
|
|
20
|
-
// Abort any in-flight request
|
|
21
|
-
abortRef.current?.abort();
|
|
22
|
-
const controller = new AbortController();
|
|
23
|
-
abortRef.current = controller;
|
|
24
|
-
await fetchResponse(url, query, {
|
|
25
|
-
controller,
|
|
26
|
-
onError: (error) => {
|
|
27
|
-
options?.onError?.(error);
|
|
28
|
-
},
|
|
29
|
-
onSettled: () => {
|
|
30
|
-
options?.onSettled?.(form);
|
|
31
|
-
},
|
|
32
|
-
onSuccess: (data) => {
|
|
33
|
-
options?.onSuccess?.(data);
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
}, [url]);
|
|
37
|
-
// Abort any in-flight request on unmount.
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
return () => {
|
|
40
|
-
abortRef.current?.abort();
|
|
41
|
-
};
|
|
42
|
-
}, []);
|
|
43
|
-
return { actions: { onSubmit } };
|
|
44
|
-
};
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { useConfig } from "../../../hooks/useConfig";
|
|
2
|
-
import { UseQuery } from "../query/types";
|
|
3
|
-
import { useQuery } from "../query/useQuery";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Adapter hook that wires AI query to app config.
|
|
7
|
-
* @returns AI query interface with actions.
|
|
8
|
-
*/
|
|
9
|
-
export function useAdapter(): UseQuery {
|
|
10
|
-
const { config } = useConfig();
|
|
11
|
-
const { ai } = config;
|
|
12
|
-
const { url } = ai || {};
|
|
13
|
-
|
|
14
|
-
if (!url) {
|
|
15
|
-
throw new Error("Chat URL is not configured");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return useQuery(url);
|
|
19
|
-
}
|