adaptform 2.0.4 → 2.1.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 CHANGED
@@ -1 +1,388 @@
1
- Library of adaptive forms of validation
1
+ # AdaptForm
2
+
3
+ [English](README.md) · [Русский](README.ru.md)
4
+
5
+ [![npm version](https://img.shields.io/npm/v/adaptform.svg)](https://www.npmjs.com/package/adaptform)
6
+ [![npm downloads](https://img.shields.io/npm/dm/adaptform.svg)](https://www.npmjs.com/package/adaptform)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)
9
+
10
+ **[Open the interactive demo](https://arhimen.github.io/AdaptForm/)** · **[Read the 2.1.0 changelog](https://github.com/ArhiMEN/AdaptForm/blob/master/CHANGELOG.md)** · **[Browse the demo source](https://github.com/ArhiMEN/AdaptForm/tree/master/demo)**
11
+
12
+ Maintainers: [automatic npm publication setup](docs/NPM_PUBLISH.md).
13
+
14
+ AdaptForm is a TypeScript-first form modeling library inspired by Django Forms and Pydantic. Define forms as classes with typed fields. Each field owns its value, casting, validation, transformation plugins, errors, and state.
15
+
16
+ ## Features
17
+
18
+ - Class-based form models with inferred TypeScript value types.
19
+ - Field-level and cross-field validation.
20
+ - Conditional required rules and custom synchronous validators.
21
+ - String, number, decimal, boolean, and JavaScript Date field types.
22
+ - Dynamic arrays of fields and arrays of nested forms.
23
+ - Input transformation plugins, including `MaskPlugin`.
24
+ - Server-error mapping with `ErrorsSchema`.
25
+ - JSON and `FormData` serialization.
26
+ - Optional Luxon integration through the `adaptform/luxon` entry point.
27
+ - Framework-agnostic core with no required runtime dependencies.
28
+
29
+ ## Installation
30
+
31
+ Install the published npm package:
32
+
33
+ ```bash
34
+ npm install adaptform
35
+ ```
36
+
37
+ Luxon integration is optional:
38
+
39
+ ```bash
40
+ npm install luxon
41
+ ```
42
+
43
+ The [interactive demo](https://arhimen.github.io/AdaptForm/) runs from repository source, with automatic deployment from `v2.1.0` and `master`. Version `2.1.0` is prepared in the repository; npm continues to serve the last published version until the maintainer publishes the release.
44
+
45
+ The demo supports English and Russian with a persistent language selector: [English](https://arhimen.github.io/AdaptForm/?lang=en) · [Русский](https://arhimen.github.io/AdaptForm/?lang=ru). Switching languages preserves form data and validation state.
46
+
47
+ ## Quick start
48
+
49
+ ```typescript
50
+ import { BooleanType, Field, Form, StringType } from 'adaptform'
51
+
52
+ class RegistrationForm extends Form {
53
+ name = new Field(StringType, null, {
54
+ isRequired: true,
55
+ minLength: 2,
56
+ maxLength: 50,
57
+ })
58
+
59
+ email = new Field(StringType, null, {
60
+ isRequired: true,
61
+ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
62
+ })
63
+
64
+ acceptedTerms = new Field(BooleanType, false, {
65
+ mustBeTrue: true,
66
+ })
67
+ }
68
+
69
+ const form = new RegistrationForm()
70
+ form.fields = {
71
+ name: 'Alex Morgan',
72
+ email: 'alex@example.com',
73
+ acceptedTerms: true,
74
+ }
75
+
76
+ if (form.isValid) {
77
+ console.log(form.fieldsValue)
78
+ console.log(form.toJSON())
79
+ } else {
80
+ console.log(form.allErrors)
81
+ }
82
+ ```
83
+
84
+ `form.fields` assigns the supplied values and validates the completed form. Values for fields omitted from the assignment are left unchanged.
85
+
86
+ ## Field types and options
87
+
88
+ All field types are constructed using `new Field(TypeConstructor, defaultValue?, options?)`.
89
+
90
+ ### StringType
91
+
92
+ ```typescript
93
+ const username = new Field(StringType, null, {
94
+ isRequired: true,
95
+ minLength: 3,
96
+ maxLength: 32,
97
+ pattern: /^[a-z0-9_]+$/i,
98
+ messages: {
99
+ isRequired: 'Enter a username.',
100
+ minLength: 'Use at least 3 characters.',
101
+ pattern: 'Use only letters, numbers, and underscores.',
102
+ },
103
+ })
104
+ ```
105
+
106
+ Supported constraints: `isRequired`, `minLength`, `maxLength`, and `pattern`.
107
+
108
+ ### NumberType
109
+
110
+ ```typescript
111
+ const age = new Field(NumberType, null, {
112
+ isRequired: true,
113
+ ge: 18,
114
+ le: 120,
115
+ })
116
+ ```
117
+
118
+ Supported constraints: `gt`, `ge`, `lt`, and `le`. Values must be finite integers; blank strings and non-finite numbers are rejected.
119
+
120
+ ### DecimalType
121
+
122
+ ```typescript
123
+ const price = new Field(DecimalType, null, {
124
+ decimalPlaces: 2,
125
+ maxDigits: 8,
126
+ ge: 0,
127
+ })
128
+ ```
129
+
130
+ Supports `gt`, `ge`, `lt`, `le`, `decimalPlaces`, and `maxDigits`. Numeric inputs with excess precision are rejected rather than silently rounded. String inputs may use a comma or a period as the decimal separator.
131
+
132
+ ### BooleanType
133
+
134
+ ```typescript
135
+ const accepted = new Field(BooleanType, false, {
136
+ mustBeTrue: true,
137
+ })
138
+ ```
139
+
140
+ Supports `mustBeTrue` and `mustBeFalse`.
141
+
142
+ ### DateType
143
+
144
+ ```typescript
145
+ const birthday = new Field(DateType, null, {
146
+ isDatetime: false,
147
+ minDate: new Date('1900-01-01T00:00:00.000Z'),
148
+ maxDate: new Date(),
149
+ })
150
+ ```
151
+
152
+ Supports `minDate`, `maxDate`, and `isDatetime`. When `isDatetime` is false, comparisons use calendar dates; when true, timestamps are compared. Invalid dates are rejected.
153
+
154
+ ### Custom validation and messages
155
+
156
+ Field options support `isRequired`, `messages`, and a synchronous `validate(value, form)` callback. A custom validator returns `true` for success, `false` for the configured validation message, or a string for a specific error.
157
+
158
+ ```typescript
159
+ class PasswordForm extends Form {
160
+ password = new Field(StringType, null, { minLength: 8 })
161
+
162
+ confirmation = new Field(StringType, null, {
163
+ isRequired: true,
164
+ validate: (value, form) =>
165
+ value === form?.password.valueClear || 'Passwords do not match.',
166
+ })
167
+ }
168
+ ```
169
+
170
+ Validators are synchronous; returning a Promise is not supported. For asynchronous checks such as server-side uniqueness, call the service separately and apply the response through the form's error API.
171
+
172
+ ## Validation lifecycle and state
173
+
174
+ - Constructor defaults begin untouched.
175
+ - Empty strings and whitespace-only strings count as empty values.
176
+ - Global and sticky regular-expression flags do not make repeated string validation stateful.
177
+ - `form.checkValid()` validates every field and returns `false` when a field is invalid or global server errors remain.
178
+ - Reading `isValid` does not run validators. Fields without a non-null default start with `isValid === null`; non-null constructor defaults pass through casting and validation but still start untouched. After `reset()`, field validity is unset until validation runs again.
179
+ - Bulk assignment validates after direct field values have been assigned, so conditional-required rules can inspect the complete form state.
180
+ - If you change dependent fields one at a time, call `form.checkValid()` after the changes to recompute cross-field rules.
181
+ - `field.setRawValue(value, false)` and `form.setFields(data, false)` allow advanced callers to defer validation. Call `checkValid()` before relying on the validity state.
182
+ - `reset()` restores defaults, clears errors, and leaves validity unset until the next validation. Collection validity is false after reset until the collection is checked again.
183
+ - Adding, removing, or clearing collection items refreshes collection validation. Invalid removal indices are ignored.
184
+
185
+ ## Dynamic collections
186
+
187
+ ### ArrayField
188
+
189
+ Use `ArrayField` for a dynamic list of similarly typed fields.
190
+
191
+ ```typescript
192
+ import { ArrayField, Field, StringType } from 'adaptform'
193
+
194
+ const tags = new ArrayField(
195
+ (value?: string) => new Field(StringType, value, {
196
+ isRequired: true,
197
+ minLength: 2,
198
+ }),
199
+ { minItems: 1, maxItems: 5 },
200
+ )
201
+
202
+ tags.add('typescript')
203
+ tags.add('forms')
204
+ console.log(tags.itemsValue)
205
+ console.log(tags.isValid)
206
+ ```
207
+
208
+ Options include `isRequired`, `minItems`, `maxItems`, `messages`, and a synchronous `validate(values, form)` callback. `itemsValue` returns an array whose entries may be `null`.
209
+
210
+ ### ArrayFormField
211
+
212
+ Use `ArrayFormField` for a list of nested form instances.
213
+
214
+ ```typescript
215
+ import { ArrayFormField, Field, Form, StringType } from 'adaptform'
216
+
217
+ class AddressForm extends Form {
218
+ city = new Field(StringType, null, { isRequired: true })
219
+ postalCode = new Field(StringType, null, { isRequired: true })
220
+ }
221
+
222
+ const addresses = new ArrayFormField(AddressForm, {
223
+ minForms: 1,
224
+ maxForms: 3,
225
+ })
226
+
227
+ addresses.add({ city: 'Berlin', postalCode: '10115' })
228
+ console.log(addresses.formsFieldsValue)
229
+ ```
230
+
231
+ Options include `isRequired`, `minForms`, `maxForms`, `messages`, and a synchronous `validate(forms, form)` callback.
232
+
233
+ ## Input masks
234
+
235
+ Plugins transform input before it is cast and validated. The built-in `MaskPlugin` supports fixed mask characters and digit or custom pattern placeholders.
236
+
237
+ ```typescript
238
+ import { Field, MaskPlugin, StringType } from 'adaptform'
239
+
240
+ const phone = new Field(StringType, null, {
241
+ plugins: [
242
+ new MaskPlugin({
243
+ maskFormat: '+1 (___) ___-____',
244
+ maskPlaceholder: '_',
245
+ digitPattern: /\d/,
246
+ }),
247
+ ],
248
+ })
249
+
250
+ phone.rawValue = '4155550134'
251
+ console.log(phone.rawValue)
252
+ console.log(phone.valueClear)
253
+ ```
254
+
255
+ ## Server-side errors with ErrorsSchema
256
+
257
+ `ErrorsSchema` maps error keys returned by a backend to form fields. By default, unmatched keys fall back to their original names.
258
+
259
+ ```typescript
260
+ import { ErrorsSchema, Form, Field, StringType } from 'adaptform'
261
+
262
+ class AccountForm extends Form {
263
+ email = new Field(StringType)
264
+ username = new Field(StringType)
265
+ }
266
+
267
+ const form = new AccountForm()
268
+ form.errorsShema = new ErrorsSchema()
269
+ .staticKey('email_address', 'email')
270
+ .staticKey('user_name', 'username')
271
+ .fallback(key => key)
272
+
273
+ form.errors = {
274
+ email_address: 'This email is already registered.',
275
+ user_name: 'This username is unavailable.',
276
+ request_id: 'The request could not be completed.',
277
+ }
278
+
279
+ console.log(form.allErrors)
280
+ ```
281
+
282
+ Available schema methods:
283
+
284
+ - `staticKey(errorKey, fieldName)` maps an exact key.
285
+ - `dynamicKey(pattern, mapping, options?)` maps keys using a regular expression and optional priority.
286
+ - `fallback(handler)` handles keys that do not match configured rules.
287
+ - `transformValue(transformer)` normalizes server error values before mapping.
288
+ - `nest(prefix)` creates a schema for nested field paths.
289
+ - `clone()` and `merge(other)` compose schemas.
290
+ - `ErrorsSchema.fromObject(mapping)` creates a schema from a key-to-field map.
291
+ - `ErrorsSchema.forArray(itemSchema, arrayFieldName, indexExtractor?)` maps array-item errors, including bracket and dotted index notation.
292
+
293
+ Assigning a new `form.errors` object clears previously applied errors before applying the new payload. Unknown field keys become global errors.
294
+
295
+ ## Serialization
296
+
297
+ ### JSON
298
+
299
+ `toJSON()` returns a JSON-compatible object. JavaScript `Date` instances are recursively converted to ISO 8601 strings, including dates nested in arrays and plain objects.
300
+
301
+ ```typescript
302
+ const payload = form.toJSON()
303
+ await fetch('/api/profile', {
304
+ method: 'POST',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify(payload),
307
+ })
308
+ ```
309
+
310
+ ### FormData
311
+
312
+ `toFormData()` omits `null` and `undefined` values, appends scalar values as strings, serializes dates as ISO 8601 strings, preserves `Blob` and `File` values, and JSON-encodes arrays and nested objects.
313
+
314
+ ```typescript
315
+ const body = form.toFormData()
316
+ await fetch('/api/profile', {
317
+ method: 'POST',
318
+ body,
319
+ })
320
+ ```
321
+
322
+ Do not set the multipart `Content-Type` header manually; the browser supplies the boundary.
323
+
324
+ ## Optional Luxon integration
325
+
326
+ Install Luxon separately, then import the additional date type from the dedicated subpath:
327
+
328
+ ```bash
329
+ npm install luxon
330
+ ```
331
+
332
+ ```typescript
333
+ import { Field } from 'adaptform'
334
+ import { DateLuxonType } from 'adaptform/luxon'
335
+ import { DateTime } from 'luxon'
336
+
337
+ const date = new Field(DateLuxonType, null, {
338
+ isDatetime: false,
339
+ minDate: DateTime.fromISO('1900-01-01'),
340
+ maxDate: DateTime.now(),
341
+ inputFormat: 'dd.MM.yyyy',
342
+ })
343
+ ```
344
+
345
+ ## API overview
346
+
347
+ | Class | Main API |
348
+ | --- | --- |
349
+ | `Form` | `fields`, `setFields()`, `fieldsValue`, `checkValid()`, `isValid`, `allErrors`, `globalErrors`, `errors`, `setGlobalError()`, `clearGlobalErrors()`, `reset()`, `toJSON()`, `toFormData()` |
350
+ | `Field` | `rawValue`, `setRawValue()`, `valueClear`, `errors`, `error`, `isValid`, `isTouched`, `isEmpty`, `checkValid()`, `reset()` |
351
+ | `ArrayField` | `items`, `itemsValue`, `add()`, `remove()`, `clear()`, `checkValid()`, `reset()` |
352
+ | `ArrayFormField` | `forms`, `formsFieldsValue`, `add()`, `remove()`, `clear()`, `checkValid()`, `reset()` |
353
+ | `ErrorsSchema` | `staticKey()`, `dynamicKey()`, `fallback()`, `transformValue()`, `parseErrors()`, `parseErrorsDetailed()`, `nest()`, `clone()`, `merge()`, `forArray()`, `fromObject()` |
354
+
355
+ ## Development
356
+
357
+ Requirements: Node.js 22 (used by CI) and npm.
358
+
359
+ ```bash
360
+ npm ci
361
+ npm run lint
362
+ npm test
363
+ npm run build
364
+ npx tsc --noEmit -p tsconfig.json
365
+ npm run check:demo
366
+ npm run build:demo
367
+ ```
368
+
369
+ To run the interactive demo locally:
370
+
371
+ ```bash
372
+ npm run dev -- --config demo/vite.config.ts
373
+ ```
374
+
375
+ The demo is built by GitHub Actions from `v2.1.0` and `master`. GitHub Pages is configured to allow both branches to publish to the same demo URL; see [Demo and GitHub Pages notes](https://github.com/ArhiMEN/AdaptForm/blob/master/docs/DEMO.md).
376
+
377
+ ## Links
378
+
379
+ - **[Interactive demo](https://arhimen.github.io/AdaptForm/)**
380
+ - [GitHub repository](https://github.com/ArhiMEN/AdaptForm)
381
+ - [npm package](https://www.npmjs.com/package/adaptform)
382
+ - [Issue tracker](https://github.com/ArhiMEN/AdaptForm/issues)
383
+ - [Changelog](https://github.com/ArhiMEN/AdaptForm/blob/master/CHANGELOG.md)
384
+ - [License](https://github.com/ArhiMEN/AdaptForm/blob/master/LICENSE.md)
385
+
386
+ ## License
387
+
388
+ MIT © Stanislav Orlov