datool 0.0.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.
- package/README.md +218 -0
- package/client-dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/client-dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/client-dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/client-dist/assets/index-BeRNeRUq.css +1 -0
- package/client-dist/assets/index-uoZ4c_I8.js +164 -0
- package/client-dist/index.html +13 -0
- package/index.html +12 -0
- package/package.json +55 -0
- package/src/client/App.tsx +885 -0
- package/src/client/components/connection-status.tsx +43 -0
- package/src/client/components/data-table-cell.tsx +235 -0
- package/src/client/components/data-table-col-icon.tsx +73 -0
- package/src/client/components/data-table-header-col.tsx +225 -0
- package/src/client/components/data-table-search-input.tsx +729 -0
- package/src/client/components/data-table.tsx +2014 -0
- package/src/client/components/stream-controls.tsx +157 -0
- package/src/client/components/theme-provider.tsx +230 -0
- package/src/client/components/ui/button.tsx +68 -0
- package/src/client/components/ui/combobox.tsx +308 -0
- package/src/client/components/ui/context-menu.tsx +261 -0
- package/src/client/components/ui/dropdown-menu.tsx +267 -0
- package/src/client/components/ui/input-group.tsx +153 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/components/viewer-settings.tsx +185 -0
- package/src/client/index.css +192 -0
- package/src/client/lib/data-table-search.ts +750 -0
- package/src/client/lib/datool-icons.ts +37 -0
- package/src/client/lib/datool-url-state.ts +159 -0
- package/src/client/lib/filterable-table.ts +146 -0
- package/src/client/lib/table-search-persistence.ts +94 -0
- package/src/client/lib/utils.ts +6 -0
- package/src/client/main.tsx +14 -0
- package/src/index.ts +19 -0
- package/src/node/cli.ts +54 -0
- package/src/node/config.ts +231 -0
- package/src/node/lines.ts +82 -0
- package/src/node/runtime.ts +102 -0
- package/src/node/server.ts +403 -0
- package/src/node/sources/command.ts +82 -0
- package/src/node/sources/file.ts +116 -0
- package/src/node/sources/ssh.ts +59 -0
- package/src/shared/columns.ts +41 -0
- package/src/shared/types.ts +188 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
export type DataTableSearchFieldKind =
|
|
2
|
+
| "date"
|
|
3
|
+
| "enum"
|
|
4
|
+
| "json"
|
|
5
|
+
| "number"
|
|
6
|
+
| "text"
|
|
7
|
+
|
|
8
|
+
export type DataTableSearchField<Row> = {
|
|
9
|
+
getValue: (row: Row) => unknown
|
|
10
|
+
id: string
|
|
11
|
+
kind: DataTableSearchFieldKind
|
|
12
|
+
options?: string[]
|
|
13
|
+
sample?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type DataTableSearchSuggestion = {
|
|
17
|
+
group: "filters" | "input" | "values"
|
|
18
|
+
id: string
|
|
19
|
+
insertText: string
|
|
20
|
+
keepOpen?: boolean
|
|
21
|
+
label: string
|
|
22
|
+
mode: "append" | "replace-token" | "replace-whole"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DataTableSearchFilterClause = {
|
|
26
|
+
operator: ":" | "<" | ">"
|
|
27
|
+
value: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DataTableSearchTokenOperator = ":" | "." | "<" | ">"
|
|
31
|
+
|
|
32
|
+
export type DataTableSearchSelectorRange = {
|
|
33
|
+
end: number
|
|
34
|
+
fieldId: string
|
|
35
|
+
operator: DataTableSearchTokenOperator
|
|
36
|
+
start: number
|
|
37
|
+
token: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type DataTableSearchTokenRange = {
|
|
41
|
+
end: number
|
|
42
|
+
start: number
|
|
43
|
+
token: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function withSuggestionMode(
|
|
47
|
+
suggestion: DataTableSearchSuggestion,
|
|
48
|
+
mode: DataTableSearchSuggestion["mode"]
|
|
49
|
+
): DataTableSearchSuggestion {
|
|
50
|
+
return {
|
|
51
|
+
...suggestion,
|
|
52
|
+
mode,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type TokenContext<Row> = {
|
|
57
|
+
end: number
|
|
58
|
+
field?: DataTableSearchField<Row>
|
|
59
|
+
fragment: string
|
|
60
|
+
operator?: DataTableSearchTokenOperator
|
|
61
|
+
start: number
|
|
62
|
+
token: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type MatchedSearchToken<Row> = {
|
|
66
|
+
field?: DataTableSearchField<Row>
|
|
67
|
+
fieldId: string
|
|
68
|
+
fragment: string
|
|
69
|
+
operator: DataTableSearchTokenOperator
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function matchSearchToken<Row>(
|
|
73
|
+
token: string,
|
|
74
|
+
fields: DataTableSearchField<Row>[]
|
|
75
|
+
): MatchedSearchToken<Row> | null {
|
|
76
|
+
const match = token.match(/^([a-zA-Z0-9_.-]+)(:|>|<|\.)(.*)$/)
|
|
77
|
+
|
|
78
|
+
if (!match) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [, fieldId, operator, fragment] = match
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
field: fields.find((item) => item.id === fieldId),
|
|
86
|
+
fieldId,
|
|
87
|
+
fragment,
|
|
88
|
+
operator: operator as DataTableSearchTokenOperator,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isSupportedOperator<Row>(
|
|
93
|
+
field: DataTableSearchField<Row>,
|
|
94
|
+
operator: DataTableSearchTokenOperator
|
|
95
|
+
) {
|
|
96
|
+
switch (field.kind) {
|
|
97
|
+
case "date":
|
|
98
|
+
case "number":
|
|
99
|
+
return operator === ":" || operator === ">" || operator === "<"
|
|
100
|
+
case "json":
|
|
101
|
+
return operator === ":" || operator === "."
|
|
102
|
+
default:
|
|
103
|
+
return operator === ":"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stringifyValue(value: unknown): string {
|
|
108
|
+
if (value === null || value === undefined) {
|
|
109
|
+
return ""
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (value instanceof Date) {
|
|
113
|
+
return value.toISOString()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof value === "object") {
|
|
117
|
+
return JSON.stringify(value)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return String(value)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getSearchTokenRanges(value: string) {
|
|
124
|
+
const ranges: DataTableSearchTokenRange[] = []
|
|
125
|
+
let tokenStart = -1
|
|
126
|
+
let isQuoted = false
|
|
127
|
+
let isEscaped = false
|
|
128
|
+
|
|
129
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
130
|
+
const character = value[index]
|
|
131
|
+
|
|
132
|
+
if (tokenStart === -1) {
|
|
133
|
+
if (/\s/.test(character)) {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
tokenStart = index
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (isEscaped) {
|
|
141
|
+
isEscaped = false
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (character === "\\") {
|
|
146
|
+
isEscaped = true
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (character === '"') {
|
|
151
|
+
isQuoted = !isQuoted
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!isQuoted && /\s/.test(character)) {
|
|
156
|
+
ranges.push({
|
|
157
|
+
end: index,
|
|
158
|
+
start: tokenStart,
|
|
159
|
+
token: value.slice(tokenStart, index),
|
|
160
|
+
})
|
|
161
|
+
tokenStart = -1
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (tokenStart !== -1) {
|
|
166
|
+
ranges.push({
|
|
167
|
+
end: value.length,
|
|
168
|
+
start: tokenStart,
|
|
169
|
+
token: value.slice(tokenStart),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return ranges
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function splitSearchQuery(value: string) {
|
|
177
|
+
return getSearchTokenRanges(value).map((range) => range.token)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function unescapeSearchTokenValue(value: string) {
|
|
181
|
+
return value.replace(/\\(["\\])/g, "$1")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function unquoteSearchTokenValue(value: string) {
|
|
185
|
+
const trimmedValue = value.trim()
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
trimmedValue.length >= 2 &&
|
|
189
|
+
trimmedValue.startsWith('"') &&
|
|
190
|
+
trimmedValue.endsWith('"')
|
|
191
|
+
) {
|
|
192
|
+
return unescapeSearchTokenValue(trimmedValue.slice(1, -1))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return unescapeSearchTokenValue(trimmedValue)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function quoteSearchTokenValue(value: string) {
|
|
199
|
+
if (!value || /[\s"]/u.test(value)) {
|
|
200
|
+
return `"${value.replace(/["\\]/g, "\\$&")}"`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseDateOperand(value: string) {
|
|
207
|
+
const normalized = value.trim().toLowerCase()
|
|
208
|
+
|
|
209
|
+
if (!normalized) {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (normalized === "today") {
|
|
214
|
+
const now = new Date()
|
|
215
|
+
|
|
216
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const relativeMatch = normalized.match(/^-(\d+)d$/)
|
|
220
|
+
|
|
221
|
+
if (relativeMatch) {
|
|
222
|
+
const days = Number(relativeMatch[1])
|
|
223
|
+
|
|
224
|
+
return new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const timestamp = Date.parse(value)
|
|
228
|
+
|
|
229
|
+
if (Number.isNaN(timestamp)) {
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return new Date(timestamp)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sameCalendarDay(left: Date, right: Date) {
|
|
237
|
+
return (
|
|
238
|
+
left.getFullYear() === right.getFullYear() &&
|
|
239
|
+
left.getMonth() === right.getMonth() &&
|
|
240
|
+
left.getDate() === right.getDate()
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function matchFieldToken<Row>(
|
|
245
|
+
row: Row,
|
|
246
|
+
field: DataTableSearchField<Row>,
|
|
247
|
+
operator: ":" | "<" | ">",
|
|
248
|
+
rawValue: string
|
|
249
|
+
) {
|
|
250
|
+
const fieldValue = field.getValue(row)
|
|
251
|
+
const stringValue = stringifyValue(fieldValue)
|
|
252
|
+
|
|
253
|
+
if (!rawValue.trim()) {
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (field.kind === "enum") {
|
|
258
|
+
return stringValue.toLowerCase() === rawValue.trim().toLowerCase()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (field.kind === "number") {
|
|
262
|
+
const left = Number(fieldValue)
|
|
263
|
+
const right = Number(rawValue)
|
|
264
|
+
|
|
265
|
+
if (Number.isNaN(left) || Number.isNaN(right)) {
|
|
266
|
+
return stringValue.toLowerCase().includes(rawValue.trim().toLowerCase())
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (operator === ">") {
|
|
270
|
+
return left > right
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (operator === "<") {
|
|
274
|
+
return left < right
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return left === right
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (field.kind === "date") {
|
|
281
|
+
const nestedOperatorMatch =
|
|
282
|
+
operator === ":" ? rawValue.match(/^([<>])(.*)$/) : null
|
|
283
|
+
const effectiveOperator = nestedOperatorMatch
|
|
284
|
+
? (nestedOperatorMatch[1] as "<" | ">")
|
|
285
|
+
: operator
|
|
286
|
+
const effectiveValue = nestedOperatorMatch
|
|
287
|
+
? nestedOperatorMatch[2]
|
|
288
|
+
: rawValue
|
|
289
|
+
const left = parseDateOperand(stringValue)
|
|
290
|
+
const right = parseDateOperand(effectiveValue)
|
|
291
|
+
|
|
292
|
+
if (!left || !right) {
|
|
293
|
+
return stringValue.toLowerCase().includes(rawValue.trim().toLowerCase())
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (effectiveOperator === ">") {
|
|
297
|
+
return left > right
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (effectiveOperator === "<") {
|
|
301
|
+
return left < right
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return sameCalendarDay(left, right)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return stringValue.toLowerCase().includes(rawValue.trim().toLowerCase())
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function matchesFieldClauses<Row>(
|
|
311
|
+
row: Row,
|
|
312
|
+
field: DataTableSearchField<Row>,
|
|
313
|
+
clauses: DataTableSearchFilterClause[]
|
|
314
|
+
) {
|
|
315
|
+
return clauses.every((clause) =>
|
|
316
|
+
matchFieldToken(row, field, clause.operator, clause.value)
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function buildBaseSuggestions<Row>(fields: DataTableSearchField<Row>[]) {
|
|
321
|
+
return fields.flatMap<DataTableSearchSuggestion>((field) => {
|
|
322
|
+
switch (field.kind) {
|
|
323
|
+
case "date":
|
|
324
|
+
return [
|
|
325
|
+
{
|
|
326
|
+
id: `${field.id}-eq`,
|
|
327
|
+
group: "filters",
|
|
328
|
+
insertText: `${field.id}:`,
|
|
329
|
+
keepOpen: true,
|
|
330
|
+
label: `${field.id}:`,
|
|
331
|
+
mode: "append",
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
id: `${field.id}-gt`,
|
|
335
|
+
group: "filters",
|
|
336
|
+
insertText: `${field.id}>`,
|
|
337
|
+
keepOpen: true,
|
|
338
|
+
label: `${field.id}>`,
|
|
339
|
+
mode: "append",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: `${field.id}-lt`,
|
|
343
|
+
group: "filters",
|
|
344
|
+
insertText: `${field.id}<`,
|
|
345
|
+
keepOpen: true,
|
|
346
|
+
label: `${field.id}<`,
|
|
347
|
+
mode: "append",
|
|
348
|
+
},
|
|
349
|
+
]
|
|
350
|
+
case "number":
|
|
351
|
+
return [
|
|
352
|
+
{
|
|
353
|
+
id: `${field.id}-eq`,
|
|
354
|
+
group: "filters",
|
|
355
|
+
insertText: `${field.id}:`,
|
|
356
|
+
keepOpen: true,
|
|
357
|
+
label: `${field.id}:`,
|
|
358
|
+
mode: "append",
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
id: `${field.id}-gt`,
|
|
362
|
+
group: "filters",
|
|
363
|
+
insertText: `${field.id}>`,
|
|
364
|
+
keepOpen: true,
|
|
365
|
+
label: `${field.id}>`,
|
|
366
|
+
mode: "append",
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: `${field.id}-lt`,
|
|
370
|
+
group: "filters",
|
|
371
|
+
insertText: `${field.id}<`,
|
|
372
|
+
keepOpen: true,
|
|
373
|
+
label: `${field.id}<`,
|
|
374
|
+
mode: "append",
|
|
375
|
+
},
|
|
376
|
+
]
|
|
377
|
+
case "json":
|
|
378
|
+
return [
|
|
379
|
+
{
|
|
380
|
+
id: `${field.id}-dot`,
|
|
381
|
+
group: "filters",
|
|
382
|
+
insertText: `${field.id}.`,
|
|
383
|
+
keepOpen: true,
|
|
384
|
+
label: `${field.id}.`,
|
|
385
|
+
mode: "append",
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
id: `${field.id}-eq`,
|
|
389
|
+
group: "filters",
|
|
390
|
+
insertText: `${field.id}:`,
|
|
391
|
+
keepOpen: true,
|
|
392
|
+
label: `${field.id}:`,
|
|
393
|
+
mode: "append",
|
|
394
|
+
},
|
|
395
|
+
]
|
|
396
|
+
default:
|
|
397
|
+
return [
|
|
398
|
+
{
|
|
399
|
+
id: `${field.id}-eq`,
|
|
400
|
+
group: "filters",
|
|
401
|
+
insertText: `${field.id}:`,
|
|
402
|
+
keepOpen: true,
|
|
403
|
+
label: `${field.id}:`,
|
|
404
|
+
mode: "append",
|
|
405
|
+
},
|
|
406
|
+
]
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function getTokenContext<Row>(
|
|
412
|
+
value: string,
|
|
413
|
+
cursor: number,
|
|
414
|
+
fields: DataTableSearchField<Row>[]
|
|
415
|
+
): TokenContext<Row> {
|
|
416
|
+
const tokenRange = getSearchTokenRanges(value).find(
|
|
417
|
+
(range) => cursor >= range.start && cursor <= range.end
|
|
418
|
+
)
|
|
419
|
+
const start = tokenRange?.start ?? cursor
|
|
420
|
+
const end = tokenRange?.end ?? cursor
|
|
421
|
+
const token = tokenRange?.token ?? ""
|
|
422
|
+
const match = matchSearchToken(token, fields)
|
|
423
|
+
|
|
424
|
+
if (!match) {
|
|
425
|
+
return {
|
|
426
|
+
end,
|
|
427
|
+
fragment: token,
|
|
428
|
+
start,
|
|
429
|
+
token,
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
end,
|
|
435
|
+
field: match.field,
|
|
436
|
+
fragment: match.fragment,
|
|
437
|
+
operator: match.operator,
|
|
438
|
+
start,
|
|
439
|
+
token,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function getSelectorHighlightRanges<Row>(
|
|
444
|
+
value: string,
|
|
445
|
+
fields: DataTableSearchField<Row>[]
|
|
446
|
+
) {
|
|
447
|
+
const ranges: DataTableSearchSelectorRange[] = []
|
|
448
|
+
|
|
449
|
+
for (const { end, start, token } of getSearchTokenRanges(value)) {
|
|
450
|
+
const parsedToken = matchSearchToken(token, fields)
|
|
451
|
+
|
|
452
|
+
if (!parsedToken?.field) {
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!isSupportedOperator(parsedToken.field, parsedToken.operator)) {
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
ranges.push({
|
|
461
|
+
end,
|
|
462
|
+
fieldId: parsedToken.field.id,
|
|
463
|
+
operator: parsedToken.operator,
|
|
464
|
+
start,
|
|
465
|
+
token,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return ranges
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function getSearchSuggestions<Row>(
|
|
473
|
+
value: string,
|
|
474
|
+
cursor: number,
|
|
475
|
+
fields: DataTableSearchField<Row>[]
|
|
476
|
+
) {
|
|
477
|
+
const token = getTokenContext(value, cursor, fields)
|
|
478
|
+
const hasTokenFragment = token.fragment.trim().length > 0
|
|
479
|
+
const inputSuggestion = value
|
|
480
|
+
? [
|
|
481
|
+
{
|
|
482
|
+
group: "input" as const,
|
|
483
|
+
id: "input-value",
|
|
484
|
+
insertText: value,
|
|
485
|
+
label: value,
|
|
486
|
+
mode: "replace-whole" as const,
|
|
487
|
+
},
|
|
488
|
+
]
|
|
489
|
+
: []
|
|
490
|
+
|
|
491
|
+
if (!token.field || !token.operator) {
|
|
492
|
+
const baseSuggestions = buildBaseSuggestions(fields)
|
|
493
|
+
const filteredSuggestions = !token.fragment.trim()
|
|
494
|
+
? baseSuggestions
|
|
495
|
+
: baseSuggestions.filter((suggestion) =>
|
|
496
|
+
suggestion.label
|
|
497
|
+
.toLowerCase()
|
|
498
|
+
.includes(token.fragment.trim().toLowerCase())
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
if (hasTokenFragment) {
|
|
502
|
+
return [
|
|
503
|
+
...inputSuggestion,
|
|
504
|
+
...filteredSuggestions.map((suggestion) =>
|
|
505
|
+
withSuggestionMode(suggestion, "replace-token")
|
|
506
|
+
),
|
|
507
|
+
]
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return [...inputSuggestion, ...filteredSuggestions]
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (token.field.kind === "enum" && token.operator === ":") {
|
|
514
|
+
const selectedValue = unquoteSearchTokenValue(token.fragment).toLowerCase()
|
|
515
|
+
|
|
516
|
+
if (
|
|
517
|
+
selectedValue &&
|
|
518
|
+
(token.field.options ?? []).some(
|
|
519
|
+
(option) => option.toLowerCase() === selectedValue
|
|
520
|
+
)
|
|
521
|
+
) {
|
|
522
|
+
return inputSuggestion
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const valueSuggestions = (token.field.options ?? [])
|
|
526
|
+
.filter((option) => option.toLowerCase().includes(selectedValue))
|
|
527
|
+
.map<DataTableSearchSuggestion>((option) => ({
|
|
528
|
+
group: "values",
|
|
529
|
+
id: `${token.field?.id}-${option}`,
|
|
530
|
+
insertText: `${token.field?.id}:${quoteSearchTokenValue(option)}`,
|
|
531
|
+
label: option,
|
|
532
|
+
mode: "replace-token",
|
|
533
|
+
}))
|
|
534
|
+
|
|
535
|
+
return [...inputSuggestion, ...valueSuggestions]
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (token.field.kind === "date") {
|
|
539
|
+
const sample = token.field.sample ?? new Date().toISOString()
|
|
540
|
+
const suggestions = [
|
|
541
|
+
{
|
|
542
|
+
group: "values" as const,
|
|
543
|
+
id: `${token.field.id}-today`,
|
|
544
|
+
insertText: `${token.field.id}${token.operator}today`,
|
|
545
|
+
label: "today",
|
|
546
|
+
mode: "replace-token" as const,
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
group: "values" as const,
|
|
550
|
+
id: `${token.field.id}-7d`,
|
|
551
|
+
insertText: `${token.field.id}${token.operator}-7d`,
|
|
552
|
+
label: "-7d",
|
|
553
|
+
mode: "replace-token" as const,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
group: "values" as const,
|
|
557
|
+
id: `${token.field.id}-sample`,
|
|
558
|
+
insertText: `${token.field.id}${token.operator}${sample}`,
|
|
559
|
+
label: sample,
|
|
560
|
+
mode: "replace-token" as const,
|
|
561
|
+
},
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
if (token.operator === ":") {
|
|
565
|
+
suggestions[1] = {
|
|
566
|
+
group: "values",
|
|
567
|
+
id: `${token.field.id}-7d`,
|
|
568
|
+
insertText: `${token.field.id}:>-7d`,
|
|
569
|
+
label: ">-7d",
|
|
570
|
+
mode: "replace-token",
|
|
571
|
+
}
|
|
572
|
+
suggestions[2] = {
|
|
573
|
+
group: "values",
|
|
574
|
+
id: `${token.field.id}-sample`,
|
|
575
|
+
insertText: `${token.field.id}:>${sample}`,
|
|
576
|
+
label: `>${sample}`,
|
|
577
|
+
mode: "replace-token",
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const filteredSuggestions = !token.fragment.trim()
|
|
582
|
+
? suggestions
|
|
583
|
+
: suggestions.filter((suggestion) =>
|
|
584
|
+
suggestion.label
|
|
585
|
+
.toLowerCase()
|
|
586
|
+
.includes(token.fragment.trim().toLowerCase())
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return [...inputSuggestion, ...filteredSuggestions]
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const filteredSuggestions = buildBaseSuggestions([token.field]).filter(
|
|
593
|
+
(suggestion) =>
|
|
594
|
+
suggestion.label.toLowerCase().includes(token.token.trim().toLowerCase())
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
return [...inputSuggestion, ...filteredSuggestions]
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function applySuggestionToValue<Row>(
|
|
601
|
+
value: string,
|
|
602
|
+
cursor: number,
|
|
603
|
+
fields: DataTableSearchField<Row>[],
|
|
604
|
+
suggestion: DataTableSearchSuggestion
|
|
605
|
+
) {
|
|
606
|
+
const token = getTokenContext(value, cursor, fields)
|
|
607
|
+
const nextValue =
|
|
608
|
+
suggestion.mode === "replace-whole"
|
|
609
|
+
? suggestion.insertText
|
|
610
|
+
: suggestion.mode === "append"
|
|
611
|
+
? `${value.trimEnd()}${value.trim() ? " " : ""}${suggestion.insertText}`
|
|
612
|
+
: `${value.slice(0, token.start)}${suggestion.insertText}${value.slice(token.end)}`
|
|
613
|
+
const nextCursor =
|
|
614
|
+
suggestion.mode === "append"
|
|
615
|
+
? nextValue.length
|
|
616
|
+
: suggestion.mode === "replace-whole"
|
|
617
|
+
? suggestion.insertText.length
|
|
618
|
+
: token.start + suggestion.insertText.length
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
keepOpen: Boolean(suggestion.keepOpen),
|
|
622
|
+
value: nextValue,
|
|
623
|
+
selectionStart: nextCursor,
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function parseSearchQuery<Row>(
|
|
628
|
+
query: string,
|
|
629
|
+
fields: DataTableSearchField<Row>[]
|
|
630
|
+
) {
|
|
631
|
+
const trimmedQuery = query.trim()
|
|
632
|
+
|
|
633
|
+
if (!trimmedQuery) {
|
|
634
|
+
return {
|
|
635
|
+
columnFilters: [] as Array<{
|
|
636
|
+
id: string
|
|
637
|
+
value: DataTableSearchFilterClause[]
|
|
638
|
+
}>,
|
|
639
|
+
globalFilter: "",
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const tokens = splitSearchQuery(trimmedQuery)
|
|
644
|
+
const columnFiltersMap = new Map<string, DataTableSearchFilterClause[]>()
|
|
645
|
+
const globalTokens: string[] = []
|
|
646
|
+
|
|
647
|
+
tokens.forEach((token) => {
|
|
648
|
+
const match = token.match(/^([a-zA-Z0-9_.-]+)(:|>|<)(.*)$/)
|
|
649
|
+
|
|
650
|
+
if (!match) {
|
|
651
|
+
globalTokens.push(token)
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const [, fieldId, operator, rawValue] = match
|
|
656
|
+
const field = fields.find((item) => item.id === fieldId)
|
|
657
|
+
|
|
658
|
+
if (!field) {
|
|
659
|
+
globalTokens.push(token)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const existingClauses = columnFiltersMap.get(field.id) ?? []
|
|
664
|
+
|
|
665
|
+
existingClauses.push({
|
|
666
|
+
operator: operator as ":" | "<" | ">",
|
|
667
|
+
value: unquoteSearchTokenValue(rawValue),
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
columnFiltersMap.set(field.id, existingClauses)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
columnFilters: Array.from(columnFiltersMap.entries()).map(
|
|
675
|
+
([id, value]) => ({
|
|
676
|
+
id,
|
|
677
|
+
value,
|
|
678
|
+
})
|
|
679
|
+
),
|
|
680
|
+
globalFilter: globalTokens.join(" "),
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function matchesSearch<Row>(
|
|
685
|
+
row: Row,
|
|
686
|
+
query: string,
|
|
687
|
+
fields: DataTableSearchField<Row>[]
|
|
688
|
+
) {
|
|
689
|
+
const parsedQuery = parseSearchQuery(query, fields)
|
|
690
|
+
const fallbackValue = fields
|
|
691
|
+
.map((field) => stringifyValue(field.getValue(row)).toLowerCase())
|
|
692
|
+
.join("\n")
|
|
693
|
+
|
|
694
|
+
const matchesGlobalFilter = parsedQuery.globalFilter
|
|
695
|
+
? parsedQuery.globalFilter
|
|
696
|
+
.split(/\s+/)
|
|
697
|
+
.filter(Boolean)
|
|
698
|
+
.every((token) => fallbackValue.includes(token.toLowerCase()))
|
|
699
|
+
: true
|
|
700
|
+
|
|
701
|
+
if (!matchesGlobalFilter) {
|
|
702
|
+
return false
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return parsedQuery.columnFilters.every((columnFilter) => {
|
|
706
|
+
const field = fields.find((item) => item.id === columnFilter.id)
|
|
707
|
+
|
|
708
|
+
if (!field) {
|
|
709
|
+
return true
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return matchesFieldClauses(row, field, columnFilter.value)
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function getColumnHighlightTerms<Row>(
|
|
717
|
+
query: string,
|
|
718
|
+
columnId: string,
|
|
719
|
+
fields: DataTableSearchField<Row>[]
|
|
720
|
+
) {
|
|
721
|
+
const parsedQuery = parseSearchQuery(query, fields)
|
|
722
|
+
const field = fields.find((item) => item.id === columnId)
|
|
723
|
+
const globalTerms = parsedQuery.globalFilter.split(/\s+/).filter(Boolean)
|
|
724
|
+
|
|
725
|
+
if (!field || field.kind !== "text") {
|
|
726
|
+
return globalTerms
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const columnTerms =
|
|
730
|
+
parsedQuery.columnFilters
|
|
731
|
+
.find((columnFilter) => columnFilter.id === columnId)
|
|
732
|
+
?.value.filter((clause) => clause.operator === ":" && clause.value.trim())
|
|
733
|
+
.map((clause) => clause.value.trim()) ?? []
|
|
734
|
+
|
|
735
|
+
return [...globalTerms, ...columnTerms]
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function buildEnumOptions<Row>(
|
|
739
|
+
rows: Row[],
|
|
740
|
+
getValue: (row: Row) => unknown
|
|
741
|
+
) {
|
|
742
|
+
return Array.from(
|
|
743
|
+
new Set(
|
|
744
|
+
rows
|
|
745
|
+
.map((row) => stringifyValue(getValue(row)).trim())
|
|
746
|
+
.filter(Boolean)
|
|
747
|
+
.map((value) => value.toUpperCase())
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
}
|