@assistant-ui/react-hook-form 0.12.27 → 0.12.29
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/README.md +1 -1
- package/dist/useAssistantForm.d.ts.map +1 -1
- package/dist/useAssistantForm.js +108 -10
- package/dist/useAssistantForm.js.map +1 -1
- package/package.json +5 -5
- package/src/useAssistantForm.test.tsx +264 -5
- package/src/useAssistantForm.ts +176 -9
package/README.md
CHANGED
|
@@ -36,4 +36,4 @@ The assistant gets two built-in tools: `set_form_field` to write values into fie
|
|
|
36
36
|
|
|
37
37
|
- `@assistant-ui/react-lexical` if you also want a rich-text composer with `@`-mention support inside the same chat.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
A guided walkthrough lives at [assistant-ui.com/examples/form-demo](https://www.assistant-ui.com/examples/form-demo). See [`examples/with-react-hook-form`](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-react-hook-form) for a complete app.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAssistantForm.d.ts","names":[],"sources":["../src/useAssistantForm.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"useAssistantForm.d.ts","names":[],"sources":["../src/useAssistantForm.ts"],"mappings":";;;;;KA6BY,sBACV,qBAAqB,aACrB,UACA,sBACE,aAAa,cAAc,UAAU;EACvC;IAEM;MAEM;QAEM,SACI,6BACE,EAAE,cACQ,UAAU;;MAOhC;QAEM,SACI,6BACE,EAAE,cAAc,UAAU;;MAMtC;QAEM,SACI,6BACE,EAAE,cAAc,UAAU;;;;;cAYvC,mBACX,qBAAqB,cAAc,aACnC,gBACA,qBAAqB,cAErB,QAAQ,sBAAsB,cAAc,UAAU,wBACrD,cAAc,cAAc,UAAU"}
|
package/dist/useAssistantForm.js
CHANGED
|
@@ -3,12 +3,66 @@ import { formTools } from "./formTools.js";
|
|
|
3
3
|
import { tool } from "@assistant-ui/core";
|
|
4
4
|
import "@assistant-ui/core/react";
|
|
5
5
|
import { useAui } from "@assistant-ui/store";
|
|
6
|
-
import { useEffect } from "react";
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
7
7
|
import { get, useForm } from "react-hook-form";
|
|
8
8
|
//#region src/useAssistantForm.ts
|
|
9
9
|
const useAssistantForm = (props) => {
|
|
10
10
|
const form = useForm(props);
|
|
11
|
-
const { control, getValues, setValue, reset, formState: { isSubmitting } } = form;
|
|
11
|
+
const { control, getValues, setValue, reset, handleSubmit: baseHandleSubmit, formState: { isSubmitting } } = form;
|
|
12
|
+
const pendingAssistantSubmitRef = useRef(null);
|
|
13
|
+
const settleAssistantSubmit = useCallback((outcome) => {
|
|
14
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
15
|
+
if (!pending) return;
|
|
16
|
+
pendingAssistantSubmitRef.current = null;
|
|
17
|
+
pending.resolve(outcome);
|
|
18
|
+
}, []);
|
|
19
|
+
const rejectAssistantSubmit = useCallback((error) => {
|
|
20
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
21
|
+
if (!pending) return;
|
|
22
|
+
pendingAssistantSubmitRef.current = null;
|
|
23
|
+
pending.reject(error);
|
|
24
|
+
}, []);
|
|
25
|
+
const handleSubmit = useCallback((onValid, onInvalid) => {
|
|
26
|
+
const submit = baseHandleSubmit((...args) => {
|
|
27
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
28
|
+
const event = args[1]?.nativeEvent ?? args[1];
|
|
29
|
+
if (pending && pending.event === event) pending.outcome = true;
|
|
30
|
+
return onValid(...args);
|
|
31
|
+
}, (...args) => {
|
|
32
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
33
|
+
const event = args[1]?.nativeEvent ?? args[1];
|
|
34
|
+
if (pending && pending.event === event) pending.outcome = false;
|
|
35
|
+
return onInvalid?.(...args);
|
|
36
|
+
});
|
|
37
|
+
return (async (event) => {
|
|
38
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
39
|
+
const nativeEvent = event?.nativeEvent ?? event;
|
|
40
|
+
const assistantSubmit = pending && (pending.event === nativeEvent || pending.dispatching && pending.event === void 0 && nativeEvent !== void 0) ? pending : null;
|
|
41
|
+
if (assistantSubmit) {
|
|
42
|
+
assistantSubmit.event = nativeEvent;
|
|
43
|
+
assistantSubmit.handlerInvoked = true;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const result = await submit(event);
|
|
47
|
+
if (assistantSubmit && pendingAssistantSubmitRef.current === assistantSubmit) settleAssistantSubmit(assistantSubmit.outcome ?? true);
|
|
48
|
+
return result;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (pendingAssistantSubmitRef.current === assistantSubmit) rejectAssistantSubmit(error);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}, [
|
|
55
|
+
baseHandleSubmit,
|
|
56
|
+
rejectAssistantSubmit,
|
|
57
|
+
settleAssistantSubmit
|
|
58
|
+
]);
|
|
59
|
+
const assistantForm = useMemo(() => ({
|
|
60
|
+
...form,
|
|
61
|
+
handleSubmit,
|
|
62
|
+
get formState() {
|
|
63
|
+
return form.formState;
|
|
64
|
+
}
|
|
65
|
+
}), [form, handleSubmit]);
|
|
12
66
|
const aui = useAui();
|
|
13
67
|
useEffect(() => {
|
|
14
68
|
const value = { tools: {
|
|
@@ -23,24 +77,66 @@ const useAssistantForm = (props) => {
|
|
|
23
77
|
submit_form: tool({
|
|
24
78
|
...formTools.submit_form,
|
|
25
79
|
execute: async () => {
|
|
26
|
-
if (isSubmitting) return {
|
|
80
|
+
if (isSubmitting || pendingAssistantSubmitRef.current) return {
|
|
27
81
|
success: false,
|
|
28
82
|
message: "The form is already submitting."
|
|
29
83
|
};
|
|
30
84
|
const { _names, _fields } = control;
|
|
85
|
+
let formElement = null;
|
|
31
86
|
for (const name of _names.mount) {
|
|
32
87
|
const field = get(_fields, name);
|
|
33
88
|
if (field?._f) {
|
|
34
89
|
const fieldReference = Array.isArray(field._f.refs) ? field._f.refs[0] : field._f.ref;
|
|
35
90
|
if (fieldReference instanceof HTMLElement) {
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
form.requestSubmit();
|
|
39
|
-
return { success: true };
|
|
40
|
-
}
|
|
91
|
+
formElement = fieldReference.closest("form");
|
|
92
|
+
if (formElement) break;
|
|
41
93
|
}
|
|
42
94
|
}
|
|
43
95
|
}
|
|
96
|
+
if (formElement) {
|
|
97
|
+
if (!formElement.noValidate && !formElement.reportValidity()) return {
|
|
98
|
+
success: false,
|
|
99
|
+
message: "The form contains invalid fields and was not submitted."
|
|
100
|
+
};
|
|
101
|
+
let resolveSubmission = () => {};
|
|
102
|
+
let rejectSubmission = () => {};
|
|
103
|
+
const submissionResult = new Promise((resolve, reject) => {
|
|
104
|
+
resolveSubmission = resolve;
|
|
105
|
+
rejectSubmission = reject;
|
|
106
|
+
});
|
|
107
|
+
const assistantSubmit = {
|
|
108
|
+
dispatching: true,
|
|
109
|
+
event: void 0,
|
|
110
|
+
handlerInvoked: false,
|
|
111
|
+
outcome: void 0,
|
|
112
|
+
resolve: resolveSubmission,
|
|
113
|
+
reject: rejectSubmission
|
|
114
|
+
};
|
|
115
|
+
pendingAssistantSubmitRef.current = assistantSubmit;
|
|
116
|
+
const onSubmit = (event) => {
|
|
117
|
+
if (pendingAssistantSubmitRef.current === assistantSubmit && assistantSubmit.event === void 0) assistantSubmit.event = event;
|
|
118
|
+
queueMicrotask(() => {
|
|
119
|
+
if (pendingAssistantSubmitRef.current === assistantSubmit && !assistantSubmit.handlerInvoked) settleAssistantSubmit(true);
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
formElement.addEventListener("submit", onSubmit, { once: true });
|
|
123
|
+
try {
|
|
124
|
+
formElement.requestSubmit();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
settleAssistantSubmit(false);
|
|
127
|
+
throw error;
|
|
128
|
+
} finally {
|
|
129
|
+
assistantSubmit.dispatching = false;
|
|
130
|
+
formElement.removeEventListener("submit", onSubmit);
|
|
131
|
+
}
|
|
132
|
+
const dispatched = assistantSubmit.event !== void 0;
|
|
133
|
+
if (!dispatched) settleAssistantSubmit(false);
|
|
134
|
+
if (await submissionResult) return { success: true };
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
message: dispatched ? "The form contains invalid fields and was not submitted." : "The form did not accept the submission."
|
|
138
|
+
};
|
|
139
|
+
}
|
|
44
140
|
return {
|
|
45
141
|
success: false,
|
|
46
142
|
message: "Unable retrieve the form element. This is a coding error."
|
|
@@ -65,7 +161,9 @@ const useAssistantForm = (props) => {
|
|
|
65
161
|
getValues,
|
|
66
162
|
aui,
|
|
67
163
|
reset,
|
|
68
|
-
isSubmitting
|
|
164
|
+
isSubmitting,
|
|
165
|
+
rejectAssistantSubmit,
|
|
166
|
+
settleAssistantSubmit
|
|
69
167
|
]);
|
|
70
168
|
const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;
|
|
71
169
|
useEffect(() => {
|
|
@@ -82,7 +180,7 @@ const useAssistantForm = (props) => {
|
|
|
82
180
|
if (!renderResetFormTool) return void 0;
|
|
83
181
|
return aui.tools.setToolUI("reset_form", renderResetFormTool);
|
|
84
182
|
}, [aui, renderResetFormTool]);
|
|
85
|
-
return
|
|
183
|
+
return assistantForm;
|
|
86
184
|
};
|
|
87
185
|
//#endregion
|
|
88
186
|
export { useAssistantForm };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAssistantForm.js","names":[],"sources":["../src/useAssistantForm.ts"],"sourcesContent":["\"use client\";\n\nimport { type ModelContext, tool } from \"@assistant-ui/core\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { type ToolCallMessagePartComponent } from \"@assistant-ui/core/react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport { useEffect } from \"react\";\nimport {\n type Field,\n type FieldValues,\n type Path,\n type PathValue,\n type UseFormProps,\n type UseFormReturn,\n get,\n useForm,\n} from \"react-hook-form\";\nimport type { z } from \"zod\";\nimport { formTools } from \"./formTools\";\n\nexport type UseAssistantFormProps<\n TFieldValues extends FieldValues,\n TContext,\n TTransformedValues,\n> = UseFormProps<TFieldValues, TContext, TTransformedValues> & {\n assistant?:\n | {\n tools?:\n | {\n set_form_field?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<\n (typeof formTools.set_form_field)[\"parameters\"]\n >,\n unknown\n >\n | undefined;\n }\n | undefined;\n submit_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.submit_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n reset_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.reset_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n }\n | undefined;\n }\n | undefined;\n};\n\nexport const useAssistantForm = <\n TFieldValues extends FieldValues = FieldValues,\n TContext = any,\n TTransformedValues = TFieldValues,\n>(\n props?: UseAssistantFormProps<TFieldValues, TContext, TTransformedValues>,\n): UseFormReturn<TFieldValues, TContext, TTransformedValues> => {\n const form = useForm<TFieldValues, TContext, TTransformedValues>(props);\n const {\n control,\n getValues,\n setValue,\n reset,\n formState: { isSubmitting },\n } = form;\n\n const aui = useAui();\n useEffect(() => {\n const value: ModelContext = {\n tools: {\n set_form_field: tool({\n ...formTools.set_form_field,\n parameters: formTools.set_form_field.parameters,\n execute: async (args) => {\n setValue(\n args.name as Path<TFieldValues>,\n args.value as PathValue<TFieldValues, Path<TFieldValues>>,\n );\n\n return { success: true };\n },\n }),\n submit_form: tool({\n ...formTools.submit_form,\n execute: async () => {\n if (isSubmitting) {\n return {\n success: false,\n message: \"The form is already submitting.\",\n };\n }\n const { _names, _fields } = control;\n for (const name of _names.mount) {\n const field: Field | undefined = get(_fields, name);\n if (field?._f) {\n const fieldReference = Array.isArray(field._f.refs)\n ? field._f.refs[0]\n : field._f.ref;\n\n if (fieldReference instanceof HTMLElement) {\n const form = fieldReference.closest(\"form\");\n if (form) {\n form.requestSubmit();\n\n return { success: true };\n }\n }\n }\n }\n\n return {\n success: false,\n message:\n \"Unable retrieve the form element. This is a coding error.\",\n };\n },\n }),\n reset_form: tool({\n ...formTools.reset_form,\n execute: async () => {\n reset();\n return { success: true };\n },\n }),\n },\n };\n\n return aui.modelContext.register({\n getModelContext: () => ({\n ...value,\n system: `Form State:\\n${JSON.stringify(getValues())}`,\n }),\n });\n }, [control, setValue, getValues, aui, reset, isSubmitting]);\n\n const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;\n useEffect(() => {\n if (!renderFormFieldTool) return undefined;\n return aui.tools.setToolUI(\"set_form_field\", renderFormFieldTool);\n }, [aui, renderFormFieldTool]);\n\n const renderSubmitFormTool = props?.assistant?.tools?.submit_form?.render;\n useEffect(() => {\n if (!renderSubmitFormTool) return undefined;\n return aui.tools.setToolUI(\"submit_form\", renderSubmitFormTool);\n }, [aui, renderSubmitFormTool]);\n\n const renderResetFormTool = props?.assistant?.tools?.reset_form?.render;\n useEffect(() => {\n if (!renderResetFormTool) return undefined;\n return aui.tools.setToolUI(\"reset_form\", renderResetFormTool);\n }, [aui, renderResetFormTool]);\n\n return form;\n};\n"],"mappings":";;;;;;;;AAmEA,MAAa,oBAKX,UAC8D;CAC9D,MAAM,OAAO,QAAoD,KAAK;CACtE,MAAM,EACJ,SACA,WACA,UACA,OACA,WAAW,EAAE,mBACX;CAEJ,MAAM,MAAM,OAAO;CACnB,gBAAgB;EACd,MAAM,QAAsB,EAC1B,OAAO;GACL,gBAAgB,KAAK;IACnB,GAAG,UAAU;IACb,YAAY,UAAU,eAAe;IACrC,SAAS,OAAO,SAAS;KACvB,SACE,KAAK,MACL,KAAK,KACP;KAEA,OAAO,EAAE,SAAS,KAAK;IACzB;GACF,CAAC;GACD,aAAa,KAAK;IAChB,GAAG,UAAU;IACb,SAAS,YAAY;KACnB,IAAI,cACF,OAAO;MACL,SAAS;MACT,SAAS;KACX;KAEF,MAAM,EAAE,QAAQ,YAAY;KAC5B,KAAK,MAAM,QAAQ,OAAO,OAAO;MAC/B,MAAM,QAA2B,IAAI,SAAS,IAAI;MAClD,IAAI,OAAO,IAAI;OACb,MAAM,iBAAiB,MAAM,QAAQ,MAAM,GAAG,IAAI,IAC9C,MAAM,GAAG,KAAK,KACd,MAAM,GAAG;OAEb,IAAI,0BAA0B,aAAa;QACzC,MAAM,OAAO,eAAe,QAAQ,MAAM;QAC1C,IAAI,MAAM;SACR,KAAK,cAAc;SAEnB,OAAO,EAAE,SAAS,KAAK;QACzB;OACF;MACF;KACF;KAEA,OAAO;MACL,SAAS;MACT,SACE;KACJ;IACF;GACF,CAAC;GACD,YAAY,KAAK;IACf,GAAG,UAAU;IACb,SAAS,YAAY;KACnB,MAAM;KACN,OAAO,EAAE,SAAS,KAAK;IACzB;GACF,CAAC;EACH,EACF;EAEA,OAAO,IAAI,aAAa,SAAS,EAC/B,wBAAwB;GACtB,GAAG;GACH,QAAQ,gBAAgB,KAAK,UAAU,UAAU,CAAC;EACpD,GACF,CAAC;CACH,GAAG;EAAC;EAAS;EAAU;EAAW;EAAK;EAAO;CAAY,CAAC;CAE3D,MAAM,sBAAsB,OAAO,WAAW,OAAO,gBAAgB;CACrE,gBAAgB;EACd,IAAI,CAAC,qBAAqB,OAAO,KAAA;EACjC,OAAO,IAAI,MAAM,UAAU,kBAAkB,mBAAmB;CAClE,GAAG,CAAC,KAAK,mBAAmB,CAAC;CAE7B,MAAM,uBAAuB,OAAO,WAAW,OAAO,aAAa;CACnE,gBAAgB;EACd,IAAI,CAAC,sBAAsB,OAAO,KAAA;EAClC,OAAO,IAAI,MAAM,UAAU,eAAe,oBAAoB;CAChE,GAAG,CAAC,KAAK,oBAAoB,CAAC;CAE9B,MAAM,sBAAsB,OAAO,WAAW,OAAO,YAAY;CACjE,gBAAgB;EACd,IAAI,CAAC,qBAAqB,OAAO,KAAA;EACjC,OAAO,IAAI,MAAM,UAAU,cAAc,mBAAmB;CAC9D,GAAG,CAAC,KAAK,mBAAmB,CAAC;CAE7B,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"useAssistantForm.js","names":[],"sources":["../src/useAssistantForm.ts"],"sourcesContent":["\"use client\";\n\nimport { type ModelContext, tool } from \"@assistant-ui/core\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { type ToolCallMessagePartComponent } from \"@assistant-ui/core/react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\nimport {\n type Field,\n type FieldValues,\n type Path,\n type PathValue,\n type UseFormProps,\n type UseFormReturn,\n get,\n useForm,\n} from \"react-hook-form\";\nimport type { z } from \"zod\";\nimport { formTools } from \"./formTools\";\n\ntype PendingAssistantSubmit = {\n dispatching: boolean;\n event: unknown;\n handlerInvoked: boolean;\n outcome: boolean | undefined;\n resolve: (outcome: boolean) => void;\n reject: (error: unknown) => void;\n};\n\nexport type UseAssistantFormProps<\n TFieldValues extends FieldValues,\n TContext,\n TTransformedValues,\n> = UseFormProps<TFieldValues, TContext, TTransformedValues> & {\n assistant?:\n | {\n tools?:\n | {\n set_form_field?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<\n (typeof formTools.set_form_field)[\"parameters\"]\n >,\n unknown\n >\n | undefined;\n }\n | undefined;\n submit_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.submit_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n reset_form?:\n | {\n render?:\n | ToolCallMessagePartComponent<\n z.infer<(typeof formTools.reset_form)[\"parameters\"]>,\n unknown\n >\n | undefined;\n }\n | undefined;\n }\n | undefined;\n }\n | undefined;\n};\n\nexport const useAssistantForm = <\n TFieldValues extends FieldValues = FieldValues,\n TContext = any,\n TTransformedValues = TFieldValues,\n>(\n props?: UseAssistantFormProps<TFieldValues, TContext, TTransformedValues>,\n): UseFormReturn<TFieldValues, TContext, TTransformedValues> => {\n const form = useForm<TFieldValues, TContext, TTransformedValues>(props);\n const {\n control,\n getValues,\n setValue,\n reset,\n handleSubmit: baseHandleSubmit,\n formState: { isSubmitting },\n } = form;\n\n const pendingAssistantSubmitRef = useRef<PendingAssistantSubmit | null>(null);\n const settleAssistantSubmit = useCallback((outcome: boolean) => {\n const pending = pendingAssistantSubmitRef.current;\n if (!pending) return;\n\n pendingAssistantSubmitRef.current = null;\n pending.resolve(outcome);\n }, []);\n const rejectAssistantSubmit = useCallback((error: unknown) => {\n const pending = pendingAssistantSubmitRef.current;\n if (!pending) return;\n\n pendingAssistantSubmitRef.current = null;\n pending.reject(error);\n }, []);\n\n const handleSubmit = useCallback<\n UseFormReturn<TFieldValues, TContext, TTransformedValues>[\"handleSubmit\"]\n >(\n (onValid, onInvalid) => {\n const submit = baseHandleSubmit(\n (...args) => {\n const pending = pendingAssistantSubmitRef.current;\n const event = args[1]?.nativeEvent ?? args[1];\n if (pending && pending.event === event) pending.outcome = true;\n return onValid(...args);\n },\n (...args) => {\n const pending = pendingAssistantSubmitRef.current;\n const event = args[1]?.nativeEvent ?? args[1];\n if (pending && pending.event === event) pending.outcome = false;\n return onInvalid?.(...args);\n },\n );\n\n return (async (event) => {\n const pending = pendingAssistantSubmitRef.current;\n const nativeEvent = event?.nativeEvent ?? event;\n const assistantSubmit =\n pending &&\n (pending.event === nativeEvent ||\n (pending.dispatching &&\n pending.event === undefined &&\n nativeEvent !== undefined))\n ? pending\n : null;\n if (assistantSubmit) {\n assistantSubmit.event = nativeEvent;\n assistantSubmit.handlerInvoked = true;\n }\n\n try {\n const result = await submit(event);\n if (\n assistantSubmit &&\n pendingAssistantSubmitRef.current === assistantSubmit\n ) {\n settleAssistantSubmit(assistantSubmit.outcome ?? true);\n }\n return result;\n } catch (error) {\n if (pendingAssistantSubmitRef.current === assistantSubmit) {\n rejectAssistantSubmit(error);\n }\n throw error;\n }\n }) as typeof submit;\n },\n [baseHandleSubmit, rejectAssistantSubmit, settleAssistantSubmit],\n );\n const assistantForm = useMemo<\n UseFormReturn<TFieldValues, TContext, TTransformedValues>\n >(\n () => ({\n ...form,\n handleSubmit,\n get formState() {\n return form.formState;\n },\n }),\n [form, handleSubmit],\n );\n\n const aui = useAui();\n useEffect(() => {\n const value: ModelContext = {\n tools: {\n set_form_field: tool({\n ...formTools.set_form_field,\n parameters: formTools.set_form_field.parameters,\n execute: async (args) => {\n setValue(\n args.name as Path<TFieldValues>,\n args.value as PathValue<TFieldValues, Path<TFieldValues>>,\n );\n\n return { success: true };\n },\n }),\n submit_form: tool({\n ...formTools.submit_form,\n execute: async () => {\n if (isSubmitting || pendingAssistantSubmitRef.current) {\n return {\n success: false,\n message: \"The form is already submitting.\",\n };\n }\n const { _names, _fields } = control;\n let formElement: HTMLFormElement | null = null;\n for (const name of _names.mount) {\n const field: Field | undefined = get(_fields, name);\n if (field?._f) {\n const fieldReference = Array.isArray(field._f.refs)\n ? field._f.refs[0]\n : field._f.ref;\n\n if (fieldReference instanceof HTMLElement) {\n formElement = fieldReference.closest(\"form\");\n if (formElement) break;\n }\n }\n }\n\n if (formElement) {\n if (!formElement.noValidate && !formElement.reportValidity()) {\n return {\n success: false,\n message:\n \"The form contains invalid fields and was not submitted.\",\n };\n }\n\n let resolveSubmission: (outcome: boolean) => void = () => {};\n let rejectSubmission: (error: unknown) => void = () => {};\n const submissionResult = new Promise<boolean>(\n (resolve, reject) => {\n resolveSubmission = resolve;\n rejectSubmission = reject;\n },\n );\n const assistantSubmit: PendingAssistantSubmit = {\n dispatching: true,\n event: undefined,\n handlerInvoked: false,\n outcome: undefined,\n resolve: resolveSubmission,\n reject: rejectSubmission,\n };\n pendingAssistantSubmitRef.current = assistantSubmit;\n const onSubmit = (event: SubmitEvent) => {\n if (\n pendingAssistantSubmitRef.current === assistantSubmit &&\n assistantSubmit.event === undefined\n ) {\n assistantSubmit.event = event;\n }\n queueMicrotask(() => {\n if (\n pendingAssistantSubmitRef.current === assistantSubmit &&\n !assistantSubmit.handlerInvoked\n ) {\n settleAssistantSubmit(true);\n }\n });\n };\n\n formElement.addEventListener(\"submit\", onSubmit, { once: true });\n try {\n formElement.requestSubmit();\n } catch (error) {\n settleAssistantSubmit(false);\n throw error;\n } finally {\n assistantSubmit.dispatching = false;\n formElement.removeEventListener(\"submit\", onSubmit);\n }\n\n const dispatched = assistantSubmit.event !== undefined;\n if (!dispatched) {\n settleAssistantSubmit(false);\n }\n\n if (await submissionResult) return { success: true };\n return {\n success: false,\n message: dispatched\n ? \"The form contains invalid fields and was not submitted.\"\n : \"The form did not accept the submission.\",\n };\n }\n\n return {\n success: false,\n message:\n \"Unable retrieve the form element. This is a coding error.\",\n };\n },\n }),\n reset_form: tool({\n ...formTools.reset_form,\n execute: async () => {\n reset();\n return { success: true };\n },\n }),\n },\n };\n\n return aui.modelContext.register({\n getModelContext: () => ({\n ...value,\n system: `Form State:\\n${JSON.stringify(getValues())}`,\n }),\n });\n }, [\n control,\n setValue,\n getValues,\n aui,\n reset,\n isSubmitting,\n rejectAssistantSubmit,\n settleAssistantSubmit,\n ]);\n\n const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;\n useEffect(() => {\n if (!renderFormFieldTool) return undefined;\n return aui.tools.setToolUI(\"set_form_field\", renderFormFieldTool);\n }, [aui, renderFormFieldTool]);\n\n const renderSubmitFormTool = props?.assistant?.tools?.submit_form?.render;\n useEffect(() => {\n if (!renderSubmitFormTool) return undefined;\n return aui.tools.setToolUI(\"submit_form\", renderSubmitFormTool);\n }, [aui, renderSubmitFormTool]);\n\n const renderResetFormTool = props?.assistant?.tools?.reset_form?.render;\n useEffect(() => {\n if (!renderResetFormTool) return undefined;\n return aui.tools.setToolUI(\"reset_form\", renderResetFormTool);\n }, [aui, renderResetFormTool]);\n\n return assistantForm;\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react-hook-form",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.29",
|
|
4
4
|
"description": "React Hook Form integration for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-hook-form",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
],
|
|
30
30
|
"sideEffects": false,
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@assistant-ui/core": "^0.3.
|
|
33
|
-
"@assistant-ui/store": "^0.3.
|
|
32
|
+
"@assistant-ui/core": "^0.3.15",
|
|
33
|
+
"@assistant-ui/store": "^0.3.10",
|
|
34
34
|
"zod": "^4.4.3"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"@types/react-dom": "^19.2.4",
|
|
50
50
|
"jsdom": "^30.0.1",
|
|
51
51
|
"react": "^19.2.8",
|
|
52
|
-
"react-hook-form": "^7.
|
|
52
|
+
"react-hook-form": "^7.85.0",
|
|
53
53
|
"vitest": "^4.1.10",
|
|
54
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
54
|
+
"@assistant-ui/x-buildutils": "0.0.23"
|
|
55
55
|
},
|
|
56
56
|
"publishConfig": {
|
|
57
57
|
"access": "public",
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/** @vitest-environment jsdom */
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
renderHook,
|
|
7
|
+
waitFor,
|
|
8
|
+
} from "@testing-library/react";
|
|
3
9
|
import type { ModelContext } from "@assistant-ui/core";
|
|
4
10
|
import type { FormEvent, ReactNode } from "react";
|
|
11
|
+
import type { Resolver, ResolverResult } from "react-hook-form";
|
|
5
12
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
13
|
|
|
7
14
|
const mocks = vi.hoisted(() => {
|
|
@@ -36,6 +43,19 @@ beforeEach(() => {
|
|
|
36
43
|
});
|
|
37
44
|
});
|
|
38
45
|
|
|
46
|
+
const executeSubmitForm = () => {
|
|
47
|
+
const submitTool = provider.getModelContext().tools?.submit_form;
|
|
48
|
+
if (!submitTool?.execute) throw new Error("submit_form is not registered");
|
|
49
|
+
return submitTool.execute({}, {} as never);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const expectSubmitBlocked = async () => {
|
|
53
|
+
await expect(executeSubmitForm()).resolves.toEqual({
|
|
54
|
+
success: false,
|
|
55
|
+
message: "The form contains invalid fields and was not submitted.",
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
39
59
|
const expectRegisteredFieldsToSubmit = async (Fields: () => ReactNode) => {
|
|
40
60
|
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
|
41
61
|
event.preventDefault();
|
|
@@ -47,10 +67,7 @@ const expectRegisteredFieldsToSubmit = async (Fields: () => ReactNode) => {
|
|
|
47
67
|
</form>,
|
|
48
68
|
);
|
|
49
69
|
|
|
50
|
-
|
|
51
|
-
if (!submitTool?.execute) throw new Error("submit_form is not registered");
|
|
52
|
-
|
|
53
|
-
await expect(submitTool.execute({}, {} as never)).resolves.toEqual({
|
|
70
|
+
await expect(executeSubmitForm()).resolves.toEqual({
|
|
54
71
|
success: true,
|
|
55
72
|
});
|
|
56
73
|
expect(onSubmit).toHaveBeenCalledOnce();
|
|
@@ -100,4 +117,246 @@ describe("useAssistantForm", () => {
|
|
|
100
117
|
);
|
|
101
118
|
});
|
|
102
119
|
});
|
|
120
|
+
|
|
121
|
+
it("reports when native form validation blocks submission", async () => {
|
|
122
|
+
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
render(
|
|
127
|
+
<form onSubmit={onSubmit}>
|
|
128
|
+
<NativeRequiredField />
|
|
129
|
+
</form>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await expectSubmitBlocked();
|
|
133
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("submits when native form validation is disabled", async () => {
|
|
137
|
+
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
render(
|
|
142
|
+
<form noValidate onSubmit={onSubmit}>
|
|
143
|
+
<NativeRequiredField />
|
|
144
|
+
</form>,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await expect(executeSubmitForm()).resolves.toEqual({
|
|
148
|
+
success: true,
|
|
149
|
+
});
|
|
150
|
+
expect(onSubmit).toHaveBeenCalledOnce();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("submits a form with react-hook-form rules and a plain onSubmit", async () => {
|
|
154
|
+
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const Fields = () => {
|
|
159
|
+
const form = useAssistantForm<{ name: string }>();
|
|
160
|
+
return <input {...form.register("name", { required: true })} />;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<form onSubmit={onSubmit}>
|
|
165
|
+
<Fields />
|
|
166
|
+
</form>,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await expect(executeSubmitForm()).resolves.toEqual({ success: true });
|
|
170
|
+
expect(onSubmit).toHaveBeenCalledOnce();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("reports when react-hook-form validation blocks submission", async () => {
|
|
174
|
+
const onValid = vi.fn();
|
|
175
|
+
const onInvalid = vi.fn();
|
|
176
|
+
|
|
177
|
+
const { getByTestId } = render(
|
|
178
|
+
<ReactHookFormRequiredForm onInvalid={onInvalid} onValid={onValid} />,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await expectSubmitBlocked();
|
|
182
|
+
expect(onValid).not.toHaveBeenCalled();
|
|
183
|
+
expect(onInvalid).toHaveBeenCalledOnce();
|
|
184
|
+
await waitFor(() => {
|
|
185
|
+
expect(getByTestId("submit-count").textContent).toBe("1");
|
|
186
|
+
expect(getByTestId("name-error").textContent).toBe("invalid");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
fireEvent.change(getByTestId("name-input"), {
|
|
190
|
+
target: { value: "Ada" },
|
|
191
|
+
});
|
|
192
|
+
await waitFor(() => {
|
|
193
|
+
expect(getByTestId("name-error").textContent).toBe("valid");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("reports validation blocked by a capture-phase handler", async () => {
|
|
198
|
+
const onValid = vi.fn();
|
|
199
|
+
|
|
200
|
+
render(<ReactHookFormRequiredForm capture onValid={onValid} />);
|
|
201
|
+
|
|
202
|
+
await expectSubmitBlocked();
|
|
203
|
+
expect(onValid).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("reports react-hook-form errors when native validation is disabled", async () => {
|
|
207
|
+
const onValid = vi.fn();
|
|
208
|
+
|
|
209
|
+
render(<ReactHookFormRequiredForm noValidate onValid={onValid} />);
|
|
210
|
+
|
|
211
|
+
await expectSubmitBlocked();
|
|
212
|
+
expect(onValid).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("submits forms that pass react-hook-form validation", async () => {
|
|
216
|
+
const onValid = vi.fn();
|
|
217
|
+
|
|
218
|
+
render(<ReactHookFormRequiredForm defaultName="Ada" onValid={onValid} />);
|
|
219
|
+
|
|
220
|
+
await expect(executeSubmitForm()).resolves.toEqual({ success: true });
|
|
221
|
+
await waitFor(() => expect(onValid).toHaveBeenCalledOnce());
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("does not let user submissions settle a pending assistant submission", async () => {
|
|
225
|
+
type FormValues = { name: string };
|
|
226
|
+
let validationCount = 0;
|
|
227
|
+
let resolveAssistantValidation: (
|
|
228
|
+
result: ResolverResult<FormValues>,
|
|
229
|
+
) => void = () => {};
|
|
230
|
+
const resolver: Resolver<FormValues> = (values) => {
|
|
231
|
+
validationCount += 1;
|
|
232
|
+
if (validationCount === 1) {
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
resolveAssistantValidation = resolve;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return Promise.resolve({ values, errors: {} });
|
|
238
|
+
};
|
|
239
|
+
const onValid = vi.fn();
|
|
240
|
+
|
|
241
|
+
const Form = () => {
|
|
242
|
+
const form = useAssistantForm<FormValues>({
|
|
243
|
+
defaultValues: { name: "" },
|
|
244
|
+
resolver,
|
|
245
|
+
});
|
|
246
|
+
return (
|
|
247
|
+
<form
|
|
248
|
+
data-testid="concurrent-form"
|
|
249
|
+
onSubmit={form.handleSubmit(onValid)}
|
|
250
|
+
>
|
|
251
|
+
<input data-testid="concurrent-name" {...form.register("name")} />
|
|
252
|
+
</form>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
const { getByTestId } = render(<Form />);
|
|
256
|
+
|
|
257
|
+
let assistantSubmitSettled = false;
|
|
258
|
+
const assistantSubmit = executeSubmitForm().finally(() => {
|
|
259
|
+
assistantSubmitSettled = true;
|
|
260
|
+
});
|
|
261
|
+
await waitFor(() => expect(validationCount).toBe(1));
|
|
262
|
+
|
|
263
|
+
fireEvent.change(getByTestId("concurrent-name"), {
|
|
264
|
+
target: { value: "Ada" },
|
|
265
|
+
});
|
|
266
|
+
fireEvent.submit(getByTestId("concurrent-form"));
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(validationCount).toBe(2);
|
|
269
|
+
expect(onValid).toHaveBeenCalledOnce();
|
|
270
|
+
});
|
|
271
|
+
expect(assistantSubmitSettled).toBe(false);
|
|
272
|
+
|
|
273
|
+
resolveAssistantValidation({
|
|
274
|
+
values: {},
|
|
275
|
+
errors: { name: { type: "required", message: "Name is required" } },
|
|
276
|
+
});
|
|
277
|
+
await expect(assistantSubmit).resolves.toEqual({
|
|
278
|
+
success: false,
|
|
279
|
+
message: "The form contains invalid fields and was not submitted.",
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("reports when requestSubmit does not dispatch a submit event", async () => {
|
|
284
|
+
const requestSubmit = vi
|
|
285
|
+
.spyOn(HTMLFormElement.prototype, "requestSubmit")
|
|
286
|
+
.mockImplementation(() => {});
|
|
287
|
+
try {
|
|
288
|
+
render(
|
|
289
|
+
<form noValidate>
|
|
290
|
+
<NativeRequiredField />
|
|
291
|
+
</form>,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
await expect(executeSubmitForm()).resolves.toEqual({
|
|
295
|
+
success: false,
|
|
296
|
+
message: "The form did not accept the submission.",
|
|
297
|
+
});
|
|
298
|
+
} finally {
|
|
299
|
+
requestSubmit.mockRestore();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("propagates errors when requestSubmit throws", async () => {
|
|
304
|
+
const error = new Error("requestSubmit unavailable");
|
|
305
|
+
const requestSubmit = vi
|
|
306
|
+
.spyOn(HTMLFormElement.prototype, "requestSubmit")
|
|
307
|
+
.mockImplementation(() => {
|
|
308
|
+
throw error;
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
render(
|
|
312
|
+
<form noValidate>
|
|
313
|
+
<NativeRequiredField />
|
|
314
|
+
</form>,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
await expect(executeSubmitForm()).rejects.toBe(error);
|
|
318
|
+
} finally {
|
|
319
|
+
requestSubmit.mockRestore();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
103
322
|
});
|
|
323
|
+
|
|
324
|
+
const NativeRequiredField = () => {
|
|
325
|
+
const form = useAssistantForm<{ name: string }>();
|
|
326
|
+
return <input required {...form.register("name")} />;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const ReactHookFormRequiredForm = ({
|
|
330
|
+
capture = false,
|
|
331
|
+
defaultName = "",
|
|
332
|
+
onInvalid,
|
|
333
|
+
onValid,
|
|
334
|
+
noValidate,
|
|
335
|
+
}: {
|
|
336
|
+
capture?: boolean | undefined;
|
|
337
|
+
defaultName?: string | undefined;
|
|
338
|
+
onInvalid?: (() => void) | undefined;
|
|
339
|
+
onValid: () => void;
|
|
340
|
+
noValidate?: boolean | undefined;
|
|
341
|
+
}) => {
|
|
342
|
+
const form = useAssistantForm<{ name: string }>({
|
|
343
|
+
defaultValues: { name: defaultName },
|
|
344
|
+
});
|
|
345
|
+
const handleSubmit = form.handleSubmit(onValid, onInvalid);
|
|
346
|
+
return (
|
|
347
|
+
<form
|
|
348
|
+
noValidate={noValidate}
|
|
349
|
+
onSubmit={capture ? undefined : handleSubmit}
|
|
350
|
+
onSubmitCapture={capture ? handleSubmit : undefined}
|
|
351
|
+
>
|
|
352
|
+
<input
|
|
353
|
+
data-testid="name-input"
|
|
354
|
+
{...form.register("name", { required: true })}
|
|
355
|
+
/>
|
|
356
|
+
<output data-testid="submit-count">{form.formState.submitCount}</output>
|
|
357
|
+
<output data-testid="name-error">
|
|
358
|
+
{form.formState.errors.name ? "invalid" : "valid"}
|
|
359
|
+
</output>
|
|
360
|
+
</form>
|
|
361
|
+
);
|
|
362
|
+
};
|
package/src/useAssistantForm.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type ModelContext, tool } from "@assistant-ui/core";
|
|
|
4
4
|
import type {} from "@assistant-ui/core/store";
|
|
5
5
|
import { type ToolCallMessagePartComponent } from "@assistant-ui/core/react";
|
|
6
6
|
import { useAui } from "@assistant-ui/store";
|
|
7
|
-
import { useEffect } from "react";
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
8
8
|
import {
|
|
9
9
|
type Field,
|
|
10
10
|
type FieldValues,
|
|
@@ -18,6 +18,15 @@ import {
|
|
|
18
18
|
import type { z } from "zod";
|
|
19
19
|
import { formTools } from "./formTools";
|
|
20
20
|
|
|
21
|
+
type PendingAssistantSubmit = {
|
|
22
|
+
dispatching: boolean;
|
|
23
|
+
event: unknown;
|
|
24
|
+
handlerInvoked: boolean;
|
|
25
|
+
outcome: boolean | undefined;
|
|
26
|
+
resolve: (outcome: boolean) => void;
|
|
27
|
+
reject: (error: unknown) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
21
30
|
export type UseAssistantFormProps<
|
|
22
31
|
TFieldValues extends FieldValues,
|
|
23
32
|
TContext,
|
|
@@ -78,9 +87,93 @@ export const useAssistantForm = <
|
|
|
78
87
|
getValues,
|
|
79
88
|
setValue,
|
|
80
89
|
reset,
|
|
90
|
+
handleSubmit: baseHandleSubmit,
|
|
81
91
|
formState: { isSubmitting },
|
|
82
92
|
} = form;
|
|
83
93
|
|
|
94
|
+
const pendingAssistantSubmitRef = useRef<PendingAssistantSubmit | null>(null);
|
|
95
|
+
const settleAssistantSubmit = useCallback((outcome: boolean) => {
|
|
96
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
97
|
+
if (!pending) return;
|
|
98
|
+
|
|
99
|
+
pendingAssistantSubmitRef.current = null;
|
|
100
|
+
pending.resolve(outcome);
|
|
101
|
+
}, []);
|
|
102
|
+
const rejectAssistantSubmit = useCallback((error: unknown) => {
|
|
103
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
104
|
+
if (!pending) return;
|
|
105
|
+
|
|
106
|
+
pendingAssistantSubmitRef.current = null;
|
|
107
|
+
pending.reject(error);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const handleSubmit = useCallback<
|
|
111
|
+
UseFormReturn<TFieldValues, TContext, TTransformedValues>["handleSubmit"]
|
|
112
|
+
>(
|
|
113
|
+
(onValid, onInvalid) => {
|
|
114
|
+
const submit = baseHandleSubmit(
|
|
115
|
+
(...args) => {
|
|
116
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
117
|
+
const event = args[1]?.nativeEvent ?? args[1];
|
|
118
|
+
if (pending && pending.event === event) pending.outcome = true;
|
|
119
|
+
return onValid(...args);
|
|
120
|
+
},
|
|
121
|
+
(...args) => {
|
|
122
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
123
|
+
const event = args[1]?.nativeEvent ?? args[1];
|
|
124
|
+
if (pending && pending.event === event) pending.outcome = false;
|
|
125
|
+
return onInvalid?.(...args);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return (async (event) => {
|
|
130
|
+
const pending = pendingAssistantSubmitRef.current;
|
|
131
|
+
const nativeEvent = event?.nativeEvent ?? event;
|
|
132
|
+
const assistantSubmit =
|
|
133
|
+
pending &&
|
|
134
|
+
(pending.event === nativeEvent ||
|
|
135
|
+
(pending.dispatching &&
|
|
136
|
+
pending.event === undefined &&
|
|
137
|
+
nativeEvent !== undefined))
|
|
138
|
+
? pending
|
|
139
|
+
: null;
|
|
140
|
+
if (assistantSubmit) {
|
|
141
|
+
assistantSubmit.event = nativeEvent;
|
|
142
|
+
assistantSubmit.handlerInvoked = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const result = await submit(event);
|
|
147
|
+
if (
|
|
148
|
+
assistantSubmit &&
|
|
149
|
+
pendingAssistantSubmitRef.current === assistantSubmit
|
|
150
|
+
) {
|
|
151
|
+
settleAssistantSubmit(assistantSubmit.outcome ?? true);
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (pendingAssistantSubmitRef.current === assistantSubmit) {
|
|
156
|
+
rejectAssistantSubmit(error);
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}) as typeof submit;
|
|
161
|
+
},
|
|
162
|
+
[baseHandleSubmit, rejectAssistantSubmit, settleAssistantSubmit],
|
|
163
|
+
);
|
|
164
|
+
const assistantForm = useMemo<
|
|
165
|
+
UseFormReturn<TFieldValues, TContext, TTransformedValues>
|
|
166
|
+
>(
|
|
167
|
+
() => ({
|
|
168
|
+
...form,
|
|
169
|
+
handleSubmit,
|
|
170
|
+
get formState() {
|
|
171
|
+
return form.formState;
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
[form, handleSubmit],
|
|
175
|
+
);
|
|
176
|
+
|
|
84
177
|
const aui = useAui();
|
|
85
178
|
useEffect(() => {
|
|
86
179
|
const value: ModelContext = {
|
|
@@ -100,13 +193,14 @@ export const useAssistantForm = <
|
|
|
100
193
|
submit_form: tool({
|
|
101
194
|
...formTools.submit_form,
|
|
102
195
|
execute: async () => {
|
|
103
|
-
if (isSubmitting) {
|
|
196
|
+
if (isSubmitting || pendingAssistantSubmitRef.current) {
|
|
104
197
|
return {
|
|
105
198
|
success: false,
|
|
106
199
|
message: "The form is already submitting.",
|
|
107
200
|
};
|
|
108
201
|
}
|
|
109
202
|
const { _names, _fields } = control;
|
|
203
|
+
let formElement: HTMLFormElement | null = null;
|
|
110
204
|
for (const name of _names.mount) {
|
|
111
205
|
const field: Field | undefined = get(_fields, name);
|
|
112
206
|
if (field?._f) {
|
|
@@ -115,14 +209,78 @@ export const useAssistantForm = <
|
|
|
115
209
|
: field._f.ref;
|
|
116
210
|
|
|
117
211
|
if (fieldReference instanceof HTMLElement) {
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
212
|
+
formElement = fieldReference.closest("form");
|
|
213
|
+
if (formElement) break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
121
217
|
|
|
122
|
-
|
|
123
|
-
|
|
218
|
+
if (formElement) {
|
|
219
|
+
if (!formElement.noValidate && !formElement.reportValidity()) {
|
|
220
|
+
return {
|
|
221
|
+
success: false,
|
|
222
|
+
message:
|
|
223
|
+
"The form contains invalid fields and was not submitted.",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let resolveSubmission: (outcome: boolean) => void = () => {};
|
|
228
|
+
let rejectSubmission: (error: unknown) => void = () => {};
|
|
229
|
+
const submissionResult = new Promise<boolean>(
|
|
230
|
+
(resolve, reject) => {
|
|
231
|
+
resolveSubmission = resolve;
|
|
232
|
+
rejectSubmission = reject;
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
const assistantSubmit: PendingAssistantSubmit = {
|
|
236
|
+
dispatching: true,
|
|
237
|
+
event: undefined,
|
|
238
|
+
handlerInvoked: false,
|
|
239
|
+
outcome: undefined,
|
|
240
|
+
resolve: resolveSubmission,
|
|
241
|
+
reject: rejectSubmission,
|
|
242
|
+
};
|
|
243
|
+
pendingAssistantSubmitRef.current = assistantSubmit;
|
|
244
|
+
const onSubmit = (event: SubmitEvent) => {
|
|
245
|
+
if (
|
|
246
|
+
pendingAssistantSubmitRef.current === assistantSubmit &&
|
|
247
|
+
assistantSubmit.event === undefined
|
|
248
|
+
) {
|
|
249
|
+
assistantSubmit.event = event;
|
|
124
250
|
}
|
|
251
|
+
queueMicrotask(() => {
|
|
252
|
+
if (
|
|
253
|
+
pendingAssistantSubmitRef.current === assistantSubmit &&
|
|
254
|
+
!assistantSubmit.handlerInvoked
|
|
255
|
+
) {
|
|
256
|
+
settleAssistantSubmit(true);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
formElement.addEventListener("submit", onSubmit, { once: true });
|
|
262
|
+
try {
|
|
263
|
+
formElement.requestSubmit();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
settleAssistantSubmit(false);
|
|
266
|
+
throw error;
|
|
267
|
+
} finally {
|
|
268
|
+
assistantSubmit.dispatching = false;
|
|
269
|
+
formElement.removeEventListener("submit", onSubmit);
|
|
125
270
|
}
|
|
271
|
+
|
|
272
|
+
const dispatched = assistantSubmit.event !== undefined;
|
|
273
|
+
if (!dispatched) {
|
|
274
|
+
settleAssistantSubmit(false);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (await submissionResult) return { success: true };
|
|
278
|
+
return {
|
|
279
|
+
success: false,
|
|
280
|
+
message: dispatched
|
|
281
|
+
? "The form contains invalid fields and was not submitted."
|
|
282
|
+
: "The form did not accept the submission.",
|
|
283
|
+
};
|
|
126
284
|
}
|
|
127
285
|
|
|
128
286
|
return {
|
|
@@ -148,7 +306,16 @@ export const useAssistantForm = <
|
|
|
148
306
|
system: `Form State:\n${JSON.stringify(getValues())}`,
|
|
149
307
|
}),
|
|
150
308
|
});
|
|
151
|
-
}, [
|
|
309
|
+
}, [
|
|
310
|
+
control,
|
|
311
|
+
setValue,
|
|
312
|
+
getValues,
|
|
313
|
+
aui,
|
|
314
|
+
reset,
|
|
315
|
+
isSubmitting,
|
|
316
|
+
rejectAssistantSubmit,
|
|
317
|
+
settleAssistantSubmit,
|
|
318
|
+
]);
|
|
152
319
|
|
|
153
320
|
const renderFormFieldTool = props?.assistant?.tools?.set_form_field?.render;
|
|
154
321
|
useEffect(() => {
|
|
@@ -168,5 +335,5 @@ export const useAssistantForm = <
|
|
|
168
335
|
return aui.tools.setToolUI("reset_form", renderResetFormTool);
|
|
169
336
|
}, [aui, renderResetFormTool]);
|
|
170
337
|
|
|
171
|
-
return
|
|
338
|
+
return assistantForm;
|
|
172
339
|
};
|