rastack 0.0.25 → 0.0.27
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/CHANGELOG.md +4 -0
- package/components/auto-form/AutoForm.tsx +254 -0
- package/components/auto-form/fields.ts +108 -0
- package/components/auto-form/use-auto-form.ts +89 -0
- package/components/data-table/DataTable.tsx +267 -0
- package/components/data-table/columns.ts +117 -0
- package/components/data-table/use-data-table.ts +141 -0
- package/components/index.ts +77 -0
- package/components/primitives/controls.tsx +291 -0
- package/components/primitives/field.tsx +117 -0
- package/components/primitives/index.ts +11 -0
- package/components/primitives/misc.tsx +173 -0
- package/components/primitives/table.tsx +147 -0
- package/components/style.ts +195 -0
- package/components/types.ts +98 -0
- package/components/util.ts +14 -0
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +3 -3
- package/hooks/form/form.ts +10 -4
- package/hooks/form/interfaces.ts +40 -1
- package/hooks/form/structure.ts +83 -26
- package/hooks/query/delete.ts +3 -1
- package/package.json +1 -1
- package/src/rastack-wasm-build.ts +3 -3
- package/test/components.spec.ts +386 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.0.27](https://github.com/theserverkid/reactapistack/compare/v0.0.26...v0.0.27) (2026-07-10)
|
|
6
|
+
|
|
7
|
+
### [0.0.26](https://github.com/theserverkid/reactapistack/compare/v0.0.25...v0.0.26) (2026-07-10)
|
|
8
|
+
|
|
5
9
|
### [0.0.25](https://github.com/theserverkid/reactapistack/compare/v0.0.24...v0.0.25) (2026-07-10)
|
|
6
10
|
|
|
7
11
|
### [0.0.24](https://github.com/theserverkid/reactapistack/compare/v0.0.23...v0.0.24) (2026-07-09)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { tokens } from "../style";
|
|
3
|
+
import type { AutoFormLike } from "../types";
|
|
4
|
+
import { Field } from "../primitives/field";
|
|
5
|
+
import {
|
|
6
|
+
Input,
|
|
7
|
+
Textarea,
|
|
8
|
+
Select,
|
|
9
|
+
Checkbox,
|
|
10
|
+
Button,
|
|
11
|
+
} from "../primitives/controls";
|
|
12
|
+
import {
|
|
13
|
+
useAutoForm,
|
|
14
|
+
type AutoFormFieldModel,
|
|
15
|
+
type AutoFormModel,
|
|
16
|
+
} from "./use-auto-form";
|
|
17
|
+
import type { AutoFormInputKind, AutoFormOptions } from "./fields";
|
|
18
|
+
|
|
19
|
+
export interface AutoFormProps extends AutoFormOptions {
|
|
20
|
+
/** The form hook result (`useUpdateXForm(...)`) driving the form. */
|
|
21
|
+
form: AutoFormLike;
|
|
22
|
+
submitLabel?: React.ReactNode;
|
|
23
|
+
/** Renders a secondary button that calls this. */
|
|
24
|
+
onCancel?: () => void;
|
|
25
|
+
cancelLabel?: React.ReactNode;
|
|
26
|
+
/** Called after the form's own submit fires (e.g. to close a modal). */
|
|
27
|
+
onSubmit?: () => void;
|
|
28
|
+
/** Hide the built-in actions row (render your own submit control). */
|
|
29
|
+
hideActions?: boolean;
|
|
30
|
+
/** Field-grid column count. Default 1. */
|
|
31
|
+
columns?: number;
|
|
32
|
+
/** Total-control escape hatch: receives the headless model. */
|
|
33
|
+
children?: (model: AutoFormModel) => React.ReactNode;
|
|
34
|
+
style?: React.CSSProperties;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Format a stored value for a native date / datetime-local input. */
|
|
39
|
+
function toInputValue(kind: AutoFormInputKind, value: unknown): string {
|
|
40
|
+
if (value == null) return "";
|
|
41
|
+
const s = String(value);
|
|
42
|
+
if (kind === "date") return s.slice(0, 10);
|
|
43
|
+
if (kind === "datetime-local") return s.slice(0, 16);
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A form that builds itself from a form hook.
|
|
49
|
+
*
|
|
50
|
+
* ```tsx
|
|
51
|
+
* <AutoForm form={useUpdateAirportForm({ formValues: { code: "", name: "" } })} />
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* Each field's control is chosen from the datatype the form hook derives from
|
|
55
|
+
* the write schema — booleans become checkboxes, `date-time` fields date
|
|
56
|
+
* pickers, enums dropdowns, and so on. Override any field (label, control,
|
|
57
|
+
* options for an FK picker, custom render) via the `fields` option.
|
|
58
|
+
*/
|
|
59
|
+
export function AutoForm(props: AutoFormProps): React.JSX.Element {
|
|
60
|
+
const {
|
|
61
|
+
form,
|
|
62
|
+
fields,
|
|
63
|
+
exclude,
|
|
64
|
+
submitLabel = "Save",
|
|
65
|
+
onCancel,
|
|
66
|
+
cancelLabel = "Cancel",
|
|
67
|
+
onSubmit,
|
|
68
|
+
hideActions = false,
|
|
69
|
+
columns = 1,
|
|
70
|
+
children,
|
|
71
|
+
style,
|
|
72
|
+
className,
|
|
73
|
+
} = props;
|
|
74
|
+
|
|
75
|
+
const model = useAutoForm(form, { fields, exclude });
|
|
76
|
+
const [touched, setTouched] = useState<Set<string>>(() => new Set());
|
|
77
|
+
const [submitted, setSubmitted] = useState(false);
|
|
78
|
+
|
|
79
|
+
const markTouched = (name: string) => {
|
|
80
|
+
setTouched((prev) => {
|
|
81
|
+
if (prev.has(name)) return prev;
|
|
82
|
+
const next = new Set(prev);
|
|
83
|
+
next.add(name);
|
|
84
|
+
return next;
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
setSubmitted(true);
|
|
91
|
+
model.submit();
|
|
92
|
+
onSubmit?.();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const renderControl = (f: AutoFormFieldModel): React.ReactNode => {
|
|
96
|
+
const { field, input, id } = f;
|
|
97
|
+
const blur = () => {
|
|
98
|
+
markTouched(f.name);
|
|
99
|
+
field.setTouched(true);
|
|
100
|
+
};
|
|
101
|
+
const invalid = (touched.has(f.name) || submitted) && field.isError;
|
|
102
|
+
|
|
103
|
+
if (f.render) return f.render(field, { name: f.name, id });
|
|
104
|
+
|
|
105
|
+
switch (input) {
|
|
106
|
+
case "checkbox":
|
|
107
|
+
return (
|
|
108
|
+
<Checkbox
|
|
109
|
+
id={id}
|
|
110
|
+
label={f.label}
|
|
111
|
+
checked={Boolean(field.value)}
|
|
112
|
+
onChange={(e) => field.setValue(e.target.checked)}
|
|
113
|
+
onBlur={blur}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
case "select": {
|
|
117
|
+
const options = f.options ?? [];
|
|
118
|
+
return (
|
|
119
|
+
<Select
|
|
120
|
+
id={id}
|
|
121
|
+
invalid={invalid}
|
|
122
|
+
value={field.value == null ? "" : String(field.value)}
|
|
123
|
+
placeholder={field.isRequired ? undefined : (f.placeholder ?? "—")}
|
|
124
|
+
options={options}
|
|
125
|
+
onChange={(e) => {
|
|
126
|
+
const match = options.find(
|
|
127
|
+
(o) => String(o.value) === e.target.value,
|
|
128
|
+
);
|
|
129
|
+
field.setValue(match ? match.value : e.target.value);
|
|
130
|
+
}}
|
|
131
|
+
onBlur={blur}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
case "textarea":
|
|
136
|
+
return (
|
|
137
|
+
<Textarea
|
|
138
|
+
id={id}
|
|
139
|
+
invalid={invalid}
|
|
140
|
+
value={field.value == null ? "" : String(field.value)}
|
|
141
|
+
placeholder={f.placeholder}
|
|
142
|
+
onChange={(e) => field.setValue(e.target.value)}
|
|
143
|
+
onBlur={blur}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
default: {
|
|
147
|
+
const htmlType =
|
|
148
|
+
input === "number"
|
|
149
|
+
? "number"
|
|
150
|
+
: input === "date"
|
|
151
|
+
? "date"
|
|
152
|
+
: input === "datetime-local"
|
|
153
|
+
? "datetime-local"
|
|
154
|
+
: input === "email"
|
|
155
|
+
? "email"
|
|
156
|
+
: input === "url"
|
|
157
|
+
? "url"
|
|
158
|
+
: "text";
|
|
159
|
+
return (
|
|
160
|
+
<Input
|
|
161
|
+
id={id}
|
|
162
|
+
type={htmlType}
|
|
163
|
+
invalid={invalid}
|
|
164
|
+
maxLength={
|
|
165
|
+
input === "text" || input === "email" || input === "url"
|
|
166
|
+
? field.maxLength
|
|
167
|
+
: undefined
|
|
168
|
+
}
|
|
169
|
+
value={toInputValue(input, field.value)}
|
|
170
|
+
placeholder={f.placeholder}
|
|
171
|
+
onChange={(e) => field.setValue(e.target.value)}
|
|
172
|
+
onBlur={blur}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const renderField = (f: AutoFormFieldModel): React.ReactNode => {
|
|
180
|
+
const invalid = (touched.has(f.name) || submitted) && f.field.isError;
|
|
181
|
+
const errorText = invalid ? f.field.error : undefined;
|
|
182
|
+
// Checkboxes carry their own inline label; don't repeat it above.
|
|
183
|
+
const label = f.input === "checkbox" ? undefined : f.label;
|
|
184
|
+
return (
|
|
185
|
+
<Field
|
|
186
|
+
key={f.name}
|
|
187
|
+
label={label}
|
|
188
|
+
htmlFor={f.id}
|
|
189
|
+
required={f.field.isRequired}
|
|
190
|
+
error={errorText}
|
|
191
|
+
hint={f.hint}
|
|
192
|
+
>
|
|
193
|
+
{renderControl(f)}
|
|
194
|
+
</Field>
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<form
|
|
200
|
+
onSubmit={handleSubmit}
|
|
201
|
+
className={className}
|
|
202
|
+
style={{
|
|
203
|
+
display: "flex",
|
|
204
|
+
flexDirection: "column",
|
|
205
|
+
gap: tokens.space.lg,
|
|
206
|
+
...style,
|
|
207
|
+
}}
|
|
208
|
+
noValidate
|
|
209
|
+
>
|
|
210
|
+
{children ? (
|
|
211
|
+
children(model)
|
|
212
|
+
) : (
|
|
213
|
+
<div
|
|
214
|
+
style={{
|
|
215
|
+
display: "grid",
|
|
216
|
+
gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
|
|
217
|
+
gap: tokens.space.lg,
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
{model.fields.map(renderField)}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{!hideActions && !children ? (
|
|
225
|
+
<div
|
|
226
|
+
style={{
|
|
227
|
+
display: "flex",
|
|
228
|
+
gap: tokens.space.sm,
|
|
229
|
+
justifyContent: "flex-end",
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{onCancel ? (
|
|
233
|
+
<Button
|
|
234
|
+
type="button"
|
|
235
|
+
variant="outline"
|
|
236
|
+
onClick={onCancel}
|
|
237
|
+
disabled={model.isSubmitting}
|
|
238
|
+
>
|
|
239
|
+
{cancelLabel}
|
|
240
|
+
</Button>
|
|
241
|
+
) : null}
|
|
242
|
+
<Button
|
|
243
|
+
type="submit"
|
|
244
|
+
variant="primary"
|
|
245
|
+
loading={model.isSubmitting}
|
|
246
|
+
disabled={!model.canSubmit}
|
|
247
|
+
>
|
|
248
|
+
{submitLabel}
|
|
249
|
+
</Button>
|
|
250
|
+
</div>
|
|
251
|
+
) : null}
|
|
252
|
+
</form>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-form field resolution — the pure core.
|
|
3
|
+
*
|
|
4
|
+
* Two decisions live here, both React-free and unit tested:
|
|
5
|
+
* 1. **Which fields, in what order** — from an explicit `fields` list, else the
|
|
6
|
+
* keys of the form's current values — minus any `exclude`d.
|
|
7
|
+
* 2. **Which control each field gets** — mapped from the enriched
|
|
8
|
+
* {@link FormType} the form hook now derives from the write JSON Schema
|
|
9
|
+
* (`boolean → checkbox`, `date → date picker`, `select → dropdown`, …).
|
|
10
|
+
* `<AutoForm>` turns these specs into live fields; you can also call
|
|
11
|
+
* {@link inputKindForFieldType} yourself to drive a custom form.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type React from "react";
|
|
15
|
+
import type { FieldOption, FormType } from "../../hooks/form/interfaces";
|
|
16
|
+
import type { AutoFormField } from "../types";
|
|
17
|
+
import { humanizeKey } from "../util";
|
|
18
|
+
|
|
19
|
+
/** The concrete control an auto-form field renders as. */
|
|
20
|
+
export type AutoFormInputKind =
|
|
21
|
+
| "text"
|
|
22
|
+
| "textarea"
|
|
23
|
+
| "number"
|
|
24
|
+
| "checkbox"
|
|
25
|
+
| "date"
|
|
26
|
+
| "datetime-local"
|
|
27
|
+
| "select"
|
|
28
|
+
| "email"
|
|
29
|
+
| "url";
|
|
30
|
+
|
|
31
|
+
/** Per-field overrides. */
|
|
32
|
+
export interface AutoFormFieldConfig {
|
|
33
|
+
label?: React.ReactNode;
|
|
34
|
+
/** Force the control kind (e.g. render an FK `relation` as a `select`). */
|
|
35
|
+
input?: AutoFormInputKind;
|
|
36
|
+
/** Options for a `select` — e.g. records loaded for a foreign key. */
|
|
37
|
+
options?: FieldOption[];
|
|
38
|
+
hint?: React.ReactNode;
|
|
39
|
+
placeholder?: string;
|
|
40
|
+
/** Fully custom render for this field, given its live structure. */
|
|
41
|
+
render?: (
|
|
42
|
+
field: AutoFormField,
|
|
43
|
+
ctx: { name: string; id: string },
|
|
44
|
+
) => React.ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A field entry with its name, usable in the `fields` option. */
|
|
48
|
+
export interface AutoFormFieldSpec extends AutoFormFieldConfig {
|
|
49
|
+
name: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AutoFormOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Explicit fields — sets the order and, optionally, per-field config. A bare
|
|
55
|
+
* string includes a field with defaults; an object configures it. When
|
|
56
|
+
* omitted, every key of the form's values is used.
|
|
57
|
+
*/
|
|
58
|
+
fields?: Array<string | AutoFormFieldSpec>;
|
|
59
|
+
/** Names to drop (applied whether fields are explicit or inferred). */
|
|
60
|
+
exclude?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Map an enriched {@link FormType} to the control the auto-form renders. */
|
|
64
|
+
export function inputKindForFieldType(type: FormType): AutoFormInputKind {
|
|
65
|
+
switch (type) {
|
|
66
|
+
case "boolean":
|
|
67
|
+
return "checkbox";
|
|
68
|
+
case "number":
|
|
69
|
+
return "number";
|
|
70
|
+
case "date":
|
|
71
|
+
return "date";
|
|
72
|
+
case "datetime":
|
|
73
|
+
return "datetime-local";
|
|
74
|
+
case "select":
|
|
75
|
+
return "select";
|
|
76
|
+
case "email":
|
|
77
|
+
return "email";
|
|
78
|
+
case "url":
|
|
79
|
+
return "url";
|
|
80
|
+
case "array":
|
|
81
|
+
return "textarea";
|
|
82
|
+
case "text":
|
|
83
|
+
default:
|
|
84
|
+
return "text";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decide the ordered, configured field specs for a form. Pure: it reads only the
|
|
90
|
+
* field-name list (the caller passes `Object.keys(formValues)`) and the options.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveFieldSpecs(
|
|
93
|
+
fieldNames: string[],
|
|
94
|
+
options: AutoFormOptions = {},
|
|
95
|
+
): AutoFormFieldSpec[] {
|
|
96
|
+
const exclude = new Set(options.exclude ?? []);
|
|
97
|
+
|
|
98
|
+
let specs: AutoFormFieldSpec[];
|
|
99
|
+
if (options.fields && options.fields.length > 0) {
|
|
100
|
+
specs = options.fields.map((f) =>
|
|
101
|
+
typeof f === "string" ? { name: f } : { ...f },
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
specs = fieldNames.map((name) => ({ name }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return specs.filter((s) => !exclude.has(s.name));
|
|
108
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useAutoForm` — the headless form model.
|
|
3
|
+
*
|
|
4
|
+
* Give it a `useForm()` result and it returns one descriptor per field —
|
|
5
|
+
* resolved label, the control kind picked from the field's datatype, and the
|
|
6
|
+
* live field structure (value, setter, error, options) — plus `submit`/`reset`
|
|
7
|
+
* and submit-state flags. `<AutoForm>` renders these; consume it directly to lay
|
|
8
|
+
* out a bespoke form while keeping the datatype-driven wiring.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type React from "react";
|
|
12
|
+
import type { FieldOption } from "../../hooks/form/interfaces";
|
|
13
|
+
import type { AutoFormField, AutoFormLike } from "../types";
|
|
14
|
+
import { humanizeKey } from "../util";
|
|
15
|
+
import {
|
|
16
|
+
inputKindForFieldType,
|
|
17
|
+
resolveFieldSpecs,
|
|
18
|
+
type AutoFormInputKind,
|
|
19
|
+
type AutoFormOptions,
|
|
20
|
+
} from "./fields";
|
|
21
|
+
|
|
22
|
+
export interface AutoFormFieldModel {
|
|
23
|
+
name: string;
|
|
24
|
+
/** DOM id linking label ↔ control. */
|
|
25
|
+
id: string;
|
|
26
|
+
label: React.ReactNode;
|
|
27
|
+
input: AutoFormInputKind;
|
|
28
|
+
/** The live field: `value`, `setValue`, `error`, `isError`, `isRequired`, `maxLength`, … */
|
|
29
|
+
field: AutoFormField;
|
|
30
|
+
/** Resolved choices for a `select` (config options, else the field's enum). */
|
|
31
|
+
options?: FieldOption[];
|
|
32
|
+
hint?: React.ReactNode;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
render?: (
|
|
35
|
+
field: AutoFormField,
|
|
36
|
+
ctx: { name: string; id: string },
|
|
37
|
+
) => React.ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AutoFormModel {
|
|
41
|
+
fields: AutoFormFieldModel[];
|
|
42
|
+
submit: () => void;
|
|
43
|
+
reset: () => void;
|
|
44
|
+
isSubmitting: boolean;
|
|
45
|
+
/** `true` when the form is valid and not mid-submit. */
|
|
46
|
+
canSubmit: boolean;
|
|
47
|
+
errors: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pure model assembly — see {@link useAutoForm}. Testable with a mock form. */
|
|
51
|
+
export function buildAutoFormModel(
|
|
52
|
+
form: AutoFormLike,
|
|
53
|
+
options: AutoFormOptions = {},
|
|
54
|
+
): AutoFormModel {
|
|
55
|
+
const fieldNames = Object.keys(form.formValues ?? {});
|
|
56
|
+
const specs = resolveFieldSpecs(fieldNames, options);
|
|
57
|
+
|
|
58
|
+
const fields: AutoFormFieldModel[] = specs.map((spec) => {
|
|
59
|
+
const field = form.getFormField(spec.name);
|
|
60
|
+
return {
|
|
61
|
+
name: spec.name,
|
|
62
|
+
id: `af-${spec.name}`,
|
|
63
|
+
label: spec.label ?? humanizeKey(spec.name),
|
|
64
|
+
input: spec.input ?? inputKindForFieldType(field.type),
|
|
65
|
+
field,
|
|
66
|
+
options: spec.options ?? field.options,
|
|
67
|
+
hint: spec.hint,
|
|
68
|
+
placeholder: spec.placeholder,
|
|
69
|
+
render: spec.render,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
fields,
|
|
75
|
+
submit: () => form.onSubmit(),
|
|
76
|
+
reset: () => form.resetForm(),
|
|
77
|
+
isSubmitting: form.isSubmitting,
|
|
78
|
+
canSubmit: !form.isSubmitDisabled && !form.isSubmitting,
|
|
79
|
+
errors: (form.errors as Record<string, unknown>) ?? {},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** React hook wrapper around {@link buildAutoFormModel}. */
|
|
84
|
+
export function useAutoForm(
|
|
85
|
+
form: AutoFormLike,
|
|
86
|
+
options: AutoFormOptions = {},
|
|
87
|
+
): AutoFormModel {
|
|
88
|
+
return buildAutoFormModel(form, options);
|
|
89
|
+
}
|