beez-ui 0.4.0 → 0.5.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/CHANGELOG.md +31 -19
- package/README.md +49 -11
- package/dist/adapters/unpic-image.js +92 -14
- package/dist/components/alert-dialog.js +444 -26
- package/dist/components/alert.js +153 -18
- package/dist/components/animated-theme-toggler.js +215 -76
- package/dist/components/avatar.js +247 -15
- package/dist/components/badge.js +18 -16
- package/dist/components/button.js +85 -28
- package/dist/components/calendar.js +327 -68
- package/dist/components/card.js +39 -9
- package/dist/components/carousel.js +454 -84
- package/dist/components/checkbox.js +50 -3
- package/dist/components/data-table.js +827 -583
- package/dist/components/dialog.js +363 -22
- package/dist/components/dropdown-menu.js +543 -32
- package/dist/components/filter-query-bar.js +809 -468
- package/dist/components/filter-query-grammar.js +781 -736
- package/dist/components/form.js +305 -43
- package/dist/components/highlighter.js +142 -87
- package/dist/components/hover-card.js +97 -7
- package/dist/components/input-group.js +261 -41
- package/dist/components/input.js +8 -2
- package/dist/components/label.js +38 -3
- package/dist/components/link.js +45 -7
- package/dist/components/pagination.js +332 -16
- package/dist/components/popover.js +222 -16
- package/dist/components/radio-group.js +85 -5
- package/dist/components/select.js +451 -22
- package/dist/components/separator.js +43 -3
- package/dist/components/sheet.js +345 -22
- package/dist/components/sidebar.js +1385 -193
- package/dist/components/skeleton.js +6 -2
- package/dist/components/svgs/whatsappIcon.js +11 -1
- package/dist/components/switch.js +55 -3
- package/dist/components/table.js +288 -18
- package/dist/components/tabs.js +161 -18
- package/dist/components/textarea.js +7 -2
- package/dist/components/themed-toaster.js +63 -20
- package/dist/components/tooltip.js +128 -9
- package/dist/components/typing-animation.js +335 -150
- package/dist/constants/sidebar.js +1 -4
- package/dist/hooks/use-mobile.js +8 -8
- package/dist/hooks/use-sidebar-persistence.js +27 -27
- package/dist/lib/image-dimensions.js +2 -2
- package/dist/lib/year-month-filter.js +38 -40
- package/dist/next.js +69 -17
- package/dist/providers/beez-ui-provider.js +48 -7
- package/dist/providers/native-provider.js +40 -6
- package/dist/tanstack.js +35 -23
- package/package.json +7 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
* Gramática pura (sin React) para una barra de filtro estilo GitHub Issues.
|
|
3
|
+
*
|
|
4
|
+
* La barra acepta una query de texto con tokens `clave:valor` más texto libre.
|
|
5
|
+
* Este módulo tokeniza la query, la parsea contra una configuración de
|
|
6
|
+
* qualifiers y la traduce a los tres canales de filtrado que ya consume la
|
|
7
|
+
* tabla: búsqueda fuzzy por descripción, exclusiones de descripción y filtros
|
|
8
|
+
* avanzados por columna (`DataTableColumnFilterValue`).
|
|
9
|
+
*/
|
|
10
10
|
const DIACRITICS_PATTERN = /[̀-ͯ]/g;
|
|
11
11
|
const YEAR_MONTH_SLUG_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;
|
|
12
12
|
const RANGE_SEPARATOR = "..";
|
|
@@ -16,815 +16,860 @@ export const YEAR_MONTH_HAS_VALUE_SLUG = "con-fechas";
|
|
|
16
16
|
export const PRESENCE_TRUE_SLUG = "si";
|
|
17
17
|
export const PRESENCE_FALSE_SLUG = "no";
|
|
18
18
|
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
* GitHub-style meta-keys to filter by presence/absence of ANY field:
|
|
20
|
+
* `tiene:<field>` (the field has a value) and `no:<field>` (the field is empty).
|
|
21
|
+
* The token value is the key of the target qualifier (e.g. `tiene:enviados`,
|
|
22
|
+
* `no:carpeta`). They unify presence across every kind into a single syntax,
|
|
23
|
+
* instead of a `<field>:si/no` per qualifier.
|
|
24
|
+
*/
|
|
25
25
|
export const PRESENCE_HAS_META_KEY = "tiene";
|
|
26
26
|
export const PRESENCE_NO_META_KEY = "no";
|
|
27
27
|
/** Identificador de "sin carpeta asignada" en un qualifier de carpeta. */
|
|
28
28
|
export const UNASSIGNED_FOLDER_FILTER_VALUE = "__unassigned__";
|
|
29
29
|
/** Normaliza una clave o slug: sin acentos, minúsculas, recortado. */
|
|
30
30
|
export function normalizeFilterSlug(value) {
|
|
31
|
-
|
|
32
|
-
.normalize("NFD")
|
|
33
|
-
.replace(DIACRITICS_PATTERN, "")
|
|
34
|
-
.toLocaleLowerCase()
|
|
35
|
-
.trim();
|
|
31
|
+
return value.normalize("NFD").replace(DIACRITICS_PATTERN, "").toLocaleLowerCase().trim();
|
|
36
32
|
}
|
|
37
33
|
function stripSurroundingQuotes(value) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
34
|
+
if (value.length >= 2) {
|
|
35
|
+
const first = value[0];
|
|
36
|
+
const last = value[value.length - 1];
|
|
37
|
+
if ((first === "\"" || first === "'") && first === last) {
|
|
38
|
+
return value.slice(1, -1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Comilla de apertura sin cierre (token a medio tipear).
|
|
42
|
+
if (value.startsWith("\"") || value.startsWith("'")) {
|
|
43
|
+
return value.slice(1);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
50
46
|
}
|
|
51
47
|
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
* Envuelve en comillas un valor que, sin comillas, se re-tokenizaría distinto:
|
|
49
|
+
* espacios, un `:` (se interpretaría como `clave:valor`) o un `-` inicial (se
|
|
50
|
+
* interpretaría como exclusión). Mantiene la query como fuente de verdad sin
|
|
51
|
+
* pérdida entre la barra unificada y los controles clásicos.
|
|
52
|
+
*/
|
|
57
53
|
function quoteTokenIfNeeded(value) {
|
|
58
|
-
|
|
59
|
-
? `"${value}"`
|
|
60
|
-
: value;
|
|
54
|
+
return /[\s:]/.test(value) || value.startsWith("-") ? `"${value}"` : value;
|
|
61
55
|
}
|
|
62
56
|
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
* Serializa el texto libre de descripción citando, palabra por palabra, los
|
|
58
|
+
* segmentos que se parsearían como un token (por ejemplo `total:100`), de modo
|
|
59
|
+
* que un filtro de descripción no se transforme en un filtro avanzado al
|
|
60
|
+
* re-parsear la query.
|
|
61
|
+
*/
|
|
68
62
|
function serializeDescriptionFilter(descriptionFilter) {
|
|
69
|
-
|
|
70
|
-
.split(/\s+/)
|
|
71
|
-
.filter((word) => word.length > 0)
|
|
72
|
-
.map(quoteTokenIfNeeded)
|
|
73
|
-
.join(" ");
|
|
63
|
+
return descriptionFilter.split(/\s+/).filter((word) => word.length > 0).map(quoteTokenIfNeeded).join(" ");
|
|
74
64
|
}
|
|
75
65
|
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
* Divide la query en tokens respetando comillas (`"..."` / `'...'` agrupan
|
|
67
|
+
* espacios). Conserva los índices de cada token para que el autocompletado
|
|
68
|
+
* pueda ubicar el token bajo el caret.
|
|
69
|
+
*/
|
|
80
70
|
export function tokenizeFilterQuery(query) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
71
|
+
const tokens = [];
|
|
72
|
+
let index = 0;
|
|
73
|
+
const { length } = query;
|
|
74
|
+
while (index < length) {
|
|
75
|
+
if (/\s/.test(query[index])) {
|
|
76
|
+
index += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const startIndex = index;
|
|
80
|
+
let quoteChar = null;
|
|
81
|
+
while (index < length) {
|
|
82
|
+
const character = query[index];
|
|
83
|
+
if (quoteChar) {
|
|
84
|
+
if (character === quoteChar) {
|
|
85
|
+
quoteChar = null;
|
|
86
|
+
}
|
|
87
|
+
index += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (character === "\"" || character === "'") {
|
|
91
|
+
quoteChar = character;
|
|
92
|
+
index += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (/\s/.test(character)) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
index += 1;
|
|
99
|
+
}
|
|
100
|
+
const raw = query.slice(startIndex, index);
|
|
101
|
+
tokens.push(buildToken(raw, startIndex, index));
|
|
102
|
+
}
|
|
103
|
+
return tokens;
|
|
114
104
|
}
|
|
115
105
|
function findKeyColonIndex(text) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
106
|
+
for (let position = 0; position < text.length; position += 1) {
|
|
107
|
+
const character = text[position];
|
|
108
|
+
if (character === "\"" || character === "'") {
|
|
109
|
+
// Una comilla antes del `:` significa que no hay clave (es texto libre).
|
|
110
|
+
return -1;
|
|
111
|
+
}
|
|
112
|
+
if (character === ":") {
|
|
113
|
+
return position;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return -1;
|
|
127
117
|
}
|
|
128
118
|
function buildToken(raw, startIndex, endIndex) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
119
|
+
// A leading `-` marks exclusion, even when it is the only character: this way,
|
|
120
|
+
// when starting an exclusion from the bar (`-`), the token is already negated
|
|
121
|
+
// and autocompletion can offer the fields to exclude. A lone `-` is ignored
|
|
122
|
+
// when parsing (empty value) and filters nothing.
|
|
123
|
+
const negated = raw.startsWith("-");
|
|
124
|
+
const remainder = negated ? raw.slice(1) : raw;
|
|
125
|
+
const colonIndex = findKeyColonIndex(remainder);
|
|
126
|
+
if (colonIndex === -1) {
|
|
127
|
+
return {
|
|
128
|
+
endIndex,
|
|
129
|
+
hasColon: false,
|
|
130
|
+
negated,
|
|
131
|
+
raw,
|
|
132
|
+
rawKey: null,
|
|
133
|
+
startIndex,
|
|
134
|
+
value: stripSurroundingQuotes(remainder)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
endIndex,
|
|
139
|
+
hasColon: true,
|
|
140
|
+
negated,
|
|
141
|
+
raw,
|
|
142
|
+
rawKey: remainder.slice(0, colonIndex),
|
|
143
|
+
startIndex,
|
|
144
|
+
value: stripSurroundingQuotes(remainder.slice(colonIndex + 1))
|
|
145
|
+
};
|
|
156
146
|
}
|
|
157
147
|
function buildQualifierLookup(configs) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
148
|
+
const lookup = new Map();
|
|
149
|
+
for (const config of configs) {
|
|
150
|
+
if (config.kind === "text" || !config.key) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
lookup.set(normalizeFilterSlug(config.key), config);
|
|
154
|
+
for (const alias of config.aliases ?? []) {
|
|
155
|
+
lookup.set(normalizeFilterSlug(alias), config);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return lookup;
|
|
169
159
|
}
|
|
170
160
|
function parseNumber(value) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
161
|
+
const normalized = value.trim();
|
|
162
|
+
if (!normalized) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const parsed = Number(normalized);
|
|
166
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
177
167
|
}
|
|
178
168
|
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
169
|
+
* Parsea el valor de un qualifier numérico. Acepta comparadores (`>`, `>=`,
|
|
170
|
+
* `<`, `<=`, `=`), igualdad implícita (`N`) y rangos (`A..B`, `A..`, `..B`).
|
|
171
|
+
* `>` y `>=` son ambos inclusivos para preservar la semántica del matcher
|
|
172
|
+
* existente (`value < min → false`); `<` y `<=` mapean a `max`.
|
|
173
|
+
*/
|
|
184
174
|
function parseNumberRangeValue(value) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
175
|
+
const normalized = value.trim();
|
|
176
|
+
if (!normalized) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (normalized.includes(RANGE_SEPARATOR)) {
|
|
180
|
+
const [rawMin, rawMax] = normalized.split(RANGE_SEPARATOR, 2);
|
|
181
|
+
const hasMin = rawMin.trim().length > 0;
|
|
182
|
+
const hasMax = rawMax.trim().length > 0;
|
|
183
|
+
if (!hasMin && !hasMax) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const min = hasMin ? parseNumber(rawMin) : null;
|
|
187
|
+
const max = hasMax ? parseNumber(rawMax) : null;
|
|
188
|
+
if (hasMin && min == null || hasMax && max == null) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
// Un rango invertido (`100..50`) no puede matchear ninguna fila: es inválido.
|
|
192
|
+
if (min != null && max != null && min > max) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
kind: "numberRange",
|
|
197
|
+
...max != null ? { max } : {},
|
|
198
|
+
...min != null ? { min } : {}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (normalized.startsWith(">=") || normalized.startsWith(">")) {
|
|
202
|
+
const min = parseNumber(normalized.replace(/^>=?/, ""));
|
|
203
|
+
return min == null ? null : {
|
|
204
|
+
kind: "numberRange",
|
|
205
|
+
min
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (normalized.startsWith("<=") || normalized.startsWith("<")) {
|
|
209
|
+
const max = parseNumber(normalized.replace(/^<=?/, ""));
|
|
210
|
+
return max == null ? null : {
|
|
211
|
+
kind: "numberRange",
|
|
212
|
+
max
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const exact = parseNumber(normalized.replace(/^=/, ""));
|
|
216
|
+
return exact == null ? null : {
|
|
217
|
+
kind: "numberRange",
|
|
218
|
+
max: exact,
|
|
219
|
+
min: exact
|
|
220
|
+
};
|
|
221
221
|
}
|
|
222
222
|
/** Convierte un slug `AAAA-MM` a su valor numérico comparable `AAAAMM`. */
|
|
223
223
|
export function parseYearMonthSlug(value) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
224
|
+
const match = YEAR_MONTH_SLUG_PATTERN.exec(value.trim());
|
|
225
|
+
if (!match) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const [, year, month] = match;
|
|
229
|
+
return Number(`${year}${month}`);
|
|
230
230
|
}
|
|
231
231
|
/** Formatea un valor numérico `AAAAMM` de vuelta a su slug `AAAA-MM`. */
|
|
232
232
|
export function formatYearMonthSlug(value) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
233
|
+
const year = Math.floor(value / 100);
|
|
234
|
+
const month = value % 100;
|
|
235
|
+
return `${year}-${String(month).padStart(2, "0")}`;
|
|
236
236
|
}
|
|
237
237
|
function parseYearMonthRangeValue(value) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
238
|
+
const normalized = normalizeFilterSlug(value);
|
|
239
|
+
if (!normalized) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
if (normalized === YEAR_MONTH_NO_VALUE_SLUG) {
|
|
243
|
+
return {
|
|
244
|
+
kind: "yearMonthRange",
|
|
245
|
+
mode: "noValue"
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (normalized === YEAR_MONTH_HAS_VALUE_SLUG) {
|
|
249
|
+
return {
|
|
250
|
+
kind: "yearMonthRange",
|
|
251
|
+
mode: "hasValue"
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// Comparators `>=`/`>` (from) and `<=`/`<` (to), analogous to the numeric
|
|
255
|
+
// ones, to bound dates with a single endpoint without writing an `A..` range.
|
|
256
|
+
if (normalized.startsWith(">=") || normalized.startsWith(">")) {
|
|
257
|
+
const min = parseYearMonthSlug(normalized.replace(/^>=?/, ""));
|
|
258
|
+
return min == null ? null : {
|
|
259
|
+
kind: "yearMonthRange",
|
|
260
|
+
min,
|
|
261
|
+
mode: "from"
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (normalized.startsWith("<=") || normalized.startsWith("<")) {
|
|
265
|
+
const max = parseYearMonthSlug(normalized.replace(/^<=?/, ""));
|
|
266
|
+
return max == null ? null : {
|
|
267
|
+
kind: "yearMonthRange",
|
|
268
|
+
max,
|
|
269
|
+
mode: "to"
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (normalized.includes(RANGE_SEPARATOR)) {
|
|
273
|
+
const [rawFrom, rawTo] = normalized.split(RANGE_SEPARATOR, 2);
|
|
274
|
+
const hasFrom = rawFrom.trim().length > 0;
|
|
275
|
+
const hasTo = rawTo.trim().length > 0;
|
|
276
|
+
if (!hasFrom && !hasTo) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const min = hasFrom ? parseYearMonthSlug(rawFrom) : null;
|
|
280
|
+
const max = hasTo ? parseYearMonthSlug(rawTo) : null;
|
|
281
|
+
if (hasFrom && min == null || hasTo && max == null) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (min != null && max != null) {
|
|
285
|
+
// Rango invertido (`2026-12..2026-06`): imposible de matchear, inválido.
|
|
286
|
+
if (min > max) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
kind: "yearMonthRange",
|
|
291
|
+
max,
|
|
292
|
+
min,
|
|
293
|
+
mode: "range"
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (min != null) {
|
|
297
|
+
return {
|
|
298
|
+
kind: "yearMonthRange",
|
|
299
|
+
min,
|
|
300
|
+
mode: "from"
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
kind: "yearMonthRange",
|
|
305
|
+
max,
|
|
306
|
+
mode: "to"
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const exact = parseYearMonthSlug(normalized);
|
|
310
|
+
if (exact == null) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
kind: "yearMonthRange",
|
|
315
|
+
max: exact,
|
|
316
|
+
min: exact,
|
|
317
|
+
mode: "range"
|
|
318
|
+
};
|
|
287
319
|
}
|
|
288
320
|
function buildColumnFilterValue(config, value) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
321
|
+
if (config.kind === "numberRange") {
|
|
322
|
+
return parseNumberRangeValue(value);
|
|
323
|
+
}
|
|
324
|
+
if (config.kind === "yearMonthRange") {
|
|
325
|
+
return parseYearMonthRangeValue(value);
|
|
326
|
+
}
|
|
327
|
+
if (config.kind === "enum") {
|
|
328
|
+
// A leading `@` mention-style prefix is tolerated (e.g. `prestamista:@vero`).
|
|
329
|
+
const normalized = normalizeFilterSlug(value).replace(/^@/, "");
|
|
330
|
+
const option = (config.options ?? []).find((candidate) => normalizeFilterSlug(candidate.slug) === normalized);
|
|
331
|
+
return option ? {
|
|
332
|
+
kind: "enum",
|
|
333
|
+
value: option.value
|
|
334
|
+
} : null;
|
|
335
|
+
}
|
|
336
|
+
if (config.kind === "presence") {
|
|
337
|
+
const normalized = normalizeFilterSlug(value);
|
|
338
|
+
if (normalized === PRESENCE_TRUE_SLUG) {
|
|
339
|
+
return {
|
|
340
|
+
kind: "presence",
|
|
341
|
+
value: "hasValue"
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (normalized === PRESENCE_FALSE_SLUG) {
|
|
345
|
+
return {
|
|
346
|
+
kind: "presence",
|
|
347
|
+
value: "noValue"
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
312
353
|
}
|
|
313
354
|
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
355
|
+
* Parses the value of a text qualifier (`textMatch`) with glob syntax. Accepts
|
|
356
|
+
* presence (`si`/`no`) and `*` wildcards: `text*` starts with, `*text` ends
|
|
357
|
+
* with, `*text*` contains, and `text` (no wildcard) is exact equality. The text
|
|
358
|
+
* is normalized (no accents, lowercase) to compare case/accent-insensitively.
|
|
359
|
+
*/
|
|
319
360
|
export function parseTextMatchValue(value) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
361
|
+
const trimmed = value.trim();
|
|
362
|
+
if (!trimmed) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
const normalizedSlug = normalizeFilterSlug(trimmed);
|
|
366
|
+
if (normalizedSlug === PRESENCE_TRUE_SLUG) {
|
|
367
|
+
return {
|
|
368
|
+
kind: "textMatch",
|
|
369
|
+
op: "has"
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (normalizedSlug === PRESENCE_FALSE_SLUG) {
|
|
373
|
+
return {
|
|
374
|
+
kind: "textMatch",
|
|
375
|
+
op: "notHas"
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const startsWithStar = trimmed.startsWith("*");
|
|
379
|
+
const endsWithStar = trimmed.endsWith("*");
|
|
380
|
+
if (startsWithStar && endsWithStar) {
|
|
381
|
+
const text = normalizeFilterSlug(trimmed.slice(1, -1));
|
|
382
|
+
return text ? {
|
|
383
|
+
kind: "textMatch",
|
|
384
|
+
op: "contains",
|
|
385
|
+
text
|
|
386
|
+
} : null;
|
|
387
|
+
}
|
|
388
|
+
if (endsWithStar) {
|
|
389
|
+
const text = normalizeFilterSlug(trimmed.slice(0, -1));
|
|
390
|
+
return text ? {
|
|
391
|
+
kind: "textMatch",
|
|
392
|
+
op: "startsWith",
|
|
393
|
+
text
|
|
394
|
+
} : null;
|
|
395
|
+
}
|
|
396
|
+
if (startsWithStar) {
|
|
397
|
+
const text = normalizeFilterSlug(trimmed.slice(1));
|
|
398
|
+
return text ? {
|
|
399
|
+
kind: "textMatch",
|
|
400
|
+
op: "endsWith",
|
|
401
|
+
text
|
|
402
|
+
} : null;
|
|
403
|
+
}
|
|
404
|
+
const text = normalizeFilterSlug(trimmed);
|
|
405
|
+
return text ? {
|
|
406
|
+
kind: "textMatch",
|
|
407
|
+
op: "equals",
|
|
408
|
+
text
|
|
409
|
+
} : null;
|
|
347
410
|
}
|
|
348
411
|
/**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
412
|
+
* Parsea el valor de un qualifier de carpeta (`folder`) contra sus opciones
|
|
413
|
+
* dinámicas (slug→folderId). `sin-carpeta` mapea al sentinel de "sin asignar".
|
|
414
|
+
*/
|
|
352
415
|
export function parseFolderValue(config, value) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
416
|
+
// Se tolera un `@` inicial al estilo "mención" (p. ej. `carpeta:@hogar`),
|
|
417
|
+
// igual que el parser de enum, para que completar el token tipeando/Enter
|
|
418
|
+
// coincida con la sugerencia que filtra strip-eando ese `@`.
|
|
419
|
+
const normalized = normalizeFilterSlug(value).replace(/^@/, "");
|
|
420
|
+
if (!normalized) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
const option = (config.options ?? []).find((candidate) => normalizeFilterSlug(candidate.slug) === normalized);
|
|
424
|
+
return option ? {
|
|
425
|
+
kind: "folder",
|
|
426
|
+
folderId: option.value
|
|
427
|
+
} : null;
|
|
362
428
|
}
|
|
363
429
|
/** Construye el valor estructurado de un qualifier según su kind. */
|
|
364
430
|
function buildAppliedFilterValue(config, value) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
431
|
+
if (config.kind === "textMatch") {
|
|
432
|
+
return parseTextMatchValue(value);
|
|
433
|
+
}
|
|
434
|
+
if (config.kind === "folder") {
|
|
435
|
+
return parseFolderValue(config, value);
|
|
436
|
+
}
|
|
437
|
+
return buildColumnFilterValue(config, value);
|
|
372
438
|
}
|
|
373
439
|
/** Discrimina un valor que vive en el path de columnas de TanStack. */
|
|
374
440
|
function isColumnFilterValue(value) {
|
|
375
|
-
|
|
441
|
+
return value.kind !== "textMatch" && value.kind !== "folder";
|
|
376
442
|
}
|
|
377
443
|
function mergeColumnFilterValue(existing, next) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const mergedMax = existing.max != null && next.max != null
|
|
409
|
-
? Math.min(existing.max, next.max)
|
|
410
|
-
: (next.max ?? existing.max);
|
|
411
|
-
const mode = mergedMin != null && mergedMax != null
|
|
412
|
-
? "range"
|
|
413
|
-
: mergedMin != null
|
|
414
|
-
? "from"
|
|
415
|
-
: "to";
|
|
416
|
-
return {
|
|
417
|
-
kind: "yearMonthRange",
|
|
418
|
-
mode,
|
|
419
|
-
...(mergedMax != null ? { max: mergedMax } : {}),
|
|
420
|
-
...(mergedMin != null ? { min: mergedMin } : {}),
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
return next;
|
|
444
|
+
if (existing && existing.kind === "numberRange" && next.kind === "numberRange") {
|
|
445
|
+
// Qualifiers repetidos se ANDean: nos quedamos con la cota más ajustada
|
|
446
|
+
// (mayor mínimo, menor máximo) para que el resultado no dependa del orden.
|
|
447
|
+
const mergedMin = existing.min != null && next.min != null ? Math.max(existing.min, next.min) : next.min ?? existing.min;
|
|
448
|
+
const mergedMax = existing.max != null && next.max != null ? Math.min(existing.max, next.max) : next.max ?? existing.max;
|
|
449
|
+
return {
|
|
450
|
+
kind: "numberRange",
|
|
451
|
+
...mergedMax != null ? { max: mergedMax } : {},
|
|
452
|
+
...mergedMin != null ? { min: mergedMin } : {}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (existing && existing.kind === "yearMonthRange" && next.kind === "yearMonthRange") {
|
|
456
|
+
const isBounded = (mode) => mode === "range" || mode === "from" || mode === "to";
|
|
457
|
+
// Los modos de presencia (con/sin fechas) no combinan con cotas: gana el
|
|
458
|
+
// último. Para range/from/to se intersectan las cotas (mayor desde, menor
|
|
459
|
+
// hasta), igual que `numberRange`, en vez de descartar la primera cota.
|
|
460
|
+
if (!isBounded(existing.mode) || !isBounded(next.mode)) {
|
|
461
|
+
return next;
|
|
462
|
+
}
|
|
463
|
+
const mergedMin = existing.min != null && next.min != null ? Math.max(existing.min, next.min) : next.min ?? existing.min;
|
|
464
|
+
const mergedMax = existing.max != null && next.max != null ? Math.min(existing.max, next.max) : next.max ?? existing.max;
|
|
465
|
+
const mode = mergedMin != null && mergedMax != null ? "range" : mergedMin != null ? "from" : "to";
|
|
466
|
+
return {
|
|
467
|
+
kind: "yearMonthRange",
|
|
468
|
+
mode,
|
|
469
|
+
...mergedMax != null ? { max: mergedMax } : {},
|
|
470
|
+
...mergedMin != null ? { min: mergedMin } : {}
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return next;
|
|
424
474
|
}
|
|
425
475
|
/**
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
476
|
+
* Detecta un rango imposible (`min > max`), por ejemplo tras combinar
|
|
477
|
+
* `total:>100 total:<50`. El matcher rechazaría toda fila, así que se reporta el
|
|
478
|
+
* token como inválido en vez de aplicar un filtro vacío.
|
|
479
|
+
*/
|
|
430
480
|
function isImpossibleRange(value) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
value.min > value.max);
|
|
439
|
-
}
|
|
440
|
-
return false;
|
|
481
|
+
if (value.kind === "numberRange") {
|
|
482
|
+
return value.min != null && value.max != null && value.min > value.max;
|
|
483
|
+
}
|
|
484
|
+
if (value.kind === "yearMonthRange") {
|
|
485
|
+
return value.mode === "range" && value.min != null && value.max != null && value.min > value.max;
|
|
486
|
+
}
|
|
487
|
+
return false;
|
|
441
488
|
}
|
|
442
489
|
/** Quita duplicados de exclusiones comparando por su forma normalizada. */
|
|
443
490
|
function dedupeByNormalizedSlug(values) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
491
|
+
const seenKeys = new Set();
|
|
492
|
+
const dedupedValues = [];
|
|
493
|
+
for (const value of values) {
|
|
494
|
+
const normalizedKey = normalizeFilterSlug(value);
|
|
495
|
+
if (!normalizedKey || seenKeys.has(normalizedKey)) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
seenKeys.add(normalizedKey);
|
|
499
|
+
dedupedValues.push(value);
|
|
500
|
+
}
|
|
501
|
+
return dedupedValues;
|
|
455
502
|
}
|
|
456
503
|
/** Parsea la query completa contra la configuración de qualifiers. */
|
|
457
504
|
export function parseFilterQuery(query, configs) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
505
|
+
const tokens = tokenizeFilterQuery(query);
|
|
506
|
+
const lookup = buildQualifierLookup(configs);
|
|
507
|
+
const descriptionParts = [];
|
|
508
|
+
const excludedDescriptionFilters = [];
|
|
509
|
+
const advancedFiltersByColumn = {};
|
|
510
|
+
const appliedFilters = [];
|
|
511
|
+
// Índice en `appliedFilters` del último filtro de rango (no negado) por clave,
|
|
512
|
+
// para mergear y validar rangos repetidos de forma uniforme tengan o no columna.
|
|
513
|
+
const rangeAppliedIndexByKey = new Map();
|
|
514
|
+
const invalidTokens = [];
|
|
515
|
+
for (const token of tokens) {
|
|
516
|
+
if (token.hasColon && token.rawKey) {
|
|
517
|
+
const normalizedKey = normalizeFilterSlug(token.rawKey);
|
|
518
|
+
// Presence meta-keys (`tiene:<field>` / `no:<field>`): the value is the
|
|
519
|
+
// key of the target field. They produce a presence filter that the domain
|
|
520
|
+
// predicate evaluates per field; they do not go through the column path.
|
|
521
|
+
if (normalizedKey === PRESENCE_HAS_META_KEY || normalizedKey === PRESENCE_NO_META_KEY) {
|
|
522
|
+
const fieldKey = normalizeFilterSlug(token.value ?? "");
|
|
523
|
+
if (!fieldKey) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const fieldConfig = lookup.get(fieldKey);
|
|
527
|
+
if (!fieldConfig || fieldConfig.supportsPresence === false) {
|
|
528
|
+
invalidTokens.push({
|
|
529
|
+
raw: token.raw,
|
|
530
|
+
reason: "invalidValue"
|
|
531
|
+
});
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// Token negation (`-tiene:x`) flips the meaning, leaving a single
|
|
535
|
+
// canonical presence filter (without residual negation).
|
|
536
|
+
const hasValue = normalizedKey === PRESENCE_HAS_META_KEY !== token.negated;
|
|
537
|
+
appliedFilters.push({
|
|
538
|
+
key: fieldConfig.key,
|
|
539
|
+
negated: false,
|
|
540
|
+
value: {
|
|
541
|
+
kind: "presence",
|
|
542
|
+
value: hasValue ? "hasValue" : "noValue"
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const config = lookup.get(normalizedKey);
|
|
548
|
+
if (!config) {
|
|
549
|
+
invalidTokens.push({
|
|
550
|
+
raw: token.raw,
|
|
551
|
+
reason: "unknownKey"
|
|
552
|
+
});
|
|
553
|
+
const freeText = token.negated ? token.raw.slice(1) : token.raw;
|
|
554
|
+
if (token.negated) {
|
|
555
|
+
excludedDescriptionFilters.push(freeText.trim());
|
|
556
|
+
} else if (freeText.trim()) {
|
|
557
|
+
descriptionParts.push(freeText.trim());
|
|
558
|
+
}
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const value = token.value ?? "";
|
|
562
|
+
const filterValue = buildAppliedFilterValue(config, value);
|
|
563
|
+
if (!filterValue) {
|
|
564
|
+
if (value.trim()) {
|
|
565
|
+
invalidTokens.push({
|
|
566
|
+
raw: token.raw,
|
|
567
|
+
reason: "invalidValue"
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (isColumnFilterValue(filterValue)) {
|
|
573
|
+
const isRange = filterValue.kind === "numberRange" || filterValue.kind === "yearMonthRange";
|
|
574
|
+
// Los rangos repetidos de una misma clave (con o sin columna) se mergean
|
|
575
|
+
// y validan igual: una combinación imposible (p. ej.
|
|
576
|
+
// `subtotal:>1000 subtotal:<50`) se reporta inválida y conserva la cota
|
|
577
|
+
// previa, en vez de filtrar todas las filas en silencio.
|
|
578
|
+
if (!token.negated && isRange) {
|
|
579
|
+
const existingIndex = rangeAppliedIndexByKey.get(config.key);
|
|
580
|
+
if (existingIndex != null) {
|
|
581
|
+
const existingValue = appliedFilters[existingIndex].value;
|
|
582
|
+
const mergedValue = mergeColumnFilterValue(existingValue, filterValue);
|
|
583
|
+
if (isImpossibleRange(mergedValue)) {
|
|
584
|
+
invalidTokens.push({
|
|
585
|
+
raw: token.raw,
|
|
586
|
+
reason: "invalidValue"
|
|
587
|
+
});
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
appliedFilters[existingIndex] = {
|
|
591
|
+
...appliedFilters[existingIndex],
|
|
592
|
+
value: mergedValue
|
|
593
|
+
};
|
|
594
|
+
if (config.columnId) {
|
|
595
|
+
advancedFiltersByColumn[config.columnId] = mergedValue;
|
|
596
|
+
}
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
appliedFilters.push({
|
|
600
|
+
key: config.key,
|
|
601
|
+
negated: false,
|
|
602
|
+
value: filterValue
|
|
603
|
+
});
|
|
604
|
+
rangeAppliedIndexByKey.set(config.key, appliedFilters.length - 1);
|
|
605
|
+
if (config.columnId) {
|
|
606
|
+
advancedFiltersByColumn[config.columnId] = filterValue;
|
|
607
|
+
}
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
// enum/presence (not range) or negated: added without merging. The
|
|
611
|
+
// non-negated ones with a column are projected to the classic TanStack
|
|
612
|
+
// path.
|
|
613
|
+
appliedFilters.push({
|
|
614
|
+
key: config.key,
|
|
615
|
+
negated: token.negated,
|
|
616
|
+
value: filterValue
|
|
617
|
+
});
|
|
618
|
+
// Presence (`<field>:si/no`) is unified with the meta-keys and resolved
|
|
619
|
+
// by the domain predicate, so it is never projected to a column.
|
|
620
|
+
if (!token.negated && config.columnId && filterValue.kind !== "presence") {
|
|
621
|
+
advancedFiltersByColumn[config.columnId] = mergeColumnFilterValue(advancedFiltersByColumn[config.columnId], filterValue);
|
|
622
|
+
}
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// textMatch / folder: sin columna y sin merge de rango.
|
|
626
|
+
appliedFilters.push({
|
|
627
|
+
key: config.key,
|
|
628
|
+
negated: token.negated,
|
|
629
|
+
value: filterValue
|
|
630
|
+
});
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const text = (token.value ?? "").trim();
|
|
634
|
+
if (!text) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (token.negated) {
|
|
638
|
+
excludedDescriptionFilters.push(text);
|
|
639
|
+
} else {
|
|
640
|
+
descriptionParts.push(text);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
advancedFiltersByColumn,
|
|
645
|
+
appliedFilters,
|
|
646
|
+
descriptionFilter: descriptionParts.join(" "),
|
|
647
|
+
excludedDescriptionFilters: dedupeByNormalizedSlug(excludedDescriptionFilters),
|
|
648
|
+
invalidTokens
|
|
649
|
+
};
|
|
595
650
|
}
|
|
596
651
|
function serializeNumberRange(key, value) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
652
|
+
const { min, max } = value;
|
|
653
|
+
if (min != null && max != null) {
|
|
654
|
+
return min === max ? `${key}:=${min}` : `${key}:${min}..${max}`;
|
|
655
|
+
}
|
|
656
|
+
if (min != null) {
|
|
657
|
+
return `${key}:>=${min}`;
|
|
658
|
+
}
|
|
659
|
+
if (max != null) {
|
|
660
|
+
return `${key}:<=${max}`;
|
|
661
|
+
}
|
|
662
|
+
return null;
|
|
608
663
|
}
|
|
609
664
|
function serializeYearMonthRange(key, value) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
665
|
+
if (value.mode === "noValue") {
|
|
666
|
+
return `${key}:${YEAR_MONTH_NO_VALUE_SLUG}`;
|
|
667
|
+
}
|
|
668
|
+
if (value.mode === "hasValue") {
|
|
669
|
+
return `${key}:${YEAR_MONTH_HAS_VALUE_SLUG}`;
|
|
670
|
+
}
|
|
671
|
+
if (value.mode === "from" && value.min != null) {
|
|
672
|
+
return `${key}:${formatYearMonthSlug(value.min)}..`;
|
|
673
|
+
}
|
|
674
|
+
if (value.mode === "to" && value.max != null) {
|
|
675
|
+
return `${key}:..${formatYearMonthSlug(value.max)}`;
|
|
676
|
+
}
|
|
677
|
+
if (value.mode === "range" && value.min != null && value.max != null) {
|
|
678
|
+
if (value.min === value.max) {
|
|
679
|
+
return `${key}:${formatYearMonthSlug(value.min)}`;
|
|
680
|
+
}
|
|
681
|
+
return `${key}:${formatYearMonthSlug(value.min)}..${formatYearMonthSlug(value.max)}`;
|
|
682
|
+
}
|
|
683
|
+
return null;
|
|
629
684
|
}
|
|
630
685
|
function serializeTextMatch(key, value) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
: value.op === "endsWith"
|
|
641
|
-
? `*${text}`
|
|
642
|
-
: value.op === "contains"
|
|
643
|
-
? `*${text}*`
|
|
644
|
-
: text;
|
|
645
|
-
return `${key}:${quoteTokenIfNeeded(decorated)}`;
|
|
686
|
+
if (value.op === "has") {
|
|
687
|
+
return `${key}:${PRESENCE_TRUE_SLUG}`;
|
|
688
|
+
}
|
|
689
|
+
if (value.op === "notHas") {
|
|
690
|
+
return `${key}:${PRESENCE_FALSE_SLUG}`;
|
|
691
|
+
}
|
|
692
|
+
const text = value.text ?? "";
|
|
693
|
+
const decorated = value.op === "startsWith" ? `${text}*` : value.op === "endsWith" ? `*${text}` : value.op === "contains" ? `*${text}*` : text;
|
|
694
|
+
return `${key}:${quoteTokenIfNeeded(decorated)}`;
|
|
646
695
|
}
|
|
647
696
|
function serializeFolderValue(config, value) {
|
|
648
|
-
|
|
649
|
-
|
|
697
|
+
const option = (config.options ?? []).find((candidate) => candidate.value === value.folderId);
|
|
698
|
+
return option ? `${config.key}:${option.slug}` : null;
|
|
650
699
|
}
|
|
651
700
|
function serializeAppliedFilterValue(config, value) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
701
|
+
if (value.kind === "numberRange") {
|
|
702
|
+
return serializeNumberRange(config.key, value);
|
|
703
|
+
}
|
|
704
|
+
if (value.kind === "yearMonthRange") {
|
|
705
|
+
return serializeYearMonthRange(config.key, value);
|
|
706
|
+
}
|
|
707
|
+
if (value.kind === "enum") {
|
|
708
|
+
const option = (config.options ?? []).find((candidate) => candidate.value === value.value);
|
|
709
|
+
return option ? `${config.key}:${option.slug}` : null;
|
|
710
|
+
}
|
|
711
|
+
if (value.kind === "textMatch") {
|
|
712
|
+
return serializeTextMatch(config.key, value);
|
|
713
|
+
}
|
|
714
|
+
if (value.kind === "folder") {
|
|
715
|
+
return serializeFolderValue(config, value);
|
|
716
|
+
}
|
|
717
|
+
// Presence: canonical form with meta-keys (`tiene:<field>` / `no:<field>`).
|
|
718
|
+
const metaKey = value.value === "hasValue" ? PRESENCE_HAS_META_KEY : PRESENCE_NO_META_KEY;
|
|
719
|
+
return `${metaKey}:${config.key}`;
|
|
671
720
|
}
|
|
672
721
|
/**
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
722
|
+
* `true` when the filter is already emitted by the classic serialization from
|
|
723
|
+
* `advancedFiltersByColumn` (not negated, with a column, and of a column kind),
|
|
724
|
+
* so it is not serialized twice while also iterating `appliedFilters`.
|
|
725
|
+
*
|
|
726
|
+
* `textMatch` and `folder` never live in the column map, so they are never
|
|
727
|
+
* covered. Presence is special: parsing keeps it only in `appliedFilters`, but a
|
|
728
|
+
* consumer (the advanced dialog) may also place it in the column map. It counts
|
|
729
|
+
* as covered only when that same column actually holds a value, so a presence
|
|
730
|
+
* that lives solely in `appliedFilters` is still serialized once and one that
|
|
731
|
+
* lives in both is not duplicated.
|
|
732
|
+
*/
|
|
684
733
|
function isCoveredByColumnSerialization(appliedFilter, config, advancedFiltersByColumn) {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return advancedFiltersByColumn[config.columnId] != null;
|
|
693
|
-
}
|
|
694
|
-
return true;
|
|
734
|
+
if (appliedFilter.negated || config.columnId == null || appliedFilter.value.kind === "textMatch" || appliedFilter.value.kind === "folder") {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
if (appliedFilter.value.kind === "presence") {
|
|
738
|
+
return advancedFiltersByColumn[config.columnId] != null;
|
|
739
|
+
}
|
|
740
|
+
return true;
|
|
695
741
|
}
|
|
696
742
|
/** Reconstruye una query canónica a partir de un resultado parseado. */
|
|
697
743
|
export function serializeFilterQuery(parsed, configs) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
return segments.join(" ");
|
|
744
|
+
const segments = [];
|
|
745
|
+
const configsByKey = new Map(configs.map((config) => [normalizeFilterSlug(config.key), config]));
|
|
746
|
+
const descriptionFilter = parsed.descriptionFilter.trim();
|
|
747
|
+
if (descriptionFilter) {
|
|
748
|
+
segments.push(serializeDescriptionFilter(descriptionFilter));
|
|
749
|
+
}
|
|
750
|
+
// Classic per-column filters (config order), for compatibility with consumers
|
|
751
|
+
// that build the parsed result only from `advancedFiltersByColumn`.
|
|
752
|
+
for (const config of configs) {
|
|
753
|
+
if (!config.columnId) {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const value = parsed.advancedFiltersByColumn[config.columnId];
|
|
757
|
+
if (!value) {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const serialized = serializeAppliedFilterValue(config, value);
|
|
761
|
+
if (serialized) {
|
|
762
|
+
segments.push(serialized);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// Extras the column path does not cover: negated, textMatch, folder, and
|
|
766
|
+
// qualifiers without a column.
|
|
767
|
+
for (const appliedFilter of parsed.appliedFilters ?? []) {
|
|
768
|
+
const config = configsByKey.get(normalizeFilterSlug(appliedFilter.key));
|
|
769
|
+
if (!config || isCoveredByColumnSerialization(appliedFilter, config, parsed.advancedFiltersByColumn)) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const serialized = serializeAppliedFilterValue(config, appliedFilter.value);
|
|
773
|
+
if (serialized) {
|
|
774
|
+
segments.push(appliedFilter.negated ? `-${serialized}` : serialized);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
for (const excluded of parsed.excludedDescriptionFilters) {
|
|
778
|
+
const trimmed = excluded.trim();
|
|
779
|
+
if (trimmed) {
|
|
780
|
+
segments.push(`-${quoteTokenIfNeeded(trimmed)}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return segments.join(" ");
|
|
739
784
|
}
|
|
740
785
|
/**
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
786
|
+
* Returns the character ranges of the VALUES (whatever comes after the `:`) that
|
|
787
|
+
* are valid for their qualifier, to highlight them in the bar. An invalid,
|
|
788
|
+
* unknown, or incomplete value produces no range (no highlight). Covers the
|
|
789
|
+
* presence meta-keys (`tiene:`/`no:` with an existing field) and the normal
|
|
790
|
+
* qualifiers (value parseable per its kind: enum, range, text, etc.).
|
|
791
|
+
*/
|
|
747
792
|
export function getValueHighlightRanges(query, configs) {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
793
|
+
const tokens = tokenizeFilterQuery(query);
|
|
794
|
+
const lookup = buildQualifierLookup(configs);
|
|
795
|
+
const ranges = [];
|
|
796
|
+
for (const token of tokens) {
|
|
797
|
+
if (!token.hasColon || !token.rawKey) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const negationOffset = token.negated ? 1 : 0;
|
|
801
|
+
const colonIndex = findKeyColonIndex(token.raw.slice(negationOffset));
|
|
802
|
+
if (colonIndex === -1) {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const valueStart = token.startIndex + negationOffset + colonIndex + 1;
|
|
806
|
+
// No value yet (`key:`): nothing to highlight.
|
|
807
|
+
if (valueStart >= token.endIndex) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const normalizedKey = normalizeFilterSlug(token.rawKey);
|
|
811
|
+
const rawValue = token.value ?? "";
|
|
812
|
+
let isValid = false;
|
|
813
|
+
if (normalizedKey === PRESENCE_HAS_META_KEY || normalizedKey === PRESENCE_NO_META_KEY) {
|
|
814
|
+
// A meta-key's value is the key of an existing field.
|
|
815
|
+
isValid = lookup.has(normalizeFilterSlug(rawValue));
|
|
816
|
+
} else {
|
|
817
|
+
const config = lookup.get(normalizedKey);
|
|
818
|
+
isValid = config != null && buildAppliedFilterValue(config, rawValue) != null;
|
|
819
|
+
}
|
|
820
|
+
if (isValid) {
|
|
821
|
+
ranges.push({
|
|
822
|
+
end: token.endIndex,
|
|
823
|
+
start: valueStart
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return ranges;
|
|
783
828
|
}
|
|
784
829
|
/**
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
830
|
+
* Describe el token bajo el caret para alimentar el autocompletado. Decide si
|
|
831
|
+
* el usuario está tipeando una clave (sin `:`) o el valor de una clave.
|
|
832
|
+
*/
|
|
788
833
|
export function getActiveFilterToken(query, caretIndex) {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
834
|
+
const tokens = tokenizeFilterQuery(query);
|
|
835
|
+
const activeToken = tokens.find((token) => caretIndex >= token.startIndex && caretIndex <= token.endIndex);
|
|
836
|
+
if (!activeToken) {
|
|
837
|
+
return {
|
|
838
|
+
keyPart: "",
|
|
839
|
+
mode: "key",
|
|
840
|
+
negated: false,
|
|
841
|
+
replaceEnd: caretIndex,
|
|
842
|
+
replaceStart: caretIndex,
|
|
843
|
+
resolvedKey: null,
|
|
844
|
+
tokenStart: caretIndex,
|
|
845
|
+
valuePart: ""
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
const negationOffset = activeToken.negated ? 1 : 0;
|
|
849
|
+
const contentStart = activeToken.startIndex + negationOffset;
|
|
850
|
+
const remainder = activeToken.raw.slice(negationOffset);
|
|
851
|
+
const colonIndex = findKeyColonIndex(remainder);
|
|
852
|
+
if (colonIndex === -1) {
|
|
853
|
+
return {
|
|
854
|
+
keyPart: remainder,
|
|
855
|
+
mode: "key",
|
|
856
|
+
negated: activeToken.negated,
|
|
857
|
+
replaceEnd: activeToken.endIndex,
|
|
858
|
+
replaceStart: activeToken.startIndex,
|
|
859
|
+
resolvedKey: null,
|
|
860
|
+
tokenStart: activeToken.startIndex,
|
|
861
|
+
valuePart: ""
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const valueStart = contentStart + colonIndex + 1;
|
|
865
|
+
return {
|
|
866
|
+
keyPart: remainder.slice(0, colonIndex),
|
|
867
|
+
mode: "value",
|
|
868
|
+
negated: activeToken.negated,
|
|
869
|
+
replaceEnd: activeToken.endIndex,
|
|
870
|
+
replaceStart: valueStart,
|
|
871
|
+
resolvedKey: remainder.slice(0, colonIndex),
|
|
872
|
+
tokenStart: activeToken.startIndex,
|
|
873
|
+
valuePart: remainder.slice(colonIndex + 1)
|
|
874
|
+
};
|
|
830
875
|
}
|