@wordpress/dataviews 9.0.1-next.a730c9c8c.0 → 9.1.1-next.233ccab9b.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -3
  2. package/README.md +4 -2
  3. package/build/components/dataviews/index.js +4 -6
  4. package/build/components/dataviews/index.js.map +1 -1
  5. package/build/components/dataviews-filters/filters-toggled.js +32 -0
  6. package/build/components/dataviews-filters/filters-toggled.js.map +1 -0
  7. package/build/components/dataviews-filters/filters.js +73 -0
  8. package/build/components/dataviews-filters/filters.js.map +1 -0
  9. package/build/components/dataviews-filters/index.js +26 -190
  10. package/build/components/dataviews-filters/index.js.map +1 -1
  11. package/build/components/dataviews-filters/toggle.js +99 -0
  12. package/build/components/dataviews-filters/toggle.js.map +1 -0
  13. package/build/components/dataviews-filters/use-filters.js +63 -0
  14. package/build/components/dataviews-filters/use-filters.js.map +1 -0
  15. package/build/components/dataviews-picker/index.js +4 -6
  16. package/build/components/dataviews-picker/index.js.map +1 -1
  17. package/build/components/dataviews-view-config/index.js +22 -3
  18. package/build/components/dataviews-view-config/index.js.map +1 -1
  19. package/build/dataform-controls/array.js +110 -24
  20. package/build/dataform-controls/array.js.map +1 -1
  21. package/build/dataviews-layouts/picker-grid/index.js +4 -1
  22. package/build/dataviews-layouts/picker-grid/index.js.map +1 -1
  23. package/build/field-types/array.js +0 -6
  24. package/build/field-types/array.js.map +1 -1
  25. package/build/index.js +7 -0
  26. package/build/index.js.map +1 -1
  27. package/build/types.js.map +1 -1
  28. package/build/validation.js +18 -1
  29. package/build/validation.js.map +1 -1
  30. package/build-module/components/dataviews/index.js +5 -7
  31. package/build-module/components/dataviews/index.js.map +1 -1
  32. package/build-module/components/dataviews-filters/filters-toggled.js +24 -0
  33. package/build-module/components/dataviews-filters/filters-toggled.js.map +1 -0
  34. package/build-module/components/dataviews-filters/filters.js +65 -0
  35. package/build-module/components/dataviews-filters/filters.js.map +1 -0
  36. package/build-module/components/dataviews-filters/index.js +4 -186
  37. package/build-module/components/dataviews-filters/index.js.map +1 -1
  38. package/build-module/components/dataviews-filters/toggle.js +91 -0
  39. package/build-module/components/dataviews-filters/toggle.js.map +1 -0
  40. package/build-module/components/dataviews-filters/use-filters.js +56 -0
  41. package/build-module/components/dataviews-filters/use-filters.js.map +1 -0
  42. package/build-module/components/dataviews-picker/index.js +5 -7
  43. package/build-module/components/dataviews-picker/index.js.map +1 -1
  44. package/build-module/components/dataviews-view-config/index.js +22 -3
  45. package/build-module/components/dataviews-view-config/index.js.map +1 -1
  46. package/build-module/dataform-controls/array.js +112 -26
  47. package/build-module/dataform-controls/array.js.map +1 -1
  48. package/build-module/dataviews-layouts/picker-grid/index.js +4 -1
  49. package/build-module/dataviews-layouts/picker-grid/index.js.map +1 -1
  50. package/build-module/field-types/array.js +0 -6
  51. package/build-module/field-types/array.js.map +1 -1
  52. package/build-module/index.js +1 -0
  53. package/build-module/index.js.map +1 -1
  54. package/build-module/types.js.map +1 -1
  55. package/build-module/validation.js +18 -1
  56. package/build-module/validation.js.map +1 -1
  57. package/build-types/components/dataform/stories/index.story.d.ts.map +1 -1
  58. package/build-types/components/dataviews/index.d.ts +3 -2
  59. package/build-types/components/dataviews/index.d.ts.map +1 -1
  60. package/build-types/components/dataviews-filters/filters-toggled.d.ts +5 -0
  61. package/build-types/components/dataviews-filters/filters-toggled.d.ts.map +1 -0
  62. package/build-types/components/dataviews-filters/filters.d.ts +6 -0
  63. package/build-types/components/dataviews-filters/filters.d.ts.map +1 -0
  64. package/build-types/components/dataviews-filters/index.d.ts +4 -8
  65. package/build-types/components/dataviews-filters/index.d.ts.map +1 -1
  66. package/build-types/components/dataviews-filters/toggle.d.ts +3 -0
  67. package/build-types/components/dataviews-filters/toggle.d.ts.map +1 -0
  68. package/build-types/components/dataviews-filters/use-filters.d.ts +4 -0
  69. package/build-types/components/dataviews-filters/use-filters.d.ts.map +1 -0
  70. package/build-types/components/dataviews-picker/index.d.ts +3 -2
  71. package/build-types/components/dataviews-picker/index.d.ts.map +1 -1
  72. package/build-types/components/dataviews-view-config/index.d.ts.map +1 -1
  73. package/build-types/dataform-controls/array.d.ts.map +1 -1
  74. package/build-types/dataviews-layouts/picker-grid/index.d.ts.map +1 -1
  75. package/build-types/field-types/array.d.ts.map +1 -1
  76. package/build-types/index.d.ts +1 -0
  77. package/build-types/index.d.ts.map +1 -1
  78. package/build-types/types.d.ts +2 -1
  79. package/build-types/types.d.ts.map +1 -1
  80. package/build-types/validation.d.ts.map +1 -1
  81. package/build-wp/index.js +899 -408
  82. package/package.json +15 -15
  83. package/src/components/dataform/stories/index.story.tsx +73 -1
  84. package/src/components/dataviews/index.tsx +8 -14
  85. package/src/components/dataviews/stories/index.story.tsx +1 -1
  86. package/src/components/dataviews-filters/filters-toggled.tsx +20 -0
  87. package/src/components/dataviews-filters/filters.tsx +73 -0
  88. package/src/components/dataviews-filters/index.tsx +4 -246
  89. package/src/components/dataviews-filters/toggle.tsx +118 -0
  90. package/src/components/dataviews-filters/use-filters.ts +73 -0
  91. package/src/components/dataviews-picker/index.tsx +8 -14
  92. package/src/components/dataviews-view-config/index.tsx +18 -3
  93. package/src/dataform-controls/array.tsx +137 -40
  94. package/src/dataviews-layouts/picker-grid/index.tsx +15 -8
  95. package/src/field-types/array.tsx +0 -8
  96. package/src/index.ts +1 -0
  97. package/src/test/validation.ts +192 -0
  98. package/src/types.ts +2 -1
  99. package/src/validation.ts +30 -0
  100. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,73 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useMemo } from '@wordpress/element';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import { ALL_OPERATORS, SINGLE_SELECTION_OPERATORS } from '../../constants';
10
+ import type { NormalizedFilter, NormalizedField, View } from '../../types';
11
+
12
+ function useFilters( fields: NormalizedField< any >[], view: View ) {
13
+ return useMemo( () => {
14
+ const filters: NormalizedFilter[] = [];
15
+ fields.forEach( ( field ) => {
16
+ if (
17
+ field.filterBy === false ||
18
+ ( ! field.elements?.length && ! field.Edit )
19
+ ) {
20
+ return;
21
+ }
22
+
23
+ const operators = field.filterBy.operators;
24
+ const isPrimary = !! field.filterBy?.isPrimary;
25
+ const isLocked =
26
+ view.filters?.some(
27
+ ( f ) => f.field === field.id && !! f.isLocked
28
+ ) ?? false;
29
+ filters.push( {
30
+ field: field.id,
31
+ name: field.label,
32
+ elements: field.elements ?? [],
33
+ singleSelection: operators.some( ( op ) =>
34
+ SINGLE_SELECTION_OPERATORS.includes( op )
35
+ ),
36
+ operators,
37
+ isVisible:
38
+ isLocked ||
39
+ isPrimary ||
40
+ !! view.filters?.some(
41
+ ( f ) =>
42
+ f.field === field.id &&
43
+ ALL_OPERATORS.includes( f.operator )
44
+ ),
45
+ isPrimary,
46
+ isLocked,
47
+ } );
48
+ } );
49
+
50
+ // Sort filters by:
51
+ // - locked filters go first
52
+ // - primary filters go next
53
+ // - then, sort by name
54
+ filters.sort( ( a, b ) => {
55
+ if ( a.isLocked && ! b.isLocked ) {
56
+ return -1;
57
+ }
58
+ if ( ! a.isLocked && b.isLocked ) {
59
+ return 1;
60
+ }
61
+ if ( a.isPrimary && ! b.isPrimary ) {
62
+ return -1;
63
+ }
64
+ if ( ! a.isPrimary && b.isPrimary ) {
65
+ return 1;
66
+ }
67
+ return a.name.localeCompare( b.name );
68
+ } );
69
+ return filters;
70
+ }, [ fields, view ] );
71
+ }
72
+
73
+ export default useFilters;
@@ -7,13 +7,7 @@ import type { ReactNode } from 'react';
7
7
  * WordPress dependencies
8
8
  */
9
9
  import { __experimentalHStack as HStack } from '@wordpress/components';
10
- import {
11
- useContext,
12
- useEffect,
13
- useMemo,
14
- useRef,
15
- useState,
16
- } from '@wordpress/element';
10
+ import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
17
11
  import { useResizeObserver, throttle } from '@wordpress/compose';
18
12
 
19
13
  /**
@@ -22,7 +16,8 @@ import { useResizeObserver, throttle } from '@wordpress/compose';
22
16
  import DataViewsContext from '../dataviews-context';
23
17
  import { VIEW_LAYOUTS } from '../../dataviews-layouts';
24
18
  import {
25
- default as DataViewsFilters,
19
+ Filters,
20
+ FiltersToggled,
26
21
  useFilters,
27
22
  FiltersToggle,
28
23
  } from '../dataviews-filters';
@@ -84,7 +79,6 @@ function DefaultUI( {
84
79
  search = true,
85
80
  searchLabel = undefined,
86
81
  }: DefaultUIProps ) {
87
- const { isShowingFilter } = useContext( DataViewsContext );
88
82
  return (
89
83
  <>
90
84
  <HStack
@@ -109,9 +103,7 @@ function DefaultUI( {
109
103
  <DataViewsViewConfig />
110
104
  </HStack>
111
105
  </HStack>
112
- { isShowingFilter && (
113
- <DataViewsFilters className="dataviews-filters__container" />
114
- ) }
106
+ <FiltersToggled className="dataviews-filters__container" />
115
107
  <DataViewsLayout />
116
108
  <DataViewsPickerFooter />
117
109
  </>
@@ -263,7 +255,8 @@ function DataViewsPicker< Item >( {
263
255
  const DataViewsPickerSubComponents =
264
256
  DataViewsPicker as typeof DataViewsPicker & {
265
257
  BulkActionToolbar: typeof DataViewsPickerFooter;
266
- Filters: typeof DataViewsFilters;
258
+ Filters: typeof Filters;
259
+ FiltersToggled: typeof FiltersToggled;
267
260
  FiltersToggle: typeof FiltersToggle;
268
261
  Layout: typeof DataViewsLayout;
269
262
  LayoutSwitcher: typeof ViewTypeMenu;
@@ -273,7 +266,8 @@ const DataViewsPickerSubComponents =
273
266
  };
274
267
 
275
268
  DataViewsPickerSubComponents.BulkActionToolbar = DataViewsPickerFooter;
276
- DataViewsPickerSubComponents.Filters = DataViewsFilters;
269
+ DataViewsPickerSubComponents.Filters = Filters;
270
+ DataViewsPickerSubComponents.FiltersToggled = FiltersToggled;
277
271
  DataViewsPickerSubComponents.FiltersToggle = FiltersToggle;
278
272
  DataViewsPickerSubComponents.Layout = DataViewsLayout;
279
273
  DataViewsPickerSubComponents.LayoutSwitcher = ViewTypeMenu;
@@ -562,9 +562,10 @@ function FieldControl() {
562
562
  ( f ) =>
563
563
  ! visibleFieldIds.includes( f.id ) &&
564
564
  ! togglableFields.includes( f.id ) &&
565
- f.type !== 'media'
565
+ f.type !== 'media' &&
566
+ f.enableHiding !== false
566
567
  );
567
- const visibleFields = visibleFieldIds
568
+ let visibleFields = visibleFieldIds
568
569
  .map( ( fieldId ) => fields.find( ( f ) => f.id === fieldId ) )
569
570
  .filter( isDefined );
570
571
 
@@ -622,7 +623,7 @@ function FieldControl() {
622
623
  isVisibleFlag: 'showDescription',
623
624
  },
624
625
  ].filter( ( { field } ) => isDefined( field ) );
625
- const visibleLockedFields = lockedFields.filter(
626
+ let visibleLockedFields = lockedFields.filter(
626
627
  ( { field, isVisibleFlag } ) =>
627
628
  // @ts-expect-error
628
629
  isDefined( field ) && ( view[ isVisibleFlag ] ?? true )
@@ -631,6 +632,20 @@ function FieldControl() {
631
632
  isVisibleFlag: string;
632
633
  ui?: ReactNode;
633
634
  } >;
635
+
636
+ // If only one locked field is visible, prevent it from being hidden.
637
+ if ( visibleLockedFields.length === 1 ) {
638
+ visibleLockedFields = visibleLockedFields.map( ( locked ) => ( {
639
+ ...locked,
640
+ field: { ...locked.field, enableHiding: false },
641
+ } ) );
642
+ }
643
+
644
+ // If no locked fields are visible but there are visibleFields, lock the last visible field.
645
+ if ( visibleLockedFields.length === 0 && visibleFields.length === 1 ) {
646
+ visibleFields = [ { ...visibleFields[ 0 ], enableHiding: false } ];
647
+ }
648
+
634
649
  const hiddenLockedFields = lockedFields.filter(
635
650
  ( { field, isVisibleFlag } ) =>
636
651
  // @ts-expect-error
@@ -1,13 +1,22 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import deepMerge from 'deepmerge';
5
+
1
6
  /**
2
7
  * WordPress dependencies
3
8
  */
4
- import { FormTokenField } from '@wordpress/components';
5
- import { useCallback, useMemo } from '@wordpress/element';
9
+ import { privateApis } from '@wordpress/components';
10
+ import { useCallback, useMemo, useState } from '@wordpress/element';
11
+ import { _n, sprintf } from '@wordpress/i18n';
6
12
 
7
13
  /**
8
14
  * Internal dependencies
9
15
  */
10
16
  import type { DataFormControlProps } from '../types';
17
+ import { unlock } from '../lock-unlock';
18
+
19
+ const { ValidatedFormTokenField } = unlock( privateApis );
11
20
 
12
21
  export default function ArrayControl< Item >( {
13
22
  data,
@@ -18,66 +27,154 @@ export default function ArrayControl< Item >( {
18
27
  const { label, placeholder, elements, getValue, setValue } = field;
19
28
  const value = getValue( { item: data } );
20
29
 
21
- const findElementByValue = useCallback(
22
- ( suggestionValue: string ) => {
23
- return elements?.find(
24
- ( suggestion ) => suggestion.value === suggestionValue
25
- );
26
- },
27
- [ elements ]
28
- );
30
+ const [ customValidity, setCustomValidity ] = useState<
31
+ | {
32
+ type: 'validating' | 'valid' | 'invalid';
33
+ message: string;
34
+ }
35
+ | undefined
36
+ >( undefined );
29
37
 
30
- const findElementByLabel = useCallback(
31
- ( suggestionLabel: string ) => {
32
- return elements?.find(
33
- ( suggestion ) => suggestion.label === suggestionLabel
34
- );
35
- },
36
- [ elements ]
37
- );
38
-
39
- // Ensure value is an array
40
- const arrayValue = useMemo(
38
+ // Convert stored values to element objects for the token field
39
+ const arrayValueAsElements = useMemo(
41
40
  () =>
42
41
  Array.isArray( value )
43
42
  ? value.map( ( token ) => {
44
- const tokenLabel = findElementByValue( token )?.label;
45
- return tokenLabel || token;
43
+ const element = elements?.find(
44
+ ( suggestion ) => suggestion.value === token
45
+ );
46
+ return element || { value: token, label: token };
46
47
  } )
47
48
  : [],
48
- [ value, findElementByValue ]
49
+ [ value, elements ]
49
50
  );
50
51
 
51
- const onChangeControl = useCallback(
52
- ( tokens: ( string | { value: string } )[] ) => {
53
- // Convert TokenItem objects to strings
54
- const stringTokens = tokens.map( ( token ) => {
55
- if ( typeof token !== 'string' ) {
52
+ const validateTokens = useCallback(
53
+ ( tokens: ( string | { value: string; label?: string } )[] ) => {
54
+ // Extract actual values from tokens for validation
55
+ const tokenValues = tokens.map( ( token ) => {
56
+ if ( typeof token === 'object' && 'value' in token ) {
56
57
  return token.value;
57
58
  }
59
+ return token;
60
+ } );
61
+
62
+ // First, check if elements validation is required and any tokens are invalid
63
+ if ( field.isValid?.elements && elements ) {
64
+ const invalidTokens = tokenValues.filter( ( tokenValue ) => {
65
+ return ! elements.some(
66
+ ( element ) => element.value === tokenValue
67
+ );
68
+ } );
69
+
70
+ if ( invalidTokens.length > 0 ) {
71
+ setCustomValidity( {
72
+ type: 'invalid',
73
+ message: sprintf(
74
+ /* translators: %s: list of invalid tokens */
75
+ _n(
76
+ 'Please select from the available options: %s is invalid.',
77
+ 'Please select from the available options: %s are invalid.',
78
+ invalidTokens.length
79
+ ),
80
+ invalidTokens.join( ', ' )
81
+ ),
82
+ } );
83
+ return;
84
+ }
85
+ }
86
+
87
+ // Then check custom validation if provided.
88
+ if ( field.isValid?.custom ) {
89
+ const result = field.isValid?.custom?.(
90
+ deepMerge(
91
+ data,
92
+ setValue( {
93
+ item: data,
94
+ value: tokenValues,
95
+ } ) as Partial< Item >
96
+ ),
97
+ field
98
+ );
99
+
100
+ if ( result ) {
101
+ setCustomValidity( {
102
+ type: 'invalid',
103
+ message: result,
104
+ } );
105
+ return;
106
+ }
107
+ }
58
108
 
59
- const tokenByLabel = findElementByLabel( token );
109
+ // If no validation errors, clear custom validity
110
+ setCustomValidity( undefined );
111
+ },
112
+ [ elements, data, field, setValue ]
113
+ );
60
114
 
61
- return tokenByLabel?.value || token;
115
+ const onChangeControl = useCallback(
116
+ ( tokens: ( string | { value: string; label?: string } )[] ) => {
117
+ const valueTokens = tokens.map( ( token ) => {
118
+ if ( typeof token === 'object' && 'value' in token ) {
119
+ return token.value;
120
+ }
121
+ // If it's a string, it's either a new suggestion value or user input
122
+ return token;
62
123
  } );
63
124
 
64
- onChange( setValue( { item: data, value: stringTokens } ) );
125
+ onChange( setValue( { item: data, value: valueTokens } ) );
65
126
  },
66
- [ onChange, setValue, data, findElementByLabel ]
127
+ [ onChange, setValue, data ]
67
128
  );
68
129
 
69
130
  return (
70
- <FormTokenField
131
+ <ValidatedFormTokenField
132
+ required={ !! field.isValid?.required }
133
+ onValidate={ validateTokens }
134
+ customValidity={ customValidity }
71
135
  label={ hideLabelFromVision ? undefined : label }
72
- value={ arrayValue }
136
+ value={ arrayValueAsElements }
73
137
  onChange={ onChangeControl }
74
138
  placeholder={ placeholder }
75
- suggestions={
76
- elements?.map( ( suggestion ) => suggestion.label ) ?? []
77
- }
139
+ suggestions={ elements?.map( ( element ) => element.value ) }
140
+ __experimentalValidateInput={ ( token: string ) => {
141
+ // If elements validation is required, check if token is valid
142
+ if ( field.isValid?.elements && elements ) {
143
+ return elements.some(
144
+ ( element ) =>
145
+ element.value === token || element.label === token
146
+ );
147
+ }
148
+
149
+ // For non-elements validation, allow all tokens
150
+ return true;
151
+ } }
78
152
  __experimentalExpandOnFocus={ elements && elements.length > 0 }
79
- __next40pxDefaultSize
80
- __nextHasNoMarginBottom
153
+ __experimentalShowHowTo={ ! field.isValid?.elements }
154
+ displayTransform={ ( token: any ) => {
155
+ // For existing tokens (element objects), display their label
156
+ if ( typeof token === 'object' && 'label' in token ) {
157
+ return token.label;
158
+ }
159
+ // For suggestions (value strings), find the corresponding element and show its label
160
+ if ( typeof token === 'string' && elements ) {
161
+ const element = elements.find(
162
+ ( el ) => el.value === token
163
+ );
164
+ return element?.label || token;
165
+ }
166
+ return token;
167
+ } }
168
+ __experimentalRenderItem={ ( { item }: { item: any } ) => {
169
+ // Custom rendering for suggestion items (item is a value string)
170
+ if ( typeof item === 'string' && elements ) {
171
+ const element = elements.find(
172
+ ( el ) => el.value === item
173
+ );
174
+ return <span>{ element?.label || item }</span>;
175
+ }
176
+ return <span>{ item }</span>;
177
+ } }
81
178
  />
82
179
  );
83
180
  }
@@ -89,6 +89,11 @@ function GridItem< Item >( {
89
89
 
90
90
  return (
91
91
  <Composite.Item
92
+ aria-label={
93
+ titleField
94
+ ? titleField.getValue( { item } ) || __( '(no title)' )
95
+ : undefined
96
+ }
92
97
  key={ id }
93
98
  render={ ( { children, ...props } ) => (
94
99
  <VStack spacing={ 0 } children={ children } { ...props } />
@@ -130,14 +135,16 @@ function GridItem< Item >( {
130
135
  tabIndex={ -1 }
131
136
  />
132
137
  ) }
133
- <HStack
134
- justify="space-between"
135
- className="dataviews-view-picker-grid__title-actions"
136
- >
137
- <div className="dataviews-view-picker-grid__title-field dataviews-title-field">
138
- { renderedTitleField }
139
- </div>
140
- </HStack>
138
+ { showTitle && (
139
+ <HStack
140
+ justify="space-between"
141
+ className="dataviews-view-picker-grid__title-actions"
142
+ >
143
+ <div className="dataviews-view-picker-grid__title-field dataviews-title-field">
144
+ { renderedTitleField }
145
+ </div>
146
+ </HStack>
147
+ ) }
141
148
  <VStack spacing={ 1 }>
142
149
  { showDescription && descriptionField?.render && (
143
150
  <descriptionField.render
@@ -59,14 +59,6 @@ const arrayFieldType: FieldTypeDefinition< any > = {
59
59
  return __( 'Every value must be a string.' );
60
60
  }
61
61
 
62
- if ( field?.elements ) {
63
- const validValues = field.elements.map( ( f ) => f.value );
64
- if (
65
- ! value.every( ( v: any ) => validValues.includes( v ) )
66
- ) {
67
- return __( 'Value must be one of the elements.' );
68
- }
69
- }
70
62
  return null;
71
63
  },
72
64
  },
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { default as DataViews } from './components/dataviews';
2
+ export { default as DataViewsPicker } from './components/dataviews-picker';
2
3
  export { default as DataForm } from './components/dataform';
3
4
  export { VIEW_LAYOUTS } from './dataviews-layouts';
4
5
  export { filterSortAndPaginate } from './filter-and-sort-data-view';
@@ -117,4 +117,196 @@ describe( 'validation', () => {
117
117
  const result = isItemValid( item, fields, form );
118
118
  expect( result ).toBe( true );
119
119
  } );
120
+
121
+ it( 'array field is invalid when required but empty', () => {
122
+ const item = { id: 1, tags: [] };
123
+ const fields: Field< {} >[] = [
124
+ {
125
+ id: 'tags',
126
+ type: 'array',
127
+ isValid: {
128
+ required: true,
129
+ },
130
+ },
131
+ ];
132
+ const form = { fields: [ 'tags' ] };
133
+ const result = isItemValid( item, fields, form );
134
+ expect( result ).toBe( false );
135
+ } );
136
+
137
+ it( 'array field is invalid when required but not an array', () => {
138
+ const item = { id: 1, tags: null };
139
+ const fields: Field< {} >[] = [
140
+ {
141
+ id: 'tags',
142
+ type: 'array',
143
+ isValid: {
144
+ required: true,
145
+ },
146
+ },
147
+ ];
148
+ const form = { fields: [ 'tags' ] };
149
+ const result = isItemValid( item, fields, form );
150
+ expect( result ).toBe( false );
151
+ } );
152
+
153
+ it( 'array field is valid when required and has values', () => {
154
+ const item = { id: 1, tags: [ 'tag1', 'tag2' ] };
155
+ const fields: Field< {} >[] = [
156
+ {
157
+ id: 'tags',
158
+ type: 'array',
159
+ isValid: {
160
+ required: true,
161
+ },
162
+ },
163
+ ];
164
+ const form = { fields: [ 'tags' ] };
165
+ const result = isItemValid( item, fields, form );
166
+ expect( result ).toBe( true );
167
+ } );
168
+
169
+ it( 'text field with isValid.elements validates against elements', () => {
170
+ const item = { id: 1, status: 'published' };
171
+ const fields: Field< {} >[] = [
172
+ {
173
+ id: 'status',
174
+ type: 'text',
175
+ elements: [
176
+ { value: 'draft', label: 'Draft' },
177
+ { value: 'published', label: 'Published' },
178
+ ],
179
+ isValid: {
180
+ elements: true,
181
+ },
182
+ },
183
+ ];
184
+ const form = { fields: [ 'status' ] };
185
+ const result = isItemValid( item, fields, form );
186
+ expect( result ).toBe( true );
187
+ } );
188
+
189
+ it( 'text field with isValid.elements rejects invalid values', () => {
190
+ const item = { id: 1, status: 'invalid-status' };
191
+ const fields: Field< {} >[] = [
192
+ {
193
+ id: 'status',
194
+ type: 'text',
195
+ elements: [
196
+ { value: 'draft', label: 'Draft' },
197
+ { value: 'published', label: 'Published' },
198
+ ],
199
+ isValid: {
200
+ elements: true,
201
+ },
202
+ },
203
+ ];
204
+ const form = { fields: [ 'status' ] };
205
+ const result = isItemValid( item, fields, form );
206
+ expect( result ).toBe( false );
207
+ } );
208
+
209
+ it( 'integer field with isValid.elements validates against elements', () => {
210
+ const item = { id: 1, priority: 2 };
211
+ const fields: Field< {} >[] = [
212
+ {
213
+ id: 'priority',
214
+ type: 'integer',
215
+ elements: [
216
+ { value: 1, label: 'Low' },
217
+ { value: 2, label: 'Medium' },
218
+ { value: 3, label: 'High' },
219
+ ],
220
+ isValid: {
221
+ elements: true,
222
+ },
223
+ },
224
+ ];
225
+ const form = { fields: [ 'priority' ] };
226
+ const result = isItemValid( item, fields, form );
227
+ expect( result ).toBe( true );
228
+ } );
229
+
230
+ it( 'integer field with isValid.elements rejects invalid values', () => {
231
+ const item = { id: 1, priority: 5 };
232
+ const fields: Field< {} >[] = [
233
+ {
234
+ id: 'priority',
235
+ type: 'integer',
236
+ elements: [
237
+ { value: 1, label: 'Low' },
238
+ { value: 2, label: 'Medium' },
239
+ { value: 3, label: 'High' },
240
+ ],
241
+ isValid: {
242
+ elements: true,
243
+ },
244
+ },
245
+ ];
246
+ const form = { fields: [ 'priority' ] };
247
+ const result = isItemValid( item, fields, form );
248
+ expect( result ).toBe( false );
249
+ } );
250
+
251
+ it( 'array field with isValid.elements validates all items against elements', () => {
252
+ const item = { id: 1, tags: [ 'red', 'blue' ] };
253
+ const fields: Field< {} >[] = [
254
+ {
255
+ id: 'tags',
256
+ type: 'array',
257
+ elements: [
258
+ { value: 'red', label: 'Red' },
259
+ { value: 'blue', label: 'Blue' },
260
+ { value: 'green', label: 'Green' },
261
+ ],
262
+ isValid: {
263
+ elements: true,
264
+ },
265
+ },
266
+ ];
267
+ const form = { fields: [ 'tags' ] };
268
+ const result = isItemValid( item, fields, form );
269
+ expect( result ).toBe( true );
270
+ } );
271
+
272
+ it( 'array field with isValid.elements rejects arrays with invalid items', () => {
273
+ const item = { id: 1, tags: [ 'red', 'yellow' ] };
274
+ const fields: Field< {} >[] = [
275
+ {
276
+ id: 'tags',
277
+ type: 'array',
278
+ elements: [
279
+ { value: 'red', label: 'Red' },
280
+ { value: 'blue', label: 'Blue' },
281
+ { value: 'green', label: 'Green' },
282
+ ],
283
+ isValid: {
284
+ elements: true,
285
+ },
286
+ },
287
+ ];
288
+ const form = { fields: [ 'tags' ] };
289
+ const result = isItemValid( item, fields, form );
290
+ expect( result ).toBe( false );
291
+ } );
292
+
293
+ it( 'array field with isValid.elements handles non-array values', () => {
294
+ const item = { id: 1, tags: 'not-an-array' };
295
+ const fields: Field< {} >[] = [
296
+ {
297
+ id: 'tags',
298
+ type: 'array',
299
+ elements: [
300
+ { value: 'red', label: 'Red' },
301
+ { value: 'blue', label: 'Blue' },
302
+ ],
303
+ isValid: {
304
+ elements: true,
305
+ },
306
+ },
307
+ ];
308
+ const form = { fields: [ 'tags' ] };
309
+ const result = isItemValid( item, fields, form );
310
+ expect( result ).toBe( false );
311
+ } );
120
312
  } );
package/src/types.ts CHANGED
@@ -164,6 +164,7 @@ export type FieldTypeDefinition< Item > = {
164
164
 
165
165
  export type Rules< Item > = {
166
166
  required?: boolean;
167
+ elements?: boolean;
167
168
  custom?: ( item: Item, field: NormalizedField< Item > ) => null | string;
168
169
  };
169
170
 
@@ -280,7 +281,7 @@ export type Field< Item > = {
280
281
  enableGlobalSearch?: boolean;
281
282
 
282
283
  /**
283
- * Whether the field is filterable.
284
+ * Whether the field can be hidden in the UI.
284
285
  */
285
286
  enableHiding?: boolean;
286
287