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.
- package/LICENSE +21 -0
- package/README.md +657 -0
- package/analog/index.d.ts +76 -0
- package/django/index.d.ts +56 -0
- package/express-validator/index.d.ts +60 -0
- package/fesm2022/ngx-api-forms-analog.mjs +195 -0
- package/fesm2022/ngx-api-forms-analog.mjs.map +1 -0
- package/fesm2022/ngx-api-forms-django.mjs +173 -0
- package/fesm2022/ngx-api-forms-django.mjs.map +1 -0
- package/fesm2022/ngx-api-forms-express-validator.mjs +202 -0
- package/fesm2022/ngx-api-forms-express-validator.mjs.map +1 -0
- package/fesm2022/ngx-api-forms-laravel.mjs +167 -0
- package/fesm2022/ngx-api-forms-laravel.mjs.map +1 -0
- package/fesm2022/ngx-api-forms-zod.mjs +226 -0
- package/fesm2022/ngx-api-forms-zod.mjs.map +1 -0
- package/fesm2022/ngx-api-forms.mjs +890 -0
- package/fesm2022/ngx-api-forms.mjs.map +1 -0
- package/index.d.ts +621 -0
- package/laravel/index.d.ts +49 -0
- package/package.json +85 -0
- package/schematics/collection.json +10 -0
- package/schematics/ng-add/index.js +300 -0
- package/schematics/ng-add/index.spec.js +119 -0
- package/schematics/ng-add/schema.json +34 -0
- package/zod/index.d.ts +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikhaël GERBET
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
# ngx-api-forms
|
|
2
|
+
|
|
3
|
+
**API error parsing library for Angular.** Normalizes validation error responses from any backend into a consistent format your forms can consume. Not a display library -- a parsing library.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/ngx-api-forms)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://angular.dev)
|
|
8
|
+
[](https://github.com/MikhaelGerbet/ngx-api-forms/actions)
|
|
9
|
+
[](https://www.npmjs.com/package/ngx-api-forms?activeTab=dependencies)
|
|
10
|
+
|
|
11
|
+
**[Live Demo](https://mikhaelgerbet.github.io/ngx-api-forms/)**
|
|
12
|
+
|
|
13
|
+
## The Problem
|
|
14
|
+
|
|
15
|
+
Libraries like `@ngneat/error-tailor` or `ngx-valdemort` handle the **display** side -- rendering `Validators.required` messages in templates. But when your API returns a 422, those libraries can't help. You're left writing backend-specific parsing logic by hand:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// Brittle, repetitive, backend-specific
|
|
19
|
+
this.http.post('/api/register', data).subscribe({
|
|
20
|
+
error: (err) => {
|
|
21
|
+
const messages = err.error?.message; // NestJS format
|
|
22
|
+
if (Array.isArray(messages)) {
|
|
23
|
+
for (const msg of messages) {
|
|
24
|
+
const ctrl = this.form.get(msg.property);
|
|
25
|
+
if (ctrl) {
|
|
26
|
+
ctrl.setErrors(msg.constraints);
|
|
27
|
+
ctrl.markAsTouched();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Switch from NestJS to Laravel and every error handler must be rewritten. Ten forms means ten copies of the same parsing logic. Most teams flatten everything into `{ serverError: message }`, losing constraint semantics entirely.
|
|
36
|
+
|
|
37
|
+
**ngx-api-forms fills the gap between the API and Reactive Forms.**
|
|
38
|
+
|
|
39
|
+
## When NOT to Use This
|
|
40
|
+
|
|
41
|
+
This library only helps when your API returns **structured, field-level validation errors** (e.g. `{ email: ["required"] }`). If your backend returns flat messages like `{ message: "Bad request" }` with no per-field breakdown, ngx-api-forms cannot map anything to form controls.
|
|
42
|
+
|
|
43
|
+
In practice, this rules out:
|
|
44
|
+
- APIs that only return a single error string for the whole request
|
|
45
|
+
- Generic 500 errors
|
|
46
|
+
- Errors not tied to user input (infrastructure failures, rate limiting)
|
|
47
|
+
|
|
48
|
+
If you're unsure, call `parseApiErrors(err.error, yourPreset())` and check the output. Empty array means the format is not supported.
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
### Minimal: parse errors without a form
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { parseApiErrors } from 'ngx-api-forms';
|
|
56
|
+
import { laravelPreset } from 'ngx-api-forms/laravel';
|
|
57
|
+
|
|
58
|
+
const errors = parseApiErrors(apiResponse, laravelPreset());
|
|
59
|
+
// [{ field: 'email', constraint: 'required', message: 'The email field is required.' }]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
One function, one preset, structured output. No form needed. Works in interceptors, NgRx effects, services, tests -- anywhere.
|
|
63
|
+
|
|
64
|
+
### Full: parse and apply to a form
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { Component, inject } from '@angular/core';
|
|
68
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
69
|
+
import { HttpClient } from '@angular/common/http';
|
|
70
|
+
import { provideFormBridge, classValidatorPreset, NgxFormErrorDirective } from 'ngx-api-forms';
|
|
71
|
+
|
|
72
|
+
@Component({
|
|
73
|
+
standalone: true,
|
|
74
|
+
imports: [ReactiveFormsModule, NgxFormErrorDirective],
|
|
75
|
+
template: `
|
|
76
|
+
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
|
77
|
+
<input formControlName="email" />
|
|
78
|
+
<span ngxFormError="email" [form]="form"></span>
|
|
79
|
+
|
|
80
|
+
<input formControlName="name" />
|
|
81
|
+
<span ngxFormError="name" [form]="form"></span>
|
|
82
|
+
|
|
83
|
+
<button type="submit">Save</button>
|
|
84
|
+
</form>
|
|
85
|
+
`
|
|
86
|
+
})
|
|
87
|
+
export class MyComponent {
|
|
88
|
+
private http = inject(HttpClient);
|
|
89
|
+
private fb = inject(FormBuilder);
|
|
90
|
+
|
|
91
|
+
form = this.fb.group({
|
|
92
|
+
email: ['', [Validators.required, Validators.email]],
|
|
93
|
+
name: ['', [Validators.required, Validators.minLength(3)]],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
bridge = provideFormBridge(this.form, {
|
|
97
|
+
preset: classValidatorPreset(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
onSubmit() {
|
|
101
|
+
this.http.post('/api/save', this.form.value).subscribe({
|
|
102
|
+
error: (err) => this.bridge.applyApiErrors(err.error)
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Installation
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm install ngx-api-forms
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Import the core library and the preset for your backend:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// Core (FormBridge, interceptor, utilities, classValidatorPreset)
|
|
118
|
+
import { provideFormBridge, classValidatorPreset } from 'ngx-api-forms';
|
|
119
|
+
|
|
120
|
+
// Backend-specific presets (secondary entry points, tree-shakable)
|
|
121
|
+
import { laravelPreset } from 'ngx-api-forms/laravel';
|
|
122
|
+
import { djangoPreset } from 'ngx-api-forms/django';
|
|
123
|
+
import { zodPreset } from 'ngx-api-forms/zod';
|
|
124
|
+
import { expressValidatorPreset } from 'ngx-api-forms/express-validator';
|
|
125
|
+
import { analogPreset } from 'ngx-api-forms/analog';
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Each preset is a separate entry point. If you only use `laravelPreset`, the Django, Zod, express-validator, and Analog code is never included in your bundle.
|
|
129
|
+
|
|
130
|
+
`ng add` installs the package and auto-injects `apiErrorInterceptor` into your `app.config.ts`:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
ng add ngx-api-forms --preset=laravel
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Available presets: `laravel`, `django`, `class-validator`, `zod`, `express-validator`, `analog`.
|
|
137
|
+
|
|
138
|
+
## Supported Backend Formats
|
|
139
|
+
|
|
140
|
+
### NestJS / class-validator
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"statusCode": 400,
|
|
144
|
+
"message": [
|
|
145
|
+
{ "property": "email", "constraints": { "isEmail": "email must be a valid email" } }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Laravel
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"message": "The given data was invalid.",
|
|
154
|
+
"errors": {
|
|
155
|
+
"email": ["The email field is required."]
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Django REST Framework
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"email": ["This field is required."],
|
|
164
|
+
"name": ["Ensure this field has at least 3 characters."]
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Zod
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"fieldErrors": {
|
|
172
|
+
"email": ["Invalid email"]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Express / express-validator
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"errors": [
|
|
181
|
+
{ "type": "field", "path": "email", "msg": "Invalid value", "location": "body" }
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Also handles the legacy v5/v6 format (`{ param, msg }`) and direct arrays.
|
|
187
|
+
|
|
188
|
+
### Analog (Nitro/h3)
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"statusCode": 422,
|
|
192
|
+
"statusMessage": "Validation failed",
|
|
193
|
+
"data": {
|
|
194
|
+
"email": ["This field is required."],
|
|
195
|
+
"name": ["Must be at least 3 characters."]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Unwraps the Nitro/h3 `createError()` envelope. Also handles direct `{ field: string[] }` format without the envelope.
|
|
201
|
+
|
|
202
|
+
## Constraint Inference and i18n Limitation
|
|
203
|
+
|
|
204
|
+
The Laravel, Django, Zod, and express-validator presets infer constraint types (e.g. "required", "email") by pattern-matching on the English text of error messages. This works reliably with default backend messages.
|
|
205
|
+
|
|
206
|
+
When a message does not match any pattern, the constraint falls back to `'serverError'` with the original message preserved. Unrecognized messages are never lost.
|
|
207
|
+
|
|
208
|
+
**Important: inference only works with default English messages.** The built-in regex patterns match strings like `"The email field is required."` (Laravel) or `"This field is required."` (Django). If your backend returns messages in another language (e.g. `"Ce champ est obligatoire."`), the pattern will not match and the error will use `constraint: 'serverError'` instead of `constraint: 'required'`.
|
|
209
|
+
|
|
210
|
+
This is by design: parsing free-text in multiple languages reliably is not feasible. If your backend returns non-English messages, you have several options:
|
|
211
|
+
|
|
212
|
+
Known limitations:
|
|
213
|
+
|
|
214
|
+
- **Translated messages**: Non-English messages fall back to `'serverError'`.
|
|
215
|
+
- **Custom messages**: Overridden validation messages may not match the built-in patterns.
|
|
216
|
+
- **NestJS/class-validator does not have this limitation** because it transmits the constraint key directly.
|
|
217
|
+
|
|
218
|
+
When inference is not enough:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// 1. Disable inference entirely and use the raw message
|
|
222
|
+
const bridge = provideFormBridge(form, {
|
|
223
|
+
preset: laravelPreset({ noInference: true }),
|
|
224
|
+
});
|
|
225
|
+
// All errors get constraint: 'serverError' with the original message preserved.
|
|
226
|
+
// Display the message directly in your template.
|
|
227
|
+
|
|
228
|
+
// 2. Custom constraintMap to map specific messages to constraints
|
|
229
|
+
const bridge = provideFormBridge(form, {
|
|
230
|
+
preset: laravelPreset(),
|
|
231
|
+
constraintMap: {
|
|
232
|
+
'Ce champ est obligatoire.': 'required',
|
|
233
|
+
'Adresse email invalide.': 'email',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// 3. catchAll to apply unmatched errors as { generic: msg }
|
|
238
|
+
const bridge = provideFormBridge(form, {
|
|
239
|
+
preset: laravelPreset(),
|
|
240
|
+
catchAll: true,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// 4. constraintPatterns: provide regex patterns for your language
|
|
244
|
+
const bridge = provideFormBridge(form, {
|
|
245
|
+
preset: laravelPreset({
|
|
246
|
+
constraintPatterns: {
|
|
247
|
+
required: /est obligatoire/i,
|
|
248
|
+
email: /courriel.*invalide/i,
|
|
249
|
+
minlength: /au moins \d+ caract/i,
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
// User patterns are checked first; unmatched messages fall through to English inference.
|
|
254
|
+
|
|
255
|
+
// 5. Write a custom preset for full control (see below)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Schema-Based Inference
|
|
259
|
+
|
|
260
|
+
When your backend returns structured error codes alongside messages, presets use them directly without text matching. This makes constraint inference fully language-independent.
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
// Django DRF with custom exception handler
|
|
264
|
+
{ "email": [{ "message": "Ce champ est obligatoire.", "code": "required" }] }
|
|
265
|
+
|
|
266
|
+
// Laravel with rule names
|
|
267
|
+
{ "errors": { "email": [{ "message": "Le champ est requis.", "rule": "required" }] } }
|
|
268
|
+
|
|
269
|
+
// express-validator with code field
|
|
270
|
+
{ "errors": [{ "type": "field", "path": "email", "msg": "Adresse invalide", "code": "email" }] }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
When `code` (Django/Analog), `rule` (Laravel), or `code` (express-validator) is present, the value is used as the constraint directly. No regex matching, no language assumption. Falls back to text inference when the structured field is absent.
|
|
274
|
+
|
|
275
|
+
## Global Errors
|
|
276
|
+
|
|
277
|
+
Some backends return errors not tied to any specific field -- Django's `non_field_errors`, Zod's `formErrors`, or a field name that does not match any form control. These errors are collected in `globalErrorsSignal` instead of being silently dropped.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
bridge.applyApiErrors({
|
|
281
|
+
non_field_errors: ['Unable to log in with provided credentials.'],
|
|
282
|
+
email: ['This field is required.'],
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Field errors applied to controls
|
|
286
|
+
console.log(form.controls.email.hasError('required')); // true
|
|
287
|
+
|
|
288
|
+
// Global errors available via signal
|
|
289
|
+
console.log(bridge.globalErrorsSignal());
|
|
290
|
+
// [{ message: 'Unable to log in with provided credentials.', constraint: 'serverError' }]
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`clearApiErrors()` clears both field errors and global errors. `hasErrorsSignal` accounts for global errors too.
|
|
294
|
+
|
|
295
|
+
Unmatched fields (errors referencing a field that does not exist in the form) are also routed to `globalErrorsSignal` with the original field name preserved in the `originalField` property.
|
|
296
|
+
|
|
297
|
+
## Switching Backends
|
|
298
|
+
|
|
299
|
+
Each backend has its own preset. Pass an array if your app talks to multiple APIs -- they are tried in order until one matches.
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
import { provideFormBridge, classValidatorPreset } from 'ngx-api-forms';
|
|
303
|
+
import { laravelPreset } from 'ngx-api-forms/laravel';
|
|
304
|
+
import { djangoPreset } from 'ngx-api-forms/django';
|
|
305
|
+
import { zodPreset } from 'ngx-api-forms/zod';
|
|
306
|
+
import { expressValidatorPreset } from 'ngx-api-forms/express-validator';
|
|
307
|
+
import { analogPreset } from 'ngx-api-forms/analog';
|
|
308
|
+
|
|
309
|
+
// Laravel
|
|
310
|
+
const bridge = provideFormBridge(form, { preset: laravelPreset() });
|
|
311
|
+
|
|
312
|
+
// Django REST Framework
|
|
313
|
+
const bridge = provideFormBridge(form, { preset: djangoPreset() });
|
|
314
|
+
|
|
315
|
+
// Zod (e.g. with tRPC)
|
|
316
|
+
const bridge = provideFormBridge(form, { preset: zodPreset() });
|
|
317
|
+
|
|
318
|
+
// Express / express-validator
|
|
319
|
+
const bridge = provideFormBridge(form, { preset: expressValidatorPreset() });
|
|
320
|
+
|
|
321
|
+
// Analog (Nitro/h3)
|
|
322
|
+
const bridge = provideFormBridge(form, { preset: analogPreset() });
|
|
323
|
+
|
|
324
|
+
// Multiple presets, tried in order
|
|
325
|
+
const bridge = provideFormBridge(form, {
|
|
326
|
+
preset: [classValidatorPreset(), laravelPreset()]
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Automatic Error Handling with HttpInterceptor
|
|
331
|
+
|
|
332
|
+
The library ships a ready-to-use `apiErrorInterceptor` that catches 422/400 responses and applies errors to the right FormBridge automatically.
|
|
333
|
+
|
|
334
|
+
### Setup
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
// app.config.ts
|
|
338
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
339
|
+
import { apiErrorInterceptor } from 'ngx-api-forms';
|
|
340
|
+
|
|
341
|
+
export const appConfig = {
|
|
342
|
+
providers: [
|
|
343
|
+
provideHttpClient(
|
|
344
|
+
withInterceptors([apiErrorInterceptor()])
|
|
345
|
+
),
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Per-request: tag with `withFormBridge()`
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
import { withFormBridge } from 'ngx-api-forms';
|
|
354
|
+
|
|
355
|
+
// Errors are applied automatically -- no error handler needed
|
|
356
|
+
this.http.post('/api/save', data, withFormBridge(this.bridge)).subscribe({
|
|
357
|
+
next: () => this.router.navigate(['/done']),
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Global: centralize with `onError`
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
apiErrorInterceptor({
|
|
365
|
+
preset: classValidatorPreset(),
|
|
366
|
+
onError: (errors, response) => {
|
|
367
|
+
errorStore.setFieldErrors(errors);
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Standalone: `parseApiErrors` in your own interceptor
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
|
376
|
+
import { catchError, throwError } from 'rxjs';
|
|
377
|
+
import { parseApiErrors, classValidatorPreset } from 'ngx-api-forms';
|
|
378
|
+
|
|
379
|
+
export const myInterceptor: HttpInterceptorFn = (req, next) => {
|
|
380
|
+
return next(req).pipe(
|
|
381
|
+
catchError((err: HttpErrorResponse) => {
|
|
382
|
+
if (err.status === 422) {
|
|
383
|
+
const fieldErrors = parseApiErrors(err.error, classValidatorPreset());
|
|
384
|
+
// route to your store, service, or whatever you need
|
|
385
|
+
}
|
|
386
|
+
return throwError(() => err);
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
## Resource Integration (Angular 19+)
|
|
393
|
+
|
|
394
|
+
When using `resource()` or `rxResource()`, a simple `effect()` is all you need to wire the error signal to a FormBridge:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { effect } from '@angular/core';
|
|
398
|
+
import { rxResource } from '@angular/core/rxjs-interop';
|
|
399
|
+
import { provideFormBridge } from 'ngx-api-forms';
|
|
400
|
+
import { djangoPreset } from 'ngx-api-forms/django';
|
|
401
|
+
|
|
402
|
+
@Component({ ... })
|
|
403
|
+
export class EditComponent {
|
|
404
|
+
private http = inject(HttpClient);
|
|
405
|
+
form = inject(FormBuilder).group({ name: [''], email: [''] });
|
|
406
|
+
bridge = provideFormBridge(this.form, { preset: djangoPreset() });
|
|
407
|
+
|
|
408
|
+
saveResource = rxResource({
|
|
409
|
+
loader: () => this.http.put('/api/profile', this.form.value),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
private ref = effect(() => {
|
|
413
|
+
const err = this.saveResource.error();
|
|
414
|
+
err ? this.bridge.applyApiErrors(err) : this.bridge.clearApiErrors();
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
This pattern works with any `Signal<unknown>` -- not limited to Angular resources. No wrapper API needed: Angular's `effect()` already tracks signal dependencies and re-runs when the error changes.
|
|
420
|
+
|
|
421
|
+
## Typed Forms
|
|
422
|
+
|
|
423
|
+
`FormBridge` is generic. When you pass a typed `FormGroup`, the `form` getter preserves the type:
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
interface LoginForm {
|
|
427
|
+
email: FormControl<string>;
|
|
428
|
+
password: FormControl<string>;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const form = new FormGroup<LoginForm>({ ... });
|
|
432
|
+
const bridge = provideFormBridge(form);
|
|
433
|
+
|
|
434
|
+
// bridge.form is typed as FormGroup<LoginForm>
|
|
435
|
+
bridge.form.controls.email; // FormControl<string> -- full autocompletion
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
## API Reference
|
|
439
|
+
|
|
440
|
+
### `parseApiErrors(error, preset?, options?)`
|
|
441
|
+
|
|
442
|
+
Parse API errors without a form. Works in interceptors, stores, effects, tests -- anywhere. Returns `ApiFieldError[]`.
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
import { parseApiErrors } from 'ngx-api-forms';
|
|
446
|
+
import { laravelPreset } from 'ngx-api-forms/laravel';
|
|
447
|
+
|
|
448
|
+
const errors = parseApiErrors(err.error, laravelPreset());
|
|
449
|
+
// [{ field: 'email', constraint: 'required', message: 'The email field is required.' }]
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### HttpInterceptor
|
|
453
|
+
|
|
454
|
+
| Export | Description |
|
|
455
|
+
|--------|-------------|
|
|
456
|
+
| `apiErrorInterceptor(config?)` | Functional interceptor. Catches 422/400 and auto-applies errors to tagged bridges |
|
|
457
|
+
| `withFormBridge(bridge)` | Attach a FormBridge to an HTTP request via HttpContext |
|
|
458
|
+
| `FORM_BRIDGE` | The `HttpContextToken` used internally (advanced) |
|
|
459
|
+
|
|
460
|
+
### FormBridge (form integration)
|
|
461
|
+
|
|
462
|
+
Create with `provideFormBridge(form, config?)` or `createFormBridge(form, config?)`. Both are equivalent.
|
|
463
|
+
|
|
464
|
+
| Method | Returns | Description |
|
|
465
|
+
|--------|---------|-------------|
|
|
466
|
+
| `applyApiErrors(error)` | `ResolvedFieldError[]` | Parse and apply API errors to form controls |
|
|
467
|
+
| `clearApiErrors()` | `void` | Remove only the API-set errors (client-side validators are preserved) |
|
|
468
|
+
| `getFirstError()` | `FirstError \| null` | First error across all controls |
|
|
469
|
+
| `getFieldErrors(field)` | `ValidationErrors \| null` | Errors for a specific field |
|
|
470
|
+
| `addInterceptor(fn)` | `() => void` | Register an error interceptor. Returns a dispose function |
|
|
471
|
+
|
|
472
|
+
### Signals
|
|
473
|
+
|
|
474
|
+
| Signal | Type | Description |
|
|
475
|
+
|--------|------|-------------|
|
|
476
|
+
| `errorsSignal` | `Signal<ResolvedFieldError[]>` | All current field-level API errors |
|
|
477
|
+
| `globalErrorsSignal` | `Signal<GlobalError[]>` | Non-field errors (Django `non_field_errors`, Zod `formErrors`, unmatched fields) |
|
|
478
|
+
| `firstErrorSignal` | `Signal<FirstError \| null>` | First error, or null |
|
|
479
|
+
| `hasErrorsSignal` | `Signal<boolean>` | Whether any API errors exist (field or global) |
|
|
480
|
+
|
|
481
|
+
### Constants
|
|
482
|
+
|
|
483
|
+
| Export | Description |
|
|
484
|
+
|--------|-------------|
|
|
485
|
+
| `GLOBAL_ERROR_FIELD` | Sentinel field name (`'__global__'`) used by presets to mark non-field errors |
|
|
486
|
+
|
|
487
|
+
### Standalone Utility Functions
|
|
488
|
+
|
|
489
|
+
| Function | Description |
|
|
490
|
+
|----------|-------------|
|
|
491
|
+
| `wrapSubmit(form, source, options?)` | Submit lifecycle (disable/enable) without FormBridge |
|
|
492
|
+
| `toFormData(data)` | Convert a plain object to FormData. Handles Files, Blobs, Arrays, nested objects |
|
|
493
|
+
| `enableForm(form, options?)` | Enable all controls, with optional `except` list |
|
|
494
|
+
| `disableForm(form, options?)` | Disable all controls, with optional `except` list |
|
|
495
|
+
| `clearFormErrors(form)` | Clear all errors from all controls |
|
|
496
|
+
| `getDirtyValues(form)` | Return only the dirty fields and their values |
|
|
497
|
+
| `hasError(form, errorKey)` | Check if any control has a specific error |
|
|
498
|
+
| `getErrorMessage(form, field, key?)` | Get the error message string for a field |
|
|
499
|
+
|
|
500
|
+
### Preset Options
|
|
501
|
+
|
|
502
|
+
All built-in presets accept a `noInference` option. The Laravel, Django, Zod, and express-validator presets also accept `constraintPatterns` for custom i18n regex matching:
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
// Skip inference entirely
|
|
506
|
+
laravelPreset({ noInference: true })
|
|
507
|
+
djangoPreset({ noInference: true })
|
|
508
|
+
zodPreset({ noInference: true })
|
|
509
|
+
expressValidatorPreset({ noInference: true })
|
|
510
|
+
analogPreset({ noInference: true })
|
|
511
|
+
classValidatorPreset({ noInference: true }) // only affects string message fallback
|
|
512
|
+
|
|
513
|
+
// Provide regex patterns for non-English messages
|
|
514
|
+
laravelPreset({
|
|
515
|
+
constraintPatterns: {
|
|
516
|
+
required: /est obligatoire/i,
|
|
517
|
+
email: /courriel.*invalide/i,
|
|
518
|
+
},
|
|
519
|
+
})
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
When `noInference: true`, all errors use `constraint: 'serverError'` with the original message preserved.
|
|
523
|
+
|
|
524
|
+
`constraintPatterns` takes a `Record<string, RegExp>`. Each regex is tested against the raw error message. Matched patterns return the corresponding constraint key. Unmatched messages fall through to the default English inference.
|
|
525
|
+
|
|
526
|
+
### Configuration
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
interface FormBridgeConfig {
|
|
530
|
+
preset?: ErrorPreset | ErrorPreset[];
|
|
531
|
+
constraintMap?: Record<string, string>;
|
|
532
|
+
i18n?: {
|
|
533
|
+
prefix?: string;
|
|
534
|
+
resolver?: (field, constraint, message) => string | null;
|
|
535
|
+
};
|
|
536
|
+
catchAll?: boolean; // Apply unmatched errors as { generic: msg }
|
|
537
|
+
mergeErrors?: boolean; // Merge with existing errors instead of replacing
|
|
538
|
+
debug?: boolean; // Log warnings when presets or fields don't match
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
## Debug Mode
|
|
543
|
+
|
|
544
|
+
Set `debug: true` to log warnings during development:
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
const bridge = provideFormBridge(form, {
|
|
548
|
+
preset: laravelPreset(),
|
|
549
|
+
debug: true,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Or standalone:
|
|
553
|
+
const errors = parseApiErrors(err.error, laravelPreset(), { debug: true });
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
The library warns when:
|
|
557
|
+
- No preset produces results for a given error payload (format might be wrong or unsupported)
|
|
558
|
+
- A parsed error field does not match any form control (possible typo or missing control)
|
|
559
|
+
|
|
560
|
+
## Submit and Loading State
|
|
561
|
+
|
|
562
|
+
`wrapSubmit` handles the disable/enable lifecycle as a standalone function:
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
import { wrapSubmit } from 'ngx-api-forms';
|
|
566
|
+
|
|
567
|
+
wrapSubmit(this.form, this.http.post('/api', data), {
|
|
568
|
+
onError: (err) => this.bridge.applyApiErrors(err.error),
|
|
569
|
+
}).subscribe({
|
|
570
|
+
next: () => this.router.navigate(['/done']),
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## i18n
|
|
575
|
+
|
|
576
|
+
Generate translation keys automatically or provide a custom resolver:
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
// Translation key prefix
|
|
580
|
+
const bridge = provideFormBridge(form, {
|
|
581
|
+
preset: classValidatorPreset(),
|
|
582
|
+
i18n: { prefix: 'validation' }
|
|
583
|
+
});
|
|
584
|
+
// Produces keys like "validation.email.isEmail"
|
|
585
|
+
|
|
586
|
+
// Custom resolver
|
|
587
|
+
const bridge = provideFormBridge(form, {
|
|
588
|
+
i18n: {
|
|
589
|
+
resolver: (field, constraint, originalMessage) => {
|
|
590
|
+
return this.translate.instant(`errors.${field}.${constraint}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## Error Interceptors
|
|
597
|
+
|
|
598
|
+
Interceptors let you filter or transform errors before they reach the form:
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
const dispose = bridge.addInterceptor((errors, form) => {
|
|
602
|
+
return errors.filter(e => e.field !== 'internalField');
|
|
603
|
+
});
|
|
604
|
+
// Later: dispose() to remove the interceptor
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
## NgxFormError Directive
|
|
608
|
+
|
|
609
|
+
```html
|
|
610
|
+
<!-- Basic usage -->
|
|
611
|
+
<span ngxFormError="email" [form]="myForm"></span>
|
|
612
|
+
|
|
613
|
+
<!-- Custom error messages -->
|
|
614
|
+
<span ngxFormError="email"
|
|
615
|
+
[form]="myForm"
|
|
616
|
+
[errorMessages]="{ required: 'Email requis', email: 'Email invalide' }">
|
|
617
|
+
</span>
|
|
618
|
+
|
|
619
|
+
<!-- Show errors before the field is touched -->
|
|
620
|
+
<span ngxFormError="email" [form]="myForm" [showOnTouched]="false"></span>
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
## Custom Preset
|
|
624
|
+
|
|
625
|
+
If your backend uses a different format, write a preset in a few lines:
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
import { ErrorPreset, ApiFieldError } from 'ngx-api-forms';
|
|
629
|
+
|
|
630
|
+
export function myBackendPreset(): ErrorPreset {
|
|
631
|
+
return {
|
|
632
|
+
name: 'my-backend',
|
|
633
|
+
parse(error: unknown): ApiFieldError[] {
|
|
634
|
+
const err = error as { validationErrors: Array<{ field: string; rule: string; msg: string }> };
|
|
635
|
+
return (err.validationErrors ?? []).map(e => ({
|
|
636
|
+
field: e.field,
|
|
637
|
+
constraint: e.rule,
|
|
638
|
+
message: e.msg,
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
## Angular Compatibility
|
|
646
|
+
|
|
647
|
+
| ngx-api-forms | Angular |
|
|
648
|
+
|:---:|:---:|
|
|
649
|
+
| 1.x | 17.x, 18.x, 19.x, 20.x |
|
|
650
|
+
|
|
651
|
+
## Contributing
|
|
652
|
+
|
|
653
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
654
|
+
|
|
655
|
+
## License
|
|
656
|
+
|
|
657
|
+
MIT - [Mikhael GERBET](https://github.com/MikhaelGerbet)
|