@rokkit/forms 1.8.0 → 1.8.2

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
@@ -86,10 +86,15 @@ form.isDirty // any field changed
86
86
  ```js
87
87
  import { validateField, validateAll, patterns } from '@rokkit/forms'
88
88
 
89
- const result = validateField(value, { required: true, pattern: patterns.email })
90
- // result → { state: 'error', text: 'Invalid email address' } | null
89
+ const result = validateField('bad', { type: 'string', required: true, pattern: patterns.email }, 'Email')
90
+ // result → { state: 'error', text: 'Email format is invalid' } — null when valid
91
+ // Rules are checked per `type`: without one, only `required` applies.
91
92
  ```
92
93
 
94
+ A field is required when it says `required: true`, or when its parent object lists it, the
95
+ JSON Schema way (`{ type: 'object', required: ['email'], properties: { email } }`), at any depth.
96
+ `validateAll` / `form.validate()` and the rendered input's `required` attribute honour both.
97
+
93
98
  ### Dependent lookups with createLookup
94
99
 
95
100
  `createLookup` creates a reactive dropdown data source that can depend on other field values:
@@ -75,9 +75,11 @@ export function findAttributeByPath(scope, schema) {
75
75
 
76
76
  const pathArray = scope.split('/').slice(1)
77
77
  let schemaPointer = schema
78
+ let parent = null
78
79
  let currentKey = ''
79
80
 
80
81
  pathArray.forEach((key) => {
82
+ parent = schemaPointer
81
83
  schemaPointer = schemaPointer.properties[key]
82
84
  currentKey = key
83
85
  })
@@ -86,10 +88,21 @@ export function findAttributeByPath(scope, schema) {
86
88
 
87
89
  return {
88
90
  key: currentKey,
89
- props: { ...schemaPointer }
91
+ props: requiredProps(schemaPointer, parent, currentKey)
90
92
  }
91
93
  }
92
94
 
95
+ /**
96
+ * A field's props with `required` as the input reads it: true when the field says so or its
97
+ * parent's JSON Schema `required` list names it. An object's own list is its children's rule —
98
+ * dropped here so it never reaches an input as a truthy `required`.
99
+ */
100
+ function requiredProps(fieldSchema, parent, key) {
101
+ const { required, ...props } = fieldSchema
102
+ const listed = Array.isArray(parent?.required) && parent.required.includes(key)
103
+ return required === true || listed ? { ...props, required: true } : props
104
+ }
105
+
93
106
  /**
94
107
  * Combines an element from layout with schema
95
108
  *
@@ -18,3 +18,15 @@ export function deriveSchemaFromValue(data: any): import("../types").DataSchema;
18
18
  * @returns {Object | null}
19
19
  */
20
20
  export function schemaAt(schema: Object | null | undefined, path: string): Object | null;
21
+ /**
22
+ * Whether the field at a slash path must have a value.
23
+ *
24
+ * JSON Schema states it on the PARENT object — `{ type: 'object', required: ['city'], … }` — at
25
+ * any depth. The field-level `required: true` form is also honoured. An object's own `required`
26
+ * array is its children's rule, never a claim that the object itself is required.
27
+ *
28
+ * @param {Object | null | undefined} schema
29
+ * @param {string} path
30
+ * @returns {boolean}
31
+ */
32
+ export function requiredAt(schema: Object | null | undefined, path: string): boolean;
@@ -56,3 +56,22 @@ export function schemaAt(schema, path) {
56
56
  for (const key of path.split('/').slice(1)) current = childSchema(current, key)
57
57
  return isSchemaObject(current) ? current : null
58
58
  }
59
+
60
+ /**
61
+ * Whether the field at a slash path must have a value.
62
+ *
63
+ * JSON Schema states it on the PARENT object — `{ type: 'object', required: ['city'], … }` — at
64
+ * any depth. The field-level `required: true` form is also honoured. An object's own `required`
65
+ * array is its children's rule, never a claim that the object itself is required.
66
+ *
67
+ * @param {Object | null | undefined} schema
68
+ * @param {string} path
69
+ * @returns {boolean}
70
+ */
71
+ export function requiredAt(schema, path) {
72
+ if (schemaAt(schema, path)?.required === true) return true
73
+ const keys = path.split('/')
74
+ const key = keys.pop()
75
+ const parent = keys.length > 0 ? schemaAt(schema, keys.join('/')) : schema
76
+ return Array.isArray(parent?.required) && parent.required.includes(key)
77
+ }
@@ -1,4 +1,4 @@
1
- import { schemaAt } from './schema.js'
1
+ import { schemaAt, requiredAt } from './schema.js'
2
2
 
3
3
  /**
4
4
  * Validation utility for FormBuilder
@@ -223,7 +223,8 @@ function validateByType(value, fieldSchema, fieldLabel) {
223
223
  * @returns {ValidationMessage|null|'skip'}
224
224
  */
225
225
  function checkPresence(value, fieldSchema, fieldLabel) {
226
- if (fieldSchema.required && isEmpty(value)) return errorMsg(`${fieldLabel} is required`)
226
+ // `=== true`: an object schema's `required` is an ARRAY naming its children, not a flag.
227
+ if (fieldSchema.required === true && isEmpty(value)) return errorMsg(`${fieldLabel} is required`)
227
228
  if (isEmpty(value)) return 'skip'
228
229
  return null
229
230
  }
@@ -243,6 +244,16 @@ export function validateField(value, fieldSchema, fieldLabel = 'Field') {
243
244
  return validateByType(value, fieldSchema, fieldLabel)
244
245
  }
245
246
 
247
+ /**
248
+ * The field's schema with `required` decided by its path — the parent's JSON Schema `required`
249
+ * list counts, not only the field's own flag. Null for a path the schema does not know.
250
+ * @private
251
+ */
252
+ function effectiveSchema(schema, path) {
253
+ const fieldSchema = schemaAt(schema, path)
254
+ return fieldSchema && { ...fieldSchema, required: requiredAt(schema, path) }
255
+ }
256
+
246
257
  /**
247
258
  * Validate one element from the layout
248
259
  * @private
@@ -253,11 +264,10 @@ function validateElement(element, data, schema, results) {
253
264
  if (!element.scope) return
254
265
 
255
266
  const fieldPath = element.scope.replace(/^#\//, '')
256
- const fieldSchema = schemaAt(schema, fieldPath)
257
267
  const fieldLabel = element.label || element.title || fieldPath
258
268
  const value = getValueByPath(data, fieldPath)
259
269
 
260
- const result = validateField(value, fieldSchema, fieldLabel)
270
+ const result = validateField(value, effectiveSchema(schema, fieldPath), fieldLabel)
261
271
  if (result) {
262
272
  results[fieldPath] = result
263
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/forms",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "Form building components for Rokkit applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1 +0,0 @@
1
- export {};
@@ -1,58 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { evaluateCondition } from './conditions.js'
3
-
4
- describe('evaluateCondition', () => {
5
- it('returns true when condition is null', () => {
6
- expect(evaluateCondition(null, { accountType: 'personal' })).toBe(true)
7
- })
8
-
9
- it('returns true when condition is undefined', () => {
10
- expect(evaluateCondition(undefined, { accountType: 'personal' })).toBe(true)
11
- })
12
-
13
- it('returns true when neither operator is set', () => {
14
- expect(evaluateCondition({ field: 'accountType' }, { accountType: 'business' })).toBe(true)
15
- })
16
-
17
- describe('equals operator', () => {
18
- it('returns true when value matches', () => {
19
- expect(
20
- evaluateCondition({ field: 'accountType', equals: 'business' }, { accountType: 'business' })
21
- ).toBe(true)
22
- })
23
-
24
- it('returns false when value does not match', () => {
25
- expect(
26
- evaluateCondition({ field: 'accountType', equals: 'business' }, { accountType: 'personal' })
27
- ).toBe(false)
28
- })
29
-
30
- it('returns false when field is absent (undefined)', () => {
31
- expect(evaluateCondition({ field: 'accountType', equals: 'business' }, {})).toBe(false)
32
- })
33
- })
34
-
35
- describe('notEquals operator', () => {
36
- it('returns true when value does not match', () => {
37
- expect(
38
- evaluateCondition(
39
- { field: 'accountType', notEquals: 'business' },
40
- { accountType: 'personal' }
41
- )
42
- ).toBe(true)
43
- })
44
-
45
- it('returns false when value matches', () => {
46
- expect(
47
- evaluateCondition(
48
- { field: 'accountType', notEquals: 'business' },
49
- { accountType: 'business' }
50
- )
51
- ).toBe(false)
52
- })
53
-
54
- it('returns true when field is absent (undefined !== value)', () => {
55
- expect(evaluateCondition({ field: 'accountType', notEquals: 'business' }, {})).toBe(true)
56
- })
57
- })
58
- })
@@ -1 +0,0 @@
1
- export {};
@@ -1,250 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { deriveSchemaFromValue } from './schema.js'
3
- import { deriveLayoutFromValue } from './layout.js'
4
- import { findAttributeByPath, getSchemaWithLayout } from './fields.js'
5
-
6
- import inputLayout from './fixtures/input-layout.json'
7
- import inputSchema from './fixtures/input-schema.json'
8
- import resultWithGeneratedSchema from './fixtures/result-with-generated-schema.json'
9
- import resultWithGeneratedLayout from './fixtures/result-with-generated-layout.json'
10
- import combinedSchemaLayout from './fixtures/combined-schema-layout.json'
11
- import resultWithOnlyData from './fixtures/result-with-only-data.json'
12
-
13
- describe('fields', () => {
14
- describe('findAttributeByPath', () => {
15
- it('should find attribute from path', () => {
16
- const schema = {
17
- type: 'object',
18
- properties: {
19
- name: { type: 'string' },
20
- age: { type: 'integer', min: 0, max: 100 }
21
- }
22
- }
23
- let attribute = findAttributeByPath('#/name', schema)
24
- expect(attribute).toEqual({
25
- key: 'name',
26
- props: {
27
- type: 'string'
28
- }
29
- })
30
-
31
- attribute = findAttributeByPath('#/age', schema)
32
- expect(attribute).toEqual({
33
- key: 'age',
34
- props: {
35
- type: 'integer',
36
- min: 0,
37
- max: 100
38
- }
39
- })
40
- })
41
-
42
- it('should find attribute from nested path', () => {
43
- const schema = {
44
- type: 'object',
45
- properties: {
46
- address: {
47
- type: 'object',
48
- properties: {
49
- street: { type: 'string' },
50
- city: { type: 'string' },
51
- state: { type: 'string' },
52
- zip: { type: 'integer' }
53
- }
54
- }
55
- }
56
- }
57
- let attribute = findAttributeByPath('#/address/street', schema)
58
- expect(attribute).toEqual({
59
- key: 'street',
60
- props: {
61
- type: 'string'
62
- }
63
- })
64
-
65
- attribute = findAttributeByPath('#/address/city', schema)
66
- expect(attribute).toEqual({
67
- key: 'city',
68
- props: {
69
- type: 'string'
70
- }
71
- })
72
-
73
- attribute = findAttributeByPath('#/address/state', schema)
74
- expect(attribute).toEqual({
75
- key: 'state',
76
- props: {
77
- type: 'string'
78
- }
79
- })
80
-
81
- attribute = findAttributeByPath('#/address/zip', schema)
82
- expect(attribute).toEqual({
83
- key: 'zip',
84
- props: {
85
- type: 'integer'
86
- }
87
- })
88
- })
89
-
90
- it('should throw error if path is invalid', () => {
91
- const schema = {
92
- type: 'object',
93
- properties: {
94
- name: { type: 'string' },
95
- age: { type: 'integer', min: 0, max: 100 }
96
- }
97
- }
98
- expect(() => {
99
- findAttributeByPath('#/invalid', schema)
100
- }).toThrowError('Invalid scope: #/invalid')
101
- })
102
-
103
- it('should return props if scope is missing', () => {
104
- const schema = {
105
- type: 'object',
106
- properties: {
107
- name: { type: 'string' },
108
- age: { type: 'integer', min: 0, max: 100 }
109
- }
110
- }
111
- const attribute = findAttributeByPath(null, schema)
112
- expect(attribute).toEqual({
113
- props: schema
114
- })
115
- })
116
- })
117
-
118
- describe('getSchemaWithLayout', () => {
119
- const value = {
120
- name: 'John',
121
- age: 30,
122
- address: {
123
- street: '123 Main St',
124
- city: 'New York',
125
- state: 'NY',
126
- zip: 10001
127
- }
128
- }
129
- const schema = inputSchema
130
- const layout = inputLayout
131
-
132
- it('should combine generated schema and layout', () => {
133
- const value = {
134
- name: 'John',
135
- age: 30
136
- }
137
- const schema = getSchemaWithLayout(deriveSchemaFromValue(value), deriveLayoutFromValue(value))
138
- expect(schema).toEqual({
139
- type: 'vertical',
140
- elements: [
141
- {
142
- key: 'name',
143
-
144
- props: {
145
- label: 'name',
146
- type: 'string'
147
- }
148
- },
149
- {
150
- key: 'age',
151
-
152
- props: {
153
- label: 'age',
154
- type: 'integer'
155
- }
156
- }
157
- ]
158
- })
159
- })
160
-
161
- it('should combine generated schema & layout for nested data', () => {
162
- const schema = getSchemaWithLayout(deriveSchemaFromValue(value), deriveLayoutFromValue(value))
163
- expect(schema).toEqual(resultWithOnlyData)
164
- })
165
-
166
- it('should combine schema with generated layout', () => {
167
- const combined = getSchemaWithLayout(schema, deriveLayoutFromValue(value))
168
- expect(combined).toEqual(resultWithGeneratedLayout)
169
- })
170
-
171
- it('should combine layout with generated schema', () => {
172
- const combined = getSchemaWithLayout(deriveSchemaFromValue(value), layout)
173
- expect(combined).toEqual(resultWithGeneratedSchema)
174
- })
175
-
176
- it('should combine the schema and layout', () => {
177
- const combined = getSchemaWithLayout(schema, layout)
178
- expect(combined).toEqual(combinedSchemaLayout)
179
- })
180
-
181
- it('should combine the schema and layout for nested array', () => {
182
- const schema = {
183
- type: 'object',
184
- properties: {
185
- users: {
186
- type: 'array',
187
- items: {
188
- type: 'object',
189
- properties: {
190
- name: { type: 'string' },
191
- age: { type: 'integer' }
192
- }
193
- }
194
- }
195
- }
196
- }
197
- const layout = {
198
- type: 'vertical',
199
- elements: [
200
- {
201
- scope: '#/users',
202
- schema: {
203
- type: 'vertical',
204
-
205
- elements: [
206
- {
207
- scope: '#/name',
208
- label: 'name'
209
- },
210
- {
211
- scope: '#/age',
212
- label: 'age'
213
- }
214
- ]
215
- }
216
- }
217
- ]
218
- }
219
-
220
- const combined = getSchemaWithLayout(schema, layout)
221
- expect(combined).toEqual({
222
- type: 'vertical',
223
- elements: [
224
- {
225
- key: 'users',
226
- props: {
227
- type: 'array',
228
- schema: {
229
- type: 'vertical',
230
- elements: [
231
- {
232
- key: 'name',
233
- props: {
234
- label: 'name',
235
- type: 'string'
236
- }
237
- },
238
- {
239
- key: 'age',
240
- props: { label: 'age', type: 'integer' }
241
- }
242
- ]
243
- }
244
- }
245
- }
246
- ]
247
- })
248
- })
249
- })
250
- })
@@ -1,64 +0,0 @@
1
- {
2
- "type": "vertical",
3
- "elements": [
4
- {
5
- "key": "name",
6
- "component": "custom",
7
- "props": {
8
- "type": "string",
9
- "label": "Name"
10
- }
11
- },
12
- {
13
- "key": "age",
14
-
15
- "props": {
16
- "label": "age",
17
- "type": "number",
18
- "min": 0,
19
- "max": 100
20
- }
21
- },
22
- {
23
- "type": "vertical",
24
- "key": "address",
25
- "title": "Address",
26
- "elements": [
27
- {
28
- "key": "street",
29
-
30
- "props": {
31
- "label": "street",
32
- "type": "string"
33
- }
34
- },
35
- {
36
- "key": "city",
37
-
38
- "props": {
39
- "label": "city",
40
- "type": "string"
41
- }
42
- },
43
- {
44
- "key": "state",
45
-
46
- "props": {
47
- "label": "state",
48
- "type": "string"
49
- }
50
- },
51
- {
52
- "key": "zip",
53
-
54
- "props": {
55
- "label": "zip",
56
- "type": "number",
57
- "min": 0,
58
- "max": 99999
59
- }
60
- }
61
- ]
62
- }
63
- ]
64
- }
@@ -1,68 +0,0 @@
1
- {
2
- "type": "vertical",
3
- "elements": [
4
- {
5
- "scope": "#/name",
6
- "key": "name",
7
- "label": "name",
8
- "component": "custom",
9
- "props": {
10
- "type": "string",
11
- "label": "Name"
12
- }
13
- },
14
- {
15
- "scope": "#/age",
16
- "key": "age",
17
- "label": "age",
18
-
19
- "props": {
20
- "type": "number"
21
- }
22
- },
23
- {
24
- "type": "vertical",
25
- "scope": "#/address",
26
- "key": "address",
27
- "title": "Address",
28
- "elements": [
29
- {
30
- "scope": "#/address/street",
31
- "key": "street",
32
- "label": "street",
33
-
34
- "props": {
35
- "type": "string"
36
- }
37
- },
38
- {
39
- "scope": "#/address/city",
40
- "key": "city",
41
- "label": "city",
42
-
43
- "props": {
44
- "type": "string"
45
- }
46
- },
47
- {
48
- "scope": "#/address/state",
49
- "key": "state",
50
- "label": "state",
51
-
52
- "props": {
53
- "type": "string"
54
- }
55
- },
56
- {
57
- "scope": "#/address/zip",
58
- "key": "zip",
59
- "label": "zip",
60
-
61
- "props": {
62
- "type": "number"
63
- }
64
- }
65
- ]
66
- }
67
- ]
68
- }
@@ -1,16 +0,0 @@
1
- {
2
- "type": "object",
3
- "properties": {
4
- "name": { "type": "string" },
5
- "age": { "type": "number", "min": 0, "max": 100 },
6
- "address": {
7
- "type": "object",
8
- "properties": {
9
- "street": { "type": "string" },
10
- "city": { "type": "string" },
11
- "state": { "type": "string" },
12
- "zip": { "type": "number", "min": 0, "max": 99999 }
13
- }
14
- }
15
- }
16
- }
@@ -1,64 +0,0 @@
1
- {
2
- "type": "vertical",
3
- "elements": [
4
- {
5
- "key": "name",
6
-
7
- "props": {
8
- "label": "name",
9
- "type": "string"
10
- }
11
- },
12
- {
13
- "key": "age",
14
-
15
- "props": {
16
- "label": "age",
17
- "type": "number",
18
- "min": 0,
19
- "max": 100
20
- }
21
- },
22
- {
23
- "type": "vertical",
24
- "key": "address",
25
- "title": "address",
26
- "elements": [
27
- {
28
- "key": "street",
29
-
30
- "props": {
31
- "label": "street",
32
- "type": "string"
33
- }
34
- },
35
- {
36
- "key": "city",
37
-
38
- "props": {
39
- "label": "city",
40
- "type": "string"
41
- }
42
- },
43
- {
44
- "key": "state",
45
-
46
- "props": {
47
- "label": "state",
48
- "type": "string"
49
- }
50
- },
51
- {
52
- "key": "zip",
53
-
54
- "props": {
55
- "label": "zip",
56
- "type": "number",
57
- "min": 0,
58
- "max": 99999
59
- }
60
- }
61
- ]
62
- }
63
- ]
64
- }
@@ -1,60 +0,0 @@
1
- {
2
- "type": "vertical",
3
- "elements": [
4
- {
5
- "key": "name",
6
- "component": "custom",
7
- "props": {
8
- "type": "string",
9
- "label": "Name"
10
- }
11
- },
12
- {
13
- "key": "age",
14
-
15
- "props": {
16
- "label": "age",
17
- "type": "number"
18
- }
19
- },
20
- {
21
- "type": "vertical",
22
- "key": "address",
23
- "title": "Address",
24
- "elements": [
25
- {
26
- "key": "street",
27
-
28
- "props": {
29
- "label": "street",
30
- "type": "string"
31
- }
32
- },
33
- {
34
- "key": "city",
35
-
36
- "props": {
37
- "label": "city",
38
- "type": "string"
39
- }
40
- },
41
- {
42
- "key": "state",
43
-
44
- "props": {
45
- "label": "state",
46
- "type": "string"
47
- }
48
- },
49
- {
50
- "key": "zip",
51
-
52
- "props": {
53
- "label": "zip",
54
- "type": "number"
55
- }
56
- }
57
- ]
58
- }
59
- ]
60
- }
@@ -1,60 +0,0 @@
1
- {
2
- "type": "vertical",
3
- "elements": [
4
- {
5
- "key": "name",
6
-
7
- "props": {
8
- "type": "string",
9
- "label": "name"
10
- }
11
- },
12
- {
13
- "key": "age",
14
-
15
- "props": {
16
- "label": "age",
17
- "type": "integer"
18
- }
19
- },
20
- {
21
- "type": "vertical",
22
- "key": "address",
23
- "title": "address",
24
- "elements": [
25
- {
26
- "key": "street",
27
-
28
- "props": {
29
- "label": "street",
30
- "type": "string"
31
- }
32
- },
33
- {
34
- "key": "city",
35
-
36
- "props": {
37
- "label": "city",
38
- "type": "string"
39
- }
40
- },
41
- {
42
- "key": "state",
43
-
44
- "props": {
45
- "label": "state",
46
- "type": "string"
47
- }
48
- },
49
- {
50
- "key": "zip",
51
-
52
- "props": {
53
- "label": "zip",
54
- "type": "integer"
55
- }
56
- }
57
- ]
58
- }
59
- ]
60
- }