@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,484 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ref,
|
|
3
|
+
computed,
|
|
4
|
+
toRaw,
|
|
5
|
+
toValue,
|
|
6
|
+
watch,
|
|
7
|
+
type Ref,
|
|
8
|
+
type MaybeRef,
|
|
9
|
+
provide,
|
|
10
|
+
inject,
|
|
11
|
+
} from "vue";
|
|
12
|
+
import {
|
|
13
|
+
getNestedValue,
|
|
14
|
+
setNestedValueMutating,
|
|
15
|
+
objectDiff,
|
|
16
|
+
debounce,
|
|
17
|
+
} from "@mapomodule/utils";
|
|
18
|
+
import type { AnyFieldDescriptor, FieldRegistry } from "../types/index.js";
|
|
19
|
+
import { resolveFieldAccessor } from "../types/index.js";
|
|
20
|
+
import { useCurrentLang } from "./useCurrentLang.js";
|
|
21
|
+
import { useSnackStore } from "@mapomodule/store/runtime/stores/snack";
|
|
22
|
+
import { useAuthStore } from "@mapomodule/store/runtime/stores/auth";
|
|
23
|
+
|
|
24
|
+
/** Options accepted by `useMapoForm()`. */
|
|
25
|
+
export interface UseMapoFormOptions<T extends object> {
|
|
26
|
+
model: Ref<T>;
|
|
27
|
+
fields: MaybeRef<AnyFieldDescriptor<T>[]>;
|
|
28
|
+
errors?: Ref<Record<string, string[]>>;
|
|
29
|
+
languages?: string[];
|
|
30
|
+
currentLang?: Ref<string>;
|
|
31
|
+
immediate?: boolean;
|
|
32
|
+
/** Global debounce in milliseconds. Override per field with `descriptor.debounce`. Default: `300`. */
|
|
33
|
+
debounce?: number;
|
|
34
|
+
/**
|
|
35
|
+
* When `false`, suppresses the toast that lists server-side field errors.
|
|
36
|
+
* Set this for sub-forms (e.g. each repeater item) so they don't duplicate the
|
|
37
|
+
* root form's error toast. Default: `true`.
|
|
38
|
+
*/
|
|
39
|
+
notifyErrors?: boolean;
|
|
40
|
+
registry: FieldRegistry;
|
|
41
|
+
/**
|
|
42
|
+
* When set, enables automatic draft persistence to localStorage.
|
|
43
|
+
* Key is prefixed with `mapo:draft:`. Drafts expire after 24 h.
|
|
44
|
+
* Call the returned `checkDraft()` after the model is populated to offer restore.
|
|
45
|
+
* Example: `"article:42"` → `mapo:draft:article:42`.
|
|
46
|
+
*/
|
|
47
|
+
draftKey?: MaybeRef<string | undefined>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** State exposed when a pending draft is found in localStorage. */
|
|
51
|
+
export interface MapoDraftBanner {
|
|
52
|
+
savedAt: Date;
|
|
53
|
+
restore: () => void;
|
|
54
|
+
discard: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reactive context shared with child form fields via provide/inject. */
|
|
58
|
+
export interface MapoFormContext<T extends object> {
|
|
59
|
+
model: Ref<T>;
|
|
60
|
+
fields: MaybeRef<AnyFieldDescriptor<T>[]>;
|
|
61
|
+
errors: Ref<Record<string, string[]>>;
|
|
62
|
+
languages: string[];
|
|
63
|
+
currentLang: Ref<string>;
|
|
64
|
+
readonly: Ref<boolean>;
|
|
65
|
+
immediate: boolean;
|
|
66
|
+
debounce: number;
|
|
67
|
+
registry: FieldRegistry;
|
|
68
|
+
setFieldValue: (key: string, val: unknown) => void;
|
|
69
|
+
getClientError: (key: string) => string | null;
|
|
70
|
+
markTouched: (key: string) => void;
|
|
71
|
+
isTouched: (key: string) => boolean;
|
|
72
|
+
submitted: Ref<boolean>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safeClone<X>(v: X): X {
|
|
76
|
+
// Unwrap reactive proxies before cloning: structuredClone fails on Vue proxies,
|
|
77
|
+
// but still preserves Date/File/Blob/Map instances that JSON would lose.
|
|
78
|
+
const raw = toRaw(v) as X;
|
|
79
|
+
if (typeof structuredClone === "function") {
|
|
80
|
+
try {
|
|
81
|
+
return structuredClone(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
/* fall back to JSON */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return JSON.parse(JSON.stringify(raw));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Injection key for the current Mapo form context. */
|
|
90
|
+
export const FORM_KEY = Symbol("mapoForm");
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Provides the current form context to descendant field components.
|
|
94
|
+
* Internal: `useMapoForm` calls it automatically; consumers use `injectMapoForm`.
|
|
95
|
+
*/
|
|
96
|
+
function provideMapoForm<T extends object>(ctx: MapoFormContext<T>) {
|
|
97
|
+
provide(FORM_KEY, ctx);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Injects the nearest form context, if present. */
|
|
101
|
+
export function injectMapoForm<T extends object>() {
|
|
102
|
+
return inject<MapoFormContext<T> | undefined>(FORM_KEY, undefined);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Creates the core reactive state and helpers for a Mapo form.
|
|
107
|
+
* Handles dirty tracking, field accessors, sync validation, and submit flows.
|
|
108
|
+
*/
|
|
109
|
+
export function useMapoForm<T extends object>(options: UseMapoFormOptions<T>) {
|
|
110
|
+
const {
|
|
111
|
+
model,
|
|
112
|
+
fields,
|
|
113
|
+
errors = ref({}),
|
|
114
|
+
languages = [],
|
|
115
|
+
currentLang: currentLangProp,
|
|
116
|
+
immediate = false,
|
|
117
|
+
debounce: debounceMs = 300,
|
|
118
|
+
notifyErrors = true,
|
|
119
|
+
registry,
|
|
120
|
+
} = options;
|
|
121
|
+
|
|
122
|
+
const currentLang = useCurrentLang(currentLangProp);
|
|
123
|
+
const snack = useSnackStore();
|
|
124
|
+
const authStore = useAuthStore();
|
|
125
|
+
const backup = ref(safeClone(model.value)) as Ref<T>;
|
|
126
|
+
const clientErrors = ref<Record<string, string>>({});
|
|
127
|
+
const isLoading = ref(false);
|
|
128
|
+
const readonly = ref(false);
|
|
129
|
+
const touched = ref<Set<string>>(new Set());
|
|
130
|
+
const submitted = ref(false);
|
|
131
|
+
|
|
132
|
+
// Show a toast listing field-level BE errors whenever the server populates `errors`.
|
|
133
|
+
// Skipped for sub-forms (notifyErrors=false) so repeater items don't duplicate it.
|
|
134
|
+
if (notifyErrors) {
|
|
135
|
+
watch(
|
|
136
|
+
errors,
|
|
137
|
+
(val) => {
|
|
138
|
+
const entries = Object.entries(val);
|
|
139
|
+
if (!entries.length) return;
|
|
140
|
+
const lines = entries
|
|
141
|
+
.map(([field, msgs]) => `${field}: ${(msgs as string[]).join(", ")}`)
|
|
142
|
+
.join("\n");
|
|
143
|
+
snack.show(lines, "error");
|
|
144
|
+
},
|
|
145
|
+
{ deep: true },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Incremental dirty tracking: updated in setFieldValue to avoid
|
|
150
|
+
// a JSON.stringify(objectDiff(...)) on every computed read.
|
|
151
|
+
const dirtyKeys = ref<Set<string>>(new Set());
|
|
152
|
+
const isDirty = computed(() => dirtyKeys.value.size > 0);
|
|
153
|
+
|
|
154
|
+
// ─── Draft persistence ──────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
const DRAFT_TTL = 86_400_000; // 24 h
|
|
157
|
+
const draftBanner = ref<MapoDraftBanner | null>(null);
|
|
158
|
+
|
|
159
|
+
function _draftStorageKey(): string | null {
|
|
160
|
+
const key = toValue(options.draftKey);
|
|
161
|
+
if (!key) return null;
|
|
162
|
+
const userId = authStore.info?.id ?? "anonymous";
|
|
163
|
+
return `mapo:draft:${userId}:${key}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function clearDraft() {
|
|
167
|
+
const key = _draftStorageKey();
|
|
168
|
+
if (key && typeof localStorage !== "undefined")
|
|
169
|
+
localStorage.removeItem(key);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reads localStorage for a pending draft and, if found, populates `draftBanner`
|
|
174
|
+
* with `restore` / `discard` callbacks. Call this after the model has been
|
|
175
|
+
* fetched from the server so the user can choose whether to apply the draft.
|
|
176
|
+
*/
|
|
177
|
+
function checkDraft() {
|
|
178
|
+
const key = _draftStorageKey();
|
|
179
|
+
if (!key || typeof localStorage === "undefined") return;
|
|
180
|
+
try {
|
|
181
|
+
const raw = localStorage.getItem(key);
|
|
182
|
+
if (!raw) return;
|
|
183
|
+
const entry = JSON.parse(raw) as { model: T; savedAt: number };
|
|
184
|
+
if (Date.now() - entry.savedAt > DRAFT_TTL) {
|
|
185
|
+
localStorage.removeItem(key);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const savedAt = new Date(entry.savedAt);
|
|
189
|
+
draftBanner.value = {
|
|
190
|
+
savedAt,
|
|
191
|
+
restore: () => {
|
|
192
|
+
Object.assign(model.value, entry.model);
|
|
193
|
+
draftBanner.value = null;
|
|
194
|
+
},
|
|
195
|
+
discard: () => {
|
|
196
|
+
localStorage.removeItem(key);
|
|
197
|
+
draftBanner.value = null;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
} catch {
|
|
201
|
+
/* corrupted entry — ignore */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (options.draftKey) {
|
|
206
|
+
const saveDraft = debounce(() => {
|
|
207
|
+
const key = _draftStorageKey();
|
|
208
|
+
if (!key || !isDirty.value || typeof localStorage === "undefined") return;
|
|
209
|
+
try {
|
|
210
|
+
const noDraftKeys = new Set(
|
|
211
|
+
toValue(fields)
|
|
212
|
+
.filter((f) => f.noDraft)
|
|
213
|
+
.map((f) => f.key as string),
|
|
214
|
+
);
|
|
215
|
+
const snapshot = toRaw(model.value) as Record<string, unknown>;
|
|
216
|
+
const filtered =
|
|
217
|
+
noDraftKeys.size > 0
|
|
218
|
+
? Object.fromEntries(
|
|
219
|
+
Object.entries(snapshot).filter(([k]) => !noDraftKeys.has(k)),
|
|
220
|
+
)
|
|
221
|
+
: snapshot;
|
|
222
|
+
localStorage.setItem(
|
|
223
|
+
key,
|
|
224
|
+
JSON.stringify({ model: filtered, savedAt: Date.now() }),
|
|
225
|
+
);
|
|
226
|
+
} catch {
|
|
227
|
+
/* quota exceeded — ignore */
|
|
228
|
+
}
|
|
229
|
+
}, 2000);
|
|
230
|
+
watch(model, saveDraft, { deep: true });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function markTouched(key: string) {
|
|
234
|
+
if (touched.value.has(key)) return;
|
|
235
|
+
touched.value.add(key);
|
|
236
|
+
touched.value = new Set(touched.value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isTouched(key: string): boolean {
|
|
240
|
+
return submitted.value || touched.value.has(key);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const allErrors = computed(() => {
|
|
244
|
+
const merged: Record<string, string[]> = { ...errors.value };
|
|
245
|
+
for (const [k, v] of Object.entries(clientErrors.value)) {
|
|
246
|
+
merged[k] = [v, ...(merged[k] ?? [])];
|
|
247
|
+
}
|
|
248
|
+
return merged;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
function resolvePath(descriptor: AnyFieldDescriptor<T>): string {
|
|
252
|
+
const lang = currentLang.value;
|
|
253
|
+
if (descriptor.translatable && lang) {
|
|
254
|
+
return `translations.${lang}.${descriptor.key}`;
|
|
255
|
+
}
|
|
256
|
+
return descriptor.key as string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function initLang(lang: string) {
|
|
260
|
+
const m = model.value as Record<string, unknown>;
|
|
261
|
+
if (!m.translations) m.translations = {};
|
|
262
|
+
const translations = m.translations as Record<string, unknown>;
|
|
263
|
+
if (!translations[lang]) translations[lang] = {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function setFieldValue(descriptor: AnyFieldDescriptor<T>, val: unknown) {
|
|
267
|
+
const accessor = resolveFieldAccessor(descriptor, registry);
|
|
268
|
+
const setVal = accessor.set
|
|
269
|
+
? accessor.set({ model: model.value, val, lang: currentLang.value })
|
|
270
|
+
: val;
|
|
271
|
+
|
|
272
|
+
if (descriptor.synci18n && languages.length) {
|
|
273
|
+
for (const lang of languages) {
|
|
274
|
+
initLang(lang);
|
|
275
|
+
setNestedValueMutating(
|
|
276
|
+
model.value as unknown as Record<string, unknown>,
|
|
277
|
+
`translations.${lang}.${descriptor.key as string}`,
|
|
278
|
+
setVal,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
if (descriptor.translatable && currentLang.value)
|
|
283
|
+
initLang(currentLang.value);
|
|
284
|
+
setNestedValueMutating(
|
|
285
|
+
model.value as unknown as Record<string, unknown>,
|
|
286
|
+
resolvePath(descriptor),
|
|
287
|
+
setVal,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
descriptor.onChange?.({ model: model.value, val: setVal });
|
|
292
|
+
|
|
293
|
+
const key = descriptor.key as string;
|
|
294
|
+
if (!dirtyKeys.value.has(key)) {
|
|
295
|
+
dirtyKeys.value.add(key);
|
|
296
|
+
dirtyKeys.value = new Set(dirtyKeys.value);
|
|
297
|
+
}
|
|
298
|
+
// Clear any stale client-side errors for this field: while the user is typing
|
|
299
|
+
// it makes no sense to leave an old message until the next validation run.
|
|
300
|
+
if (clientErrors.value[key]) {
|
|
301
|
+
const next = { ...clientErrors.value };
|
|
302
|
+
delete next[key];
|
|
303
|
+
clientErrors.value = next;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function getFieldValue(descriptor: AnyFieldDescriptor<T>): unknown {
|
|
308
|
+
const raw = getNestedValue(model.value, resolvePath(descriptor));
|
|
309
|
+
const accessor = resolveFieldAccessor(descriptor, registry);
|
|
310
|
+
return accessor.get
|
|
311
|
+
? accessor.get({ model: model.value, val: raw, lang: currentLang.value })
|
|
312
|
+
: raw;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function checkRequired(
|
|
316
|
+
descriptor: AnyFieldDescriptor<T>,
|
|
317
|
+
val: unknown,
|
|
318
|
+
): string | null {
|
|
319
|
+
if (!descriptor.required) return null;
|
|
320
|
+
if (val === null || val === undefined || val === "")
|
|
321
|
+
return "This field is required.";
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getClientError(descriptor: AnyFieldDescriptor<T>): string | null {
|
|
326
|
+
if (!descriptor.required && !descriptor.validate) return null;
|
|
327
|
+
// "No-anxiety" gate: the error appears only after blur or submit, never on mount.
|
|
328
|
+
// Bypassed when immediate=true so errors show from the very first render.
|
|
329
|
+
if (!immediate && !isTouched(descriptor.key as string)) return null;
|
|
330
|
+
const val = getFieldValue(descriptor);
|
|
331
|
+
return (
|
|
332
|
+
checkRequired(descriptor, val) ??
|
|
333
|
+
descriptor.validate?.(val as T[keyof T & string] | null | undefined, {
|
|
334
|
+
model: model.value,
|
|
335
|
+
}) ??
|
|
336
|
+
null
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function validateClient(): {
|
|
341
|
+
valid: boolean;
|
|
342
|
+
errors: Record<string, string>;
|
|
343
|
+
} {
|
|
344
|
+
// Mark as submitted so getClientError() shows errors for all fields,
|
|
345
|
+
// not just touched ones. Called both from submit() and from external
|
|
346
|
+
// consumers (e.g. MapoDetail) that bypass the submit() handler.
|
|
347
|
+
submitted.value = true;
|
|
348
|
+
const errs: Record<string, string> = {};
|
|
349
|
+
const fs = toValue(fields);
|
|
350
|
+
for (const descriptor of fs) {
|
|
351
|
+
if (!descriptor.required && !descriptor.validate) continue;
|
|
352
|
+
const val = getFieldValue(descriptor);
|
|
353
|
+
const err =
|
|
354
|
+
checkRequired(descriptor, val) ??
|
|
355
|
+
descriptor.validate?.(val as T[keyof T & string] | null | undefined, {
|
|
356
|
+
model: model.value,
|
|
357
|
+
}) ??
|
|
358
|
+
null;
|
|
359
|
+
if (err) errs[descriptor.key as string] = err;
|
|
360
|
+
}
|
|
361
|
+
clientErrors.value = errs;
|
|
362
|
+
return { valid: Object.keys(errs).length === 0, errors: errs };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function resetDirty() {
|
|
366
|
+
backup.value = safeClone(model.value);
|
|
367
|
+
dirtyKeys.value = new Set();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getPatch(): Partial<T> {
|
|
371
|
+
return objectDiff(backup.value, model.value) as Partial<T>;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function submit(
|
|
375
|
+
handler: (model: T | Partial<T>, isNew: boolean) => Promise<void>,
|
|
376
|
+
isNew = false,
|
|
377
|
+
) {
|
|
378
|
+
if (isLoading.value) return;
|
|
379
|
+
submitted.value = true;
|
|
380
|
+
const { valid } = validateClient();
|
|
381
|
+
if (!valid) {
|
|
382
|
+
snack.show("Please fix the errors before saving.", "error");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
isLoading.value = true;
|
|
386
|
+
try {
|
|
387
|
+
await handler(isNew ? model.value : getPatch(), isNew);
|
|
388
|
+
resetDirty();
|
|
389
|
+
clearDraft();
|
|
390
|
+
submitted.value = false;
|
|
391
|
+
touched.value = new Set();
|
|
392
|
+
} finally {
|
|
393
|
+
isLoading.value = false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// O(1) key→descriptor lookup. Rebuilt only when the field list itself changes,
|
|
398
|
+
// not on every model edit — this keeps per-field error/value resolution O(1)
|
|
399
|
+
// instead of O(fields), avoiding O(fields²) work across a large form on each keystroke.
|
|
400
|
+
const fieldIndex = computed(() => {
|
|
401
|
+
const map = new Map<string, AnyFieldDescriptor<T>>();
|
|
402
|
+
for (const f of toValue(fields)) map.set(f.key as string, f);
|
|
403
|
+
return map;
|
|
404
|
+
});
|
|
405
|
+
const findDescriptor = (key: string) => fieldIndex.value.get(key);
|
|
406
|
+
|
|
407
|
+
const ctx: MapoFormContext<T> = {
|
|
408
|
+
model,
|
|
409
|
+
fields,
|
|
410
|
+
errors: allErrors,
|
|
411
|
+
languages,
|
|
412
|
+
currentLang,
|
|
413
|
+
readonly,
|
|
414
|
+
immediate,
|
|
415
|
+
debounce: debounceMs,
|
|
416
|
+
registry,
|
|
417
|
+
setFieldValue: (key: string, val: unknown) => {
|
|
418
|
+
const descriptor = findDescriptor(key);
|
|
419
|
+
if (descriptor) setFieldValue(descriptor, val);
|
|
420
|
+
},
|
|
421
|
+
getClientError: (key: string) => {
|
|
422
|
+
const descriptor = findDescriptor(key);
|
|
423
|
+
return descriptor ? getClientError(descriptor) : null;
|
|
424
|
+
},
|
|
425
|
+
markTouched,
|
|
426
|
+
isTouched,
|
|
427
|
+
submitted,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Public API: accepts either a string key or a full descriptor, making it ergonomic
|
|
431
|
+
// for external consumers (tests, user code) as well as internal callers.
|
|
432
|
+
function setFieldValuePublic(
|
|
433
|
+
keyOrDescriptor: string | AnyFieldDescriptor<T>,
|
|
434
|
+
val: unknown,
|
|
435
|
+
) {
|
|
436
|
+
if (typeof keyOrDescriptor === "string") {
|
|
437
|
+
const descriptor = findDescriptor(keyOrDescriptor);
|
|
438
|
+
if (descriptor) setFieldValue(descriptor, val);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
setFieldValue(keyOrDescriptor, val);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function getClientErrorPublic(
|
|
445
|
+
keyOrDescriptor: string | AnyFieldDescriptor<T>,
|
|
446
|
+
): string | null {
|
|
447
|
+
if (typeof keyOrDescriptor === "string") {
|
|
448
|
+
const descriptor = findDescriptor(keyOrDescriptor);
|
|
449
|
+
return descriptor ? getClientError(descriptor) : null;
|
|
450
|
+
}
|
|
451
|
+
return getClientError(keyOrDescriptor);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Automatically provide the context so descendant MapoForm / MapoFormField
|
|
455
|
+
// components inherit submitted/readonly state with no explicit wiring.
|
|
456
|
+
provideMapoForm(ctx);
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
state: computed(() => ({
|
|
460
|
+
model: model.value,
|
|
461
|
+
errors: allErrors.value,
|
|
462
|
+
isDirty: isDirty.value,
|
|
463
|
+
currentLang: currentLang.value,
|
|
464
|
+
isLoading: isLoading.value,
|
|
465
|
+
})),
|
|
466
|
+
isDirty,
|
|
467
|
+
isLoading,
|
|
468
|
+
readonly,
|
|
469
|
+
currentLang,
|
|
470
|
+
getFieldValue,
|
|
471
|
+
setFieldValue: setFieldValuePublic,
|
|
472
|
+
getClientError: getClientErrorPublic,
|
|
473
|
+
markTouched,
|
|
474
|
+
isTouched,
|
|
475
|
+
validateClient,
|
|
476
|
+
resetDirty,
|
|
477
|
+
getPatch,
|
|
478
|
+
submit,
|
|
479
|
+
ctx,
|
|
480
|
+
draftBanner,
|
|
481
|
+
checkDraft,
|
|
482
|
+
clearDraft,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { getNestedValue } from "@mapomodule/utils";
|
|
2
|
+
|
|
3
|
+
type Matcher<T = unknown> = (val: T) => boolean;
|
|
4
|
+
type Condition<TModel extends object> = (ctx: { model: TModel }) => boolean;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Combines conditions with AND: all conditions must match.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* visible: when(matchesField('seo_enabled', true), matchesField('status', isOneOf(['published'])))
|
|
11
|
+
*/
|
|
12
|
+
export function when<TModel extends object>(
|
|
13
|
+
...conditions: Condition<TModel>[]
|
|
14
|
+
): Condition<TModel> {
|
|
15
|
+
return (ctx) => conditions.every((c) => c(ctx));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Combines conditions with OR: at least one condition must match.
|
|
20
|
+
*/
|
|
21
|
+
export function whenAny<TModel extends object>(
|
|
22
|
+
...conditions: Condition<TModel>[]
|
|
23
|
+
): Condition<TModel> {
|
|
24
|
+
return (ctx) => conditions.some((c) => c(ctx));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Negates a condition.
|
|
29
|
+
*/
|
|
30
|
+
export function whenNot<TModel extends object>(
|
|
31
|
+
condition: Condition<TModel>,
|
|
32
|
+
): Condition<TModel> {
|
|
33
|
+
return (ctx) => !condition(ctx);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks that a field value satisfies a matcher, or equals a literal value.
|
|
38
|
+
* Supports dotted paths such as `meta.status`.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* matchesField('type', 'article')
|
|
42
|
+
* matchesField('count', greaterThan(0))
|
|
43
|
+
*/
|
|
44
|
+
export function matchesField<TModel extends object>(
|
|
45
|
+
key: string,
|
|
46
|
+
matcher: unknown | Matcher,
|
|
47
|
+
): Condition<TModel> {
|
|
48
|
+
return (ctx) => {
|
|
49
|
+
const val = getNestedValue(ctx.model as Record<string, unknown>, key);
|
|
50
|
+
return typeof matcher === "function"
|
|
51
|
+
? (matcher as Matcher)(val)
|
|
52
|
+
: val === matcher;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Matcher helpers ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** The value is included in the options array. */
|
|
59
|
+
export const isOneOf =
|
|
60
|
+
<T>(options: T[]) =>
|
|
61
|
+
(val: unknown): boolean =>
|
|
62
|
+
options.includes(val as T);
|
|
63
|
+
|
|
64
|
+
/** The value is NOT included in the options array. */
|
|
65
|
+
export const isNoneOf =
|
|
66
|
+
<T>(options: T[]) =>
|
|
67
|
+
(val: unknown): boolean =>
|
|
68
|
+
!options.includes(val as T);
|
|
69
|
+
|
|
70
|
+
/** The value is non-empty (not null, not undefined, not '', not false, array with at least one item). */
|
|
71
|
+
export const isNotEmpty =
|
|
72
|
+
() =>
|
|
73
|
+
(val: unknown): boolean =>
|
|
74
|
+
val != null &&
|
|
75
|
+
val !== "" &&
|
|
76
|
+
val !== false &&
|
|
77
|
+
(!Array.isArray(val) || val.length > 0);
|
|
78
|
+
|
|
79
|
+
/** The value is empty. */
|
|
80
|
+
export const isEmpty =
|
|
81
|
+
() =>
|
|
82
|
+
(val: unknown): boolean =>
|
|
83
|
+
!isNotEmpty()(val);
|
|
84
|
+
|
|
85
|
+
/** Returns true when the numeric value is greater than `n`. */
|
|
86
|
+
export const greaterThan =
|
|
87
|
+
(n: number) =>
|
|
88
|
+
(val: unknown): boolean =>
|
|
89
|
+
Number(val) > n;
|
|
90
|
+
|
|
91
|
+
/** Returns true when the numeric value is less than `n`. */
|
|
92
|
+
export const lessThan =
|
|
93
|
+
(n: number) =>
|
|
94
|
+
(val: unknown): boolean =>
|
|
95
|
+
Number(val) < n;
|
|
96
|
+
|
|
97
|
+
/** Returns true when the stringified value matches the given regular expression. */
|
|
98
|
+
export const matches =
|
|
99
|
+
(pattern: RegExp) =>
|
|
100
|
+
(val: unknown): boolean =>
|
|
101
|
+
pattern.test(String(val ?? ""));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { onBeforeUnmount, type Ref } from "vue";
|
|
2
|
+
import { onBeforeRouteLeave } from "vue-router";
|
|
3
|
+
|
|
4
|
+
/** Optional configuration for the unsaved-changes navigation guard. */
|
|
5
|
+
export interface UnsavedChangesGuardOptions {
|
|
6
|
+
message?: string;
|
|
7
|
+
enabled?: Ref<boolean> | (() => boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers two guards that warn the user before abandoning a dirty form:
|
|
12
|
+
* - A Vue Router `onBeforeRouteLeave` guard (in-app navigation).
|
|
13
|
+
* - A native `beforeunload` event listener (tab close / hard refresh).
|
|
14
|
+
*
|
|
15
|
+
* Both guards are no-ops when `isDirty` is `false` or when `options.enabled`
|
|
16
|
+
* returns `false`. The browser guard is skipped in SSR environments.
|
|
17
|
+
*
|
|
18
|
+
* @param isDirty - Reactive flag indicating whether the form has unsaved changes.
|
|
19
|
+
* @param options - Optional message override and an `enabled` predicate.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* useUnsavedChangesGuard(form.isDirty, {
|
|
23
|
+
* message: 'Leave without saving your article?',
|
|
24
|
+
* })
|
|
25
|
+
*/
|
|
26
|
+
export function useUnsavedChangesGuard(
|
|
27
|
+
isDirty: Ref<boolean>,
|
|
28
|
+
options: UnsavedChangesGuardOptions = {},
|
|
29
|
+
) {
|
|
30
|
+
const message =
|
|
31
|
+
options.message ?? "You have unsaved changes. Do you really want to leave?";
|
|
32
|
+
const canUseWindow = typeof window !== "undefined";
|
|
33
|
+
|
|
34
|
+
const isEnabled = (): boolean => {
|
|
35
|
+
if (typeof options.enabled === "function") return options.enabled();
|
|
36
|
+
if (options.enabled) return options.enabled.value;
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
onBeforeRouteLeave(() => {
|
|
41
|
+
if (!isEnabled() || !isDirty.value) return true;
|
|
42
|
+
if (!canUseWindow) return true;
|
|
43
|
+
return window.confirm(message);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const beforeUnload = (e: BeforeUnloadEvent) => {
|
|
47
|
+
if (!isEnabled() || !isDirty.value) return;
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
e.returnValue = "";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (canUseWindow) {
|
|
53
|
+
window.addEventListener("beforeunload", beforeUnload);
|
|
54
|
+
onBeforeUnmount(() =>
|
|
55
|
+
window.removeEventListener("beforeunload", beforeUnload),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|