@mapomodule/form 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +34 -0
- package/dist/module.d.cts +2 -0
- package/dist/module.d.mts +2 -0
- package/dist/module.d.ts +2 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +18 -0
- package/dist/types.d.mts +13 -0
- package/package.json +78 -0
- package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
- package/src/__tests__/registry.test.ts +105 -0
- package/src/__tests__/useFormFromSchema.test.ts +415 -0
- package/src/__tests__/useMapoForm.test.ts +319 -0
- package/src/index.ts +49 -0
- package/src/module.ts +213 -0
- package/src/runtime/components/MapoFocusPortal.vue +154 -0
- package/src/runtime/components/MapoForm.vue +247 -0
- package/src/runtime/components/MapoFormField.vue +350 -0
- package/src/runtime/components/MapoFormFlatSection.vue +163 -0
- package/src/runtime/components/MapoFormGroup.vue +283 -0
- package/src/runtime/components/MapoFormTabs.vue +116 -0
- package/src/runtime/components/MapoUnknownField.vue +74 -0
- package/src/runtime/components/fields/MapoDateField.vue +98 -0
- package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
- package/src/runtime/components/fields/MapoFileField.vue +106 -0
- package/src/runtime/components/fields/MapoFksField.vue +151 -0
- package/src/runtime/components/fields/MapoMapField.vue +117 -0
- package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
- package/src/runtime/components/fields/MapoRepeater.vue +553 -0
- package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
- package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
- package/src/runtime/components/fields/MapoTimeField.vue +59 -0
- package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
- package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
- package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
- package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
- package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
- package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
- package/src/runtime/composables/defineFormField.ts +62 -0
- package/src/runtime/composables/useCurrentLang.ts +16 -0
- package/src/runtime/composables/useFocusMode.ts +86 -0
- package/src/runtime/composables/useFormDraft.ts +119 -0
- package/src/runtime/composables/useFormFromSchema.ts +253 -0
- package/src/runtime/composables/useMapoForm.ts +484 -0
- package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
- package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
- package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
- package/src/runtime/plugins/form-registry.ts +43 -0
- package/src/runtime/registry/defaults.ts +97 -0
- package/src/runtime/types/descriptor.ts +411 -0
- package/src/runtime/types/index.ts +40 -0
- package/src/runtime/types/layout.ts +22 -0
- package/src/runtime/types/registry.ts +98 -0
- package/src/runtime/utils/flattenFieldGroups.ts +86 -0
- package/src/vue-shim.d.ts +12 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { resolveSchema, extractDefs, matchesSchema } from "@mapomodule/utils";
|
|
2
|
+
import type { JSONSchema } from "@mapomodule/utils";
|
|
3
|
+
import type { AnyFieldDescriptor, FieldType } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
// ─── Map JSON Schema types to FieldType ──────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function mapType(schema: JSONSchema, key: string): FieldType {
|
|
8
|
+
// Enum on any scalar type maps to a select field.
|
|
9
|
+
if (schema.enum) return "select";
|
|
10
|
+
|
|
11
|
+
const t = Array.isArray(schema.type)
|
|
12
|
+
? schema.type.find((v) => v !== "null")
|
|
13
|
+
: schema.type;
|
|
14
|
+
const fmt = schema.format as string | undefined;
|
|
15
|
+
|
|
16
|
+
if (t === "string") {
|
|
17
|
+
if (fmt === "date") return "date";
|
|
18
|
+
if (fmt === "date-time") return "datetime";
|
|
19
|
+
if (fmt === "time") return "time";
|
|
20
|
+
if (fmt === "color") return "color";
|
|
21
|
+
if (typeof schema.maxLength === "number" && schema.maxLength > 255)
|
|
22
|
+
return "textarea";
|
|
23
|
+
return "text";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (t === "integer" || t === "number") return "number";
|
|
27
|
+
if (t === "boolean") return "boolean";
|
|
28
|
+
|
|
29
|
+
// SWE extension: custom `relation` type.
|
|
30
|
+
if (t === "relation") {
|
|
31
|
+
return (schema as JSONSchema & { multiple?: boolean }).multiple
|
|
32
|
+
? "m2m"
|
|
33
|
+
: "fks";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (t === "array") {
|
|
37
|
+
const items = schema.items;
|
|
38
|
+
// Array of relations maps to m2m.
|
|
39
|
+
if (items?.type === "relation") return "m2m";
|
|
40
|
+
// Array of objects maps to a repeater.
|
|
41
|
+
if (items?.type === "object" || items?.properties) return "repeater";
|
|
42
|
+
// Flat arrays of strings/numbers map to multi-select.
|
|
43
|
+
return "select";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (t === "object") {
|
|
47
|
+
// Heuristic: title + description + url/slug looks like an SEO object.
|
|
48
|
+
const props = Object.keys(schema.properties ?? {});
|
|
49
|
+
if (
|
|
50
|
+
props.includes("title") &&
|
|
51
|
+
props.includes("description") &&
|
|
52
|
+
(props.includes("url") || props.includes("slug"))
|
|
53
|
+
)
|
|
54
|
+
return "seo";
|
|
55
|
+
// Otherwise treat it as a generic object through a custom type.
|
|
56
|
+
return `object:${key}` as FieldType;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return "text";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Build extra attrs from the schema ───────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function buildAttrs(
|
|
65
|
+
schema: JSONSchema,
|
|
66
|
+
type: FieldType,
|
|
67
|
+
): Record<string, unknown> {
|
|
68
|
+
const attrs: Record<string, unknown> = {};
|
|
69
|
+
|
|
70
|
+
if (type === "select") {
|
|
71
|
+
if (schema.enum) {
|
|
72
|
+
attrs.options = (schema.enum as unknown[]).map((v) => ({
|
|
73
|
+
text: String(v),
|
|
74
|
+
value: v,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (type === "number") {
|
|
80
|
+
if (schema.minimum !== undefined) attrs.min = schema.minimum;
|
|
81
|
+
if (schema.maximum !== undefined) attrs.max = schema.maximum;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (type === "text" || type === "textarea") {
|
|
85
|
+
if (schema.maxLength !== undefined) attrs.maxLength = schema.maxLength;
|
|
86
|
+
if (schema.minLength !== undefined) attrs.minLength = schema.minLength;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (type === "fks" || type === "m2m") {
|
|
90
|
+
const ext = schema as JSONSchema & { endpoint?: string; relation?: string };
|
|
91
|
+
attrs.endpoint = ext.endpoint ?? ext.relation ?? "";
|
|
92
|
+
attrs.returnObject = true;
|
|
93
|
+
if (type === "m2m") attrs.multiple = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type === "date" || type === "datetime") {
|
|
97
|
+
if (schema.minimum) attrs.min = schema.minimum;
|
|
98
|
+
if (schema.maximum) attrs.max = schema.maximum;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return attrs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Collect conditional rules from the root schema ──────────────────────────
|
|
105
|
+
|
|
106
|
+
interface ConditionalRule {
|
|
107
|
+
ifSchema: JSONSchema;
|
|
108
|
+
thenKeys: Set<string>;
|
|
109
|
+
elseKeys: Set<string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function collectRules(schema: JSONSchema): ConditionalRule[] {
|
|
113
|
+
const rules: ConditionalRule[] = [];
|
|
114
|
+
|
|
115
|
+
function addRule(
|
|
116
|
+
ifSchema: JSONSchema,
|
|
117
|
+
then?: JSONSchema,
|
|
118
|
+
elseBranch?: JSONSchema,
|
|
119
|
+
) {
|
|
120
|
+
rules.push({
|
|
121
|
+
ifSchema,
|
|
122
|
+
thenKeys: new Set(Object.keys(then?.properties ?? {})),
|
|
123
|
+
elseKeys: new Set(Object.keys(elseBranch?.properties ?? {})),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (schema.if) addRule(schema.if, schema.then, schema.else);
|
|
128
|
+
|
|
129
|
+
if (Array.isArray(schema.allOf)) {
|
|
130
|
+
for (const entry of schema.allOf) {
|
|
131
|
+
if (entry?.if) addRule(entry.if, entry.then, entry.else);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return rules;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// visible() closure that captures the ifSchema and the active branch (then/else).
|
|
139
|
+
function makeVisible(
|
|
140
|
+
ifSchema: JSONSchema,
|
|
141
|
+
side: "then" | "else",
|
|
142
|
+
): (ctx: { model: Record<string, unknown> }) => boolean {
|
|
143
|
+
return ({ model }) => {
|
|
144
|
+
const matches = matchesSchema(model, ifSchema);
|
|
145
|
+
return side === "then" ? matches : !matches;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Single property → AnyFieldDescriptor ──────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function propertyToDescriptor(
|
|
152
|
+
key: string,
|
|
153
|
+
rawSchema: JSONSchema,
|
|
154
|
+
defs: Record<string, JSONSchema>,
|
|
155
|
+
requiredKeys: Set<string>,
|
|
156
|
+
rules: ConditionalRule[],
|
|
157
|
+
): AnyFieldDescriptor {
|
|
158
|
+
const schema = resolveSchema(rawSchema, defs);
|
|
159
|
+
const type = mapType(schema, key);
|
|
160
|
+
const attrs = buildAttrs(schema, type);
|
|
161
|
+
|
|
162
|
+
// Conditional visibility: if the key only appears in either then or else.
|
|
163
|
+
let visible: AnyFieldDescriptor["visible"] | undefined;
|
|
164
|
+
|
|
165
|
+
for (const rule of rules) {
|
|
166
|
+
if (rule.thenKeys.has(key) && !rule.elseKeys.has(key)) {
|
|
167
|
+
visible = makeVisible(rule.ifSchema, "then");
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
if (rule.elseKeys.has(key) && !rule.thenKeys.has(key)) {
|
|
171
|
+
visible = makeVisible(rule.ifSchema, "else");
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Repeater: build child descriptors recursively.
|
|
177
|
+
const base: Record<string, unknown> = {
|
|
178
|
+
key,
|
|
179
|
+
type,
|
|
180
|
+
label: schema.title ?? undefined,
|
|
181
|
+
required: requiredKeys.has(key),
|
|
182
|
+
...(visible ? { visible } : {}),
|
|
183
|
+
...(Object.keys(attrs).length ? { attrs } : {}),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (type === "repeater" && schema.items) {
|
|
187
|
+
const itemSchema = resolveSchema(schema.items, defs);
|
|
188
|
+
const childRequired = new Set<string>(itemSchema.required ?? []);
|
|
189
|
+
const childProps = itemSchema.properties ?? {};
|
|
190
|
+
const childRules = collectRules(itemSchema);
|
|
191
|
+
base.fields = Object.entries(childProps).map(([k, v]) =>
|
|
192
|
+
propertyToDescriptor(k, v, defs, childRequired, childRules),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return base as unknown as AnyFieldDescriptor;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Public composable ───────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Options for generating field descriptors from a JSON Schema document. */
|
|
202
|
+
export interface UseFormFromSchemaOptions {
|
|
203
|
+
/** Keys to exclude from generation, for example `id` or `created_at`. */
|
|
204
|
+
exclude?: string[];
|
|
205
|
+
/** Per-field overrides that shallow-merge onto the generated descriptor. */
|
|
206
|
+
overrides?: Record<string, Partial<AnyFieldDescriptor>>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Builds a `AnyFieldDescriptor[]` array from a JSON Schema.
|
|
211
|
+
* Supports Pydantic v2 (`$defs`, nullable `anyOf`), DRF Spectacular, and `if/then/else` conditionals.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* const fields = useFormFromSchema(myApiSchema, {
|
|
215
|
+
* exclude: ['id', 'created_at'],
|
|
216
|
+
* overrides: { body: { type: 'editor', translatable: true } },
|
|
217
|
+
* })
|
|
218
|
+
*/
|
|
219
|
+
export function useFormFromSchema(
|
|
220
|
+
schema: JSONSchema,
|
|
221
|
+
options: UseFormFromSchemaOptions = {},
|
|
222
|
+
): AnyFieldDescriptor[] {
|
|
223
|
+
const { exclude = [], overrides = {} } = options;
|
|
224
|
+
const defs = extractDefs(schema);
|
|
225
|
+
|
|
226
|
+
const properties = schema.properties ?? {};
|
|
227
|
+
const requiredKeys = new Set<string>(schema.required ?? []);
|
|
228
|
+
const rules = collectRules(schema);
|
|
229
|
+
|
|
230
|
+
const descriptors: AnyFieldDescriptor[] = [];
|
|
231
|
+
|
|
232
|
+
for (const [key, rawProp] of Object.entries(properties)) {
|
|
233
|
+
if (exclude.includes(key)) continue;
|
|
234
|
+
|
|
235
|
+
const descriptor = propertyToDescriptor(
|
|
236
|
+
key,
|
|
237
|
+
rawProp,
|
|
238
|
+
defs,
|
|
239
|
+
requiredKeys,
|
|
240
|
+
rules,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Apply consumer overrides with a shallow merge.
|
|
244
|
+
const override = overrides[key];
|
|
245
|
+
const final = override
|
|
246
|
+
? ({ ...descriptor, ...override } as AnyFieldDescriptor)
|
|
247
|
+
: descriptor;
|
|
248
|
+
|
|
249
|
+
descriptors.push(final);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return descriptors;
|
|
253
|
+
}
|