beez-ui 0.4.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 +34 -0
- package/LICENSE.md +21 -0
- package/README.md +113 -0
- package/assets/fonts/geist-LICENSE.txt +93 -0
- package/assets/fonts/geist-latin-variable.woff2 +0 -0
- package/assets/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/assets/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/assets/fonts/ibmplexmono-LICENSE.txt +93 -0
- package/assets/fonts/poppins-LICENSE.txt +93 -0
- package/assets/fonts/poppins-latin-600.woff2 +0 -0
- package/dist/adapters/unpic-image.d.ts +3 -0
- package/dist/adapters/unpic-image.js +20 -0
- package/dist/components/alert-dialog.d.ts +18 -0
- package/dist/components/alert-dialog.js +42 -0
- package/dist/components/alert.d.ts +10 -0
- package/dist/components/alert.js +28 -0
- package/dist/components/animated-theme-toggler.d.ts +8 -0
- package/dist/components/animated-theme-toggler.js +88 -0
- package/dist/components/avatar.d.ts +16 -0
- package/dist/components/avatar.js +26 -0
- package/dist/components/badge.d.ts +9 -0
- package/dist/components/badge.js +24 -0
- package/dist/components/button.d.ts +10 -0
- package/dist/components/button.js +37 -0
- package/dist/components/calendar.d.ts +10 -0
- package/dist/components/calendar.js +77 -0
- package/dist/components/card.d.ts +11 -0
- package/dist/components/card.js +24 -0
- package/dist/components/carousel.d.ts +28 -0
- package/dist/components/carousel.js +98 -0
- package/dist/components/checkbox.d.ts +4 -0
- package/dist/components/checkbox.js +9 -0
- package/dist/components/data-table.d.ts +117 -0
- package/dist/components/data-table.js +612 -0
- package/dist/components/dialog.d.ts +17 -0
- package/dist/components/dialog.js +37 -0
- package/dist/components/dropdown-menu.d.ts +29 -0
- package/dist/components/dropdown-menu.js +51 -0
- package/dist/components/filter-query-bar.d.ts +21 -0
- package/dist/components/filter-query-bar.js +526 -0
- package/dist/components/filter-query-grammar.d.ts +188 -0
- package/dist/components/filter-query-grammar.js +830 -0
- package/dist/components/form.d.ts +24 -0
- package/dist/components/form.js +59 -0
- package/dist/components/highlighter.d.ts +15 -0
- package/dist/components/highlighter.js +91 -0
- package/dist/components/hover-card.d.ts +6 -0
- package/dist/components/hover-card.js +14 -0
- package/dist/components/input-group.d.ts +16 -0
- package/dist/components/input-group.js +57 -0
- package/dist/components/input.d.ts +3 -0
- package/dist/components/input.js +7 -0
- package/dist/components/label.d.ts +4 -0
- package/dist/components/label.js +8 -0
- package/dist/components/link.d.ts +9 -0
- package/dist/components/link.js +11 -0
- package/dist/components/pagination.d.ts +18 -0
- package/dist/components/pagination.js +28 -0
- package/dist/components/popover.d.ts +10 -0
- package/dist/components/popover.js +26 -0
- package/dist/components/radio-group.d.ts +5 -0
- package/dist/components/radio-group.js +11 -0
- package/dist/components/select.d.ts +15 -0
- package/dist/components/select.js +36 -0
- package/dist/components/separator.d.ts +3 -0
- package/dist/components/separator.js +8 -0
- package/dist/components/sheet.d.ts +14 -0
- package/dist/components/sheet.js +37 -0
- package/dist/components/sidebar.d.ts +71 -0
- package/dist/components/sidebar.js +221 -0
- package/dist/components/skeleton.d.ts +2 -0
- package/dist/components/skeleton.js +6 -0
- package/dist/components/svgs/whatsappIcon.d.ts +3 -0
- package/dist/components/svgs/whatsappIcon.js +3 -0
- package/dist/components/switch.d.ts +6 -0
- package/dist/components/switch.js +8 -0
- package/dist/components/table.d.ts +10 -0
- package/dist/components/table.js +28 -0
- package/dist/components/tabs.d.ts +11 -0
- package/dist/components/tabs.js +29 -0
- package/dist/components/textarea.d.ts +3 -0
- package/dist/components/textarea.js +6 -0
- package/dist/components/themed-toaster.d.ts +4 -0
- package/dist/components/themed-toaster.js +27 -0
- package/dist/components/tooltip.d.ts +7 -0
- package/dist/components/tooltip.js +17 -0
- package/dist/components/typing-animation.d.ts +34 -0
- package/dist/components/typing-animation.js +155 -0
- package/dist/constants/sidebar.d.ts +9 -0
- package/dist/constants/sidebar.js +20 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +19 -0
- package/dist/hooks/use-sidebar-persistence.d.ts +4 -0
- package/dist/hooks/use-sidebar-persistence.js +45 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +47 -0
- package/dist/lib/image-dimensions.d.ts +2 -0
- package/dist/lib/image-dimensions.js +7 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +2 -0
- package/dist/lib/year-month-filter.d.ts +16 -0
- package/dist/lib/year-month-filter.js +48 -0
- package/dist/next.d.ts +14 -0
- package/dist/next.js +27 -0
- package/dist/providers/beez-ui-provider.d.ts +27 -0
- package/dist/providers/beez-ui-provider.js +18 -0
- package/dist/providers/native-provider.d.ts +4 -0
- package/dist/providers/native-provider.js +11 -0
- package/dist/sonner.d.ts +2 -0
- package/dist/sonner.js +3 -0
- package/dist/styles.css +2 -0
- package/dist/tanstack.d.ts +11 -0
- package/dist/tanstack.js +38 -0
- package/dist/theme.d.ts +3 -0
- package/dist/theme.js +1 -0
- package/dist/types/data-table.d.ts +27 -0
- package/dist/types/data-table.js +1 -0
- package/fonts.css +5 -0
- package/package.json +335 -0
- package/styles.css +4 -0
- package/theme.css +87 -0
|
@@ -0,0 +1,830 @@
|
|
|
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
|
+
*/
|
|
10
|
+
const DIACRITICS_PATTERN = /[̀-ͯ]/g;
|
|
11
|
+
const YEAR_MONTH_SLUG_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;
|
|
12
|
+
const RANGE_SEPARATOR = "..";
|
|
13
|
+
/** Slug de valor para los modos de presencia/rango mes-año sin cota. */
|
|
14
|
+
export const YEAR_MONTH_NO_VALUE_SLUG = "sin-fechas";
|
|
15
|
+
export const YEAR_MONTH_HAS_VALUE_SLUG = "con-fechas";
|
|
16
|
+
export const PRESENCE_TRUE_SLUG = "si";
|
|
17
|
+
export const PRESENCE_FALSE_SLUG = "no";
|
|
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
|
+
*/
|
|
25
|
+
export const PRESENCE_HAS_META_KEY = "tiene";
|
|
26
|
+
export const PRESENCE_NO_META_KEY = "no";
|
|
27
|
+
/** Identificador de "sin carpeta asignada" en un qualifier de carpeta. */
|
|
28
|
+
export const UNASSIGNED_FOLDER_FILTER_VALUE = "__unassigned__";
|
|
29
|
+
/** Normaliza una clave o slug: sin acentos, minúsculas, recortado. */
|
|
30
|
+
export function normalizeFilterSlug(value) {
|
|
31
|
+
return value
|
|
32
|
+
.normalize("NFD")
|
|
33
|
+
.replace(DIACRITICS_PATTERN, "")
|
|
34
|
+
.toLocaleLowerCase()
|
|
35
|
+
.trim();
|
|
36
|
+
}
|
|
37
|
+
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;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
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
|
+
*/
|
|
57
|
+
function quoteTokenIfNeeded(value) {
|
|
58
|
+
return /[\s:]/.test(value) || value.startsWith("-")
|
|
59
|
+
? `"${value}"`
|
|
60
|
+
: value;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
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
|
+
*/
|
|
68
|
+
function serializeDescriptionFilter(descriptionFilter) {
|
|
69
|
+
return descriptionFilter
|
|
70
|
+
.split(/\s+/)
|
|
71
|
+
.filter((word) => word.length > 0)
|
|
72
|
+
.map(quoteTokenIfNeeded)
|
|
73
|
+
.join(" ");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
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
|
+
*/
|
|
80
|
+
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;
|
|
114
|
+
}
|
|
115
|
+
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;
|
|
127
|
+
}
|
|
128
|
+
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
|
+
};
|
|
156
|
+
}
|
|
157
|
+
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;
|
|
169
|
+
}
|
|
170
|
+
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;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
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
|
+
*/
|
|
184
|
+
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 };
|
|
221
|
+
}
|
|
222
|
+
/** Convierte un slug `AAAA-MM` a su valor numérico comparable `AAAAMM`. */
|
|
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}`);
|
|
230
|
+
}
|
|
231
|
+
/** Formatea un valor numérico `AAAAMM` de vuelta a su slug `AAAA-MM`. */
|
|
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")}`;
|
|
236
|
+
}
|
|
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" };
|
|
287
|
+
}
|
|
288
|
+
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;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
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
|
+
*/
|
|
319
|
+
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;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
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
|
+
*/
|
|
352
|
+
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;
|
|
362
|
+
}
|
|
363
|
+
/** Construye el valor estructurado de un qualifier según su kind. */
|
|
364
|
+
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);
|
|
372
|
+
}
|
|
373
|
+
/** Discrimina un valor que vive en el path de columnas de TanStack. */
|
|
374
|
+
function isColumnFilterValue(value) {
|
|
375
|
+
return value.kind !== "textMatch" && value.kind !== "folder";
|
|
376
|
+
}
|
|
377
|
+
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;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
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
|
+
*/
|
|
430
|
+
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;
|
|
441
|
+
}
|
|
442
|
+
/** Quita duplicados de exclusiones comparando por su forma normalizada. */
|
|
443
|
+
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;
|
|
455
|
+
}
|
|
456
|
+
/** Parsea la query completa contra la configuración de qualifiers. */
|
|
457
|
+
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
|
+
};
|
|
595
|
+
}
|
|
596
|
+
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;
|
|
608
|
+
}
|
|
609
|
+
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;
|
|
629
|
+
}
|
|
630
|
+
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)}`;
|
|
646
|
+
}
|
|
647
|
+
function serializeFolderValue(config, value) {
|
|
648
|
+
const option = (config.options ?? []).find((candidate) => candidate.value === value.folderId);
|
|
649
|
+
return option ? `${config.key}:${option.slug}` : null;
|
|
650
|
+
}
|
|
651
|
+
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}`;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
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
|
+
*/
|
|
684
|
+
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;
|
|
695
|
+
}
|
|
696
|
+
/** Reconstruye una query canónica a partir de un resultado parseado. */
|
|
697
|
+
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(" ");
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
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
|
+
*/
|
|
747
|
+
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;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
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
|
+
*/
|
|
788
|
+
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
|
+
};
|
|
830
|
+
}
|