@marimo-team/islands 0.23.7-dev56 → 0.23.7-dev57
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/dist/{code-visibility-nPfbiA_L.js → code-visibility-PjV7HUDZ.js} +10621 -1448
- package/dist/main.js +1347 -9983
- package/dist/{reveal-component-DuRlSS4j.js → reveal-component-Phd-LTXq.js} +1 -1
- package/package.json +1 -1
- package/src/components/data-table/__tests__/column-header.test.tsx +106 -1
- package/src/components/data-table/__tests__/filter-pill-editor.test.tsx +88 -2
- package/src/components/data-table/__tests__/filters.test.ts +84 -13
- package/src/components/data-table/column-header.tsx +152 -26
- package/src/components/data-table/date-filter-inputs.tsx +325 -0
- package/src/components/data-table/filter-pill-editor.tsx +139 -30
- package/src/components/data-table/filter-pills.tsx +31 -57
- package/src/components/data-table/filters.ts +88 -66
- package/src/core/websocket/transports/ws.ts +3 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type {
|
|
3
|
+
CalendarDate,
|
|
4
|
+
CalendarDateTime,
|
|
5
|
+
Time,
|
|
6
|
+
} from "@internationalized/date";
|
|
7
|
+
import { parseDate, parseDateTime, parseTime } from "@internationalized/date";
|
|
8
|
+
import { MinusIcon } from "lucide-react";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import type { DateValue, TimeValue } from "react-aria-components";
|
|
11
|
+
import { TimeField } from "@/components/ui/date-input";
|
|
12
|
+
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker";
|
|
13
|
+
import {
|
|
14
|
+
dateToISODate,
|
|
15
|
+
dateToISODateTime,
|
|
16
|
+
dateToISOTime,
|
|
17
|
+
type FilterType,
|
|
18
|
+
} from "./filters";
|
|
19
|
+
|
|
20
|
+
export type DateLikeFilterType = Extract<
|
|
21
|
+
FilterType,
|
|
22
|
+
"date" | "datetime" | "time"
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
function dateToAria(
|
|
26
|
+
filterType: DateLikeFilterType,
|
|
27
|
+
d: Date,
|
|
28
|
+
): DateValue | TimeValue {
|
|
29
|
+
switch (filterType) {
|
|
30
|
+
case "date":
|
|
31
|
+
return parseDate(dateToISODate(d));
|
|
32
|
+
case "datetime":
|
|
33
|
+
return parseDateTime(dateToISODateTime(d));
|
|
34
|
+
case "time":
|
|
35
|
+
return parseTime(dateToISOTime(d));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ariaToDate(
|
|
40
|
+
filterType: DateLikeFilterType,
|
|
41
|
+
aria: DateValue | TimeValue,
|
|
42
|
+
): Date {
|
|
43
|
+
if (filterType === "time") {
|
|
44
|
+
const t = aria as Time;
|
|
45
|
+
return new Date(1970, 0, 1, t.hour, t.minute, t.second, t.millisecond);
|
|
46
|
+
}
|
|
47
|
+
if (filterType === "date") {
|
|
48
|
+
const c = aria as CalendarDate;
|
|
49
|
+
return new Date(c.year, c.month - 1, c.day);
|
|
50
|
+
}
|
|
51
|
+
const c = aria as Partial<CalendarDateTime> & CalendarDate;
|
|
52
|
+
return new Date(
|
|
53
|
+
c.year,
|
|
54
|
+
c.month - 1,
|
|
55
|
+
c.day,
|
|
56
|
+
c.hour ?? 0,
|
|
57
|
+
c.minute ?? 0,
|
|
58
|
+
c.second ?? 0,
|
|
59
|
+
c.millisecond ?? 0,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Parses a pasted string into a Date appropriate for the filter type.
|
|
64
|
+
// Accepts ISO, US, RFC formats via the Date constructor; time-only strings
|
|
65
|
+
// (HH:MM[:SS]) are handled explicitly since `new Date("12:30")` is invalid.
|
|
66
|
+
export function parsePastedDate(
|
|
67
|
+
filterType: DateLikeFilterType,
|
|
68
|
+
text: string,
|
|
69
|
+
): Date | undefined {
|
|
70
|
+
const trimmed = text.trim();
|
|
71
|
+
if (!trimmed) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const timeMatch =
|
|
76
|
+
filterType === "time"
|
|
77
|
+
? trimmed.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?$/i)
|
|
78
|
+
: null;
|
|
79
|
+
if (timeMatch) {
|
|
80
|
+
const [, hStr, mStr, sStr, ampm] = timeMatch;
|
|
81
|
+
let hour = Number.parseInt(hStr, 10);
|
|
82
|
+
const minute = Number.parseInt(mStr, 10);
|
|
83
|
+
const second = sStr ? Number.parseInt(sStr, 10) : 0;
|
|
84
|
+
if (ampm) {
|
|
85
|
+
const isPm = ampm.toLowerCase() === "pm";
|
|
86
|
+
if (hour === 12) {
|
|
87
|
+
hour = isPm ? 12 : 0;
|
|
88
|
+
} else if (isPm) {
|
|
89
|
+
hour += 12;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return new Date(1970, 0, 1, hour, minute, second);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const dateOnlyMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
96
|
+
if (dateOnlyMatch) {
|
|
97
|
+
const [, y, m, d] = dateOnlyMatch;
|
|
98
|
+
return new Date(
|
|
99
|
+
Number.parseInt(y, 10),
|
|
100
|
+
Number.parseInt(m, 10) - 1,
|
|
101
|
+
Number.parseInt(d, 10),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Parse ISO datetimes as wall-clock to stay consistent with the picker's
|
|
106
|
+
// local-time basis. Trailing `Z` or offsets are stripped so that pasting
|
|
107
|
+
// `2024-01-15T08:30:00Z` displays `08:30` instead of being shifted by the
|
|
108
|
+
// viewer's timezone.
|
|
109
|
+
const isoMatch = trimmed.match(
|
|
110
|
+
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
111
|
+
);
|
|
112
|
+
if (isoMatch) {
|
|
113
|
+
const [, y, mo, d, h, mi, s] = isoMatch;
|
|
114
|
+
return new Date(
|
|
115
|
+
Number.parseInt(y, 10),
|
|
116
|
+
Number.parseInt(mo, 10) - 1,
|
|
117
|
+
Number.parseInt(d, 10),
|
|
118
|
+
Number.parseInt(h, 10),
|
|
119
|
+
Number.parseInt(mi, 10),
|
|
120
|
+
s ? Number.parseInt(s, 10) : 0,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parsed = new Date(trimmed);
|
|
125
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
return parsed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parsePastedRange(
|
|
132
|
+
filterType: DateLikeFilterType,
|
|
133
|
+
text: string,
|
|
134
|
+
): { min: Date; max: Date } | undefined {
|
|
135
|
+
const parts = text.split(/\s+(?:-|–|—|to)\s+/i);
|
|
136
|
+
if (parts.length === 2) {
|
|
137
|
+
const min = parsePastedDate(filterType, parts[0]);
|
|
138
|
+
const max = parsePastedDate(filterType, parts[1]);
|
|
139
|
+
if (min && max) {
|
|
140
|
+
return { min, max };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const single = parsePastedDate(filterType, text);
|
|
144
|
+
if (single) {
|
|
145
|
+
return { min: single, max: single };
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface DateLikeInputProps {
|
|
151
|
+
filterType: DateLikeFilterType;
|
|
152
|
+
value: Date | undefined;
|
|
153
|
+
onChange: (value: Date | undefined) => void;
|
|
154
|
+
"aria-label"?: string;
|
|
155
|
+
className?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const DateLikeInput = ({
|
|
159
|
+
filterType,
|
|
160
|
+
value,
|
|
161
|
+
onChange,
|
|
162
|
+
"aria-label": ariaLabel,
|
|
163
|
+
className,
|
|
164
|
+
}: DateLikeInputProps) => {
|
|
165
|
+
const [seedKey, setSeedKey] = useState(0);
|
|
166
|
+
const [seed, setSeed] = useState(value);
|
|
167
|
+
|
|
168
|
+
const handleChange = (next: DateValue | TimeValue | null) => {
|
|
169
|
+
if (next === null) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
onChange(ariaToDate(filterType, next));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
176
|
+
const text = e.clipboardData.getData("text");
|
|
177
|
+
const parsed = parsePastedDate(filterType, text);
|
|
178
|
+
if (!parsed) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
onChange(parsed);
|
|
183
|
+
setSeed(parsed);
|
|
184
|
+
setSeedKey((k) => k + 1);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const seedValue =
|
|
188
|
+
seed === undefined ? undefined : dateToAria(filterType, seed);
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div onPasteCapture={handlePaste} className="contents">
|
|
192
|
+
{filterType === "time" ? (
|
|
193
|
+
<TimeField<Time>
|
|
194
|
+
key={seedKey}
|
|
195
|
+
aria-label={ariaLabel}
|
|
196
|
+
defaultValue={seedValue as Time | undefined}
|
|
197
|
+
onChange={handleChange}
|
|
198
|
+
className={className}
|
|
199
|
+
/>
|
|
200
|
+
) : filterType === "date" ? (
|
|
201
|
+
<DatePicker<CalendarDate>
|
|
202
|
+
key={seedKey}
|
|
203
|
+
aria-label={ariaLabel}
|
|
204
|
+
defaultValue={seedValue as CalendarDate | undefined}
|
|
205
|
+
onChange={handleChange}
|
|
206
|
+
className={className}
|
|
207
|
+
/>
|
|
208
|
+
) : (
|
|
209
|
+
<DatePicker<CalendarDateTime>
|
|
210
|
+
key={seedKey}
|
|
211
|
+
aria-label={ariaLabel}
|
|
212
|
+
defaultValue={seedValue as CalendarDateTime | undefined}
|
|
213
|
+
granularity="second"
|
|
214
|
+
onChange={handleChange}
|
|
215
|
+
className={className}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
interface DateLikeRangeInputProps {
|
|
223
|
+
filterType: DateLikeFilterType;
|
|
224
|
+
min: Date | undefined;
|
|
225
|
+
max: Date | undefined;
|
|
226
|
+
onRangeChange: (min: Date | undefined, max: Date | undefined) => void;
|
|
227
|
+
className?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const DateLikeRangeInput = ({
|
|
231
|
+
filterType,
|
|
232
|
+
min,
|
|
233
|
+
max,
|
|
234
|
+
onRangeChange,
|
|
235
|
+
className,
|
|
236
|
+
}: DateLikeRangeInputProps) => {
|
|
237
|
+
const [seedKey, setSeedKey] = useState(0);
|
|
238
|
+
const [seedMin, setSeedMin] = useState(min);
|
|
239
|
+
const [seedMax, setSeedMax] = useState(max);
|
|
240
|
+
|
|
241
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
242
|
+
const text = e.clipboardData.getData("text");
|
|
243
|
+
const parsed = parsePastedRange(filterType, text);
|
|
244
|
+
if (!parsed) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
onRangeChange(parsed.min, parsed.max);
|
|
250
|
+
setSeedMin(parsed.min);
|
|
251
|
+
setSeedMax(parsed.max);
|
|
252
|
+
setSeedKey((k) => k + 1);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (filterType === "time") {
|
|
256
|
+
return (
|
|
257
|
+
<div onPasteCapture={handlePaste} className="flex gap-1 items-center">
|
|
258
|
+
<DateLikeInput
|
|
259
|
+
key={`min-${seedKey}`}
|
|
260
|
+
filterType="time"
|
|
261
|
+
value={seedMin}
|
|
262
|
+
onChange={(nextMin) => onRangeChange(nextMin, max)}
|
|
263
|
+
aria-label="min"
|
|
264
|
+
className={className}
|
|
265
|
+
/>
|
|
266
|
+
<MinusIcon className="h-5 w-5 text-muted-foreground" />
|
|
267
|
+
<DateLikeInput
|
|
268
|
+
key={`max-${seedKey}`}
|
|
269
|
+
filterType="time"
|
|
270
|
+
value={seedMax}
|
|
271
|
+
onChange={(nextMax) => onRangeChange(min, nextMax)}
|
|
272
|
+
aria-label="max"
|
|
273
|
+
className={className}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const handleChange = (next: { start: DateValue; end: DateValue } | null) => {
|
|
280
|
+
if (next === null) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
onRangeChange(
|
|
284
|
+
ariaToDate(filterType, next.start),
|
|
285
|
+
ariaToDate(filterType, next.end),
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const seedRange =
|
|
290
|
+
seedMin === undefined || seedMax === undefined
|
|
291
|
+
? undefined
|
|
292
|
+
: {
|
|
293
|
+
start: dateToAria(filterType, seedMin),
|
|
294
|
+
end: dateToAria(filterType, seedMax),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<div onPasteCapture={handlePaste} className="contents">
|
|
299
|
+
{filterType === "date" ? (
|
|
300
|
+
<DateRangePicker<CalendarDate>
|
|
301
|
+
key={seedKey}
|
|
302
|
+
aria-label="range"
|
|
303
|
+
defaultValue={
|
|
304
|
+
seedRange as { start: CalendarDate; end: CalendarDate } | undefined
|
|
305
|
+
}
|
|
306
|
+
onChange={handleChange}
|
|
307
|
+
className={className}
|
|
308
|
+
/>
|
|
309
|
+
) : (
|
|
310
|
+
<DateRangePicker<CalendarDateTime>
|
|
311
|
+
key={seedKey}
|
|
312
|
+
aria-label="range"
|
|
313
|
+
defaultValue={
|
|
314
|
+
seedRange as
|
|
315
|
+
| { start: CalendarDateTime; end: CalendarDateTime }
|
|
316
|
+
| undefined
|
|
317
|
+
}
|
|
318
|
+
granularity="second"
|
|
319
|
+
onChange={handleChange}
|
|
320
|
+
className={className}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
};
|
|
@@ -17,23 +17,46 @@ import {
|
|
|
17
17
|
SelectValue,
|
|
18
18
|
} from "../ui/select";
|
|
19
19
|
import { Button } from "../ui/button";
|
|
20
|
+
import { DateLikeInput, DateLikeRangeInput } from "./date-filter-inputs";
|
|
20
21
|
import { FilterByValuesPicker } from "./filter-by-values-picker";
|
|
21
22
|
import { RegexInput } from "./regex-input";
|
|
22
23
|
import {
|
|
23
24
|
type ColumnFilterValue,
|
|
25
|
+
DATETIME_OPS,
|
|
24
26
|
Filter,
|
|
27
|
+
isDatetimeComparisonOp,
|
|
28
|
+
isNumberComparisonOp,
|
|
29
|
+
isTextScalarOp,
|
|
25
30
|
MEMBERSHIP_OPS,
|
|
26
|
-
NUMBER_COMPARISON_OPS,
|
|
27
|
-
type NumberComparisonOp,
|
|
28
31
|
NUMBER_OPS,
|
|
29
32
|
TEXT_OPS,
|
|
30
|
-
TEXT_SCALAR_OPS,
|
|
31
|
-
type TextScalarOp,
|
|
32
33
|
} from "./filters";
|
|
33
34
|
import { OPERATOR_LABELS } from "./operator-labels";
|
|
34
35
|
import { Tooltip } from "../ui/tooltip";
|
|
35
36
|
|
|
36
|
-
type EditableFilterType =
|
|
37
|
+
type EditableFilterType =
|
|
38
|
+
| "number"
|
|
39
|
+
| "text"
|
|
40
|
+
| "boolean"
|
|
41
|
+
| "select"
|
|
42
|
+
| "date"
|
|
43
|
+
| "datetime"
|
|
44
|
+
| "time";
|
|
45
|
+
|
|
46
|
+
type DateLikeEditableFilterType = Extract<
|
|
47
|
+
EditableFilterType,
|
|
48
|
+
"date" | "datetime" | "time"
|
|
49
|
+
>;
|
|
50
|
+
|
|
51
|
+
const DATE_LIKE_TYPES: ReadonlySet<EditableFilterType> = new Set([
|
|
52
|
+
"date",
|
|
53
|
+
"datetime",
|
|
54
|
+
"time",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const isDateLikeType = (
|
|
58
|
+
type: EditableFilterType,
|
|
59
|
+
): type is DateLikeEditableFilterType => DATE_LIKE_TYPES.has(type);
|
|
37
60
|
|
|
38
61
|
const BOOLEAN_OPS = ["is_true", "is_false", "is_null", "is_not_null"] as const;
|
|
39
62
|
const SELECT_OPS = MEMBERSHIP_OPS;
|
|
@@ -46,6 +69,9 @@ const OPERATORS_BY_TYPE: Record<
|
|
|
46
69
|
text: TEXT_OPS,
|
|
47
70
|
boolean: BOOLEAN_OPS,
|
|
48
71
|
select: SELECT_OPS,
|
|
72
|
+
date: DATETIME_OPS,
|
|
73
|
+
datetime: DATETIME_OPS,
|
|
74
|
+
time: DATETIME_OPS,
|
|
49
75
|
};
|
|
50
76
|
|
|
51
77
|
const DEFAULT_OPERATOR: Record<EditableFilterType, OperatorType> = {
|
|
@@ -53,6 +79,9 @@ const DEFAULT_OPERATOR: Record<EditableFilterType, OperatorType> = {
|
|
|
53
79
|
text: "contains",
|
|
54
80
|
boolean: "is_true",
|
|
55
81
|
select: "in",
|
|
82
|
+
date: "between",
|
|
83
|
+
datetime: "between",
|
|
84
|
+
time: "between",
|
|
56
85
|
};
|
|
57
86
|
|
|
58
87
|
const OPERATORS_WITHOUT_VALUE = new Set<OperatorType>([
|
|
@@ -63,22 +92,14 @@ const OPERATORS_WITHOUT_VALUE = new Set<OperatorType>([
|
|
|
63
92
|
"is_empty",
|
|
64
93
|
]);
|
|
65
94
|
|
|
66
|
-
const NUMBER_COMPARISON_SET: ReadonlySet<OperatorType> = new Set(
|
|
67
|
-
NUMBER_COMPARISON_OPS,
|
|
68
|
-
);
|
|
69
|
-
const TEXT_SCALAR_SET: ReadonlySet<OperatorType> = new Set(TEXT_SCALAR_OPS);
|
|
70
|
-
|
|
71
|
-
const isNumberComparisonOp = (op: OperatorType): op is NumberComparisonOp =>
|
|
72
|
-
NUMBER_COMPARISON_SET.has(op);
|
|
73
|
-
const isTextScalarOp = (op: OperatorType): op is TextScalarOp =>
|
|
74
|
-
TEXT_SCALAR_SET.has(op);
|
|
75
|
-
|
|
76
95
|
type DraftValue =
|
|
77
96
|
| { kind: "between"; min?: number; max?: number }
|
|
78
97
|
| { kind: "single-number"; value?: number }
|
|
79
98
|
| { kind: "single-text"; text?: string }
|
|
80
99
|
| { kind: "multi-text"; values?: string[] }
|
|
81
100
|
| { kind: "options"; options?: unknown[] }
|
|
101
|
+
| { kind: "date-between"; min?: Date; max?: Date }
|
|
102
|
+
| { kind: "date-single"; value?: Date }
|
|
82
103
|
| { kind: "none" };
|
|
83
104
|
|
|
84
105
|
interface Snapshot {
|
|
@@ -116,7 +137,13 @@ export const FilterPillEditor = <TData,>({
|
|
|
116
137
|
const editableColumns = table.getAllColumns().filter((c) => {
|
|
117
138
|
const ft = c.columnDef.meta?.filterType;
|
|
118
139
|
return (
|
|
119
|
-
ft === "number" ||
|
|
140
|
+
ft === "number" ||
|
|
141
|
+
ft === "text" ||
|
|
142
|
+
ft === "boolean" ||
|
|
143
|
+
ft === "select" ||
|
|
144
|
+
ft === "date" ||
|
|
145
|
+
ft === "datetime" ||
|
|
146
|
+
ft === "time"
|
|
120
147
|
);
|
|
121
148
|
});
|
|
122
149
|
|
|
@@ -200,7 +227,7 @@ export const FilterPillEditor = <TData,>({
|
|
|
200
227
|
const operatorTriggerRef = useRef<HTMLButtonElement>(null);
|
|
201
228
|
useEffect(() => {
|
|
202
229
|
const firstInput = valueSlotRef.current?.querySelector<HTMLElement>(
|
|
203
|
-
'input, [role="combobox"], button',
|
|
230
|
+
'input, [role="spinbutton"], [role="combobox"], button',
|
|
204
231
|
);
|
|
205
232
|
if (firstInput) {
|
|
206
233
|
firstInput.focus();
|
|
@@ -216,6 +243,11 @@ export const FilterPillEditor = <TData,>({
|
|
|
216
243
|
e.preventDefault();
|
|
217
244
|
handleApply();
|
|
218
245
|
}}
|
|
246
|
+
onKeyDownCapture={(e) => {
|
|
247
|
+
if (e.key === "Tab") {
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
}
|
|
250
|
+
}}
|
|
219
251
|
>
|
|
220
252
|
<div className="flex flex-col gap-1">
|
|
221
253
|
<label className="text-xs text-muted-foreground" htmlFor={columnId}>
|
|
@@ -426,6 +458,36 @@ const ValueSlot = <TData, TValue>({
|
|
|
426
458
|
/>
|
|
427
459
|
);
|
|
428
460
|
}
|
|
461
|
+
if (isDateLikeType(type) && operator === "between") {
|
|
462
|
+
const v =
|
|
463
|
+
value.kind === "date-between" ? value : { kind: "date-between" as const };
|
|
464
|
+
return (
|
|
465
|
+
<DateLikeRangeInput
|
|
466
|
+
key={`${column?.id ?? "_"}-${operator}`}
|
|
467
|
+
filterType={type}
|
|
468
|
+
min={v.min}
|
|
469
|
+
max={v.max}
|
|
470
|
+
onRangeChange={(min, max) =>
|
|
471
|
+
onChange({ kind: "date-between", min, max })
|
|
472
|
+
}
|
|
473
|
+
className="border-input"
|
|
474
|
+
/>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (isDateLikeType(type) && isDatetimeComparisonOp(operator)) {
|
|
478
|
+
const v =
|
|
479
|
+
value.kind === "date-single" ? value : { kind: "date-single" as const };
|
|
480
|
+
return (
|
|
481
|
+
<DateLikeInput
|
|
482
|
+
key={`${column?.id ?? "_"}-${operator}`}
|
|
483
|
+
filterType={type}
|
|
484
|
+
value={v.value}
|
|
485
|
+
onChange={(next) => onChange({ kind: "date-single", value: next })}
|
|
486
|
+
aria-label="value"
|
|
487
|
+
className="border-input"
|
|
488
|
+
/>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
429
491
|
if (type === "select" && column) {
|
|
430
492
|
const v = value.kind === "options" ? value : { kind: "options" as const };
|
|
431
493
|
return (
|
|
@@ -443,19 +505,18 @@ const ValueSlot = <TData, TValue>({
|
|
|
443
505
|
};
|
|
444
506
|
|
|
445
507
|
function getEditableType(value: ColumnFilterValue): EditableFilterType {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
508
|
+
switch (value.type) {
|
|
509
|
+
case "number":
|
|
510
|
+
case "text":
|
|
511
|
+
case "boolean":
|
|
512
|
+
case "select":
|
|
513
|
+
case "date":
|
|
514
|
+
case "datetime":
|
|
515
|
+
case "time":
|
|
516
|
+
return value.type;
|
|
517
|
+
default:
|
|
518
|
+
return "text";
|
|
457
519
|
}
|
|
458
|
-
return "text";
|
|
459
520
|
}
|
|
460
521
|
|
|
461
522
|
function toDraftValue(value: ColumnFilterValue): DraftValue {
|
|
@@ -486,6 +547,21 @@ function toDraftValue(value: ColumnFilterValue): DraftValue {
|
|
|
486
547
|
if (value.type === "select") {
|
|
487
548
|
return { kind: "options", options: [...value.options] };
|
|
488
549
|
}
|
|
550
|
+
if (
|
|
551
|
+
value.type === "date" ||
|
|
552
|
+
value.type === "datetime" ||
|
|
553
|
+
value.type === "time"
|
|
554
|
+
) {
|
|
555
|
+
switch (value.operator) {
|
|
556
|
+
case "between":
|
|
557
|
+
return { kind: "date-between", min: value.min, max: value.max };
|
|
558
|
+
case "is_null":
|
|
559
|
+
case "is_not_null":
|
|
560
|
+
return { kind: "none" };
|
|
561
|
+
default:
|
|
562
|
+
return { kind: "date-single", value: value.value };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
489
565
|
return { kind: "none" };
|
|
490
566
|
}
|
|
491
567
|
|
|
@@ -509,6 +585,11 @@ function emptyDraftFor(
|
|
|
509
585
|
if (type === "select") {
|
|
510
586
|
return { kind: "options", options: [] };
|
|
511
587
|
}
|
|
588
|
+
if (isDateLikeType(type)) {
|
|
589
|
+
return operator === "between"
|
|
590
|
+
? { kind: "date-between" }
|
|
591
|
+
: { kind: "date-single" };
|
|
592
|
+
}
|
|
512
593
|
return { kind: "none" };
|
|
513
594
|
}
|
|
514
595
|
|
|
@@ -516,7 +597,7 @@ function getMissingValueMessage(
|
|
|
516
597
|
type: EditableFilterType,
|
|
517
598
|
operator: OperatorType,
|
|
518
599
|
): string {
|
|
519
|
-
if (
|
|
600
|
+
if (operator === "between") {
|
|
520
601
|
return "Min and max are required";
|
|
521
602
|
}
|
|
522
603
|
if (type === "text" && (operator === "in" || operator === "not_in")) {
|
|
@@ -611,5 +692,33 @@ function buildFilterValue({
|
|
|
611
692
|
operator: operator === "not_in" ? "not_in" : "in",
|
|
612
693
|
});
|
|
613
694
|
}
|
|
695
|
+
if (isDateLikeType(type)) {
|
|
696
|
+
const factory =
|
|
697
|
+
type === "date"
|
|
698
|
+
? Filter.date
|
|
699
|
+
: type === "datetime"
|
|
700
|
+
? Filter.datetime
|
|
701
|
+
: Filter.time;
|
|
702
|
+
if (operator === "is_null" || operator === "is_not_null") {
|
|
703
|
+
return factory({ operator });
|
|
704
|
+
}
|
|
705
|
+
if (operator === "between") {
|
|
706
|
+
if (
|
|
707
|
+
draft.kind !== "date-between" ||
|
|
708
|
+
draft.min === undefined ||
|
|
709
|
+
draft.max === undefined
|
|
710
|
+
) {
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
return factory({ operator: "between", min: draft.min, max: draft.max });
|
|
714
|
+
}
|
|
715
|
+
if (!isDatetimeComparisonOp(operator)) {
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
if (draft.kind !== "date-single" || draft.value === undefined) {
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
return factory({ operator, value: draft.value });
|
|
722
|
+
}
|
|
614
723
|
return undefined;
|
|
615
724
|
}
|