@navikt/ds-react 8.5.1 → 8.5.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.
Files changed (95) hide show
  1. package/cjs/data/table/root/DataTableRoot.d.ts +27 -0
  2. package/cjs/data/table/root/DataTableRoot.js +8 -2
  3. package/cjs/data/table/root/DataTableRoot.js.map +1 -1
  4. package/cjs/data/table/td/DataTableTd.d.ts +5 -4
  5. package/cjs/data/table/td/DataTableTd.js +2 -2
  6. package/cjs/data/table/td/DataTableTd.js.map +1 -1
  7. package/cjs/data/token-filter/AutoSuggest.d.ts +2 -14
  8. package/cjs/data/token-filter/AutoSuggest.js +16 -89
  9. package/cjs/data/token-filter/AutoSuggest.js.map +1 -1
  10. package/cjs/data/token-filter/AutoSuggest.types.d.ts +12 -0
  11. package/cjs/data/token-filter/AutoSuggest.types.js +3 -0
  12. package/cjs/data/token-filter/AutoSuggest.types.js.map +1 -0
  13. package/cjs/data/token-filter/TokenFilter.js +18 -7
  14. package/cjs/data/token-filter/TokenFilter.js.map +1 -1
  15. package/cjs/data/token-filter/TokenFilter.types.d.ts +8 -2
  16. package/cjs/data/token-filter/helpers/generate-autocomplete-options.d.ts +12 -58
  17. package/cjs/data/token-filter/helpers/generate-autocomplete-options.js +159 -133
  18. package/cjs/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -1
  19. package/cjs/data/token-filter/helpers/grouping.d.ts +28 -0
  20. package/cjs/data/token-filter/helpers/grouping.js +61 -0
  21. package/cjs/data/token-filter/helpers/grouping.js.map +1 -0
  22. package/cjs/data/token-filter/helpers/operators.d.ts +22 -0
  23. package/cjs/data/token-filter/helpers/operators.js +66 -0
  24. package/cjs/data/token-filter/helpers/operators.js.map +1 -0
  25. package/cjs/data/token-filter/helpers/parse-query-text.d.ts +1 -7
  26. package/cjs/data/token-filter/helpers/parse-query-text.js +5 -50
  27. package/cjs/data/token-filter/helpers/parse-query-text.js.map +1 -1
  28. package/cjs/data/token-filter/helpers/query-builder.d.ts +20 -0
  29. package/cjs/data/token-filter/helpers/query-builder.js +38 -0
  30. package/cjs/data/token-filter/helpers/query-builder.js.map +1 -0
  31. package/cjs/data/token-filter/helpers/text-matching.d.ts +16 -0
  32. package/cjs/data/token-filter/helpers/text-matching.js +47 -0
  33. package/cjs/data/token-filter/helpers/text-matching.js.map +1 -0
  34. package/cjs/form/combobox/Input/InputController.js +1 -1
  35. package/cjs/form/combobox/Input/InputController.js.map +1 -1
  36. package/cjs/form/file-upload/dropzone/FileUploadDropzone.js +1 -1
  37. package/cjs/form/file-upload/dropzone/FileUploadDropzone.js.map +1 -1
  38. package/esm/data/table/root/DataTableRoot.d.ts +27 -0
  39. package/esm/data/table/root/DataTableRoot.js +8 -2
  40. package/esm/data/table/root/DataTableRoot.js.map +1 -1
  41. package/esm/data/table/td/DataTableTd.d.ts +5 -4
  42. package/esm/data/table/td/DataTableTd.js +2 -2
  43. package/esm/data/table/td/DataTableTd.js.map +1 -1
  44. package/esm/data/token-filter/AutoSuggest.d.ts +2 -14
  45. package/esm/data/token-filter/AutoSuggest.js +17 -90
  46. package/esm/data/token-filter/AutoSuggest.js.map +1 -1
  47. package/esm/data/token-filter/AutoSuggest.types.d.ts +12 -0
  48. package/esm/data/token-filter/AutoSuggest.types.js +2 -0
  49. package/esm/data/token-filter/AutoSuggest.types.js.map +1 -0
  50. package/esm/data/token-filter/TokenFilter.js +18 -7
  51. package/esm/data/token-filter/TokenFilter.js.map +1 -1
  52. package/esm/data/token-filter/TokenFilter.types.d.ts +8 -2
  53. package/esm/data/token-filter/helpers/generate-autocomplete-options.d.ts +12 -58
  54. package/esm/data/token-filter/helpers/generate-autocomplete-options.js +159 -133
  55. package/esm/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -1
  56. package/esm/data/token-filter/helpers/grouping.d.ts +28 -0
  57. package/esm/data/token-filter/helpers/grouping.js +59 -0
  58. package/esm/data/token-filter/helpers/grouping.js.map +1 -0
  59. package/esm/data/token-filter/helpers/operators.d.ts +22 -0
  60. package/esm/data/token-filter/helpers/operators.js +60 -0
  61. package/esm/data/token-filter/helpers/operators.js.map +1 -0
  62. package/esm/data/token-filter/helpers/parse-query-text.d.ts +1 -7
  63. package/esm/data/token-filter/helpers/parse-query-text.js +2 -45
  64. package/esm/data/token-filter/helpers/parse-query-text.js.map +1 -1
  65. package/esm/data/token-filter/helpers/query-builder.d.ts +20 -0
  66. package/esm/data/token-filter/helpers/query-builder.js +34 -0
  67. package/esm/data/token-filter/helpers/query-builder.js.map +1 -0
  68. package/esm/data/token-filter/helpers/text-matching.d.ts +16 -0
  69. package/esm/data/token-filter/helpers/text-matching.js +45 -0
  70. package/esm/data/token-filter/helpers/text-matching.js.map +1 -0
  71. package/esm/form/combobox/Input/InputController.js +1 -1
  72. package/esm/form/combobox/Input/InputController.js.map +1 -1
  73. package/esm/form/file-upload/dropzone/FileUploadDropzone.js +1 -1
  74. package/esm/form/file-upload/dropzone/FileUploadDropzone.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/data/table/root/DataTableRoot.tsx +30 -1
  77. package/src/data/table/td/DataTableTd.tsx +13 -6
  78. package/src/data/token-filter/AutoSuggest.tsx +38 -162
  79. package/src/data/token-filter/AutoSuggest.types.ts +14 -0
  80. package/src/data/token-filter/TokenFilter.tsx +16 -11
  81. package/src/data/token-filter/TokenFilter.types.ts +8 -2
  82. package/src/data/token-filter/helpers/generate-autocomplete-options.test.ts +896 -0
  83. package/src/data/token-filter/helpers/generate-autocomplete-options.ts +233 -188
  84. package/src/data/token-filter/helpers/grouping.test.ts +206 -0
  85. package/src/data/token-filter/helpers/grouping.ts +73 -0
  86. package/src/data/token-filter/helpers/operators.test.ts +281 -0
  87. package/src/data/token-filter/helpers/operators.ts +91 -0
  88. package/src/data/token-filter/helpers/parse-query-text.test.ts +4 -213
  89. package/src/data/token-filter/helpers/parse-query-text.ts +7 -69
  90. package/src/data/token-filter/helpers/query-builder.test.ts +126 -0
  91. package/src/data/token-filter/helpers/query-builder.ts +41 -0
  92. package/src/data/token-filter/helpers/text-matching.test.ts +125 -0
  93. package/src/data/token-filter/helpers/text-matching.ts +58 -0
  94. package/src/form/combobox/Input/InputController.tsx +0 -1
  95. package/src/form/file-upload/dropzone/FileUploadDropzone.tsx +0 -1
@@ -1,244 +1,289 @@
1
- import type { ParsedOption, ParsedProperty } from "../TokenFilter.types";
2
- import { type ParsedText, QUERY_OPERATORS } from "./parse-query-text";
1
+ import type { AutoCompleteOption, OptionGroup } from "../AutoSuggest.types";
2
+ import type {
3
+ ParsedOption,
4
+ ParsedProperty,
5
+ QueryFilterOperator,
6
+ } from "../TokenFilter.types";
7
+ import { createGroups } from "./grouping";
8
+ import { QUERY_OPERATORS } from "./operators";
9
+ import { type ParsedText } from "./parse-query-text";
10
+ import { OPERATOR_LABELS, buildQueryString } from "./query-builder";
11
+ import { matchesFilterText } from "./text-matching";
3
12
 
4
- interface OptionGroup<T> {
5
- label: string;
6
- options: T[];
7
- }
8
-
9
- interface AutoCompleteOption {
10
- value: string;
11
- label: string;
12
- tags?: string[];
13
- filteringTags?: string[];
14
- description?: string;
15
- }
16
-
17
- function buildQueryString(
18
- propertyLabel: string,
19
- operator: string,
20
- value: string,
21
- ): string {
22
- const parts = [propertyLabel, operator, value].filter(Boolean);
23
- return parts.join(" ");
24
- }
25
-
26
- /* TODO: i18n */
27
- const OPERATOR_LABELS: Record<string, string> = {
28
- ":": "contains",
29
- "!:": "does not contain",
30
- "=": "is",
31
- "!=": "is not",
32
- "^": "starts with",
33
- "!^": "does not start with",
34
- ">=": "is greater than or equal to",
35
- "<=": "is less than or equal to",
36
- ">": "is greater than",
37
- "<": "is less than",
38
- };
39
13
  /**
40
- * Grouping option for autocomplete suggestions structures:
41
- *
42
- * Step: "free-text" + empty value:
43
- * - Group: "Properties" with all properties.
44
- *
45
- * Step: "free-text" with non-empty value:
46
- * - Group: "Properties". All properties including the filter text in label or description or tags. String match.
47
- * - Group: "Values". All "property = value" combinations where either the property label or value label or description or tags include the filter text. String match.
48
- * - - Ignore all other operators than "=" for value suggestions.
14
+ * Generates "options" to be used as autosuggest-ottion based on the current query state.
49
15
  *
50
- * Step: "property" + empty value:
51
- * - Group: "Operators". All operators valid for the selected property.
16
+ * The query parser recognizes three states:
17
+ * - "property": User has selected/matched a property and operator ("Status = active")
18
+ * - "operator": User has matched a property but is typing the operator ("Status" or "Status !")
19
+ * - "free-text": User is typing freely without a property match (e.g., "act" or "!: test")
52
20
  *
53
- * Step: "property" + non-empty value:
54
- * - Group: "Operators". All operators valid for the selected property with string match. Only relevant for multi letter operators like "!="
55
- *
56
- * Step: "operator" + empty value:
57
- * - Group: "<Property> values". All values valid for the selected property and operator. String match on value label, description and tags.
58
- *
59
- * Step: "operator" + non-empty value:
60
- * - Group: "<Property> values". All values valid for the selected property and operator with string match. String match on value label, description and tags.
61
- *
62
- *
63
- * TODO:
64
- * - Handle custom groups
65
- * - Multi vs single-select: Allow operators for each options where user can define type to be enum: { operator: "=", tokenType: "enum" }. Enum-type options allow selecting multiple values, i.e state = ("active", "pending"))
66
- */
67
-
68
- /**
69
- * TODO: Update based on instructions above.
21
+ * @returns
22
+ * - value: The canonical query string representation for the current state.
23
+ * Used by the UI to determine cursor position and input replacement.
24
+ * - options: Grouped suggestions to display (properties, operators, or values).
70
25
  */
71
26
  function generateAutoCompleteOptions(
72
27
  queryState: ParsedText,
73
28
  filteringProperties: ParsedProperty[] = [],
74
29
  filteringOptions: ParsedOption[] = [],
75
30
  ) {
31
+ /* State: Property and operator are matched, suggest values */
76
32
  if (queryState.step === "property") {
77
- if (!queryState.property) {
78
- return {
79
- value: queryState.value,
80
- options: [],
81
- };
82
- }
83
- const { propertyLabel, groupValuesLabel } = queryState.property;
84
- const options = filteringOptions.filter(
85
- (o) => o.property === queryState.property,
86
- );
33
+ const filterText = queryState.value || "";
87
34
 
88
35
  return {
89
36
  value: queryState.value,
90
- options: [
91
- {
92
- label: groupValuesLabel,
93
- options: options.map(({ label, value, tags, filteringTags }) => ({
94
- value: buildQueryString(propertyLabel, queryState.operator, value),
95
- label,
96
- tags,
97
- filteringTags,
98
- })),
99
- },
100
- ],
37
+ options: createValueSuggestions(
38
+ filteringOptions,
39
+ queryState.operator,
40
+ filterText,
41
+ queryState.property,
42
+ ),
101
43
  };
102
44
  }
45
+
46
+ /* State: Property matched, but operator is incomplete */
103
47
  if (queryState.step === "operator") {
48
+ const operators = filterOperatorsByPrefix(
49
+ getValidOperatorsForProperty(queryState.property),
50
+ queryState.operatorPrefix,
51
+ );
52
+
53
+ const partialQuery = buildQueryString(
54
+ queryState.property.propertyLabel,
55
+ queryState.operatorPrefix,
56
+ "",
57
+ );
58
+
59
+ /**
60
+ * Edge case: User typed an invalid operator prefix that doesn't match any operators.
61
+ * This can happen when typing characters that don't start any valid operator.
62
+ * Return empty suggestions gracefully - the UI will show "no results".
63
+ *
64
+ * TODO: When per-property operator configuration is implemented,
65
+ * this could also occur when a property restricts which operators are valid.
66
+ */
67
+ if (operators.length === 0) {
68
+ return {
69
+ value: partialQuery,
70
+ options: [],
71
+ };
72
+ }
73
+
104
74
  return {
105
- value: buildQueryString(
106
- queryState.property.propertyLabel,
75
+ value: partialQuery,
76
+ options: generateOperatorSuggestions(
77
+ queryState.property,
107
78
  queryState.operatorPrefix,
108
- "",
109
79
  ),
110
- options: [
111
- ...generatePropertySuggestions(filteringProperties),
112
- {
113
- options: QUERY_OPERATORS.map((value) => ({
114
- value: buildQueryString(
115
- queryState.property.propertyLabel,
116
- value,
117
- "",
118
- ),
119
- label: buildQueryString(
120
- queryState.property.propertyLabel,
121
- value,
122
- "",
123
- ),
124
- description: OPERATOR_LABELS[value] ?? "",
125
- })),
126
- /* TODO: i18n */
127
- label: "Operator",
128
- },
129
- ],
130
80
  };
131
81
  }
132
82
 
133
- const needsValueSuggestions = !!queryState.value;
134
- const needsPropertySuggestions = !(
135
- queryState.step === "free-text" && queryState.operator === "!:"
136
- );
83
+ /*
84
+ * Edge case: Input starts with operator but has no value yet (user typed just "!=")
85
+ * Wait for value before showing suggestions
86
+ */
87
+ if (!queryState.value && queryState.operator) {
88
+ return {
89
+ value: "",
90
+ options: [],
91
+ };
92
+ }
137
93
 
138
- return {
139
- value: queryState.value,
140
- options: [
141
- ...(needsPropertySuggestions
142
- ? generatePropertySuggestions(filteringProperties)
143
- : []),
144
- ...(needsValueSuggestions
145
- ? generateAllValueSuggestions(filteringOptions)
146
- : []),
147
- ],
148
- };
149
- }
94
+ /* Empty input: Show all properties */
95
+ if (!queryState.value) {
96
+ return {
97
+ value: "",
98
+ options: generatePropertySuggestions(filteringProperties),
99
+ };
100
+ }
150
101
 
151
- function createAutoCompleteOption(
152
- propertyLabel: string,
153
- operator: string,
154
- value: string,
155
- label: string,
156
- tags?: string[],
157
- filteringTags?: string[],
158
- ): AutoCompleteOption {
102
+ /*
103
+ * Free-text search: Show matching values across all properties
104
+ * Use the detected operator if input started with one (e.g., "!= test"), otherwise default to "="
105
+ */
159
106
  return {
160
- value: buildQueryString(propertyLabel, operator, value),
161
- label: buildQueryString(propertyLabel, operator, label),
162
- tags,
163
- filteringTags,
107
+ value: queryState.value,
108
+ options: createValueSuggestions(
109
+ filteringOptions,
110
+ queryState.operator ?? "=",
111
+ queryState.value,
112
+ ),
164
113
  };
165
114
  }
166
115
 
167
- function generateAllValueSuggestions(
168
- filteringOptions: ParsedOption[] = [],
169
- ): OptionGroup<AutoCompleteOption>[] {
170
- const groups: Record<string, OptionGroup<AutoCompleteOption>> = {};
171
-
172
- for (const option of filteringOptions) {
173
- if (!option || !option.property) {
174
- continue;
175
- }
116
+ /**
117
+ * Returns the valid operators for a given property.
118
+ * Extracts operators from the property's custom operator configuration.
119
+ * If none are configured, falls back to all available operators.
120
+ *
121
+ * The QueryFilteringScopedOperator can be a simple string (e.g., "=")
122
+ * or an object with operator and tokenType (e.g., { operator: ":", tokenType: "single" }).
123
+ * This function normalizes both formats and returns just the operator strings.
124
+ *
125
+ * @returns Array of valid operators for the property
126
+ *
127
+ * TODO: We omit passing the tokenType for now since it's not currently used in the UI. But will be needed for single/multi-selection.
128
+ */
129
+ function getValidOperatorsForProperty(
130
+ property: ParsedProperty,
131
+ ): QueryFilterOperator[] {
132
+ const { operators } = property;
176
133
 
177
- const groupLabel = option.property.groupValuesLabel || "Values";
134
+ /* If no operators configured, return all available operators */
135
+ if (!operators || operators.length === 0) {
136
+ return QUERY_OPERATORS;
137
+ }
178
138
 
179
- if (!groups[groupLabel]) {
180
- groups[groupLabel] = {
181
- label: groupLabel,
182
- options: [],
183
- };
184
- }
139
+ /*
140
+ * Extract operator strings from QueryFilteringScopedOperator format
141
+ * Handle both simple strings and objects with operator property
142
+ */
143
+ const operatorStrings = operators.map((op) =>
144
+ typeof op === "string" ? op : op.operator,
145
+ );
185
146
 
186
- const { label, value, tags, filteringTags, property } = option;
187
- const options = QUERY_OPERATORS.map((operator) =>
188
- createAutoCompleteOption(
189
- property.propertyLabel,
190
- operator,
191
- value,
192
- label,
193
- tags,
194
- filteringTags,
195
- ),
196
- );
147
+ /* Filter to only valid QUERY_OPERATORS to ensure type safety */
148
+ return operatorStrings.filter((op) =>
149
+ QUERY_OPERATORS.includes(op as QueryFilterOperator),
150
+ ) as QueryFilterOperator[];
151
+ }
197
152
 
198
- groups[groupLabel].options.push(...options);
153
+ /**
154
+ * Filters the list of operators based on the provided prefix.
155
+ * If the prefix is empty, all operators are returned.
156
+ */
157
+ function filterOperatorsByPrefix(
158
+ operators: QueryFilterOperator[],
159
+ prefix: string,
160
+ ): QueryFilterOperator[] {
161
+ if (!prefix) {
162
+ return operators;
199
163
  }
200
164
 
201
- return Object.values(groups);
165
+ return operators.filter((operator) => operator.startsWith(prefix));
202
166
  }
203
167
 
204
168
  function generatePropertySuggestions(
205
169
  filteringProperties: ParsedProperty[] = [],
170
+ filterText = "",
206
171
  ): OptionGroup<ParsedProperty>[] {
207
- const defaultGroup: OptionGroup<ParsedProperty> = {
208
- label: "Properties",
209
- options: [],
210
- };
211
- const customGroups: Record<string, OptionGroup<ParsedProperty>> = {};
172
+ const filteredProperties: ParsedProperty[] = [];
212
173
 
213
174
  for (const property of filteringProperties) {
214
175
  if (!property) {
215
176
  continue;
216
177
  }
217
- const groupLabel = property.propertyGroup?.trim();
218
-
219
- if (groupLabel) {
220
- if (!customGroups[groupLabel]) {
221
- customGroups[groupLabel] = {
222
- label: groupLabel,
223
- options: [],
224
- };
225
- }
226
- customGroups[groupLabel].options.push(property);
227
- continue;
178
+
179
+ if (
180
+ matchesFilterText(
181
+ [
182
+ property.propertyLabel,
183
+ property.groupValuesLabel,
184
+ property.propertyGroup,
185
+ ].filter(Boolean),
186
+ filterText,
187
+ )
188
+ ) {
189
+ filteredProperties.push(property);
228
190
  }
191
+ }
192
+
193
+ return createGroups(
194
+ filteredProperties,
195
+ (property) => property.propertyGroup,
196
+ "Properties",
197
+ );
198
+ }
199
+
200
+ function generateOperatorSuggestions(
201
+ property: ParsedProperty,
202
+ operatorPrefix = "",
203
+ ): OptionGroup<AutoCompleteOption>[] {
204
+ const operators = filterOperatorsByPrefix(
205
+ getValidOperatorsForProperty(property),
206
+ operatorPrefix,
207
+ );
229
208
 
230
- defaultGroup.options.push(property);
209
+ if (operators.length === 0) {
210
+ return [];
231
211
  }
232
212
 
233
- const groups: OptionGroup<ParsedProperty>[] = [
234
- ...Object.values(customGroups),
213
+ return [
214
+ {
215
+ label: "Operators",
216
+ options: operators.map((operator) => ({
217
+ value: buildQueryString(property.propertyLabel, operator, ""),
218
+ label: buildQueryString(property.propertyLabel, operator, ""),
219
+ description: OPERATOR_LABELS[operator] ?? "",
220
+ })),
221
+ },
235
222
  ];
223
+ }
236
224
 
237
- if (defaultGroup.options.length > 0) {
238
- groups.push(defaultGroup);
225
+ /**
226
+ * Creates value suggestions for autocomplete.
227
+ * When scopedProperty is provided, only shows values for that property (single group).
228
+ * When scopedProperty is omitted, searches across all properties (multiple groups).
229
+ */
230
+ function createValueSuggestions(
231
+ filteringOptions: ParsedOption[] = [],
232
+ operator: QueryFilterOperator,
233
+ filterText = "",
234
+ scopedProperty?: ParsedProperty,
235
+ ): OptionGroup<AutoCompleteOption>[] {
236
+ const groups: Record<string, OptionGroup<AutoCompleteOption>> = {};
237
+
238
+ for (const option of filteringOptions) {
239
+ if (!option?.property) {
240
+ continue;
241
+ }
242
+
243
+ /* If scoped to a property, filter to only that property's options */
244
+ if (scopedProperty && option.property !== scopedProperty) {
245
+ continue;
246
+ }
247
+
248
+ /* Build search fields */
249
+ const searchFields = [
250
+ option.label,
251
+ ...(option.tags ?? []),
252
+ ...(option.filteringTags ?? []),
253
+ ];
254
+
255
+ if (!scopedProperty) {
256
+ searchFields.push(option.property.propertyLabel);
257
+ }
258
+
259
+ const matches = matchesFilterText(searchFields.filter(Boolean), filterText);
260
+
261
+ if (!matches) {
262
+ continue;
263
+ }
264
+
265
+ const groupLabel = option.property.groupValuesLabel || "Values";
266
+
267
+ if (!groups[groupLabel]) {
268
+ groups[groupLabel] = {
269
+ label: groupLabel,
270
+ options: [],
271
+ };
272
+ }
273
+
274
+ groups[groupLabel].options.push({
275
+ value: buildQueryString(
276
+ option.property.propertyLabel,
277
+ operator,
278
+ option.value,
279
+ ),
280
+ label: option.label,
281
+ tags: option.tags,
282
+ filteringTags: option.filteringTags,
283
+ });
239
284
  }
240
285
 
241
- return groups;
286
+ return Object.values(groups).filter((group) => group.options.length > 0);
242
287
  }
243
288
 
244
289
  export { generateAutoCompleteOptions };
@@ -0,0 +1,206 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createGroups } from "./grouping";
3
+
4
+ interface TestItem {
5
+ name: string;
6
+ group?: string;
7
+ value: string;
8
+ }
9
+
10
+ describe("createGroups", () => {
11
+ describe("basic grouping", () => {
12
+ test("groups items by label", () => {
13
+ const items: TestItem[] = [
14
+ { name: "Item 1", group: "Group A", value: "1" },
15
+ { name: "Item 2", group: "Group B", value: "2" },
16
+ { name: "Item 3", group: "Group A", value: "3" },
17
+ ];
18
+
19
+ const groups = createGroups(items, (item) => item.group);
20
+
21
+ expect(groups).toHaveLength(2);
22
+ expect(groups[0].label).toBe("Group A");
23
+ expect(groups[0].options).toHaveLength(2);
24
+ expect(groups[1].label).toBe("Group B");
25
+ expect(groups[1].options).toHaveLength(1);
26
+ });
27
+
28
+ test("uses default group for items without group label", () => {
29
+ const items: TestItem[] = [
30
+ { name: "Item 1", group: "Group A", value: "1" },
31
+ { name: "Item 2", value: "2" },
32
+ ];
33
+
34
+ const groups = createGroups(items, (item) => item.group, "Default");
35
+
36
+ expect(groups).toHaveLength(2);
37
+ expect(groups[0].label).toBe("Group A");
38
+ expect(groups[1].label).toBe("Default");
39
+ expect(groups[1].options).toHaveLength(1);
40
+ });
41
+
42
+ test("places default group last", () => {
43
+ const items: TestItem[] = [
44
+ { name: "No group", value: "0" },
45
+ { name: "Item 1", group: "Group A", value: "1" },
46
+ { name: "Item 2", group: "Group B", value: "2" },
47
+ ];
48
+
49
+ const groups = createGroups(items, (item) => item.group, "Default");
50
+
51
+ expect(groups).toHaveLength(3);
52
+ expect(groups[2].label).toBe("Default");
53
+ });
54
+
55
+ test("returns empty array when no items", () => {
56
+ const groups = createGroups([], (item: TestItem) => item.group);
57
+
58
+ expect(groups).toEqual([]);
59
+ });
60
+ });
61
+
62
+ describe("empty string handling", () => {
63
+ test("treats empty string as default group", () => {
64
+ const items: TestItem[] = [
65
+ { name: "Item 1", group: "Group A", value: "1" },
66
+ { name: "Item 2", group: "", value: "2" },
67
+ ];
68
+
69
+ const groups = createGroups(items, (item) => item.group, "Properties");
70
+
71
+ expect(groups).toHaveLength(2);
72
+ expect(groups[0].label).toBe("Group A");
73
+ expect(groups[1].label).toBe("Properties");
74
+ expect(groups[1].options).toHaveLength(1);
75
+ });
76
+
77
+ test("treats whitespace-only string as default group", () => {
78
+ const items: TestItem[] = [
79
+ { name: "Item 1", group: "Group A", value: "1" },
80
+ { name: "Item 2", group: " ", value: "2" },
81
+ ];
82
+
83
+ const groups = createGroups(items, (item) => item.group, "Default");
84
+
85
+ expect(groups).toHaveLength(2);
86
+ expect(groups[1].label).toBe("Default");
87
+ expect(groups[1].options).toHaveLength(1);
88
+ });
89
+ });
90
+
91
+ describe("null/undefined handling", () => {
92
+ test("treats undefined group as default group", () => {
93
+ const items: TestItem[] = [
94
+ { name: "Item 1", group: "Group A", value: "1" },
95
+ { name: "Item 2", group: undefined, value: "2" },
96
+ ];
97
+
98
+ const groups = createGroups(items, (item) => item.group, "Default");
99
+
100
+ expect(groups).toHaveLength(2);
101
+ expect(groups[1].label).toBe("Default");
102
+ });
103
+
104
+ test("treats null group as default group", () => {
105
+ const items: TestItem[] = [
106
+ { name: "Item 1", group: "Group A", value: "1" },
107
+ { name: "Item 2", group: null as any, value: "2" },
108
+ ];
109
+
110
+ const groups = createGroups(items, (item) => item.group, "Default");
111
+
112
+ expect(groups).toHaveLength(2);
113
+ expect(groups[1].label).toBe("Default");
114
+ });
115
+
116
+ test("skips null/undefined items", () => {
117
+ const items = [
118
+ { name: "Item 1", group: "Group A", value: "1" },
119
+ null as any,
120
+ undefined as any,
121
+ { name: "Item 2", group: "Group A", value: "2" },
122
+ ];
123
+
124
+ const groups = createGroups(items, (item) => item.group);
125
+
126
+ expect(groups).toHaveLength(1);
127
+ expect(groups[0].options).toHaveLength(2);
128
+ });
129
+ });
130
+
131
+ describe("edge cases", () => {
132
+ test("handles all items in default group", () => {
133
+ const items: TestItem[] = [
134
+ { name: "Item 1", value: "1" },
135
+ { name: "Item 2", value: "2" },
136
+ ];
137
+
138
+ const groups = createGroups(items, (item) => item.group, "All");
139
+
140
+ expect(groups).toHaveLength(1);
141
+ expect(groups[0].label).toBe("All");
142
+ expect(groups[0].options).toHaveLength(2);
143
+ });
144
+
145
+ test("handles single item", () => {
146
+ const items: TestItem[] = [
147
+ { name: "Item 1", group: "Group A", value: "1" },
148
+ ];
149
+
150
+ const groups = createGroups(items, (item) => item.group);
151
+
152
+ expect(groups).toHaveLength(1);
153
+ expect(groups[0].label).toBe("Group A");
154
+ expect(groups[0].options).toHaveLength(1);
155
+ });
156
+
157
+ test("preserves item order within groups", () => {
158
+ const items: TestItem[] = [
159
+ { name: "Item 3", group: "Group A", value: "3" },
160
+ { name: "Item 1", group: "Group A", value: "1" },
161
+ { name: "Item 2", group: "Group A", value: "2" },
162
+ ];
163
+
164
+ const groups = createGroups(items, (item) => item.group);
165
+
166
+ expect(groups[0].options[0].value).toBe("3");
167
+ expect(groups[0].options[1].value).toBe("1");
168
+ expect(groups[0].options[2].value).toBe("2");
169
+ });
170
+
171
+ test("trims group labels", () => {
172
+ const items: TestItem[] = [
173
+ { name: "Item 1", group: " Group A ", value: "1" },
174
+ { name: "Item 2", group: "Group A", value: "2" },
175
+ ];
176
+
177
+ const groups = createGroups(items, (item) => item.group);
178
+
179
+ expect(groups).toHaveLength(1);
180
+ expect(groups[0].label).toBe("Group A");
181
+ expect(groups[0].options).toHaveLength(2);
182
+ });
183
+ });
184
+
185
+ describe("custom default group label", () => {
186
+ test("uses custom default group label", () => {
187
+ const items: TestItem[] = [{ name: "Item 1", value: "1" }];
188
+
189
+ const groups = createGroups(
190
+ items,
191
+ (item) => item.group,
192
+ "Custom Default",
193
+ );
194
+
195
+ expect(groups[0].label).toBe("Custom Default");
196
+ });
197
+
198
+ test("defaults to 'Default' when not provided", () => {
199
+ const items: TestItem[] = [{ name: "Item 1", value: "1" }];
200
+
201
+ const groups = createGroups(items, (item) => item.group);
202
+
203
+ expect(groups[0].label).toBe("Default");
204
+ });
205
+ });
206
+ });