@inglorious/web 4.4.0 → 4.5.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.
@@ -0,0 +1,332 @@
1
+ import { clone, get, set } from "@inglorious/utils/data-structures/object.js"
2
+
3
+ /**
4
+ * @typedef {import('../../types/form.js').FormEntity} FormEntity
5
+ * @typedef {import('../../types/form.js').FormFieldChangePayload} FormFieldChangePayload
6
+ * @typedef {import('../../types/form.js').FormFieldBlurPayload} FormFieldBlurPayload
7
+ *
8
+ * @typedef {object} FormValidatePayload
9
+ * @property {string|number} entityId - The ID of the target form entity.
10
+ * @property {(values: object) => import('../../types/form.js').FormErrors<object>} validate - A function that validates the entire form's values and returns a complete error object.
11
+ *
12
+ * @typedef {object} FormValidateAsyncPayload
13
+ * @property {string|number} entityId - The ID of the target form entity.
14
+ * @property {(values: object) => Promise<import('../../types/form.js').FormErrors<object>>} validate - An async function that validates the entire form's values.
15
+ *
16
+ * @typedef {object} FormValidationCompletePayload
17
+ * @property {string|number} entityId - The ID of the target form entity.
18
+ * @property {import('../../types/form.js').FormErrors<object>} errors - The validation errors.
19
+ * @property {boolean} isValid - Whether the form is valid.
20
+ *
21
+ * @typedef {object} FormValidationErrorPayload
22
+ * @property {string|number} entityId - The ID of the target form entity.
23
+ * @property {string} error - The error message.
24
+ */
25
+
26
+ const NO_ITEMS_REMOVED = 0
27
+ const ONE_ITEM_REMOVED = 1
28
+
29
+ let areListenersInitialized = false
30
+
31
+ /**
32
+ * Initializes the form entity by resetting it to its initial state.
33
+ * @param {FormEntity} entity - The form entity.
34
+ */
35
+ export function init() {
36
+ if (areListenersInitialized) return
37
+
38
+ document.addEventListener("click", (event) => {
39
+ const button = event.target.closest("button")
40
+
41
+ if (!button || button.getAttribute("type") === "submit") return
42
+
43
+ event.preventDefault()
44
+ })
45
+
46
+ document.addEventListener("submit", (event) => {
47
+ const form = event.target.closest("form")
48
+
49
+ if (!form) return
50
+ event.preventDefault()
51
+ })
52
+
53
+ areListenersInitialized = true
54
+ }
55
+
56
+ /**
57
+ * Resets the form entity with default state.
58
+ * @param {FormEntity} entity - The form entity.
59
+ */
60
+ export function create(entity) {
61
+ resetForm(entity)
62
+ }
63
+
64
+ /**
65
+ * Appends an item to a field array.
66
+ * @param {FormEntity} entity - The form entity.
67
+ * @param {Object} payload
68
+ * @param {string|number} payload.entityId - The form entity ID.
69
+ * @param {string} payload.path - Path to the array field (e.g., 'addresses').
70
+ * @param {*} payload.value - The value to append.
71
+ */
72
+ export function fieldArrayAppend(entity, { path, value }) {
73
+ const array = get(entity.values, path)
74
+ if (!Array.isArray(array)) {
75
+ console.warn(`Field at path '${path}' is not an array`)
76
+ return
77
+ }
78
+
79
+ array.push(value)
80
+
81
+ const errorsArray = get(entity.errors, path, [])
82
+ errorsArray.push(initMetadata(value))
83
+
84
+ const touchedArray = get(entity.touched, path, [])
85
+ touchedArray.push(initMetadata(value))
86
+
87
+ entity.isPristine = false
88
+ }
89
+
90
+ /**
91
+ * Removes an item from a field array by index.
92
+ * @param {FormEntity} entity - The form entity.
93
+ * @param {Object} payload
94
+ * @param {string|number} payload.entityId - The form entity ID.
95
+ * @param {string} payload.path - Path to the array field.
96
+ * @param {number} payload.index - The index to remove.
97
+ */
98
+ export function fieldArrayRemove(entity, { path, index }) {
99
+ const array = get(entity.values, path)
100
+ if (!Array.isArray(array)) {
101
+ console.warn(`Field at path '${path}' is not an array`)
102
+ return
103
+ }
104
+
105
+ array.splice(index, ONE_ITEM_REMOVED)
106
+
107
+ const errorsArray = get(entity.errors, path)
108
+ errorsArray.splice(index, ONE_ITEM_REMOVED)
109
+
110
+ const touchedArray = get(entity.touched, path)
111
+ touchedArray.splice(index, ONE_ITEM_REMOVED)
112
+
113
+ entity.isPristine = false
114
+ }
115
+
116
+ /**
117
+ * Inserts an item into a field array at a specific index.
118
+ * @param {FormEntity} entity - The form entity.
119
+ * @param {Object} payload
120
+ * @param {string|number} payload.entityId - The form entity ID.
121
+ * @param {string} payload.path - Path to the array field.
122
+ * @param {number} payload.index - The index to insert at.
123
+ * @param {*} payload.value - The value to insert.
124
+ */
125
+ export function fieldArrayInsert(entity, { path, index, value }) {
126
+ const array = get(entity.values, path)
127
+ if (!Array.isArray(array)) {
128
+ console.warn(`Field at path '${path}' is not an array`)
129
+ return
130
+ }
131
+
132
+ array.splice(index, NO_ITEMS_REMOVED, value)
133
+
134
+ const errorsArray = get(entity.errors, path)
135
+ errorsArray.splice(index, NO_ITEMS_REMOVED, initMetadata(value))
136
+
137
+ const touchedArray = get(entity.touched, path)
138
+ touchedArray.splice(index, NO_ITEMS_REMOVED, initMetadata(value))
139
+
140
+ entity.isPristine = false
141
+ }
142
+
143
+ /**
144
+ * Moves an item in a field array from one index to another.
145
+ * @param {FormEntity} entity - The form entity.
146
+ * @param {Object} payload
147
+ * @param {string|number} payload.entityId - The form entity ID.
148
+ * @param {string} payload.path - Path to the array field.
149
+ * @param {number} payload.fromIndex - The source index.
150
+ * @param {number} payload.toIndex - The destination index.
151
+ */
152
+ export function fieldArrayMove(entity, { path, fromIndex, toIndex }) {
153
+ const array = get(entity.values, path)
154
+ if (!Array.isArray(array)) {
155
+ console.warn(`Field at path '${path}' is not an array`)
156
+ return
157
+ }
158
+
159
+ const [item] = array.splice(fromIndex, ONE_ITEM_REMOVED)
160
+ array.splice(toIndex, NO_ITEMS_REMOVED, item)
161
+
162
+ const errorsArray = get(entity.errors, path)
163
+ const [errorItem] = errorsArray.splice(fromIndex, ONE_ITEM_REMOVED)
164
+ errorsArray.splice(toIndex, NO_ITEMS_REMOVED, errorItem)
165
+
166
+ const touchedArray = get(entity.touched, path)
167
+ const [touchedItem] = touchedArray.splice(fromIndex, ONE_ITEM_REMOVED)
168
+ touchedArray.splice(toIndex, NO_ITEMS_REMOVED, touchedItem)
169
+
170
+ entity.isPristine = false
171
+ }
172
+
173
+ /**
174
+ * Handles the blur event for a form field, marking it as touched and optionally validating it.
175
+ * @param {FormEntity} entity - The form entity.
176
+ * @param {FormFieldBlurPayload} payload - The event payload.
177
+ */
178
+ export function fieldBlur(entity, { path, validate }) {
179
+ set(entity.touched, path, true)
180
+
181
+ if (!validate) return
182
+
183
+ const error = validate(get(entity.values, path))
184
+ setFieldError(entity, path, error)
185
+ }
186
+
187
+ /**
188
+ * Handles a change in a form field's value and optionally validates it.
189
+ * @param {FormEntity} entity - The form entity.
190
+ * @param {FormFieldChangePayload} payload - The event payload.
191
+ */
192
+ export function fieldChange(entity, { path, value, validate }) {
193
+ setFieldValue(entity, path, value)
194
+
195
+ if (!validate) return
196
+
197
+ const error = validate(get(entity.values, path))
198
+ setFieldError(entity, path, error)
199
+ }
200
+
201
+ /**
202
+ * Resets the form to its initial state.
203
+ * @param {FormEntity} entity - The form entity.
204
+ * @param {string|number} entityId - The ID of the target form entity.
205
+ */
206
+ export function reset(entity) {
207
+ resetForm(entity)
208
+ }
209
+
210
+ /**
211
+ * Synchronously validates the entire form.
212
+ * @param {FormEntity} entity - The form entity.
213
+ * @param {FormValidatePayload} payload - The event payload.
214
+ */
215
+ export function validate(entity, { validate }) {
216
+ entity.errors = validate(entity.values)
217
+ entity.isValid = !hasErrors(entity.errors)
218
+ }
219
+
220
+ /**
221
+ * Asynchronously validates the entire form.
222
+ * Dispatches 'formValidationComplete' on success or 'formValidationError' on failure.
223
+ * @param {FormEntity} entity - The form entity.
224
+ * @param {FormValidateAsyncPayload} payload - The event payload.
225
+ * @param {Object} api - The API object for notifying events.
226
+ */
227
+ export async function validateAsync(entity, { validate }, api) {
228
+ try {
229
+ entity.isValidating = true
230
+ const errors = await validate(entity.values)
231
+ api.notify(`#${entity.id}:validationComplete`, {
232
+ errors,
233
+ isValid: !hasErrors(errors),
234
+ })
235
+ } catch (error) {
236
+ api.notify(`#${entity.id}:validationError`, {
237
+ error: error.message,
238
+ })
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Handles the completion of async validation.
244
+ * @param {FormEntity} entity - The form entity.
245
+ * @param {FormValidationCompletePayload} payload - The event payload.
246
+ */
247
+ export function validationComplete(entity, { errors, isValid }) {
248
+ entity.isValidating = false
249
+ entity.errors = errors
250
+ entity.isValid = isValid
251
+ }
252
+
253
+ /**
254
+ * Handles validation errors from async validation.
255
+ * @param {FormEntity} entity - The form entity.
256
+ * @param {FormValidationErrorPayload} payload - The event payload.
257
+ */
258
+ export function validationError(entity, { error }) {
259
+ entity.isValidating = false
260
+ entity.submitError = error
261
+ }
262
+
263
+ function hasErrors(errors) {
264
+ if (errors === null || errors === undefined) {
265
+ return false
266
+ }
267
+
268
+ if (typeof errors !== "object") {
269
+ // Leaf value - check if it's truthy (error string)
270
+ return Boolean(errors)
271
+ }
272
+
273
+ if (Array.isArray(errors)) {
274
+ return errors.some((item) => hasErrors(item))
275
+ }
276
+
277
+ // Object - check all properties
278
+ return Object.values(errors).some((value) => hasErrors(value))
279
+ }
280
+
281
+ function initMetadata(value) {
282
+ if (Array.isArray(value)) {
283
+ return value.map((item) => initMetadata(item))
284
+ }
285
+
286
+ if (typeof value === "object" && value !== null) {
287
+ return Object.fromEntries(
288
+ Object.entries(value).map(([key, val]) => [key, initMetadata(val)]),
289
+ )
290
+ }
291
+
292
+ return null
293
+ }
294
+
295
+ function resetForm(form) {
296
+ form.values = clone(form.initialValues)
297
+ form.errors = initMetadata(form.initialValues)
298
+ form.touched = initMetadata(form.initialValues)
299
+
300
+ form.isValid = true
301
+ form.isPristine = true
302
+
303
+ form.isValidating = false
304
+ form.isSubmitting = false
305
+ }
306
+
307
+ function setFieldValue(form, path, value) {
308
+ set(form.values, path, value)
309
+ set(form.touched, path, true)
310
+
311
+ form.isPristine = false
312
+
313
+ // By clearing the error for this path, the form might become valid.
314
+ // We delegate to setFieldError(..., null) to correctly run the check.
315
+ setFieldError(form, path, null)
316
+ }
317
+
318
+ function setFieldError(form, path, error) {
319
+ // Ensure we set null if error is falsy, but not an empty string
320
+ const newError = error || null
321
+ set(form.errors, path, newError)
322
+
323
+ if (newError) {
324
+ // If we are adding an error, the form is definitely invalid.
325
+ // No need for an expensive check.
326
+ form.isValid = false
327
+ } else {
328
+ // If we are removing an error, the form MIGHT become valid.
329
+ // This is when we must perform the check.
330
+ form.isValid = !hasErrors(form.errors)
331
+ }
332
+ }
@@ -0,0 +1,32 @@
1
+ import { get } from "@inglorious/utils/data-structures/object.js"
2
+
3
+ /**
4
+ * Retrieves the validation error for a specific form field.
5
+ * @param {FormEntity} form - The form entity object.
6
+ * @param {string} path - The dot-separated path to the field (e.g., 'user.name').
7
+ * @returns {string|null|undefined} The error message for the field, or null/undefined if there is no error.
8
+ */
9
+ export function getFieldError(form, path) {
10
+ return get(form.errors, path)
11
+ }
12
+
13
+ /**
14
+ * Retrieves the value of a specific form field.
15
+ * @param {FormEntity} form - The form entity object.
16
+ * @param {string} path - The dot-separated path to the field (e.g., 'user.name').
17
+ * @param {*} [defaultValue] - An optional default value to return if the path does not exist.
18
+ * @returns {*} The value of the field, or the default value if not found.
19
+ */
20
+ export function getFieldValue(form, path, defaultValue) {
21
+ return get(form.values, path, defaultValue)
22
+ }
23
+
24
+ /**
25
+ * Checks if a specific form field has been touched (i.e., has received a blur event).
26
+ * @param {FormEntity} form - The form entity object.
27
+ * @param {string} path - The dot-separated path to the field (e.g., 'user.name').
28
+ * @returns {boolean} `true` if the field has been touched, otherwise `false`.
29
+ */
30
+ export function isFieldTouched(form, path) {
31
+ return Boolean(get(form.touched, path))
32
+ }