@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.
Files changed (60) hide show
  1. package/README.md +155 -0
  2. package/dist/index.d.cts +1 -0
  3. package/dist/index.d.mts +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.mjs +34 -0
  6. package/dist/module.d.cts +2 -0
  7. package/dist/module.d.mts +2 -0
  8. package/dist/module.d.ts +2 -0
  9. package/dist/module.json +9 -0
  10. package/dist/module.mjs +18 -0
  11. package/dist/types.d.mts +13 -0
  12. package/package.json +78 -0
  13. package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
  14. package/src/__tests__/registry.test.ts +105 -0
  15. package/src/__tests__/useFormFromSchema.test.ts +415 -0
  16. package/src/__tests__/useMapoForm.test.ts +319 -0
  17. package/src/index.ts +49 -0
  18. package/src/module.ts +213 -0
  19. package/src/runtime/components/MapoFocusPortal.vue +154 -0
  20. package/src/runtime/components/MapoForm.vue +247 -0
  21. package/src/runtime/components/MapoFormField.vue +350 -0
  22. package/src/runtime/components/MapoFormFlatSection.vue +163 -0
  23. package/src/runtime/components/MapoFormGroup.vue +283 -0
  24. package/src/runtime/components/MapoFormTabs.vue +116 -0
  25. package/src/runtime/components/MapoUnknownField.vue +74 -0
  26. package/src/runtime/components/fields/MapoDateField.vue +98 -0
  27. package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
  28. package/src/runtime/components/fields/MapoFileField.vue +106 -0
  29. package/src/runtime/components/fields/MapoFksField.vue +151 -0
  30. package/src/runtime/components/fields/MapoMapField.vue +117 -0
  31. package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
  32. package/src/runtime/components/fields/MapoRepeater.vue +553 -0
  33. package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
  34. package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
  35. package/src/runtime/components/fields/MapoTimeField.vue +59 -0
  36. package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
  37. package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
  38. package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
  39. package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
  40. package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
  41. package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
  42. package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
  43. package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
  44. package/src/runtime/composables/defineFormField.ts +62 -0
  45. package/src/runtime/composables/useCurrentLang.ts +16 -0
  46. package/src/runtime/composables/useFocusMode.ts +86 -0
  47. package/src/runtime/composables/useFormDraft.ts +119 -0
  48. package/src/runtime/composables/useFormFromSchema.ts +253 -0
  49. package/src/runtime/composables/useMapoForm.ts +484 -0
  50. package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
  51. package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
  52. package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
  53. package/src/runtime/plugins/form-registry.ts +43 -0
  54. package/src/runtime/registry/defaults.ts +97 -0
  55. package/src/runtime/types/descriptor.ts +411 -0
  56. package/src/runtime/types/index.ts +40 -0
  57. package/src/runtime/types/layout.ts +22 -0
  58. package/src/runtime/types/registry.ts +98 -0
  59. package/src/runtime/utils/flattenFieldGroups.ts +86 -0
  60. package/src/vue-shim.d.ts +12 -0
@@ -0,0 +1,415 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { useFormFromSchema } from "../runtime/composables/useFormFromSchema.js";
3
+ import type { JSONSchema } from "@mapomodule/utils";
4
+
5
+ type DescriptorWithAttrs = {
6
+ attrs?: {
7
+ options?: Array<{ text: string; value: unknown }>;
8
+ min?: number;
9
+ max?: number;
10
+ };
11
+ fields?: unknown[];
12
+ visible?: (ctx: { model: Record<string, unknown> }) => boolean;
13
+ };
14
+
15
+ // Pydantic v2 / DRF Spectacular style schema.
16
+ const articleSchema: JSONSchema = {
17
+ type: "object",
18
+ title: "Article",
19
+ required: ["title", "slug"],
20
+ properties: {
21
+ id: { type: "integer", title: "ID" },
22
+ title: { type: "string", title: "Titolo", maxLength: 255 },
23
+ slug: { type: "string", title: "Slug", maxLength: 100 },
24
+ body: { type: "string", title: "Corpo", maxLength: 10000 },
25
+ excerpt: { type: "string", title: "Estratto", maxLength: 300 },
26
+ published_at: {
27
+ anyOf: [{ type: "string", format: "date-time" }, { type: "null" }],
28
+ },
29
+ date_only: { type: "string", format: "date" },
30
+ is_draft: { type: "boolean", title: "Bozza" },
31
+ view_count: { type: "integer", title: "Visualizzazioni" },
32
+ rating: { type: "number", title: "Rating", minimum: 0, maximum: 5 },
33
+ status: { type: "string", enum: ["draft", "published", "archived"] },
34
+ seo: {
35
+ type: "object",
36
+ title: "SEO",
37
+ properties: {
38
+ title: { type: "string" },
39
+ description: { type: "string" },
40
+ url: { type: "string" },
41
+ },
42
+ },
43
+ tags: { type: "array", items: { type: "string" } },
44
+ blocks: {
45
+ type: "array",
46
+ title: "Blocchi",
47
+ items: {
48
+ type: "object",
49
+ properties: {
50
+ type: { type: "string" },
51
+ content: { type: "string" },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ };
57
+
58
+ describe("useFormFromSchema — basic mapping", () => {
59
+ it("maps string → text", () => {
60
+ const fields = useFormFromSchema(articleSchema, {
61
+ exclude: [
62
+ "id",
63
+ "slug",
64
+ "body",
65
+ "excerpt",
66
+ "published_at",
67
+ "date_only",
68
+ "is_draft",
69
+ "view_count",
70
+ "rating",
71
+ "status",
72
+ "seo",
73
+ "tags",
74
+ "blocks",
75
+ ],
76
+ });
77
+ const title = fields.find((f) => f.key === "title");
78
+ expect(title?.type).toBe("text");
79
+ });
80
+
81
+ it("maps long string → textarea", () => {
82
+ const fields = useFormFromSchema(articleSchema, {
83
+ exclude: [
84
+ "id",
85
+ "slug",
86
+ "title",
87
+ "excerpt",
88
+ "published_at",
89
+ "date_only",
90
+ "is_draft",
91
+ "view_count",
92
+ "rating",
93
+ "status",
94
+ "seo",
95
+ "tags",
96
+ "blocks",
97
+ ],
98
+ });
99
+ const body = fields.find((f) => f.key === "body");
100
+ expect(body?.type).toBe("textarea");
101
+ });
102
+
103
+ it("maps date-time format → datetime", () => {
104
+ // published_at: anyOf[string+date-time, null] -> resolved as datetime
105
+ const fields = useFormFromSchema(articleSchema, {
106
+ exclude: [
107
+ "id",
108
+ "title",
109
+ "slug",
110
+ "body",
111
+ "excerpt",
112
+ "is_draft",
113
+ "view_count",
114
+ "rating",
115
+ "status",
116
+ "seo",
117
+ "tags",
118
+ "blocks",
119
+ "date_only",
120
+ ],
121
+ });
122
+ const pub = fields.find((f) => f.key === "published_at");
123
+ expect(pub?.type).toBe("datetime");
124
+ });
125
+
126
+ it("maps date format → date", () => {
127
+ const fields = useFormFromSchema(articleSchema, {
128
+ exclude: [
129
+ "id",
130
+ "title",
131
+ "slug",
132
+ "body",
133
+ "excerpt",
134
+ "published_at",
135
+ "is_draft",
136
+ "view_count",
137
+ "rating",
138
+ "status",
139
+ "seo",
140
+ "tags",
141
+ "blocks",
142
+ ],
143
+ });
144
+ const d = fields.find((f) => f.key === "date_only");
145
+ expect(d?.type).toBe("date");
146
+ });
147
+
148
+ it("maps boolean → boolean", () => {
149
+ const fields = useFormFromSchema(articleSchema, {
150
+ exclude: [
151
+ "id",
152
+ "title",
153
+ "slug",
154
+ "body",
155
+ "excerpt",
156
+ "published_at",
157
+ "date_only",
158
+ "view_count",
159
+ "rating",
160
+ "status",
161
+ "seo",
162
+ "tags",
163
+ "blocks",
164
+ ],
165
+ });
166
+ const f = fields.find((f) => f.key === "is_draft");
167
+ expect(f?.type).toBe("boolean");
168
+ });
169
+
170
+ it("maps integer → number", () => {
171
+ const fields = useFormFromSchema(articleSchema, {
172
+ exclude: [
173
+ "id",
174
+ "title",
175
+ "slug",
176
+ "body",
177
+ "excerpt",
178
+ "published_at",
179
+ "date_only",
180
+ "is_draft",
181
+ "rating",
182
+ "status",
183
+ "seo",
184
+ "tags",
185
+ "blocks",
186
+ ],
187
+ });
188
+ const f = fields.find((f) => f.key === "view_count");
189
+ expect(f?.type).toBe("number");
190
+ });
191
+
192
+ it("maps enum → select with options", () => {
193
+ const fields = useFormFromSchema(articleSchema, {
194
+ exclude: [
195
+ "id",
196
+ "title",
197
+ "slug",
198
+ "body",
199
+ "excerpt",
200
+ "published_at",
201
+ "date_only",
202
+ "is_draft",
203
+ "view_count",
204
+ "rating",
205
+ "seo",
206
+ "tags",
207
+ "blocks",
208
+ ],
209
+ });
210
+ const f = fields.find((f) => f.key === "status");
211
+ const typed = f as DescriptorWithAttrs | undefined;
212
+ expect(f?.type).toBe("select");
213
+ expect(typed?.attrs?.options).toHaveLength(3);
214
+ expect(typed?.attrs?.options?.[0]).toEqual({
215
+ text: "draft",
216
+ value: "draft",
217
+ });
218
+ });
219
+
220
+ it("maps object with title+description+url → seo", () => {
221
+ const fields = useFormFromSchema(articleSchema, {
222
+ exclude: [
223
+ "id",
224
+ "title",
225
+ "slug",
226
+ "body",
227
+ "excerpt",
228
+ "published_at",
229
+ "date_only",
230
+ "is_draft",
231
+ "view_count",
232
+ "rating",
233
+ "status",
234
+ "tags",
235
+ "blocks",
236
+ ],
237
+ });
238
+ const f = fields.find((f) => f.key === "seo");
239
+ expect(f?.type).toBe("seo");
240
+ });
241
+
242
+ it("maps array of objects → repeater with child fields", () => {
243
+ const fields = useFormFromSchema(articleSchema, {
244
+ exclude: [
245
+ "id",
246
+ "title",
247
+ "slug",
248
+ "body",
249
+ "excerpt",
250
+ "published_at",
251
+ "date_only",
252
+ "is_draft",
253
+ "view_count",
254
+ "rating",
255
+ "status",
256
+ "seo",
257
+ "tags",
258
+ ],
259
+ });
260
+ const f = fields.find((f) => f.key === "blocks");
261
+ const typed = f as DescriptorWithAttrs | undefined;
262
+ expect(f?.type).toBe("repeater");
263
+ expect(typed?.fields?.length).toBeGreaterThan(0);
264
+ });
265
+
266
+ it("sets required=true for required fields", () => {
267
+ const fields = useFormFromSchema(articleSchema, {
268
+ exclude: [
269
+ "id",
270
+ "body",
271
+ "excerpt",
272
+ "published_at",
273
+ "date_only",
274
+ "is_draft",
275
+ "view_count",
276
+ "rating",
277
+ "status",
278
+ "seo",
279
+ "tags",
280
+ "blocks",
281
+ ],
282
+ });
283
+ const title = fields.find((f) => f.key === "title");
284
+ const slug = fields.find((f) => f.key === "slug");
285
+ expect(title?.required).toBe(true);
286
+ expect(slug?.required).toBe(true);
287
+ });
288
+ });
289
+
290
+ describe("useFormFromSchema — options", () => {
291
+ it("exclude removes fields", () => {
292
+ const fields = useFormFromSchema(articleSchema, {
293
+ exclude: [
294
+ "id",
295
+ "slug",
296
+ "body",
297
+ "excerpt",
298
+ "published_at",
299
+ "date_only",
300
+ "is_draft",
301
+ "view_count",
302
+ "rating",
303
+ "status",
304
+ "seo",
305
+ "tags",
306
+ "blocks",
307
+ ],
308
+ });
309
+ expect(fields.find((f) => f.key === "id")).toBeUndefined();
310
+ });
311
+
312
+ it("overrides replace generated descriptor fields", () => {
313
+ const fields = useFormFromSchema(articleSchema, {
314
+ exclude: [
315
+ "id",
316
+ "slug",
317
+ "body",
318
+ "excerpt",
319
+ "published_at",
320
+ "date_only",
321
+ "is_draft",
322
+ "view_count",
323
+ "rating",
324
+ "status",
325
+ "seo",
326
+ "tags",
327
+ "blocks",
328
+ ],
329
+ overrides: { title: { type: "textarea", translatable: true } },
330
+ });
331
+ const title = fields.find((f) => f.key === "title");
332
+ expect(title?.type).toBe("textarea");
333
+ expect(title?.translatable).toBe(true);
334
+ });
335
+
336
+ it("uses schema title as label when present", () => {
337
+ const fields = useFormFromSchema(articleSchema, {
338
+ exclude: [
339
+ "id",
340
+ "slug",
341
+ "body",
342
+ "excerpt",
343
+ "published_at",
344
+ "date_only",
345
+ "is_draft",
346
+ "view_count",
347
+ "rating",
348
+ "status",
349
+ "seo",
350
+ "tags",
351
+ "blocks",
352
+ ],
353
+ });
354
+ const f = fields.find((f) => f.key === "title");
355
+ expect(f?.label).toBe("Titolo");
356
+ });
357
+ });
358
+
359
+ describe("useFormFromSchema — conditionals", () => {
360
+ const conditionalSchema: JSONSchema = {
361
+ type: "object",
362
+ properties: {
363
+ type: { type: "string", enum: ["video", "article"] },
364
+ url: { type: "string" },
365
+ content: { type: "string" },
366
+ },
367
+ if: { properties: { type: { const: "video" } } },
368
+ then: { properties: { url: { type: "string" } } },
369
+ else: { properties: { content: { type: "string" } } },
370
+ };
371
+
372
+ it("adds visible(ctx) to fields gated by if/then", () => {
373
+ const fields = useFormFromSchema(conditionalSchema);
374
+ const url = fields.find((f) => f.key === "url");
375
+ const typed = url as DescriptorWithAttrs | undefined;
376
+ expect(typeof typed?.visible).toBe("function");
377
+ expect(typed?.visible?.({ model: { type: "video" } })).toBe(true);
378
+ expect(typed?.visible?.({ model: { type: "article" } })).toBe(false);
379
+ });
380
+
381
+ it("adds visible(ctx) negated for else branch", () => {
382
+ const fields = useFormFromSchema(conditionalSchema);
383
+ const content = fields.find((f) => f.key === "content");
384
+ const typed = content as DescriptorWithAttrs | undefined;
385
+ expect(typeof typed?.visible).toBe("function");
386
+ expect(typed?.visible?.({ model: { type: "article" } })).toBe(true);
387
+ expect(typed?.visible?.({ model: { type: "video" } })).toBe(false);
388
+ });
389
+ });
390
+
391
+ describe("useFormFromSchema — number attrs", () => {
392
+ it("adds min/max to number attrs", () => {
393
+ const fields = useFormFromSchema(articleSchema, {
394
+ exclude: [
395
+ "id",
396
+ "title",
397
+ "slug",
398
+ "body",
399
+ "excerpt",
400
+ "published_at",
401
+ "date_only",
402
+ "is_draft",
403
+ "view_count",
404
+ "status",
405
+ "seo",
406
+ "tags",
407
+ "blocks",
408
+ ],
409
+ });
410
+ const f = fields.find((f) => f.key === "rating");
411
+ const typed = f as DescriptorWithAttrs | undefined;
412
+ expect(typed?.attrs?.min).toBe(0);
413
+ expect(typed?.attrs?.max).toBe(5);
414
+ });
415
+ });