rastack 0.0.49 → 0.0.51
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 +13 -0
- package/components/auto-form/use-auto-form.ts +28 -0
- package/components/types.ts +8 -0
- package/dist/admin.js +13 -13
- package/dist/compile/analyze.d.ts +20 -0
- package/dist/compile/analyze.js +60 -49
- package/dist/compile/entities.d.ts +19 -0
- package/dist/compile/entities.js +87 -13
- package/dist/compile/index.d.ts +23 -4
- package/dist/compile/index.js +86 -7
- package/dist/compile/model.d.ts +38 -0
- package/dist/compile/openapi.d.ts +9 -0
- package/dist/compile/openapi.js +14 -0
- package/dist/define/index.d.ts +162 -21
- package/dist/define/index.js +28 -22
- package/dist/define/manifest.d.ts +64 -0
- package/dist/define/manifest.js +250 -0
- package/dist/import/tabular.d.ts +8 -2
- package/dist/import/tabular.js +1 -1
- package/dist/plugin/core.d.ts +108 -0
- package/dist/plugin/core.js +198 -0
- package/dist/plugin/index.d.ts +112 -0
- package/dist/plugin/index.js +203 -0
- package/dist/rastack-import.js +4 -1
- package/dist/validate/adapters.js +2 -0
- package/dist/validate/index.d.ts +1 -0
- package/dist/validate/index.js +1 -0
- package/dist/validate/machine.d.ts +23 -2
- package/dist/validate/machine.js +35 -2
- package/dist/validate/transitions.d.ts +86 -0
- package/dist/validate/transitions.js +199 -0
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/hooks/data.ts +221 -0
- package/hooks/entity.ts +228 -0
- package/hooks/form/entity-form.ts +358 -0
- package/hooks/form/form.ts +8 -1
- package/hooks/form/index.ts +7 -1
- package/hooks/index.ts +4 -0
- package/hooks/manifest.ts +77 -0
- package/hooks/registry.ts +56 -0
- package/package.json +1 -1
- package/plugin.ts +8 -0
- package/provider/provider.tsx +26 -5
- package/provider/types.ts +15 -3
- package/src/compile/analyze.ts +74 -45
- package/src/compile/entities.ts +111 -11
- package/src/compile/index.ts +108 -11
- package/src/compile/model.ts +40 -0
- package/src/compile/openapi.ts +13 -1
- package/src/define/index.ts +233 -29
- package/src/define/manifest.ts +278 -0
- package/src/import/tabular.ts +9 -2
- package/src/plugin/core.ts +236 -0
- package/src/plugin/index.ts +243 -0
- package/src/rastack-import.ts +4 -1
- package/src/validate/adapters.ts +1 -0
- package/src/validate/index.ts +1 -0
- package/src/validate/machine.ts +55 -3
- package/src/validate/transitions.ts +232 -0
- package/test/components.spec.ts +22 -0
- package/test/plugin.spec.ts +315 -0
- package/test/runtime-manifest.spec.ts +309 -0
- package/test/transitions.spec.ts +372 -0
- package/test/typed-hooks.spec.ts +412 -0
- package/wasm/rastack_wasm.js +1 -1
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/hooks/entity.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity references — how `useData(...)` / `useForm(...)` know which resource
|
|
3
|
+
* a plain TypeScript type is, with **no generated hooks in between**.
|
|
4
|
+
*
|
|
5
|
+
* A hook accepts any of:
|
|
6
|
+
*
|
|
7
|
+
* - **the entity class itself** — `useData(Airport)`. Classes are values, so
|
|
8
|
+
* the class *is* the reference; its name resolves against the manifest with
|
|
9
|
+
* the same normalisation the compiler applies (`IFlight` → `flight`).
|
|
10
|
+
* - **a `resource()` definition** — `useData(Flight)`. The DSL value carries
|
|
11
|
+
* `app`/`model` (and its literal-typed options, so transition names are
|
|
12
|
+
* typed) at runtime.
|
|
13
|
+
* - **an `"app.model"` (or bare `"model"`) string** — the escape hatch for
|
|
14
|
+
* interface entities, which have no runtime value:
|
|
15
|
+
* `useData<Terminal>("airports.terminal")`.
|
|
16
|
+
* - **any `{ app, model }` object** — e.g. a sync `EntityDescriptor`.
|
|
17
|
+
*
|
|
18
|
+
* Resolution is a pure function over the compiled manifest
|
|
19
|
+
* (`schema.rastack.json`), which `<RAStackProvider manifest={…}>` supplies via
|
|
20
|
+
* context — the type system names the entity, the manifest carries its
|
|
21
|
+
* schema, and the codegen'd per-entity hook layer disappears.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
Manifest,
|
|
26
|
+
ResourceModel,
|
|
27
|
+
TransitionsModel,
|
|
28
|
+
} from "../src/compile/model";
|
|
29
|
+
import type { ResourceType, RowOf } from "../src/define";
|
|
30
|
+
import { isResourceValue, resourceModel } from "../src/define/manifest";
|
|
31
|
+
import type { EntityTypes } from "./registry";
|
|
32
|
+
import { normalizeTransitions } from "../src/validate/transitions";
|
|
33
|
+
|
|
34
|
+
/** A class used as an entity reference (`useData(Airport)`). */
|
|
35
|
+
export type EntityClass<TRow = any> = new (...args: any[]) => TRow;
|
|
36
|
+
|
|
37
|
+
/** A `resource()` definition used as an entity reference (`useData(Flight)`). */
|
|
38
|
+
export type EntityResource = ResourceType<string, string, any, any>;
|
|
39
|
+
|
|
40
|
+
/** Anything that can name an entity. */
|
|
41
|
+
export type EntityRef<TRow = any> =
|
|
42
|
+
| string
|
|
43
|
+
| EntityClass<TRow>
|
|
44
|
+
| { app: string; model: string };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The row type an entity reference implies — every reference form infers:
|
|
48
|
+
* classes carry it (`InstanceType`), `resource()` values derive it from their
|
|
49
|
+
* fields ({@link RowOf}), and `"app.model"` strings resolve through the
|
|
50
|
+
* {@link EntityTypes} registry — populated by a one-line hand-written
|
|
51
|
+
* `EntityTypesOf<…>` declaration, or by the `rastack-env.d.ts` that
|
|
52
|
+
* `rastack compile` emits. No path involves generated runtime code.
|
|
53
|
+
*/
|
|
54
|
+
export type EntityRow<E> =
|
|
55
|
+
E extends EntityClass<infer R>
|
|
56
|
+
? R
|
|
57
|
+
: E extends EntityResource
|
|
58
|
+
? RowOf<E>
|
|
59
|
+
: E extends keyof EntityTypes
|
|
60
|
+
? EntityTypes[E]
|
|
61
|
+
: any;
|
|
62
|
+
|
|
63
|
+
/** A reference resolved against the manifest — everything a hook needs. */
|
|
64
|
+
export interface ResolvedEntity {
|
|
65
|
+
app: string;
|
|
66
|
+
model: string;
|
|
67
|
+
/** `"app.model"` — cache keys, sync store keys. */
|
|
68
|
+
key: string;
|
|
69
|
+
/** `/api/{app}/v1/{model}/` — the URL convention is unchanged. */
|
|
70
|
+
listUrl: string;
|
|
71
|
+
detailUrl: (id: string | number) => string;
|
|
72
|
+
/** The manifest resource (present whenever a manifest was available). */
|
|
73
|
+
resource?: ResourceModel;
|
|
74
|
+
/** The resource's state machine, when it declares one. */
|
|
75
|
+
transitions?: TransitionsModel;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The manifest model name a TypeScript type name maps to — the *same*
|
|
80
|
+
* normalisation the compiler applies (`entityNaming` in `compile/entities.ts`):
|
|
81
|
+
* a leading `I` prefix is stripped (`IFlight` → `Flight`) and the first letter
|
|
82
|
+
* lower-cased.
|
|
83
|
+
*/
|
|
84
|
+
export function entityModelName(typeName: string): string {
|
|
85
|
+
const stripped = /^I[A-Z]/.test(typeName) ? typeName.slice(1) : typeName;
|
|
86
|
+
return stripped.charAt(0).toLowerCase() + stripped.slice(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolved(
|
|
90
|
+
app: string,
|
|
91
|
+
model: string,
|
|
92
|
+
resource?: ResourceModel,
|
|
93
|
+
fallbackTransitions?: unknown,
|
|
94
|
+
): ResolvedEntity {
|
|
95
|
+
const listUrl = `/api/${app}/v1/${model}/`;
|
|
96
|
+
return {
|
|
97
|
+
app,
|
|
98
|
+
model,
|
|
99
|
+
key: `${app}.${model}`,
|
|
100
|
+
listUrl,
|
|
101
|
+
detailUrl: (id) => `${listUrl}${id}/`,
|
|
102
|
+
resource,
|
|
103
|
+
transitions:
|
|
104
|
+
resource?.transitions ?? normalizeTransitions(fallbackTransitions),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findByModel(manifest: Manifest, model: string): ResourceModel[] {
|
|
109
|
+
return manifest.resources.filter((r) => r.model === model);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve an entity reference against the manifest. Throws a descriptive
|
|
114
|
+
* error on an unknown or ambiguous reference — resolution failures are
|
|
115
|
+
* authoring mistakes and should fail loudly, not degrade into 404s.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveEntityRef(
|
|
118
|
+
ref: EntityRef,
|
|
119
|
+
manifest?: Manifest,
|
|
120
|
+
): ResolvedEntity {
|
|
121
|
+
// `{ app, model }` — a `resource()` value, an EntityDescriptor, or a plain
|
|
122
|
+
// object. The manifest enriches it (fields, transitions) when available;
|
|
123
|
+
// a `resource()` value that a manifest doesn't cover is **its own
|
|
124
|
+
// manifest** — the definition carries its fields/options at runtime, so no
|
|
125
|
+
// compile step (and no provider config) is needed for the DSL path.
|
|
126
|
+
if (typeof ref === "object" && ref !== null) {
|
|
127
|
+
const { app, model } = ref as { app?: unknown; model?: unknown };
|
|
128
|
+
if (typeof app !== "string" || typeof model !== "string") {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"useData/useForm: an object entity reference needs string `app` and `model` properties",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const resource =
|
|
134
|
+
manifest?.resources.find((r) => r.app === app && r.model === model) ??
|
|
135
|
+
(isResourceValue(ref) ? resourceModel(ref) : undefined);
|
|
136
|
+
const opts = (ref as { options?: { transitions?: unknown } }).options;
|
|
137
|
+
return resolved(app, model, resource, opts?.transitions);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A class — the flagship form. Its name resolves like the compiler resolves
|
|
141
|
+
// it, and a `static transitions` block rides along as a manifest fallback.
|
|
142
|
+
if (typeof ref === "function") {
|
|
143
|
+
const name = ref.name;
|
|
144
|
+
if (!name) {
|
|
145
|
+
throw new Error("useData/useForm: anonymous classes cannot name an entity");
|
|
146
|
+
}
|
|
147
|
+
if (!manifest) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`useData/useForm: resolving the ${name} class needs the compiled manifest — pass \`manifest\` to <RAStackProvider> (or in the hook's options)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const model = entityModelName(name);
|
|
153
|
+
const matches = findByModel(manifest, model);
|
|
154
|
+
if (matches.length === 1) {
|
|
155
|
+
return resolved(
|
|
156
|
+
matches[0].app,
|
|
157
|
+
matches[0].model,
|
|
158
|
+
matches[0],
|
|
159
|
+
(ref as { transitions?: unknown }).transitions,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (matches.length === 0) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`useData/useForm: no resource named "${model}" in the manifest — is ${name} part of the compiled resource graph (\`rastack compile\`)?`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
throw new Error(
|
|
168
|
+
`useData/useForm: "${model}" exists in ${matches.length} apps (${matches
|
|
169
|
+
.map((m) => `${m.app}.${m.model}`)
|
|
170
|
+
.join(", ")}) — pass the explicit "app.model" string instead`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A string — `"app.model"`, or a bare `"model"` resolved via the manifest.
|
|
175
|
+
if (typeof ref === "string" && ref) {
|
|
176
|
+
const dot = ref.indexOf(".");
|
|
177
|
+
if (dot > 0) {
|
|
178
|
+
const app = ref.slice(0, dot);
|
|
179
|
+
const model = ref.slice(dot + 1);
|
|
180
|
+
const resource = manifest?.resources.find(
|
|
181
|
+
(r) => r.app === app && r.model === model,
|
|
182
|
+
);
|
|
183
|
+
if (manifest && !resource) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`useData/useForm: no resource "${ref}" in the manifest`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return resolved(app, model, resource);
|
|
189
|
+
}
|
|
190
|
+
if (!manifest) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`useData/useForm: resolving "${ref}" needs the compiled manifest — pass \`manifest\` to <RAStackProvider>, or use the full "app.model" form`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const matches = findByModel(manifest, ref);
|
|
196
|
+
if (matches.length === 1) {
|
|
197
|
+
return resolved(matches[0].app, matches[0].model, matches[0]);
|
|
198
|
+
}
|
|
199
|
+
throw new Error(
|
|
200
|
+
matches.length === 0
|
|
201
|
+
? `useData/useForm: no resource named "${ref}" in the manifest`
|
|
202
|
+
: `useData/useForm: "${ref}" exists in ${matches.length} apps — use the "app.model" form`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
throw new Error("useData/useForm: unsupported entity reference");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Whether a value is an entity reference (vs. legacy `useForm(props)`). */
|
|
210
|
+
export function isEntityRef(value: unknown): value is EntityRef {
|
|
211
|
+
if (typeof value === "string" || typeof value === "function") return true;
|
|
212
|
+
return (
|
|
213
|
+
!!value &&
|
|
214
|
+
typeof value === "object" &&
|
|
215
|
+
typeof (value as { app?: unknown }).app === "string" &&
|
|
216
|
+
typeof (value as { model?: unknown }).model === "string"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `snake_or_camel` → `Snake or camel` — column/action labels. */
|
|
221
|
+
export function humanizeName(key: string): string {
|
|
222
|
+
const spaced = key
|
|
223
|
+
.replace(/_/g, " ")
|
|
224
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
225
|
+
.toLowerCase()
|
|
226
|
+
.trim();
|
|
227
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
228
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useForm` — pass your type in, get the whole form out.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* const form = useForm(Flight, { id });
|
|
6
|
+
*
|
|
7
|
+
* <AutoForm form={form} />
|
|
8
|
+
* form.transitions // [{ name: "board", to: "boarding", … }] — from the current state
|
|
9
|
+
* form.transition("board") // fire a declared edge: state change + `set` patches, then save
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The generic replacement for the generated `useUpdate{Model}Form` hooks:
|
|
13
|
+
* the entity reference resolves against the compiled manifest, the write
|
|
14
|
+
* JSON Schema is derived from the *same* `writeSchema()` the OpenAPI emitter
|
|
15
|
+
* uses (so the form validates exactly the contract the API enforces —
|
|
16
|
+
* datatypes, `maxLength`, `enum`, FK relations, the constraint state
|
|
17
|
+
* machine), the create/put/patch mutations are built from the URL convention,
|
|
18
|
+
* and — when the resource declares a `transitions` block — the form exposes
|
|
19
|
+
* the state machine as typed actions.
|
|
20
|
+
*
|
|
21
|
+
* `form.transition(name)` is the client half of the declarative backend
|
|
22
|
+
* function: it validates the edge against the record's current state, stages
|
|
23
|
+
* the state change plus the edge's `set` patches on top of the form values,
|
|
24
|
+
* and submits. The server (`rastack-api-core` — HTTP, Lambda, and in-browser
|
|
25
|
+
* WASM alike) re-derives and re-enforces the same edge from the same
|
|
26
|
+
* manifest, so an illegal jump is rejected even if a client bypasses the
|
|
27
|
+
* form. Transition *names* are typed: `useForm(Flight).transition(…)` only
|
|
28
|
+
* accepts the names `Flight` declares.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { useMemo } from "react";
|
|
32
|
+
import { useSchemaForm } from "./form";
|
|
33
|
+
import type {
|
|
34
|
+
FormProps,
|
|
35
|
+
FormStructure,
|
|
36
|
+
FormUpdate,
|
|
37
|
+
FormValuesType,
|
|
38
|
+
} from "./interfaces";
|
|
39
|
+
import { useGetQuery } from "../query/fetch";
|
|
40
|
+
import { useUpdateQuery } from "../query/update";
|
|
41
|
+
import type { IGetQueryResult } from "../query/interfaces";
|
|
42
|
+
import {
|
|
43
|
+
EntityClass,
|
|
44
|
+
EntityRef,
|
|
45
|
+
EntityRow,
|
|
46
|
+
humanizeName,
|
|
47
|
+
isEntityRef,
|
|
48
|
+
ResolvedEntity,
|
|
49
|
+
resolveEntityRef,
|
|
50
|
+
} from "../entity";
|
|
51
|
+
import { useRastackManifest } from "../manifest";
|
|
52
|
+
import type { EntityTypes, RegisteredEntityKey } from "../registry";
|
|
53
|
+
import { writeSchema } from "../../src/compile/openapi";
|
|
54
|
+
import type { FieldModel, ResourceModel } from "../../src/compile/model";
|
|
55
|
+
import { toManifest, type ManifestInput } from "../../src/define/manifest";
|
|
56
|
+
import {
|
|
57
|
+
allowedTransitions,
|
|
58
|
+
applyTransition,
|
|
59
|
+
currentState,
|
|
60
|
+
} from "../../src/validate/transitions";
|
|
61
|
+
import type { TransitionNamesOf } from "../../src/define";
|
|
62
|
+
|
|
63
|
+
export interface UseEntityFormOptions<TRow> {
|
|
64
|
+
/** Editing an existing record: its id. Absent = a create form. */
|
|
65
|
+
id?: string | number;
|
|
66
|
+
/** Initial/override values, merged over defaults and the fetched record. */
|
|
67
|
+
formValues?: Partial<TRow>;
|
|
68
|
+
/** `"put"` to save with PUT; default is POST on create / PATCH on update. */
|
|
69
|
+
updateType?: FormUpdate;
|
|
70
|
+
onSuccess?: (row: TRow) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Manifest override — tests, or callers outside `<RAStackProvider>`.
|
|
73
|
+
* Accepts a compiled manifest or `resource()` definitions (array/module).
|
|
74
|
+
*/
|
|
75
|
+
manifest?: ManifestInput;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One transition available from the record's current state. */
|
|
79
|
+
export interface FormTransition {
|
|
80
|
+
name: string;
|
|
81
|
+
/** Humanised name — a ready-made button label. */
|
|
82
|
+
label: string;
|
|
83
|
+
/** The state the transition enters. */
|
|
84
|
+
to: string;
|
|
85
|
+
/** The edge's `set` patches (applied alongside the state change). */
|
|
86
|
+
set?: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface EntityFormResult<TRow, TName extends string = string>
|
|
90
|
+
extends FormStructure<Partial<TRow>, Record<string, unknown>> {
|
|
91
|
+
/** The resolved entity — urls, manifest resource, state machine. */
|
|
92
|
+
entity: ResolvedEntity;
|
|
93
|
+
/** The persisted record being edited (id mode), once fetched. */
|
|
94
|
+
record: TRow | undefined;
|
|
95
|
+
isRecordLoading: boolean;
|
|
96
|
+
/** The record's current machine state (resources with `transitions`). */
|
|
97
|
+
state: string | undefined;
|
|
98
|
+
/** The transitions available from the current state — render as actions. */
|
|
99
|
+
transitions: FormTransition[];
|
|
100
|
+
/** Whether a named transition may fire from the current state. */
|
|
101
|
+
canTransition: (name: TName) => boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Fire a declared transition: guard the edge, stage `{ [field]: to, ...set }`
|
|
104
|
+
* over the current form values, and save. Throws on an edge the state
|
|
105
|
+
* machine does not allow — the same rule the engine enforces on write.
|
|
106
|
+
*/
|
|
107
|
+
transition: (name: TName) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Form-friendly initial values straight from the manifest resource. */
|
|
111
|
+
export function initialValuesFromResource(
|
|
112
|
+
resource: ResourceModel,
|
|
113
|
+
): Record<string, unknown> {
|
|
114
|
+
const values: Record<string, unknown> = {};
|
|
115
|
+
for (const field of resource.fields) {
|
|
116
|
+
if (field.primaryKey) continue;
|
|
117
|
+
values[field.name] = initialFieldValue(field);
|
|
118
|
+
}
|
|
119
|
+
return values;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function initialFieldValue(field: FieldModel): unknown {
|
|
123
|
+
if (field.default !== undefined) return field.default;
|
|
124
|
+
if (field.type === "bool") return false;
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The writable (non-PK) slice of a fetched record, for form seeding. */
|
|
129
|
+
function writableValues(
|
|
130
|
+
resource: ResourceModel,
|
|
131
|
+
record: Record<string, unknown> | undefined,
|
|
132
|
+
): Record<string, unknown> {
|
|
133
|
+
if (!record) return {};
|
|
134
|
+
const values: Record<string, unknown> = {};
|
|
135
|
+
for (const field of resource.fields) {
|
|
136
|
+
if (field.primaryKey) continue;
|
|
137
|
+
if (record[field.name] !== undefined) values[field.name] = record[field.name];
|
|
138
|
+
}
|
|
139
|
+
return values;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function useEntityForm<TRow = any, TName extends string = string>(
|
|
143
|
+
entity: EntityRef<TRow>,
|
|
144
|
+
options: UseEntityFormOptions<TRow> = {},
|
|
145
|
+
): EntityFormResult<TRow, TName> {
|
|
146
|
+
const contextManifest = useRastackManifest();
|
|
147
|
+
const optionManifest = useMemo(
|
|
148
|
+
() => toManifest(options.manifest),
|
|
149
|
+
[options.manifest],
|
|
150
|
+
);
|
|
151
|
+
const manifest = optionManifest ?? contextManifest;
|
|
152
|
+
const resolvedEntity = useMemo(
|
|
153
|
+
() => resolveEntityRef(entity, manifest),
|
|
154
|
+
[entity, manifest],
|
|
155
|
+
);
|
|
156
|
+
const resource = resolvedEntity.resource;
|
|
157
|
+
if (!resource) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`useForm: the ${resolvedEntity.key} resource is not in the manifest — pass \`manifest\` to <RAStackProvider> (or in the hook's options)`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const id = options.id !== undefined && options.id !== null ? String(options.id) : undefined;
|
|
164
|
+
const listQueryCacheKey = `${resolvedEntity.key}-list`;
|
|
165
|
+
const itemQueryCacheKey = `${resolvedEntity.key}-detail`;
|
|
166
|
+
|
|
167
|
+
// The persisted record (id mode) — seeds the form and anchors the state
|
|
168
|
+
// machine's "from" state.
|
|
169
|
+
const recordQuery: IGetQueryResult<any> = useGetQuery<
|
|
170
|
+
Record<string, unknown>,
|
|
171
|
+
any
|
|
172
|
+
>({
|
|
173
|
+
getUrl: resolvedEntity.detailUrl(id ?? ""),
|
|
174
|
+
queryKey: itemQueryCacheKey,
|
|
175
|
+
pathParameters: [],
|
|
176
|
+
requiredParams: [],
|
|
177
|
+
isAuthenticated: id !== undefined,
|
|
178
|
+
options: { params: {} },
|
|
179
|
+
});
|
|
180
|
+
const record = recordQuery.data as Record<string, unknown> | undefined;
|
|
181
|
+
|
|
182
|
+
// The same write schema the OpenAPI emitter publishes — derived from the
|
|
183
|
+
// manifest at runtime instead of imported from generated code.
|
|
184
|
+
const createSchema = useMemo(() => writeSchema(resource, false), [resource]);
|
|
185
|
+
const patchSchema = useMemo(() => writeSchema(resource, true), [resource]);
|
|
186
|
+
|
|
187
|
+
const createMutation = useUpdateQuery<any, any, any>({
|
|
188
|
+
postUrl: resolvedEntity.listUrl,
|
|
189
|
+
listQueryCacheKey,
|
|
190
|
+
});
|
|
191
|
+
const putMutation = useUpdateQuery<any, any, any>({
|
|
192
|
+
putUrl: (params: any) => resolvedEntity.detailUrl(params.id),
|
|
193
|
+
listQueryCacheKey,
|
|
194
|
+
itemQueryCacheKey,
|
|
195
|
+
});
|
|
196
|
+
const patchMutation = useUpdateQuery<any, any, any>({
|
|
197
|
+
patchUrl: (params: any) => resolvedEntity.detailUrl(params.id),
|
|
198
|
+
listQueryCacheKey,
|
|
199
|
+
itemQueryCacheKey,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const formValues = useMemo(
|
|
203
|
+
() => ({
|
|
204
|
+
...initialValuesFromResource(resource),
|
|
205
|
+
...writableValues(resource, record),
|
|
206
|
+
...options.formValues,
|
|
207
|
+
}),
|
|
208
|
+
[resource, record, options.formValues],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const form = useSchemaForm({
|
|
212
|
+
id,
|
|
213
|
+
formValues: formValues as any,
|
|
214
|
+
updateType: options.updateType as any,
|
|
215
|
+
mutations: {
|
|
216
|
+
post: { mutation: createMutation, validation: createSchema },
|
|
217
|
+
put: { mutation: putMutation, validation: createSchema },
|
|
218
|
+
patch: { mutation: patchMutation, validation: patchSchema },
|
|
219
|
+
},
|
|
220
|
+
onSuccess: options.onSuccess as any,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// -- the state machine, as form actions ------------------------------------
|
|
224
|
+
const spec = resolvedEntity.transitions;
|
|
225
|
+
const state = spec
|
|
226
|
+
? currentState(spec, record ?? (formValues as Record<string, unknown>))
|
|
227
|
+
: undefined;
|
|
228
|
+
const transitions: FormTransition[] = useMemo(
|
|
229
|
+
() =>
|
|
230
|
+
spec
|
|
231
|
+
? allowedTransitions(spec, state).map((t) => ({
|
|
232
|
+
name: t.name,
|
|
233
|
+
label: humanizeName(t.name),
|
|
234
|
+
to: t.to,
|
|
235
|
+
set: t.set,
|
|
236
|
+
}))
|
|
237
|
+
: [],
|
|
238
|
+
[spec, state],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const canTransition = (name: TName): boolean =>
|
|
242
|
+
transitions.some((t) => t.name === name);
|
|
243
|
+
|
|
244
|
+
const transition = (name: TName): void => {
|
|
245
|
+
if (!spec) {
|
|
246
|
+
throw new Error(`useForm: ${resolvedEntity.key} declares no transitions`);
|
|
247
|
+
}
|
|
248
|
+
if (id === undefined) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`useForm: transitions fire on a persisted record — pass \`id\``,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const applied = applyTransition(spec, name, record ?? formValues);
|
|
254
|
+
if (!applied.ok) {
|
|
255
|
+
throw new Error(`useForm: ${applied.message}`);
|
|
256
|
+
}
|
|
257
|
+
// The transition *is* the save: current form values + the edge's patch,
|
|
258
|
+
// written through the normal update mutation. The engine re-checks the
|
|
259
|
+
// edge and re-applies `set` server-side — the client stages it so the
|
|
260
|
+
// optimistic UI and the persisted row agree.
|
|
261
|
+
const payload = {
|
|
262
|
+
...(form.responseValues as Record<string, unknown>),
|
|
263
|
+
...applied.patch,
|
|
264
|
+
};
|
|
265
|
+
const mutation = options.updateType === "put" ? putMutation : patchMutation;
|
|
266
|
+
mutation.mutate(
|
|
267
|
+
{ params: { id }, payload },
|
|
268
|
+
options.onSuccess
|
|
269
|
+
? { onSuccess: (row: any) => options.onSuccess!(row) }
|
|
270
|
+
: undefined,
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
...(form as FormStructure<Partial<TRow>, Record<string, unknown>>),
|
|
276
|
+
entity: resolvedEntity,
|
|
277
|
+
record: record as TRow | undefined,
|
|
278
|
+
isRecordLoading: id !== undefined && recordQuery.isLoading === true,
|
|
279
|
+
state,
|
|
280
|
+
transitions,
|
|
281
|
+
canTransition,
|
|
282
|
+
transition,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// The public `useForm` — entity-first, legacy-props compatible
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Pass your type to `useForm` (class, `resource()` value, or `"app.model"`),
|
|
292
|
+
* or the legacy explicit-props object (what generated form hooks call) — the
|
|
293
|
+
* argument shape picks the implementation. A call site's shape never changes
|
|
294
|
+
* between renders, so the dispatch is hook-order safe.
|
|
295
|
+
*/
|
|
296
|
+
// A registered "app.model" string infers its rows through the EntityTypes
|
|
297
|
+
// registry (`rastack compile` emits rastack-env.d.ts) — no type argument.
|
|
298
|
+
export function useForm<K extends RegisteredEntityKey>(
|
|
299
|
+
entity: K,
|
|
300
|
+
options?: UseEntityFormOptions<EntityTypes[K]>,
|
|
301
|
+
): EntityFormResult<EntityTypes[K], string>;
|
|
302
|
+
export function useForm<E extends EntityClass<any> | { app: string; model: string }>(
|
|
303
|
+
entity: E,
|
|
304
|
+
options?: UseEntityFormOptions<EntityRow<E>>,
|
|
305
|
+
): EntityFormResult<EntityRow<E>, TransitionNamesOf<E>>;
|
|
306
|
+
export function useForm<TRow = any>(
|
|
307
|
+
entity: string,
|
|
308
|
+
options?: UseEntityFormOptions<TRow>,
|
|
309
|
+
): EntityFormResult<TRow, string>;
|
|
310
|
+
// The legacy explicit-props signature — what the generated per-entity form
|
|
311
|
+
// hooks call (with explicit type arguments), kept verbatim for compatibility.
|
|
312
|
+
export function useForm<
|
|
313
|
+
TFormValues extends object,
|
|
314
|
+
TPostParams,
|
|
315
|
+
TPutParams,
|
|
316
|
+
TPatchParams,
|
|
317
|
+
TPostPayload,
|
|
318
|
+
TPutPayload,
|
|
319
|
+
TPatchPayload,
|
|
320
|
+
TPostResult,
|
|
321
|
+
TPutResult,
|
|
322
|
+
TPatchResult,
|
|
323
|
+
TFormUpdate extends FormUpdate,
|
|
324
|
+
TSourceId extends string | undefined,
|
|
325
|
+
>(
|
|
326
|
+
props: FormProps<
|
|
327
|
+
TFormValues,
|
|
328
|
+
TPostParams,
|
|
329
|
+
TPutParams,
|
|
330
|
+
TPatchParams,
|
|
331
|
+
TPostPayload,
|
|
332
|
+
TPutPayload,
|
|
333
|
+
TPatchPayload,
|
|
334
|
+
TPostResult,
|
|
335
|
+
TPutResult,
|
|
336
|
+
TPatchResult,
|
|
337
|
+
TFormUpdate,
|
|
338
|
+
TSourceId
|
|
339
|
+
>,
|
|
340
|
+
): FormStructure<
|
|
341
|
+
TFormValues,
|
|
342
|
+
FormValuesType<
|
|
343
|
+
TFormUpdate,
|
|
344
|
+
TSourceId,
|
|
345
|
+
TPutParams,
|
|
346
|
+
TPutPayload,
|
|
347
|
+
TPostParams,
|
|
348
|
+
TPostPayload,
|
|
349
|
+
TPatchParams,
|
|
350
|
+
TPatchPayload
|
|
351
|
+
>
|
|
352
|
+
>;
|
|
353
|
+
export function useForm(entityOrProps: any, options?: any): any {
|
|
354
|
+
if (isEntityRef(entityOrProps)) {
|
|
355
|
+
return useEntityForm(entityOrProps, options);
|
|
356
|
+
}
|
|
357
|
+
return useSchemaForm(entityOrProps);
|
|
358
|
+
}
|
package/hooks/form/form.ts
CHANGED
|
@@ -11,7 +11,14 @@ import { formikJSONSchemaValidation } from "./validate-schema";
|
|
|
11
11
|
import { getFieldMachine, getFieldStructure } from "./structure";
|
|
12
12
|
import { describeMachine, runFieldMachine } from "../../src/validate";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The schema-driven form core: Formik + AJV over an explicit `mutations`
|
|
16
|
+
* bundle (one mutation + write JSON Schema per HTTP method). The generic
|
|
17
|
+
* `useForm(Entity)` (see `./entity-form.ts`) derives that bundle from the
|
|
18
|
+
* manifest and delegates here, and the generated per-entity form hooks call
|
|
19
|
+
* it with compile-time `Deref*` schemas — one form brain either way.
|
|
20
|
+
*/
|
|
21
|
+
export function useSchemaForm<
|
|
15
22
|
TFormValues extends object,
|
|
16
23
|
TPostParams,
|
|
17
24
|
TPutParams,
|
package/hooks/form/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { useSchemaForm } from "./form";
|
|
2
|
+
export { useForm, useEntityForm, initialValuesFromResource } from "./entity-form";
|
|
3
|
+
export type {
|
|
4
|
+
UseEntityFormOptions,
|
|
5
|
+
EntityFormResult,
|
|
6
|
+
FormTransition,
|
|
7
|
+
} from "./entity-form";
|
|
2
8
|
export type {
|
|
3
9
|
FormStructure,
|
|
4
10
|
FormUpdateMethod,
|