@navikt/ds-react 8.5.1 → 8.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/cjs/data/table/root/DataTableRoot.d.ts +27 -0
- package/cjs/data/table/root/DataTableRoot.js +8 -2
- package/cjs/data/table/root/DataTableRoot.js.map +1 -1
- package/cjs/data/table/td/DataTableTd.d.ts +5 -4
- package/cjs/data/table/td/DataTableTd.js +2 -2
- package/cjs/data/table/td/DataTableTd.js.map +1 -1
- package/cjs/data/token-filter/AutoSuggest.d.ts +2 -14
- package/cjs/data/token-filter/AutoSuggest.js +13 -89
- package/cjs/data/token-filter/AutoSuggest.js.map +1 -1
- package/cjs/data/token-filter/AutoSuggest.types.d.ts +11 -0
- package/cjs/data/token-filter/AutoSuggest.types.js +3 -0
- package/cjs/data/token-filter/AutoSuggest.types.js.map +1 -0
- package/cjs/data/token-filter/TokenFilter.d.ts +5 -0
- package/cjs/data/token-filter/TokenFilter.js +20 -10
- package/cjs/data/token-filter/TokenFilter.js.map +1 -1
- package/cjs/data/token-filter/TokenFilter.types.d.ts +8 -4
- package/cjs/data/token-filter/helpers/generate-autocomplete-options.d.ts +13 -61
- package/cjs/data/token-filter/helpers/generate-autocomplete-options.js +152 -135
- package/cjs/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -1
- package/cjs/data/token-filter/helpers/grouping.d.ts +28 -0
- package/cjs/data/token-filter/helpers/grouping.js +61 -0
- package/cjs/data/token-filter/helpers/grouping.js.map +1 -0
- package/cjs/data/token-filter/helpers/operators.d.ts +22 -0
- package/cjs/data/token-filter/helpers/operators.js +66 -0
- package/cjs/data/token-filter/helpers/operators.js.map +1 -0
- package/cjs/data/token-filter/helpers/parse-query-text.d.ts +1 -7
- package/cjs/data/token-filter/helpers/parse-query-text.js +5 -50
- package/cjs/data/token-filter/helpers/parse-query-text.js.map +1 -1
- package/cjs/data/token-filter/helpers/query-builder.d.ts +20 -0
- package/cjs/data/token-filter/helpers/query-builder.js +38 -0
- package/cjs/data/token-filter/helpers/query-builder.js.map +1 -0
- package/cjs/data/token-filter/helpers/text-matching.d.ts +16 -0
- package/cjs/data/token-filter/helpers/text-matching.js +47 -0
- package/cjs/data/token-filter/helpers/text-matching.js.map +1 -0
- package/cjs/form/combobox/Input/InputController.js +1 -1
- package/cjs/form/combobox/Input/InputController.js.map +1 -1
- package/cjs/form/file-upload/dropzone/FileUploadDropzone.js +1 -1
- package/cjs/form/file-upload/dropzone/FileUploadDropzone.js.map +1 -1
- package/cjs/toggle-group/useToggleGroup.js +5 -3
- package/cjs/toggle-group/useToggleGroup.js.map +1 -1
- package/esm/data/table/root/DataTableRoot.d.ts +27 -0
- package/esm/data/table/root/DataTableRoot.js +8 -2
- package/esm/data/table/root/DataTableRoot.js.map +1 -1
- package/esm/data/table/td/DataTableTd.d.ts +5 -4
- package/esm/data/table/td/DataTableTd.js +2 -2
- package/esm/data/table/td/DataTableTd.js.map +1 -1
- package/esm/data/token-filter/AutoSuggest.d.ts +2 -14
- package/esm/data/token-filter/AutoSuggest.js +14 -90
- package/esm/data/token-filter/AutoSuggest.js.map +1 -1
- package/esm/data/token-filter/AutoSuggest.types.d.ts +11 -0
- package/esm/data/token-filter/AutoSuggest.types.js +2 -0
- package/esm/data/token-filter/AutoSuggest.types.js.map +1 -0
- package/esm/data/token-filter/TokenFilter.d.ts +5 -0
- package/esm/data/token-filter/TokenFilter.js +20 -10
- package/esm/data/token-filter/TokenFilter.js.map +1 -1
- package/esm/data/token-filter/TokenFilter.types.d.ts +8 -4
- package/esm/data/token-filter/helpers/generate-autocomplete-options.d.ts +13 -61
- package/esm/data/token-filter/helpers/generate-autocomplete-options.js +152 -135
- package/esm/data/token-filter/helpers/generate-autocomplete-options.js.map +1 -1
- package/esm/data/token-filter/helpers/grouping.d.ts +28 -0
- package/esm/data/token-filter/helpers/grouping.js +59 -0
- package/esm/data/token-filter/helpers/grouping.js.map +1 -0
- package/esm/data/token-filter/helpers/operators.d.ts +22 -0
- package/esm/data/token-filter/helpers/operators.js +60 -0
- package/esm/data/token-filter/helpers/operators.js.map +1 -0
- package/esm/data/token-filter/helpers/parse-query-text.d.ts +1 -7
- package/esm/data/token-filter/helpers/parse-query-text.js +2 -45
- package/esm/data/token-filter/helpers/parse-query-text.js.map +1 -1
- package/esm/data/token-filter/helpers/query-builder.d.ts +20 -0
- package/esm/data/token-filter/helpers/query-builder.js +34 -0
- package/esm/data/token-filter/helpers/query-builder.js.map +1 -0
- package/esm/data/token-filter/helpers/text-matching.d.ts +16 -0
- package/esm/data/token-filter/helpers/text-matching.js +45 -0
- package/esm/data/token-filter/helpers/text-matching.js.map +1 -0
- package/esm/form/combobox/Input/InputController.js +1 -1
- package/esm/form/combobox/Input/InputController.js.map +1 -1
- package/esm/form/file-upload/dropzone/FileUploadDropzone.js +1 -1
- package/esm/form/file-upload/dropzone/FileUploadDropzone.js.map +1 -1
- package/esm/toggle-group/useToggleGroup.js +6 -4
- package/esm/toggle-group/useToggleGroup.js.map +1 -1
- package/package.json +3 -3
- package/src/data/table/root/DataTableRoot.tsx +30 -1
- package/src/data/table/td/DataTableTd.tsx +13 -6
- package/src/data/token-filter/AutoSuggest.tsx +33 -163
- package/src/data/token-filter/AutoSuggest.types.ts +13 -0
- package/src/data/token-filter/TokenFilter.tsx +21 -13
- package/src/data/token-filter/TokenFilter.types.ts +8 -4
- package/src/data/token-filter/helpers/generate-autocomplete-options.test.ts +836 -0
- package/src/data/token-filter/helpers/generate-autocomplete-options.ts +241 -186
- package/src/data/token-filter/helpers/grouping.test.ts +206 -0
- package/src/data/token-filter/helpers/grouping.ts +73 -0
- package/src/data/token-filter/helpers/operators.test.ts +281 -0
- package/src/data/token-filter/helpers/operators.ts +91 -0
- package/src/data/token-filter/helpers/parse-query-text.test.ts +4 -213
- package/src/data/token-filter/helpers/parse-query-text.ts +7 -69
- package/src/data/token-filter/helpers/query-builder.test.ts +126 -0
- package/src/data/token-filter/helpers/query-builder.ts +41 -0
- package/src/data/token-filter/helpers/text-matching.test.ts +125 -0
- package/src/data/token-filter/helpers/text-matching.ts +58 -0
- package/src/form/combobox/Input/InputController.tsx +0 -1
- package/src/form/file-upload/dropzone/FileUploadDropzone.tsx +0 -1
- package/src/toggle-group/useToggleGroup.ts +6 -5
|
@@ -1,244 +1,299 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import
|
|
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
|
-
*
|
|
14
|
+
* Generates "options" to be used as autosuggest-ottion based on the current query state.
|
|
41
15
|
*
|
|
42
|
-
*
|
|
43
|
-
* -
|
|
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")
|
|
44
20
|
*
|
|
45
|
-
*
|
|
46
|
-
* -
|
|
47
|
-
*
|
|
48
|
-
* -
|
|
49
|
-
*
|
|
50
|
-
* Step: "property" + empty value:
|
|
51
|
-
* - Group: "Operators". All operators valid for the selected property.
|
|
52
|
-
*
|
|
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"))
|
|
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).
|
|
66
25
|
*/
|
|
26
|
+
type AutoCompleteResult = {
|
|
27
|
+
value: string;
|
|
28
|
+
options: OptionGroup<AutoCompleteOption>[];
|
|
29
|
+
};
|
|
67
30
|
|
|
68
|
-
/**
|
|
69
|
-
* TODO: Update based on instructions above.
|
|
70
|
-
*/
|
|
71
31
|
function generateAutoCompleteOptions(
|
|
72
32
|
queryState: ParsedText,
|
|
73
33
|
filteringProperties: ParsedProperty[] = [],
|
|
74
34
|
filteringOptions: ParsedOption[] = [],
|
|
75
|
-
) {
|
|
35
|
+
): AutoCompleteResult {
|
|
36
|
+
/* State: Property and operator are matched, suggest values */
|
|
76
37
|
if (queryState.step === "property") {
|
|
77
|
-
|
|
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
|
-
);
|
|
38
|
+
const filterText = queryState.value || "";
|
|
87
39
|
|
|
88
40
|
return {
|
|
89
41
|
value: queryState.value,
|
|
90
|
-
options:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
tags,
|
|
97
|
-
filteringTags,
|
|
98
|
-
})),
|
|
99
|
-
},
|
|
100
|
-
],
|
|
42
|
+
options: createValueSuggestions(
|
|
43
|
+
filteringOptions,
|
|
44
|
+
queryState.operator,
|
|
45
|
+
filterText,
|
|
46
|
+
queryState.property,
|
|
47
|
+
),
|
|
101
48
|
};
|
|
102
49
|
}
|
|
50
|
+
|
|
51
|
+
/* State: Property matched, but operator is incomplete */
|
|
103
52
|
if (queryState.step === "operator") {
|
|
53
|
+
const operators = filterOperatorsByPrefix(
|
|
54
|
+
getValidOperatorsForProperty(queryState.property),
|
|
55
|
+
queryState.operatorPrefix,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const partialQuery = buildQueryString(
|
|
59
|
+
queryState.property.propertyLabel,
|
|
60
|
+
queryState.operatorPrefix,
|
|
61
|
+
"",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Edge case: User typed an invalid operator prefix that doesn't match any operators.
|
|
66
|
+
* This can happen when typing characters that don't start any valid operator.
|
|
67
|
+
* Return empty suggestions gracefully - the UI will show "no results".
|
|
68
|
+
*/
|
|
69
|
+
if (operators.length === 0) {
|
|
70
|
+
return {
|
|
71
|
+
value: partialQuery,
|
|
72
|
+
options: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
104
76
|
return {
|
|
105
|
-
value:
|
|
106
|
-
|
|
77
|
+
value: partialQuery,
|
|
78
|
+
options: generateOperatorSuggestions(
|
|
79
|
+
queryState.property,
|
|
107
80
|
queryState.operatorPrefix,
|
|
108
|
-
"",
|
|
109
81
|
),
|
|
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
82
|
};
|
|
131
83
|
}
|
|
132
84
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
85
|
+
/*
|
|
86
|
+
* Edge case: Input starts with operator but has no value yet (user typed just "!=")
|
|
87
|
+
* Wait for value before showing suggestions
|
|
88
|
+
*/
|
|
89
|
+
if (!queryState.value && queryState.operator) {
|
|
90
|
+
return {
|
|
91
|
+
value: "",
|
|
92
|
+
options: [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
137
95
|
|
|
96
|
+
/* Empty input: Show all properties */
|
|
97
|
+
if (!queryState.value) {
|
|
98
|
+
return {
|
|
99
|
+
value: "",
|
|
100
|
+
options: generatePropertySuggestions(filteringProperties),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
* Free-text search: Show matching values across all properties
|
|
106
|
+
* Use the detected operator if input started with one (e.g., "!= test"), otherwise default to "="
|
|
107
|
+
*/
|
|
138
108
|
return {
|
|
139
109
|
value: queryState.value,
|
|
140
110
|
options: [
|
|
141
|
-
...(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
111
|
+
...generatePropertySuggestions(filteringProperties, queryState.value),
|
|
112
|
+
...createValueSuggestions(
|
|
113
|
+
filteringOptions,
|
|
114
|
+
queryState.operator ?? "=",
|
|
115
|
+
queryState.value,
|
|
116
|
+
),
|
|
147
117
|
],
|
|
148
118
|
};
|
|
149
119
|
}
|
|
150
120
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Returns the valid operators for a given property.
|
|
123
|
+
* Extracts operators from the property's custom operator configuration.
|
|
124
|
+
* If none are configured, falls back to all available operators.
|
|
125
|
+
*
|
|
126
|
+
* The QueryFilteringScopedOperator can be a simple string (e.g., "=")
|
|
127
|
+
* or an object with operator and tokenType (e.g., { operator: ":", tokenType: "single" }).
|
|
128
|
+
* This function normalizes both formats and returns just the operator strings.
|
|
129
|
+
*
|
|
130
|
+
* @returns Array of valid operators for the property
|
|
131
|
+
*
|
|
132
|
+
* 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.
|
|
133
|
+
*/
|
|
134
|
+
function getValidOperatorsForProperty(
|
|
135
|
+
property: ParsedProperty,
|
|
136
|
+
): QueryFilterOperator[] {
|
|
137
|
+
const { operators } = property;
|
|
138
|
+
|
|
139
|
+
/* If no operators configured, return all available operators */
|
|
140
|
+
if (!operators || operators.length === 0) {
|
|
141
|
+
return QUERY_OPERATORS;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* Extract operator strings from QueryFilteringScopedOperator format
|
|
146
|
+
* Handle both simple strings and objects with operator property
|
|
147
|
+
*/
|
|
148
|
+
const operatorStrings = operators.map((op) =>
|
|
149
|
+
typeof op === "string" ? op : op.operator,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/* Filter to only valid QUERY_OPERATORS to ensure type safety */
|
|
153
|
+
return operatorStrings.filter((op) =>
|
|
154
|
+
QUERY_OPERATORS.includes(op as QueryFilterOperator),
|
|
155
|
+
) as QueryFilterOperator[];
|
|
165
156
|
}
|
|
166
157
|
|
|
167
|
-
|
|
168
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Filters the list of operators based on the provided prefix.
|
|
160
|
+
* If the prefix is empty, all operators are returned.
|
|
161
|
+
*/
|
|
162
|
+
function filterOperatorsByPrefix(
|
|
163
|
+
operators: QueryFilterOperator[],
|
|
164
|
+
prefix: string,
|
|
165
|
+
): QueryFilterOperator[] {
|
|
166
|
+
if (!prefix) {
|
|
167
|
+
return operators;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return operators.filter((operator) => operator.startsWith(prefix));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function generatePropertySuggestions(
|
|
174
|
+
filteringProperties: ParsedProperty[] = [],
|
|
175
|
+
filterText = "",
|
|
169
176
|
): OptionGroup<AutoCompleteOption>[] {
|
|
170
|
-
const
|
|
177
|
+
const filteredProperties: ParsedProperty[] = [];
|
|
171
178
|
|
|
172
|
-
for (const
|
|
173
|
-
if (!
|
|
179
|
+
for (const property of filteringProperties) {
|
|
180
|
+
if (!property) {
|
|
174
181
|
continue;
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
+
if (
|
|
185
|
+
matchesFilterText(
|
|
186
|
+
[
|
|
187
|
+
property.propertyLabel,
|
|
188
|
+
property.groupValuesLabel,
|
|
189
|
+
property.propertyGroup,
|
|
190
|
+
].filter(Boolean),
|
|
191
|
+
filterText,
|
|
192
|
+
)
|
|
193
|
+
) {
|
|
194
|
+
filteredProperties.push(property);
|
|
184
195
|
}
|
|
196
|
+
}
|
|
185
197
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
value,
|
|
192
|
-
label,
|
|
193
|
-
tags,
|
|
194
|
-
filteringTags,
|
|
195
|
-
),
|
|
196
|
-
);
|
|
198
|
+
const groups = createGroups(
|
|
199
|
+
filteredProperties,
|
|
200
|
+
(property) => property.propertyGroup,
|
|
201
|
+
"Properties",
|
|
202
|
+
);
|
|
197
203
|
|
|
198
|
-
|
|
204
|
+
return groups.map((group) => ({
|
|
205
|
+
label: group.label,
|
|
206
|
+
options: group.options.map((property) => ({
|
|
207
|
+
value: buildQueryString(property.propertyLabel, "", ""),
|
|
208
|
+
label: property.propertyLabel,
|
|
209
|
+
})),
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function generateOperatorSuggestions(
|
|
214
|
+
property: ParsedProperty,
|
|
215
|
+
operatorPrefix = "",
|
|
216
|
+
): OptionGroup<AutoCompleteOption>[] {
|
|
217
|
+
const operators = filterOperatorsByPrefix(
|
|
218
|
+
getValidOperatorsForProperty(property),
|
|
219
|
+
operatorPrefix,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (operators.length === 0) {
|
|
223
|
+
return [];
|
|
199
224
|
}
|
|
200
225
|
|
|
201
|
-
return
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
label: "Operators",
|
|
229
|
+
options: operators.map((operator) => ({
|
|
230
|
+
value: buildQueryString(property.propertyLabel, operator, ""),
|
|
231
|
+
label: buildQueryString(property.propertyLabel, operator, ""),
|
|
232
|
+
description: OPERATOR_LABELS[operator] ?? "",
|
|
233
|
+
})),
|
|
234
|
+
},
|
|
235
|
+
];
|
|
202
236
|
}
|
|
203
237
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Creates value suggestions for autocomplete.
|
|
240
|
+
* When scopedProperty is provided, only shows values for that property (single group).
|
|
241
|
+
* When scopedProperty is omitted, searches across all properties (multiple groups).
|
|
242
|
+
* TODO: This could potentially contain an unlimited number of options if there are many values across properties.
|
|
243
|
+
* May need virtualization/async or other filtering mechanism.
|
|
244
|
+
*/
|
|
245
|
+
function createValueSuggestions(
|
|
246
|
+
filteringOptions: ParsedOption[] = [],
|
|
247
|
+
operator: QueryFilterOperator,
|
|
248
|
+
filterText = "",
|
|
249
|
+
scopedProperty?: ParsedProperty,
|
|
250
|
+
): OptionGroup<AutoCompleteOption>[] {
|
|
251
|
+
const groups: Record<string, OptionGroup<AutoCompleteOption>> = {};
|
|
212
252
|
|
|
213
|
-
for (const
|
|
214
|
-
if (!property) {
|
|
253
|
+
for (const option of filteringOptions) {
|
|
254
|
+
if (!option?.property) {
|
|
215
255
|
continue;
|
|
216
256
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (
|
|
220
|
-
if (!customGroups[groupLabel]) {
|
|
221
|
-
customGroups[groupLabel] = {
|
|
222
|
-
label: groupLabel,
|
|
223
|
-
options: [],
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
customGroups[groupLabel].options.push(property);
|
|
257
|
+
|
|
258
|
+
/* If scoped to a property, filter to only that property's options */
|
|
259
|
+
if (scopedProperty && option.property !== scopedProperty) {
|
|
227
260
|
continue;
|
|
228
261
|
}
|
|
229
262
|
|
|
230
|
-
|
|
231
|
-
|
|
263
|
+
/* Build search fields */
|
|
264
|
+
const searchFields = [option.label, ...(option.tags ?? [])];
|
|
232
265
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
266
|
+
if (!scopedProperty) {
|
|
267
|
+
searchFields.push(option.property.propertyLabel);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const matches = matchesFilterText(searchFields.filter(Boolean), filterText);
|
|
236
271
|
|
|
237
|
-
|
|
238
|
-
|
|
272
|
+
if (!matches) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const groupLabel = option.property.groupValuesLabel || "Values";
|
|
277
|
+
|
|
278
|
+
if (!groups[groupLabel]) {
|
|
279
|
+
groups[groupLabel] = {
|
|
280
|
+
label: groupLabel,
|
|
281
|
+
options: [],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
groups[groupLabel].options.push({
|
|
286
|
+
value: buildQueryString(
|
|
287
|
+
option.property.propertyLabel,
|
|
288
|
+
operator,
|
|
289
|
+
option.value,
|
|
290
|
+
),
|
|
291
|
+
label: option.label,
|
|
292
|
+
tags: option.tags,
|
|
293
|
+
});
|
|
239
294
|
}
|
|
240
295
|
|
|
241
|
-
return groups;
|
|
296
|
+
return Object.values(groups).filter((group) => group.options.length > 0);
|
|
242
297
|
}
|
|
243
298
|
|
|
244
299
|
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
|
+
});
|