ngx-api-forms 1.0.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,890 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, computed, inject, ElementRef, Renderer2, DestroyRef, Input, Directive } from '@angular/core';
3
+ import { FormGroup, FormArray } from '@angular/forms';
4
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
5
+ import { Subject, merge, takeUntil, tap, catchError, throwError } from 'rxjs';
6
+ import { HttpContextToken, HttpContext } from '@angular/common/http';
7
+
8
+ /**
9
+ * ngx-api-forms - Models & Interfaces
10
+ *
11
+ * Core types for bridging API validation errors to Angular Reactive Forms.
12
+ */
13
+ // ---------------------------------------------------------------------------
14
+ // Global (Non-Field) Errors
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * Sentinel field name used by presets to tag errors that are not bound to a
18
+ * specific form control (e.g. Django `non_field_errors`, Zod `formErrors`).
19
+ *
20
+ * FormBridge also routes any ApiFieldError whose field does not match a control
21
+ * to `globalErrorsSignal`, so custom presets do not need to use this constant -
22
+ * unmatched fields are captured automatically.
23
+ */
24
+ const GLOBAL_ERROR_FIELD = '__global__';
25
+
26
+ function flattenErrors(errors, parentPath = '') {
27
+ const result = [];
28
+ for (const error of errors) {
29
+ const path = parentPath ? `${parentPath}.${error.property}` : error.property;
30
+ if (error.constraints) {
31
+ for (const [constraint, message] of Object.entries(error.constraints)) {
32
+ result.push({ field: path, constraint, message });
33
+ }
34
+ }
35
+ // Recursively handle nested validation (e.g. nested DTOs)
36
+ if (error.children && error.children.length > 0) {
37
+ result.push(...flattenErrors(error.children, path));
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+ function parseStringMessage(message) {
43
+ // Attempt to extract field name and constraint from common class-validator messages
44
+ const patterns = [
45
+ { regex: /^(\w+) is required$/, constraint: 'required' },
46
+ { regex: /^(\w+) must be shorter/, constraint: 'maxLength' },
47
+ { regex: /^(\w+) must be longer/, constraint: 'minLength' },
48
+ { regex: /^(\w+) must not be less/, constraint: 'min' },
49
+ { regex: /^(\w+) must not be more/, constraint: 'max' },
50
+ { regex: /^(\w+) must be an? email/, constraint: 'isEmail' },
51
+ { regex: /^(\w+) must be an? valid phone/, constraint: 'isPhoneNumber' },
52
+ { regex: /^(\w+) must be one of the following/, constraint: 'isEnum' },
53
+ { regex: /^(\w+) must be an? Date/, constraint: 'isDate' },
54
+ { regex: /^(\w+) must be an? IBAN/, constraint: 'isIBAN' },
55
+ { regex: /^(\w+) must be an? EAN/, constraint: 'isEAN' },
56
+ { regex: /^(\w+) must be an? URL/, constraint: 'isUrl' },
57
+ { regex: /^(\w+) must be an? valid url/i, constraint: 'isUrl' },
58
+ { regex: /^(\w+) is not valid/i, constraint: 'invalid' },
59
+ { regex: /^(\w+) is already taken/i, constraint: 'unique' },
60
+ { regex: /^(\w+) is already used/i, constraint: 'unique' },
61
+ { regex: /^(\w+) must be a valid decimal/i, constraint: 'isDecimal' },
62
+ ];
63
+ for (const { regex, constraint } of patterns) {
64
+ const match = message.match(regex);
65
+ if (match) {
66
+ const field = match[1]
67
+ ? match[1].charAt(0).toLowerCase() + match[1].slice(1)
68
+ : 'unknown';
69
+ return [{ field, constraint, message }];
70
+ }
71
+ }
72
+ // Specific messages without a field name captured by regex
73
+ if (/^file is required/i.test(message)) {
74
+ return [{ field: 'file', constraint: 'required', message }];
75
+ }
76
+ return [];
77
+ }
78
+ /**
79
+ * Creates a class-validator / NestJS error preset.
80
+ *
81
+ * @param options.noInference - When true, skips constraint guessing for string messages.
82
+ * Structured `constraints` objects (e.g. `{ isEmail: 'message' }`) are always used as-is.
83
+ * Only affects the fallback string message parser.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * import { classValidatorPreset } from 'ngx-api-forms';
88
+ *
89
+ * const bridge = createFormBridge(form, { preset: classValidatorPreset() });
90
+ *
91
+ * // No inference on string messages
92
+ * const bridge = createFormBridge(form, { preset: classValidatorPreset({ noInference: true }) });
93
+ * ```
94
+ */
95
+ function classValidatorPreset(options) {
96
+ const skipInference = options?.noInference ?? false;
97
+ return {
98
+ name: 'class-validator',
99
+ constraintMap: CLASS_VALIDATOR_CONSTRAINT_MAP,
100
+ parse(error) {
101
+ if (!error || typeof error !== 'object')
102
+ return [];
103
+ const err = error;
104
+ // Standard NestJS ValidationPipe format: { message: ClassValidatorError[] }
105
+ if (Array.isArray(err['message'])) {
106
+ const messages = err['message'];
107
+ // Check if it's an array of ClassValidatorError objects
108
+ if (messages.length > 0 && typeof messages[0] === 'object' && 'property' in messages[0]) {
109
+ return flattenErrors(messages);
110
+ }
111
+ // It might be an array of strings (simple messages)
112
+ if (messages.length > 0 && typeof messages[0] === 'string') {
113
+ if (skipInference) {
114
+ return messages.map(msg => ({ field: 'unknown', constraint: 'serverError', message: msg }));
115
+ }
116
+ const results = [];
117
+ for (const msg of messages) {
118
+ results.push(...parseStringMessage(msg));
119
+ }
120
+ return results;
121
+ }
122
+ }
123
+ // Single string message: { message: "email is required" }
124
+ if (typeof err['message'] === 'string') {
125
+ if (skipInference) {
126
+ return [{ field: 'unknown', constraint: 'serverError', message: err['message'] }];
127
+ }
128
+ return parseStringMessage(err['message']);
129
+ }
130
+ // Direct array (no wrapper): [{ property: 'email', constraints: {...} }]
131
+ if (Array.isArray(error)) {
132
+ const items = error;
133
+ if (items.length > 0 && typeof items[0] === 'object' && items[0] !== null && 'property' in items[0]) {
134
+ return flattenErrors(items);
135
+ }
136
+ }
137
+ return [];
138
+ },
139
+ };
140
+ }
141
+ /**
142
+ * Default constraint map for class-validator.
143
+ * Maps class-validator constraint names to Angular form error keys.
144
+ */
145
+ const CLASS_VALIDATOR_CONSTRAINT_MAP = {
146
+ isNotEmpty: 'required',
147
+ isEmail: 'email',
148
+ minLength: 'minlength',
149
+ maxLength: 'maxlength',
150
+ min: 'min',
151
+ max: 'max',
152
+ isPhoneNumber: 'phone',
153
+ isEnum: 'enum',
154
+ isDate: 'date',
155
+ isDateString: 'date',
156
+ isIBAN: 'iban',
157
+ isEAN: 'ean',
158
+ isUrl: 'url',
159
+ isURL: 'url',
160
+ isDecimal: 'decimal',
161
+ isNumber: 'number',
162
+ isInt: 'integer',
163
+ isBoolean: 'boolean',
164
+ isString: 'string',
165
+ isArray: 'array',
166
+ arrayMinSize: 'minlength',
167
+ arrayMaxSize: 'maxlength',
168
+ matches: 'pattern',
169
+ isStrongPassword: 'password',
170
+ unique: 'unique',
171
+ invalid: 'invalid',
172
+ required: 'required',
173
+ serverError: 'serverError',
174
+ };
175
+
176
+ /**
177
+ * FormBridge - The core class of ngx-api-forms.
178
+ *
179
+ * Bridges API validation errors to Angular Reactive Forms with:
180
+ * - Automatic error parsing via presets (class-validator, Laravel, Django, Zod)
181
+ * - i18n-friendly error messages
182
+ * - Angular Signals support
183
+ * - SSR-safe operations
184
+ */
185
+ /**
186
+ * FormBridge wraps an Angular FormGroup and provides a clean API
187
+ * for bridging API validation errors and managing form state.
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * const bridge = new FormBridge(myForm, {
192
+ * preset: classValidatorPreset(),
193
+ * });
194
+ *
195
+ * // Apply API errors
196
+ * bridge.applyApiErrors(err.error);
197
+ *
198
+ * // Use signals in templates
199
+ * readonly errors = bridge.errorsSignal;
200
+ * readonly firstError = bridge.firstErrorSignal;
201
+ * ```
202
+ */
203
+ class FormBridge {
204
+ _form;
205
+ _presets;
206
+ _constraintMap;
207
+ _i18n;
208
+ _catchAll;
209
+ _mergeErrors;
210
+ _debug;
211
+ _interceptors = [];
212
+ /** Signal containing the last set of resolved API errors */
213
+ _apiErrors = signal([], ...(ngDevMode ? [{ debugName: "_apiErrors" }] : []));
214
+ /** Signal containing global (non-field) errors */
215
+ _globalErrors = signal([], ...(ngDevMode ? [{ debugName: "_globalErrors" }] : []));
216
+ /** Tracks which error keys were set by the API on each control */
217
+ _apiErrorKeys = new Map();
218
+ // ---- Public Signals ----
219
+ /** Reactive signal of all current API errors applied to the form */
220
+ errorsSignal = this._apiErrors.asReadonly();
221
+ /**
222
+ * Reactive signal of global (non-field) errors.
223
+ *
224
+ * Contains errors from:
225
+ * - Django `non_field_errors` / `detail`
226
+ * - Zod `formErrors`
227
+ * - Any API error whose field does not match a form control
228
+ */
229
+ globalErrorsSignal = this._globalErrors.asReadonly();
230
+ /** Reactive signal of the first error (or null) */
231
+ firstErrorSignal = computed(() => {
232
+ const errors = this._apiErrors();
233
+ return errors.length > 0 ? errors[0] : null;
234
+ }, ...(ngDevMode ? [{ debugName: "firstErrorSignal" }] : []));
235
+ /** Whether any API errors are currently applied */
236
+ hasErrorsSignal = computed(() => this._apiErrors().length > 0 || this._globalErrors().length > 0, ...(ngDevMode ? [{ debugName: "hasErrorsSignal" }] : []));
237
+ constructor(form, config) {
238
+ this._form = form;
239
+ this._catchAll = config?.catchAll ?? false;
240
+ this._mergeErrors = config?.mergeErrors ?? false;
241
+ this._debug = config?.debug ?? false;
242
+ this._i18n = config?.i18n;
243
+ // Resolve presets
244
+ if (config?.preset) {
245
+ this._presets = Array.isArray(config.preset) ? config.preset : [config.preset];
246
+ }
247
+ else {
248
+ this._presets = [classValidatorPreset()];
249
+ }
250
+ // Build constraint map: preset defaults + custom overrides
251
+ const presetDefaults = this._resolvePresetConstraintMap(this._presets);
252
+ this._constraintMap = {
253
+ ...presetDefaults,
254
+ ...(config?.constraintMap ?? {}),
255
+ };
256
+ }
257
+ // ---- Public API - Error Management ----
258
+ /**
259
+ * Parse and apply API validation errors to the form.
260
+ *
261
+ * Tries each configured preset in order until one returns results.
262
+ * Errors are mapped to Angular form control errors.
263
+ *
264
+ * @param apiError - The raw error body from the API (e.g. `err.error`)
265
+ * @returns The array of resolved field errors that were applied
266
+ */
267
+ applyApiErrors(apiError) {
268
+ let fieldErrors = [];
269
+ // Try each preset until one returns results
270
+ for (const preset of this._presets) {
271
+ fieldErrors = preset.parse(apiError);
272
+ if (fieldErrors.length > 0)
273
+ break;
274
+ }
275
+ if (this._debug && fieldErrors.length === 0) {
276
+ console.warn('[ngx-api-forms] No preset produced results for the given error payload.', 'Presets tried:', this._presets.map(p => p.name).join(', '), 'Payload:', apiError);
277
+ }
278
+ // Run interceptors
279
+ for (const interceptor of this._interceptors) {
280
+ fieldErrors = interceptor(fieldErrors, this._form);
281
+ }
282
+ // Map and apply to form
283
+ const resolved = this._applyErrors(fieldErrors);
284
+ this._apiErrors.set(resolved);
285
+ return resolved;
286
+ }
287
+ /**
288
+ * Clear only the errors that were set by `applyApiErrors()`.
289
+ * Client-side validation errors (e.g. `Validators.required`) are preserved.
290
+ */
291
+ clearApiErrors() {
292
+ for (const [control, keys] of this._apiErrorKeys) {
293
+ if (!control.errors)
294
+ continue;
295
+ const remaining = { ...control.errors };
296
+ for (const key of keys) {
297
+ delete remaining[key];
298
+ }
299
+ control.setErrors(Object.keys(remaining).length > 0 ? remaining : null);
300
+ control.updateValueAndValidity();
301
+ }
302
+ this._apiErrorKeys.clear();
303
+ this._form.updateValueAndValidity({ onlySelf: false, emitEvent: true });
304
+ this._apiErrors.set([]);
305
+ this._globalErrors.set([]);
306
+ }
307
+ /**
308
+ * Get the first error across all form controls.
309
+ */
310
+ getFirstError() {
311
+ // Check API errors first
312
+ const apiErrors = this._apiErrors();
313
+ if (apiErrors.length > 0) {
314
+ return apiErrors[0];
315
+ }
316
+ // Fall back to client-side validation errors
317
+ for (const key of Object.keys(this._form.controls)) {
318
+ const control = this._form.controls[key];
319
+ if (control?.errors) {
320
+ const errorKeys = Object.keys(control.errors);
321
+ if (errorKeys.length > 0) {
322
+ const errorKey = errorKeys[0];
323
+ const errorValue = control.errors[errorKey];
324
+ return {
325
+ field: key,
326
+ errorKey,
327
+ message: typeof errorValue === 'string' ? errorValue : errorKey,
328
+ };
329
+ }
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+ /**
335
+ * Get all errors for a specific field.
336
+ */
337
+ getFieldErrors(fieldName) {
338
+ return this._form.controls[fieldName]?.errors ?? null;
339
+ }
340
+ /**
341
+ * Register an error interceptor that can modify or filter errors
342
+ * before they are applied to the form.
343
+ * Returns a dispose function to remove the interceptor.
344
+ */
345
+ addInterceptor(interceptor) {
346
+ this._interceptors.push(interceptor);
347
+ return () => {
348
+ this._interceptors = this._interceptors.filter(i => i !== interceptor);
349
+ };
350
+ }
351
+ // ---- Public API - Utilities ----
352
+ /**
353
+ * Access the underlying FormGroup.
354
+ */
355
+ get form() {
356
+ return this._form;
357
+ }
358
+ // ---- Private Methods ----
359
+ _resolvePresetConstraintMap(presets) {
360
+ const merged = {};
361
+ for (const preset of presets) {
362
+ if (preset.constraintMap)
363
+ Object.assign(merged, preset.constraintMap);
364
+ }
365
+ // If no preset provided a constraint map, fall back to class-validator
366
+ if (Object.keys(merged).length === 0) {
367
+ Object.assign(merged, CLASS_VALIDATOR_CONSTRAINT_MAP);
368
+ }
369
+ return merged;
370
+ }
371
+ _applyErrors(fieldErrors) {
372
+ const resolved = [];
373
+ const globalErrors = [];
374
+ // Accumulate errors per control to avoid overwrite within a single applyApiErrors call
375
+ const pendingErrors = new Map();
376
+ for (const fieldError of fieldErrors) {
377
+ // Global errors (explicit sentinel or unmatched field)
378
+ if (fieldError.field === GLOBAL_ERROR_FIELD) {
379
+ globalErrors.push({
380
+ message: fieldError.message,
381
+ constraint: fieldError.constraint,
382
+ });
383
+ continue;
384
+ }
385
+ let control = this._form.controls[fieldError.field] ?? null;
386
+ if (!control) {
387
+ // Try nested path (e.g. 'address.city' or 'items.0.name')
388
+ control = this._resolveNestedControl(fieldError.field);
389
+ if (!control) {
390
+ // Route unmatched field errors to global errors
391
+ globalErrors.push({
392
+ message: fieldError.message,
393
+ constraint: fieldError.constraint,
394
+ originalField: fieldError.field,
395
+ });
396
+ if (this._debug) {
397
+ console.warn(`[ngx-api-forms] Field "${fieldError.field}" does not match any form control - routed to globalErrorsSignal.`, 'Available controls:', Object.keys(this._form.controls).join(', '));
398
+ }
399
+ continue;
400
+ }
401
+ }
402
+ const errorKey = this._resolveErrorKey(fieldError.constraint);
403
+ const message = this._resolveMessage(fieldError);
404
+ if (!errorKey && !this._catchAll)
405
+ continue;
406
+ const finalErrorKey = errorKey || 'generic';
407
+ // Deduplicate: if the same key already exists on this control, suffix it
408
+ const existing = pendingErrors.get(control) ?? (this._mergeErrors ? (control.errors ?? {}) : {});
409
+ let uniqueKey = finalErrorKey;
410
+ if (existing[uniqueKey] !== undefined) {
411
+ let idx = 1;
412
+ while (existing[`${finalErrorKey}_${idx}`] !== undefined)
413
+ idx++;
414
+ uniqueKey = `${finalErrorKey}_${idx}`;
415
+ }
416
+ pendingErrors.set(control, { ...existing, [uniqueKey]: message });
417
+ // Track this key as API-set
418
+ if (!this._apiErrorKeys.has(control)) {
419
+ this._apiErrorKeys.set(control, new Set());
420
+ }
421
+ this._apiErrorKeys.get(control).add(uniqueKey);
422
+ resolved.push({ field: fieldError.field, errorKey: uniqueKey, message });
423
+ }
424
+ // Apply accumulated errors once per control
425
+ for (const [control, errors] of pendingErrors) {
426
+ control.markAsTouched();
427
+ control.setErrors(errors);
428
+ }
429
+ // Publish global errors
430
+ this._globalErrors.set(globalErrors);
431
+ return resolved;
432
+ }
433
+ _resolveNestedControl(path) {
434
+ const parts = path.split('.');
435
+ let current = this._form;
436
+ for (const part of parts) {
437
+ if (current instanceof FormGroup) {
438
+ const child = current.controls[part];
439
+ if (!child)
440
+ return null;
441
+ current = child;
442
+ }
443
+ else if (current instanceof FormArray) {
444
+ const index = parseInt(part, 10);
445
+ if (isNaN(index) || index < 0 || index >= current.length)
446
+ return null;
447
+ current = current.at(index);
448
+ }
449
+ else {
450
+ return null;
451
+ }
452
+ }
453
+ return current;
454
+ }
455
+ _resolveErrorKey(constraint) {
456
+ // If constraint is in the map, use the mapped value.
457
+ // Otherwise, use the constraint as-is (pass-through).
458
+ // This returns '' only when constraint is empty.
459
+ if (this._constraintMap[constraint] !== undefined) {
460
+ return this._constraintMap[constraint];
461
+ }
462
+ return constraint;
463
+ }
464
+ _resolveMessage(error) {
465
+ if (this._i18n?.resolver) {
466
+ const resolved = this._i18n.resolver(error.field, error.constraint, error.message);
467
+ if (resolved !== null)
468
+ return resolved;
469
+ }
470
+ if (this._i18n?.prefix) {
471
+ // Return the i18n key (to be resolved by the consumer's i18n system)
472
+ return `${this._i18n.prefix}.${error.field}.${error.constraint}`;
473
+ }
474
+ return error.message;
475
+ }
476
+ }
477
+ function createFormBridge(form, config) {
478
+ return new FormBridge(form, config);
479
+ }
480
+ function provideFormBridge(form, config) {
481
+ return new FormBridge(form, config);
482
+ }
483
+
484
+ /**
485
+ * NgxFormError directive.
486
+ *
487
+ * Automatically displays the error message for a form control.
488
+ * Works with both client-side validators and API errors set by FormBridge.
489
+ *
490
+ * @example
491
+ * ```html
492
+ * <input formControlName="email" />
493
+ * <span ngxFormError="email" [form]="myForm"></span>
494
+ * <!-- Displays: "email must be a valid email" -->
495
+ *
496
+ * <!-- With custom error map -->
497
+ * <span ngxFormError="email"
498
+ * [form]="myForm"
499
+ * [errorMessages]="{ required: 'Email is required', email: 'Invalid email' }">
500
+ * </span>
501
+ * ```
502
+ */
503
+ class NgxFormErrorDirective {
504
+ el = inject(ElementRef);
505
+ renderer = inject(Renderer2);
506
+ destroyRef = inject(DestroyRef);
507
+ resubscribe$ = new Subject();
508
+ /** The form control name to observe */
509
+ controlName;
510
+ /** The parent FormGroup */
511
+ form;
512
+ /**
513
+ * Optional map of error keys to display messages.
514
+ * If not provided, the raw error value is used (which is the API message
515
+ * when set by FormBridge).
516
+ */
517
+ errorMessages;
518
+ /**
519
+ * CSS class applied to the host element when an error is displayed.
520
+ * Default: 'ngx-form-error-visible'
521
+ */
522
+ errorClass = 'ngx-form-error-visible';
523
+ /**
524
+ * Whether to show errors only when the control is touched.
525
+ * Default: true
526
+ */
527
+ showOnTouched = true;
528
+ ngOnInit() {
529
+ this._subscribe();
530
+ }
531
+ ngOnChanges(changes) {
532
+ if ((changes['form'] || changes['controlName']) && !changes['form']?.firstChange) {
533
+ this._subscribe();
534
+ }
535
+ }
536
+ _subscribe() {
537
+ // Signal existing subscription to complete
538
+ this.resubscribe$.next();
539
+ const control = this.form?.get(this.controlName);
540
+ if (!control) {
541
+ this._hide();
542
+ return;
543
+ }
544
+ merge(control.statusChanges, control.valueChanges).pipe(takeUntil(this.resubscribe$), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
545
+ this._updateDisplay(control);
546
+ });
547
+ this._updateDisplay(control);
548
+ }
549
+ _updateDisplay(control) {
550
+ if (!control.errors || (this.showOnTouched && !control.touched && !control.dirty)) {
551
+ this._hide();
552
+ return;
553
+ }
554
+ const errorKeys = Object.keys(control.errors);
555
+ if (errorKeys.length === 0) {
556
+ this._hide();
557
+ return;
558
+ }
559
+ const firstKey = errorKeys[0];
560
+ const rawValue = control.errors[firstKey];
561
+ let message;
562
+ // Priority: custom errorMessages map > raw string value > error key
563
+ if (this.errorMessages?.[firstKey]) {
564
+ message = this.errorMessages[firstKey];
565
+ }
566
+ else if (typeof rawValue === 'string') {
567
+ message = rawValue;
568
+ }
569
+ else {
570
+ message = firstKey;
571
+ }
572
+ this._show(message);
573
+ }
574
+ _show(message) {
575
+ const el = this.el.nativeElement;
576
+ this.renderer.setProperty(el, 'textContent', message);
577
+ this.renderer.addClass(el, this.errorClass);
578
+ this.renderer.setStyle(el, 'display', '');
579
+ }
580
+ _hide() {
581
+ const el = this.el.nativeElement;
582
+ this.renderer.setProperty(el, 'textContent', '');
583
+ this.renderer.removeClass(el, this.errorClass);
584
+ this.renderer.setStyle(el, 'display', 'none');
585
+ }
586
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: NgxFormErrorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
587
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.16", type: NgxFormErrorDirective, isStandalone: true, selector: "[ngxFormError]", inputs: { controlName: ["ngxFormError", "controlName"], form: "form", errorMessages: "errorMessages", errorClass: "errorClass", showOnTouched: "showOnTouched" }, usesOnChanges: true, ngImport: i0 });
588
+ }
589
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: NgxFormErrorDirective, decorators: [{
590
+ type: Directive,
591
+ args: [{
592
+ selector: '[ngxFormError]',
593
+ standalone: true,
594
+ }]
595
+ }], propDecorators: { controlName: [{
596
+ type: Input,
597
+ args: ['ngxFormError']
598
+ }], form: [{
599
+ type: Input
600
+ }], errorMessages: [{
601
+ type: Input
602
+ }], errorClass: [{
603
+ type: Input
604
+ }], showOnTouched: [{
605
+ type: Input
606
+ }] } });
607
+
608
+ /**
609
+ * Parse raw API error body into normalized field errors without touching any form.
610
+ * Tries each preset in order until one returns results.
611
+ *
612
+ * Use this when you only need the parsing logic (e.g. in an HttpInterceptor,
613
+ * a store effect, or any context where you don't have a FormBridge).
614
+ *
615
+ * @param apiError - The raw error body from the API (e.g. `err.error`)
616
+ * @param preset - One or more presets to try. Defaults to class-validator.
617
+ * @param options - Optional settings. `debug: true` logs a warning when no preset matches.
618
+ * @returns Normalized array of field errors
619
+ *
620
+ * @example
621
+ * ```typescript
622
+ * import { parseApiErrors } from 'ngx-api-forms';
623
+ * import { laravelPreset } from 'ngx-api-forms/laravel';
624
+ *
625
+ * const errors = parseApiErrors(err.error, laravelPreset());
626
+ * // [{ field: 'email', constraint: 'required', message: 'The email field is required.' }]
627
+ * ```
628
+ */
629
+ function parseApiErrors(apiError, preset, options) {
630
+ const presets = preset
631
+ ? (Array.isArray(preset) ? preset : [preset])
632
+ : [classValidatorPreset()];
633
+ for (const p of presets) {
634
+ const result = p.parse(apiError);
635
+ if (result.length > 0)
636
+ return result;
637
+ }
638
+ if (options?.debug) {
639
+ console.warn('[ngx-api-forms] parseApiErrors: no preset produced results.', 'Presets tried:', presets.map(p => p.name).join(', '), 'Payload:', apiError);
640
+ }
641
+ return [];
642
+ }
643
+ /**
644
+ * Wrap an Observable with form submit lifecycle management.
645
+ *
646
+ * Standalone submit helper. Useful when you want disable/enable lifecycle
647
+ * without a full FormBridge instance.
648
+ *
649
+ * - Disables the form before subscribing
650
+ * - Re-enables the form on success or error
651
+ * - Returns the original Observable (errors are re-thrown)
652
+ *
653
+ * @param form - The FormGroup to manage
654
+ * @param source - The Observable to wrap (e.g. HttpClient call)
655
+ * @param options.onError - Callback invoked with the error before re-throwing
656
+ * @returns The wrapped Observable
657
+ *
658
+ * @example
659
+ * ```typescript
660
+ * import { wrapSubmit } from 'ngx-api-forms';
661
+ *
662
+ * wrapSubmit(this.form, this.http.post('/api', data), {
663
+ * onError: (err) => this.bridge.applyApiErrors(err.error),
664
+ * }).subscribe();
665
+ * ```
666
+ */
667
+ function wrapSubmit(form, source, options) {
668
+ form.disable();
669
+ return source.pipe(tap(() => form.enable()), catchError((err) => {
670
+ form.enable();
671
+ options?.onError?.(err);
672
+ throw err;
673
+ }));
674
+ }
675
+ /**
676
+ * Convert a plain object to FormData.
677
+ * Handles Files, Blobs, Arrays, Dates, nested objects.
678
+ */
679
+ function toFormData(data) {
680
+ const formData = new FormData();
681
+ for (const [key, value] of Object.entries(data)) {
682
+ if (value === null || value === undefined)
683
+ continue;
684
+ if (value instanceof File || value instanceof Blob) {
685
+ formData.append(key, value);
686
+ continue;
687
+ }
688
+ if (Array.isArray(value)) {
689
+ for (const item of value) {
690
+ if (item instanceof File || item instanceof Blob) {
691
+ formData.append(key, item);
692
+ }
693
+ else if (typeof item === 'object' && item !== null) {
694
+ formData.append(key, JSON.stringify(item));
695
+ }
696
+ else {
697
+ formData.append(key, String(item));
698
+ }
699
+ }
700
+ continue;
701
+ }
702
+ if (value instanceof Date) {
703
+ formData.append(key, value.toISOString());
704
+ continue;
705
+ }
706
+ if (typeof value === 'object') {
707
+ formData.append(key, JSON.stringify(value));
708
+ continue;
709
+ }
710
+ formData.append(key, String(value));
711
+ }
712
+ return formData;
713
+ }
714
+ /**
715
+ * Enable all controls in a form, with optional exceptions.
716
+ */
717
+ function enableForm(form, options) {
718
+ for (const key of Object.keys(form.controls)) {
719
+ if (options?.except?.includes(key))
720
+ continue;
721
+ form.controls[key].enable();
722
+ }
723
+ form.updateValueAndValidity({ onlySelf: false, emitEvent: true });
724
+ }
725
+ /**
726
+ * Disable all controls in a form, with optional exceptions.
727
+ */
728
+ function disableForm(form, options) {
729
+ for (const key of Object.keys(form.controls)) {
730
+ if (options?.except?.includes(key))
731
+ continue;
732
+ form.controls[key].disable();
733
+ }
734
+ }
735
+ /**
736
+ * Reset all errors on a form's controls (client-side and API).
737
+ */
738
+ function clearFormErrors(form) {
739
+ for (const key of Object.keys(form.controls)) {
740
+ form.controls[key].setErrors(null);
741
+ form.controls[key].updateValueAndValidity();
742
+ }
743
+ form.updateValueAndValidity({ onlySelf: false, emitEvent: true });
744
+ }
745
+ /**
746
+ * Get a flat record of only the dirty (changed) fields.
747
+ */
748
+ function getDirtyValues(form) {
749
+ const result = {};
750
+ for (const key of Object.keys(form.controls)) {
751
+ if (form.controls[key].dirty) {
752
+ result[key] = form.controls[key].value;
753
+ }
754
+ }
755
+ return result;
756
+ }
757
+ /**
758
+ * Check if any control in the form has a specific error key.
759
+ */
760
+ function hasError(form, errorKey) {
761
+ for (const key of Object.keys(form.controls)) {
762
+ if (form.controls[key].hasError(errorKey)) {
763
+ return true;
764
+ }
765
+ }
766
+ return false;
767
+ }
768
+ /**
769
+ * Get the error message for a specific field and error key.
770
+ * Returns the error value if it's a string, otherwise the error key.
771
+ */
772
+ function getErrorMessage(form, fieldName, errorKey) {
773
+ const control = form.controls[fieldName];
774
+ if (!control?.errors)
775
+ return null;
776
+ if (errorKey) {
777
+ const value = control.errors[errorKey];
778
+ return value ? (typeof value === 'string' ? value : errorKey) : null;
779
+ }
780
+ // Return first error
781
+ const keys = Object.keys(control.errors);
782
+ if (keys.length === 0)
783
+ return null;
784
+ const firstValue = control.errors[keys[0]];
785
+ return typeof firstValue === 'string' ? firstValue : keys[0];
786
+ }
787
+
788
+ /**
789
+ * Angular HttpInterceptorFn that automatically applies API validation errors
790
+ * to a FormBridge instance attached via HttpContext.
791
+ *
792
+ * Two usage patterns:
793
+ * 1. Per-request: tag individual requests with `withFormBridge(bridge)` --
794
+ * the interceptor catches 422/400 and calls `bridge.applyApiErrors()`.
795
+ * 2. Global: provide a custom `onError` callback in the interceptor config
796
+ * to centralize error handling (e.g. dispatch to a store or service).
797
+ */
798
+ /**
799
+ * HttpContext token that carries a FormBridge reference on a request.
800
+ * Used by `withFormBridge()` to tag requests for automatic error handling.
801
+ */
802
+ const FORM_BRIDGE = new HttpContextToken(() => null);
803
+ /**
804
+ * Create an Angular functional HTTP interceptor that handles API validation errors.
805
+ *
806
+ * When a response matches one of the configured status codes (default: 422, 400):
807
+ * - If the request was tagged with `withFormBridge(bridge)`, errors are automatically
808
+ * applied to that bridge. Zero code needed in `subscribe()`.
809
+ * - If an `onError` callback is provided, it is called with the parsed errors for
810
+ * global handling (store, toast, logging, etc.).
811
+ * - The error is always re-thrown so downstream `catchError` or `error` callbacks
812
+ * still fire when needed.
813
+ *
814
+ * @example
815
+ * ```typescript
816
+ * // app.config.ts
817
+ * import { provideHttpClient, withInterceptors } from '@angular/common/http';
818
+ * import { apiErrorInterceptor } from 'ngx-api-forms';
819
+ *
820
+ * export const appConfig = {
821
+ * providers: [
822
+ * provideHttpClient(
823
+ * withInterceptors([apiErrorInterceptor()])
824
+ * ),
825
+ * ],
826
+ * };
827
+ *
828
+ * // component.ts -- zero error handling code
829
+ * this.http.post('/api/save', data, withFormBridge(this.bridge)).subscribe({
830
+ * next: () => this.router.navigate(['/done']),
831
+ * });
832
+ * ```
833
+ */
834
+ function apiErrorInterceptor(config) {
835
+ const statusCodes = config?.statusCodes ?? [422, 400];
836
+ const fallbackPreset = config?.preset ?? classValidatorPreset();
837
+ return (req, next) => {
838
+ return next(req).pipe(catchError((error) => {
839
+ if (statusCodes.includes(error.status)) {
840
+ const bridge = req.context.get(FORM_BRIDGE);
841
+ if (bridge) {
842
+ bridge.applyApiErrors(error.error);
843
+ }
844
+ if (config?.onError) {
845
+ const parsed = bridge
846
+ ? bridge.errorsSignal()
847
+ : parseApiErrors(error.error, fallbackPreset);
848
+ config.onError(parsed, error);
849
+ }
850
+ }
851
+ return throwError(() => error);
852
+ }));
853
+ };
854
+ }
855
+ /**
856
+ * Attach a FormBridge to an HTTP request via HttpContext.
857
+ *
858
+ * When used with `apiErrorInterceptor`, the interceptor automatically calls
859
+ * `bridge.applyApiErrors()` on 422/400 responses. No error handling needed
860
+ * in `subscribe()`.
861
+ *
862
+ * @param bridge - The FormBridge instance to receive API errors
863
+ * @returns An options object to spread into HttpClient methods
864
+ *
865
+ * @example
866
+ * ```typescript
867
+ * // Errors are applied automatically -- no subscribe error handler needed
868
+ * this.http.post('/api/save', data, withFormBridge(this.bridge)).subscribe();
869
+ *
870
+ * // Works with all HttpClient methods
871
+ * this.http.put('/api/users/1', data, withFormBridge(this.bridge)).subscribe();
872
+ * ```
873
+ */
874
+ function withFormBridge(bridge) {
875
+ return {
876
+ context: new HttpContext().set(FORM_BRIDGE, bridge),
877
+ };
878
+ }
879
+
880
+ /*
881
+ * Public API Surface of ngx-api-forms
882
+ */
883
+ // Models and types
884
+
885
+ /**
886
+ * Generated bundle index. Do not edit.
887
+ */
888
+
889
+ export { CLASS_VALIDATOR_CONSTRAINT_MAP, FORM_BRIDGE, FormBridge, GLOBAL_ERROR_FIELD, NgxFormErrorDirective, apiErrorInterceptor, classValidatorPreset, clearFormErrors, createFormBridge, disableForm, enableForm, getDirtyValues, getErrorMessage, hasError, parseApiErrors, provideFormBridge, toFormData, withFormBridge, wrapSubmit };
890
+ //# sourceMappingURL=ngx-api-forms.mjs.map