@contractspec/lib.contracts-runtime-client-react 2.0.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.
@@ -0,0 +1,360 @@
1
+ // @bun
2
+ // src/form-render.impl.tsx
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import {
5
+ Controller,
6
+ useFieldArray,
7
+ useForm
8
+ } from "react-hook-form";
9
+ import { zodResolver } from "@hookform/resolvers/zod";
10
+ import {
11
+ buildZodWithRelations,
12
+ evalPredicate
13
+ } from "@contractspec/lib.contracts-spec/forms";
14
+ import { jsxDEV, Fragment } from "react/jsx-dev-runtime";
15
+ "use client";
16
+ function toOptionsArray(src) {
17
+ if (!src)
18
+ return;
19
+ if (Array.isArray(src))
20
+ return { kind: "static", options: src };
21
+ return src;
22
+ }
23
+ function getAtPath(values, path) {
24
+ if (!path)
25
+ return;
26
+ const segs = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
27
+ let cur = values;
28
+ for (const s of segs) {
29
+ if (cur == null)
30
+ return;
31
+ cur = cur[s];
32
+ }
33
+ return cur;
34
+ }
35
+ function makeDepsKey(values, deps) {
36
+ if (!deps || deps.length === 0)
37
+ return "[]";
38
+ try {
39
+ return JSON.stringify(deps.map((d) => getAtPath(values, d)));
40
+ } catch {
41
+ return "[]";
42
+ }
43
+ }
44
+ function useResolvedOptions(values, source, resolvers) {
45
+ const [opts, setOpts] = useState([]);
46
+ const depKey = useMemo(() => {
47
+ if (!source)
48
+ return "nil";
49
+ if (source.kind === "static")
50
+ return JSON.stringify(source.options ?? []);
51
+ return makeDepsKey(values, source.deps);
52
+ }, [source, values]);
53
+ useEffect(() => {
54
+ let mounted = true;
55
+ const run = async () => {
56
+ if (!source)
57
+ return setOpts([]);
58
+ if (source.kind === "static")
59
+ return setOpts([...source.options ?? []]);
60
+ const fn = resolvers?.[source.resolverKey];
61
+ if (!fn)
62
+ return setOpts([]);
63
+ const res = await fn(values, source.args);
64
+ if (mounted)
65
+ setOpts([...res ?? []]);
66
+ };
67
+ run();
68
+ return () => {
69
+ mounted = false;
70
+ };
71
+ }, [
72
+ depKey,
73
+ source && source.kind === "resolver" ? source.resolverKey : undefined
74
+ ]);
75
+ return opts;
76
+ }
77
+ function fieldPath(parent, name, arrayIndex) {
78
+ if (!name)
79
+ return parent ?? "";
80
+ const child = typeof arrayIndex === "number" ? `${name.replace(/^\$index$/, String(arrayIndex))}` : name;
81
+ return parent ? `${parent}${typeof arrayIndex === "number" ? `.${arrayIndex}` : ""}.${child}`.replace(/\.+/g, ".") : child;
82
+ }
83
+ function createFormRenderer(base) {
84
+ const conf = base;
85
+ const { driver } = conf;
86
+ function InternalForm(props) {
87
+ const { spec, options, merged } = props;
88
+ const baseZod = useMemo(() => buildZodWithRelations(spec), [spec]);
89
+ const form = useForm({
90
+ ...merged.formOptions,
91
+ resolver: zodResolver(baseZod),
92
+ defaultValues: options?.defaultValues
93
+ });
94
+ const values = form.watch();
95
+ const renderOne = (f, parent, arrayIndex) => {
96
+ const DriverField = driver.Field;
97
+ const DriverLabel = driver.FieldLabel;
98
+ const DriverDesc = driver.FieldDescription;
99
+ const DriverError = driver.FieldError;
100
+ const name = fieldPath(parent, f.name, arrayIndex);
101
+ const visible = evalPredicate(values, f.visibleWhen);
102
+ const enabled = evalPredicate(values, f.enabledWhen);
103
+ const invalid = Boolean(form.getFieldState(name)?.invalid);
104
+ if (!visible)
105
+ return null;
106
+ const id = name?.replace(/\./g, "-");
107
+ const commonWrapProps = {
108
+ "data-invalid": invalid,
109
+ hidden: !visible,
110
+ disabled: !enabled
111
+ };
112
+ const labelNode = f.labelI18n ? /* @__PURE__ */ jsxDEV(DriverLabel, {
113
+ htmlFor: id,
114
+ children: f.labelI18n
115
+ }, undefined, false, undefined, this) : null;
116
+ const descNode = f.descriptionI18n ? /* @__PURE__ */ jsxDEV(DriverDesc, {
117
+ children: f.descriptionI18n
118
+ }, undefined, false, undefined, this) : null;
119
+ if (f.kind === "group") {
120
+ const children = f.fields.map((c, i) => /* @__PURE__ */ jsxDEV(React.Fragment, {
121
+ children: renderOne(c, name, arrayIndex)
122
+ }, `${name}-${i}`, false, undefined, this));
123
+ return /* @__PURE__ */ jsxDEV(DriverField, {
124
+ ...commonWrapProps,
125
+ children: [
126
+ labelNode,
127
+ children,
128
+ descNode
129
+ ]
130
+ }, undefined, true, undefined, this);
131
+ }
132
+ if (f.kind === "array") {
133
+ return renderArray(f, parent);
134
+ }
135
+ return /* @__PURE__ */ jsxDEV(Controller, {
136
+ name,
137
+ control: form.control,
138
+ render: ({ field, fieldState }) => {
139
+ const err = fieldState.error ? [fieldState.error] : [];
140
+ const ariaInvalid = fieldState.invalid || undefined;
141
+ if (f.kind === "text") {
142
+ const textField = f;
143
+ const Input = driver.Input;
144
+ return /* @__PURE__ */ jsxDEV(DriverField, {
145
+ ...commonWrapProps,
146
+ children: [
147
+ labelNode,
148
+ /* @__PURE__ */ jsxDEV(Input, {
149
+ id,
150
+ "aria-invalid": ariaInvalid,
151
+ placeholder: f.placeholderI18n,
152
+ autoComplete: textField.autoComplete,
153
+ inputMode: textField.inputMode,
154
+ maxLength: textField.maxLength,
155
+ minLength: textField.minLength,
156
+ disabled: !enabled,
157
+ ...field,
158
+ ...f.uiProps
159
+ }, undefined, false, undefined, this),
160
+ descNode,
161
+ fieldState.invalid ? /* @__PURE__ */ jsxDEV(DriverError, {
162
+ errors: err
163
+ }, undefined, false, undefined, this) : null
164
+ ]
165
+ }, undefined, true, undefined, this);
166
+ }
167
+ if (f.kind === "textarea") {
168
+ const textareaField = f;
169
+ const Textarea = driver.Textarea;
170
+ return /* @__PURE__ */ jsxDEV(DriverField, {
171
+ ...commonWrapProps,
172
+ children: [
173
+ labelNode,
174
+ /* @__PURE__ */ jsxDEV(Textarea, {
175
+ id,
176
+ "aria-invalid": ariaInvalid,
177
+ placeholder: f.placeholderI18n,
178
+ rows: textareaField.rows,
179
+ maxLength: textareaField.maxLength,
180
+ disabled: !enabled,
181
+ ...field,
182
+ ...f.uiProps
183
+ }, undefined, false, undefined, this),
184
+ descNode,
185
+ fieldState.invalid ? /* @__PURE__ */ jsxDEV(DriverError, {
186
+ errors: err
187
+ }, undefined, false, undefined, this) : null
188
+ ]
189
+ }, undefined, true, undefined, this);
190
+ }
191
+ if (f.kind === "select") {
192
+ const selectField = f;
193
+ const Select = driver.Select;
194
+ const src = toOptionsArray(selectField.options);
195
+ const opts = useResolvedOptions(values, src, merged.resolvers);
196
+ return /* @__PURE__ */ jsxDEV(DriverField, {
197
+ ...commonWrapProps,
198
+ children: [
199
+ labelNode,
200
+ /* @__PURE__ */ jsxDEV(Select, {
201
+ id,
202
+ name,
203
+ "aria-invalid": ariaInvalid,
204
+ disabled: !enabled,
205
+ value: field.value,
206
+ onChange: (v) => field.onChange(v),
207
+ options: opts,
208
+ ...f.uiProps
209
+ }, undefined, false, undefined, this),
210
+ descNode,
211
+ fieldState.invalid ? /* @__PURE__ */ jsxDEV(DriverError, {
212
+ errors: err
213
+ }, undefined, false, undefined, this) : null
214
+ ]
215
+ }, undefined, true, undefined, this);
216
+ }
217
+ if (f.kind === "checkbox") {
218
+ const Checkbox = driver.Checkbox;
219
+ return /* @__PURE__ */ jsxDEV(DriverField, {
220
+ ...commonWrapProps,
221
+ children: [
222
+ labelNode,
223
+ /* @__PURE__ */ jsxDEV(Checkbox, {
224
+ id,
225
+ name,
226
+ disabled: !enabled,
227
+ checked: !!field.value,
228
+ onCheckedChange: (v) => field.onChange(v),
229
+ ...f.uiProps
230
+ }, undefined, false, undefined, this),
231
+ descNode,
232
+ fieldState.invalid ? /* @__PURE__ */ jsxDEV(DriverError, {
233
+ errors: err
234
+ }, undefined, false, undefined, this) : null
235
+ ]
236
+ }, undefined, true, undefined, this);
237
+ }
238
+ if (f.kind === "radio") {
239
+ const radioField = f;
240
+ const RadioGroup = driver.RadioGroup;
241
+ const src = toOptionsArray(radioField.options);
242
+ const opts = useResolvedOptions(values, src, merged.resolvers);
243
+ return /* @__PURE__ */ jsxDEV(DriverField, {
244
+ ...commonWrapProps,
245
+ children: [
246
+ labelNode,
247
+ /* @__PURE__ */ jsxDEV(RadioGroup, {
248
+ id,
249
+ name,
250
+ disabled: !enabled,
251
+ value: field.value,
252
+ onValueChange: (v) => field.onChange(v),
253
+ options: opts,
254
+ ...f.uiProps
255
+ }, undefined, false, undefined, this),
256
+ descNode,
257
+ fieldState.invalid ? /* @__PURE__ */ jsxDEV(DriverError, {
258
+ errors: err
259
+ }, undefined, false, undefined, this) : null
260
+ ]
261
+ }, undefined, true, undefined, this);
262
+ }
263
+ if (f.kind === "switch") {
264
+ const Switch = driver.Switch;
265
+ return /* @__PURE__ */ jsxDEV(DriverField, {
266
+ ...commonWrapProps,
267
+ children: [
268
+ labelNode,
269
+ /* @__PURE__ */ jsxDEV(Switch, {
270
+ id,
271
+ name,
272
+ disabled: !enabled,
273
+ checked: !!field.value,
274
+ onCheckedChange: (v) => field.onChange(v),
275
+ ...f.uiProps
276
+ }, undefined, false, undefined, this),
277
+ descNode,
278
+ fieldState.invalid ? /* @__PURE__ */ jsxDEV(DriverError, {
279
+ errors: err
280
+ }, undefined, false, undefined, this) : null
281
+ ]
282
+ }, undefined, true, undefined, this);
283
+ }
284
+ return /* @__PURE__ */ jsxDEV(Fragment, {}, undefined, false, undefined, this);
285
+ }
286
+ }, name, false, undefined, this);
287
+ };
288
+ const renderArray = (f, parent) => {
289
+ const name = fieldPath(parent, f.name);
290
+ const { fields, append, remove } = useFieldArray({
291
+ control: form.control,
292
+ name
293
+ });
294
+ const canAdd = f.max == null || fields.length < f.max;
295
+ const canRemove = (idx) => (f.min == null ? fields.length > 0 : fields.length > f.min) && idx >= 0;
296
+ const Button2 = driver.Button;
297
+ const Label = driver.FieldLabel;
298
+ return /* @__PURE__ */ jsxDEV("div", {
299
+ children: [
300
+ f.labelI18n ? /* @__PURE__ */ jsxDEV(Label, {
301
+ children: f.labelI18n
302
+ }, undefined, false, undefined, this) : null,
303
+ fields.map((row, idx) => /* @__PURE__ */ jsxDEV("div", {
304
+ children: [
305
+ renderOne(f.of, name, idx),
306
+ canRemove(idx) ? /* @__PURE__ */ jsxDEV(Button2, {
307
+ type: "button",
308
+ variant: "ghost",
309
+ size: "sm",
310
+ onClick: () => remove(idx),
311
+ children: "Remove"
312
+ }, undefined, false, undefined, this) : null
313
+ ]
314
+ }, row.id ?? idx, true, undefined, this)),
315
+ canAdd ? /* @__PURE__ */ jsxDEV(Button2, {
316
+ type: "button",
317
+ variant: "outline",
318
+ size: "sm",
319
+ onClick: () => append({}),
320
+ children: "Add"
321
+ }, undefined, false, undefined, this) : null
322
+ ]
323
+ }, name, true, undefined, this);
324
+ };
325
+ const onSubmit = async (data) => {
326
+ const actionKey = spec.actions?.[0]?.key ?? "submit";
327
+ if (merged.onSubmitOverride) {
328
+ return merged.onSubmitOverride(data, actionKey);
329
+ }
330
+ };
331
+ const Button = driver.Button;
332
+ return /* @__PURE__ */ jsxDEV("form", {
333
+ onSubmit: form.handleSubmit(onSubmit),
334
+ children: [
335
+ (spec.fields || []).map((f, i) => /* @__PURE__ */ jsxDEV(React.Fragment, {
336
+ children: renderOne(f)
337
+ }, i, false, undefined, this)),
338
+ spec.actions && spec.actions.length ? /* @__PURE__ */ jsxDEV("div", {
339
+ children: spec.actions.map((a) => /* @__PURE__ */ jsxDEV(Button, {
340
+ type: "submit",
341
+ children: a.labelI18n
342
+ }, a.key, false, undefined, this))
343
+ }, undefined, false, undefined, this) : null
344
+ ]
345
+ }, undefined, true, undefined, this);
346
+ }
347
+ return {
348
+ render: (spec, options) => /* @__PURE__ */ jsxDEV(InternalForm, {
349
+ spec,
350
+ options,
351
+ merged: {
352
+ ...conf,
353
+ ...options?.overrides ?? {}
354
+ }
355
+ }, undefined, false, undefined, this)
356
+ };
357
+ }
358
+ export {
359
+ createFormRenderer
360
+ };