beez-ui 0.4.2 → 0.5.1

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