@whatsaaaa/form-builder-schema 0.5.0 → 0.6.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
@@ -129,7 +129,7 @@ const { build } = createSchemaBuilder({
129
129
  })
130
130
  ```
131
131
 
132
- The built-in `email` and `url` types use the `email` and `url` rules, so keep `formatRules` (or replace those types), or `createSchemaBuilder` throws a `TypeError`.
132
+ The built-in `email` and `url` types use the `email` and `url` rules, so keep `formatRules` (or replace those types), or `createSchemaBuilder` throws a `TypeError`. `includeDefaultRules: false` leaves out the built-in form rules too; add them back with `formRules: defaultFormRules`.
133
133
 
134
134
  ### Listing rules
135
135
 
@@ -140,6 +140,43 @@ const { rulesForKind } = createSchemaBuilder()
140
140
  rulesForKind('file').map(([name, def]) => def.meta?.label) // ['Minimum size', …]
141
141
  ```
142
142
 
143
+ ## Form rules
144
+
145
+ A form rule checks a field against other fields, such as a repeated password or a date range. Form rules go in `formRules`, next to `fields`, and each one names the `field` its error shows on:
146
+
147
+ ```json
148
+ {
149
+ "version": "1",
150
+ "fields": [
151
+ { "name": "password", "type": "password", "required": true },
152
+ { "name": "password2", "type": "password", "required": true },
153
+ { "name": "start", "type": "date" },
154
+ { "name": "end", "type": "date" }
155
+ ],
156
+ "formRules": [
157
+ { "rule": "sameAs", "field": "password2", "other": "password", "message": "Passwords differ" },
158
+ {
159
+ "rule": "compare",
160
+ "field": "end",
161
+ "op": "gt",
162
+ "other": "start",
163
+ "message": "After the start"
164
+ }
165
+ ]
166
+ }
167
+ ```
168
+
169
+ | Rule | Params | Kinds | Passes when |
170
+ | --------------- | ------------------------------------------ | ----------------------------- | ----------------- |
171
+ | `sameAs` | `other` | string, number, boolean, date | `field === other` |
172
+ | `differentFrom` | `other` | string, number, boolean, date | `field !== other` |
173
+ | `compare` | `op` (`lt`, `lte`, `gt` or `gte`), `other` | number, date | `field op other` |
174
+
175
+ - **Paths** name fields by their value: `'password2'`, `'address.zip'` for a field in a group, and `'lines.*.qty'` for a field in the items of an array. A rule on a path with `*` runs once per item, and its error shows on that item's field. A path it reads with `*` means the same item, so `{ "field": "lines.*.qty", "op": "lte", "other": "lines.*.stock" }` compares each line with itself.
176
+ - **A form rule runs last**, after every field's own rules, and only when the fields it reads have no issues. So a password that is too short never also shows "Passwords differ". It never runs on an empty value either: make the fields required when they must be filled.
177
+ - **`message` is required.** It goes through `resolveMessage` like every other message, with the rule's field in `ctx.field`.
178
+ - The fields a rule reads must have the same kind: `compare` on a number and a date is `kind_mismatch`. A date with `output: 'date'` is compared by its time, so compare it with another date that has `output: 'date'`.
179
+
143
180
  ## Checking definitions
144
181
 
145
182
  `validate(json)` never throws. It returns every problem in the definition, in document order:
@@ -151,16 +188,17 @@ if (!result.success) {
151
188
  }
152
189
  ```
153
190
 
154
- `build(json)` runs the same checks and throws one `DefinitionError` with all of them. Issue codes are `invalid_shape`, `unsupported_version`, `unknown_key`, `unknown_type`, `missing_name`, `invalid_name`, `duplicate_name`, `invalid_props`, `invalid_default`, `unknown_rule`, `unknown_param`, `invalid_params` and `kind_mismatch`. An issue in a nested field has its full path, such as `['fields', 2, 'fields', 0, 'rules', 1]`.
191
+ `build(json)` runs the same checks and throws one `DefinitionError` with all of them. Issue codes are `invalid_shape`, `unsupported_version`, `unknown_key`, `unknown_type`, `missing_name`, `invalid_name`, `duplicate_name`, `invalid_props`, `invalid_default`, `unknown_rule`, `unknown_param`, `invalid_params`, `kind_mismatch` and `unknown_field`. An issue in a nested field has its full path, such as `['fields', 2, 'fields', 0, 'rules', 1]`.
155
192
 
156
193
  - **Names** are one key of the value, so a name with `.`, `[` or `]` is `invalid_name`.
157
194
  - **Defaults** must pass the field's own schema, rules included, or they are `invalid_default`. An empty default, like `''`, counts as none.
195
+ - **Form rules** report on `['formRules', 0]` and below. A `field` path that names no field with a value is `unknown_field` on `['formRules', 0, 'field']`, and a path the rule reads gets it on `['formRules', 0]`. When a field the rule reads has issues of its own, the rule adds none.
158
196
 
159
197
  Keys on a field must be reserved keys (`RESERVED_FIELD_KEYS`) or props of its type. Put renderer settings under `ui`, which is never checked.
160
198
 
161
199
  ## Extending
162
200
 
163
- `createSchemaBuilder({ kinds, fieldTypes, rules })` merges your entries over the defaults by key. The merged registries are returned as `kinds`, `fieldTypes` and `rules`.
201
+ `createSchemaBuilder({ kinds, fieldTypes, rules, formRules })` merges your entries over the defaults by key. The merged registries are returned as `kinds`, `fieldTypes`, `rules` and `formRules`.
164
202
 
165
203
  ```ts
166
204
  const tags = defineFieldType({
@@ -198,6 +236,22 @@ const { build } = createSchemaBuilder({ fieldTypes: { tags }, rules: { even } })
198
236
  const schema = myType.schema?.(field, createBuildContext({ path: ['fields', 0] }))
199
237
  ```
200
238
 
239
+ - A **form rule** lists the paths it reads besides `field`, and checks their values with a plain function. The builder checks that the fields exist and have one of `kinds`, and runs `check` only when none of them has an issue or is empty:
240
+
241
+ ```ts
242
+ const sameZip = defineFormRule({
243
+ params: v.object({ others: v.array(v.string()) }),
244
+ reads: (params) => params.others,
245
+ kinds: ['string'],
246
+ check: (values) => new Set(values).size === 1, // the value of `field`, then the others
247
+ meta: { label: 'Same ZIP', category: 'comparison' },
248
+ })
249
+
250
+ const { build } = createSchemaBuilder({ formRules: { sameZip } })
251
+ ```
252
+
253
+ `sameKind: true` requires every field it reads to have the same kind. `rule`, `field` and `message` are reserved (`RESERVED_FORM_RULE_KEYS`), so params can't use them.
254
+
201
255
  - Mistakes in the registry, such as an unknown kind or a reserved props key, throw a `TypeError` from `createSchemaBuilder`.
202
256
 
203
257
  ## Initial input and walking fields
package/dist/index.d.ts CHANGED
@@ -12,6 +12,8 @@ type ValueKind = 'string' | 'number' | 'boolean' | 'date' | 'array' | 'file' | '
12
12
  interface JsonForm {
13
13
  version: '1';
14
14
  fields: readonly JsonField[];
15
+ /** Rules that check a field against other fields. They run after every field's own rules. */
16
+ formRules?: readonly JsonFormRule[];
15
17
  }
16
18
  interface JsonField {
17
19
  name?: string;
@@ -34,10 +36,17 @@ interface JsonRule {
34
36
  message?: string;
35
37
  [param: string]: unknown;
36
38
  }
39
+ interface JsonFormRule {
40
+ rule: string;
41
+ /** Where the error shows, as a dot path: `'password2'`, `'address.zip'` or `'lines.*.qty'`. */
42
+ field: string;
43
+ message: string;
44
+ [param: string]: unknown;
45
+ }
37
46
  /** Where something is in a definition, e.g. `['fields', 0, 'rules', 1]`. */
38
47
  type DefinitionPath = readonly (string | number)[];
39
48
  /** Public API: adding a code is a minor change, renaming one is breaking. */
40
- type DefinitionIssueCode = 'invalid_shape' | 'unsupported_version' | 'unknown_key' | 'unknown_type' | 'missing_name' | 'invalid_name' | 'duplicate_name' | 'invalid_props' | 'invalid_default' | 'unknown_rule' | 'unknown_param' | 'invalid_params' | 'kind_mismatch';
49
+ type DefinitionIssueCode = 'invalid_shape' | 'unsupported_version' | 'unknown_key' | 'unknown_type' | 'missing_name' | 'invalid_name' | 'duplicate_name' | 'invalid_props' | 'invalid_default' | 'unknown_rule' | 'unknown_param' | 'invalid_params' | 'kind_mismatch' | 'unknown_field';
41
50
  interface DefinitionIssue {
42
51
  code: DefinitionIssueCode;
43
52
  path: DefinitionPath;
@@ -150,6 +159,29 @@ interface RuleDef<TParams extends AnyObjectSchema = AnyObjectSchema> {
150
159
  /** Method syntax keeps `create` bivariant, so specific rules fit in a `Record<string, RuleDef>`. */
151
160
  create(params: v.InferOutput<TParams>, message?: string): AnyAction;
152
161
  }
162
+ /**
163
+ * A rule that checks a field against other fields, e.g. that two passwords match. It goes in the
164
+ * definition's `formRules`, and its error shows on the field named by `field`.
165
+ */
166
+ interface FormRuleDef<TParams extends AnyObjectSchema = AnyObjectSchema> {
167
+ /** Its params, next to `rule`, `field` and `message` in the definition. Unknown ones fail. */
168
+ params?: TParams;
169
+ /**
170
+ * The paths the rule reads besides `field`, e.g. `[params.other]`, so the builder can check
171
+ * that those fields exist. Method syntax keeps it bivariant, like `check()`.
172
+ */
173
+ reads(params: v.InferOutput<TParams>): readonly string[];
174
+ /** The kinds the fields it reads may have. Any kind when omitted. */
175
+ kinds?: readonly ValueKind[];
176
+ /** True when every field it reads must have the same kind, e.g. to compare them. */
177
+ sameKind?: boolean;
178
+ /**
179
+ * True when the values pass. `values` holds the value of `field`, then the values of the paths
180
+ * from `reads()`, in that order. It only runs when all of them are valid and none is empty.
181
+ */
182
+ check(values: readonly unknown[], params: v.InferOutput<TParams>): boolean;
183
+ meta?: RuleMeta;
184
+ }
153
185
  interface MessageContext {
154
186
  field: JsonField;
155
187
  rule?: string;
@@ -161,6 +193,8 @@ interface SchemaBuilderOptions {
161
193
  kinds?: Record<string, KindDef>;
162
194
  fieldTypes?: Record<string, FieldTypeDef>;
163
195
  rules?: Record<string, RuleDef>;
196
+ formRules?: Record<string, FormRuleDef>;
197
+ /** With false, only your own rules and form rules are registered. Defaults to true. */
164
198
  includeDefaultRules?: boolean;
165
199
  messages?: {
166
200
  required?: string;
@@ -178,6 +212,7 @@ export declare function createSchemaBuilder(options?: SchemaBuilderOptions): {
178
212
  kinds: Readonly<Record<string, KindDef>>;
179
213
  fieldTypes: Readonly<Record<string, FieldTypeDef<AnyObjectSchema>>>;
180
214
  rules: Readonly<Record<string, RuleDef<AnyObjectSchema>>>;
215
+ formRules: Readonly<Record<string, FormRuleDef<AnyObjectSchema>>>;
181
216
  };
182
217
  /**
183
218
  * Formisch's `initialInput` for a definition: each field's `default`, nested the way the value
@@ -598,6 +633,7 @@ export declare const defaultRules: {
598
633
  readonly value: import("valibot").SchemaWithPipe<readonly [import("valibot").NumberSchema<undefined>, import("valibot").IntegerAction<number, undefined>, import("valibot").MinValueAction<number, 0, undefined>]>;
599
634
  }, undefined>>;
600
635
  };
636
+ export declare const defineFormRule: <T extends AnyObjectSchema>(def: FormRuleDef<T>) => FormRuleDef<T>;
601
637
  export declare const defineRule: <T extends AnyObjectSchema>(def: RuleDef<T>) => RuleDef<T>;
602
638
  /** Size and type of a File or Blob. */
603
639
  export declare const fileRules: {
@@ -650,6 +686,19 @@ export declare const formatRules: {
650
686
  readonly types: v.SchemaWithPipe<readonly [v.ArraySchema<v.PicklistSchema<readonly ["md4", "md5", "sha1", "sha256", "sha384", "sha512", "ripemd128", "ripemd160", "tiger128", "tiger160", "tiger192", "crc32", "crc32b", "adler32"], undefined>, undefined>, v.MinLengthAction<("md4" | "md5" | "sha1" | "sha256" | "sha384" | "sha512" | "ripemd128" | "ripemd160" | "tiger128" | "tiger160" | "tiger192" | "crc32" | "crc32b" | "adler32")[], 1, "Pick at least one hash type">]>;
651
687
  }, undefined>>;
652
688
  };
689
+ /** Every built-in form rule. */
690
+ export declare const defaultFormRules: {
691
+ sameAs: FormRuleDef<v.ObjectSchema<{
692
+ readonly other: v.StringSchema<undefined>;
693
+ }, undefined>>;
694
+ differentFrom: FormRuleDef<v.ObjectSchema<{
695
+ readonly other: v.StringSchema<undefined>;
696
+ }, undefined>>;
697
+ compare: FormRuleDef<v.ObjectSchema<{
698
+ readonly op: v.PicklistSchema<["lt", "lte", "gt", "gte"], undefined>;
699
+ readonly other: v.StringSchema<undefined>;
700
+ }, undefined>>;
701
+ };
653
702
  /** Characters of a string, or items of an array */
654
703
  export declare const lengthRules: {
655
704
  length: RuleDef<v.ObjectSchema<{
@@ -744,5 +793,9 @@ export declare const RESERVED_FIELD_KEYS: readonly ["name", "type", "label", "de
744
793
  * Rule keys this package owns. A rule's params can't use them.
745
794
  */
746
795
  export declare const RESERVED_RULE_KEYS: readonly ["rule", "message"];
747
- export type { AnyAction, AnyObjectSchema, AnySchema, BuildContext, DefinitionIssue, DefinitionIssueCode, DefinitionPath, FieldTypeDef, FieldWithProps, JsonField, JsonForm, JsonRule, KindDef, KindOf, MessageContext, RuleCategory, RuleDef, RuleMeta, RuleStage, SchemaBuilderOptions, ValidateResult, ValueKind, WalkedField };
796
+ /**
797
+ * Form rule keys this package owns. A form rule's params can't use them.
798
+ */
799
+ export declare const RESERVED_FORM_RULE_KEYS: readonly ["rule", "field", "message"];
800
+ export type { AnyAction, AnyObjectSchema, AnySchema, BuildContext, DefinitionIssue, DefinitionIssueCode, DefinitionPath, FieldTypeDef, FieldWithProps, FormRuleDef, JsonField, JsonForm, JsonFormRule, JsonRule, KindDef, KindOf, MessageContext, RuleCategory, RuleDef, RuleMeta, RuleStage, SchemaBuilderOptions, ValidateResult, ValueKind, WalkedField };
748
801
  //# sourceMappingURL=index.d.ts.map