@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.
@@ -8,7 +8,7 @@ import { t as require_react } from "./react-DA-nE2FX.js";
8
8
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
9
  import "./html-to-image-CpggM7u1.js";
10
10
  import "./chunk-5FQGJX7Z-BOg95xG5.js";
11
- import { Ft as Code, Mt as Expand, a as DEFAULT_SLIDE_TYPE, c as Slide, i as DEFAULT_DECK_TRANSITION, jt as EyeOff, s as SlideSidebar, t as useNotebookCodeAvailable } from "./code-visibility-nPfbiA_L.js";
11
+ import { Ft as EyeOff, It as Expand, a as DEFAULT_SLIDE_TYPE, c as Slide, i as DEFAULT_DECK_TRANSITION, s as SlideSidebar, t as useNotebookCodeAvailable, zt as Code } from "./code-visibility-PjV7HUDZ.js";
12
12
  import "./input-D4kjoQUB.js";
13
13
  import "./toDate-CIpC_34u.js";
14
14
  import "./react-dom-BWRJ_g_k.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.7-dev56",
3
+ "version": "0.23.7-dev57",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -2,7 +2,11 @@
2
2
  import type { Column } from "@tanstack/react-table";
3
3
  import { fireEvent, render, screen, within } from "@testing-library/react";
4
4
  import { beforeAll, describe, expect, it, vi } from "vitest";
5
- import { NumberFilterMenu, TextFilterMenu } from "../column-header";
5
+ import {
6
+ DateFilterMenu,
7
+ NumberFilterMenu,
8
+ TextFilterMenu,
9
+ } from "../column-header";
6
10
  import { Filter } from "../filters";
7
11
 
8
12
  beforeAll(() => {
@@ -201,3 +205,104 @@ describe("TextFilterMenu", () => {
201
205
  expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled();
202
206
  });
203
207
  });
208
+
209
+ type DateFilterValue = ReturnType<typeof Filter.date>;
210
+
211
+ function mockDateColumn(
212
+ filterType: "date" | "datetime" | "time" = "date",
213
+ initial?: DateFilterValue,
214
+ ): Column<unknown, unknown> & {
215
+ setFilterValue: ReturnType<typeof vi.fn>;
216
+ } {
217
+ let filterValue = initial;
218
+ const setFilterValue = vi.fn((next) => {
219
+ filterValue = next;
220
+ });
221
+ return {
222
+ id: "when",
223
+ columnDef: { meta: { dataType: filterType, filterType } },
224
+ getFilterValue: () => filterValue,
225
+ setFilterValue,
226
+ } as unknown as Column<unknown, unknown> & {
227
+ setFilterValue: ReturnType<typeof vi.fn>;
228
+ };
229
+ }
230
+
231
+ describe("DateFilterMenu", () => {
232
+ it("shows all expected operators in the dropdown", () => {
233
+ const column = mockDateColumn("date");
234
+ render(<DateFilterMenu column={column} filterType="date" />);
235
+ fireEvent.click(screen.getByRole("combobox"));
236
+ const listbox = screen.getByRole("listbox");
237
+ const labels = within(listbox)
238
+ .getAllByRole("option")
239
+ .map((o) => o.textContent);
240
+ expect(labels).toEqual([
241
+ "Between",
242
+ "Equals",
243
+ "Doesn't equal",
244
+ "Greater than",
245
+ "Greater than or equal",
246
+ "Less than",
247
+ "Less than or equal",
248
+ "Is null",
249
+ "Is not null",
250
+ ]);
251
+ });
252
+
253
+ it("defaults to between mode and disables Apply until both bounds set", () => {
254
+ const column = mockDateColumn("date");
255
+ render(<DateFilterMenu column={column} filterType="date" />);
256
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
257
+ expect(screen.getByRole("button", { name: /apply/i })).toBeDisabled();
258
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
259
+ });
260
+
261
+ it("seeds between min/max from current filter", () => {
262
+ const column = mockDateColumn(
263
+ "date",
264
+ Filter.date({
265
+ operator: "between",
266
+ min: new Date("2024-01-01T00:00:00Z"),
267
+ max: new Date("2024-06-01T00:00:00Z"),
268
+ }),
269
+ );
270
+ render(<DateFilterMenu column={column} filterType="date" />);
271
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
272
+ expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled();
273
+ });
274
+
275
+ it("comparison operator swaps range for a single value picker", () => {
276
+ const column = mockDateColumn(
277
+ "date",
278
+ Filter.date({
279
+ operator: ">",
280
+ value: new Date("2024-01-01T00:00:00Z"),
281
+ }),
282
+ );
283
+ render(<DateFilterMenu column={column} filterType="date" />);
284
+ expect(screen.getByLabelText("value")).toBeInTheDocument();
285
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
286
+ });
287
+
288
+ it("time filter type renders two TimeFields for between", () => {
289
+ const column = mockDateColumn("time");
290
+ render(<DateFilterMenu column={column} filterType="time" />);
291
+ expect(screen.getByLabelText("min")).toBeInTheDocument();
292
+ expect(screen.getByLabelText("max")).toBeInTheDocument();
293
+ });
294
+
295
+ it("selecting a nullish operator hides value inputs and commits on Apply", () => {
296
+ const column = mockDateColumn("date");
297
+ render(<DateFilterMenu column={column} filterType="date" />);
298
+ fireEvent.click(screen.getByRole("combobox"));
299
+ const listbox = screen.getByRole("listbox");
300
+ fireEvent.click(within(listbox).getByText("Is null"));
301
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
302
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
303
+ fireEvent.click(screen.getByRole("button", { name: /apply/i }));
304
+ expect(column.setFilterValue).toHaveBeenCalledWith(
305
+ Filter.date({ operator: "is_null" }),
306
+ );
307
+ });
308
+ });
@@ -26,7 +26,14 @@ beforeAll(() => {
26
26
 
27
27
  function makeColumn(
28
28
  id: string,
29
- filterType: "text" | "number" | "boolean" | "select",
29
+ filterType:
30
+ | "text"
31
+ | "number"
32
+ | "boolean"
33
+ | "select"
34
+ | "date"
35
+ | "datetime"
36
+ | "time",
30
37
  ): Column<unknown, unknown> {
31
38
  return {
32
39
  id,
@@ -35,7 +42,13 @@ function makeColumn(
35
42
  }
36
43
 
37
44
  function mockTable(): Table<unknown> {
38
- const columns = [makeColumn("name", "text"), makeColumn("age", "number")];
45
+ const columns = [
46
+ makeColumn("name", "text"),
47
+ makeColumn("age", "number"),
48
+ makeColumn("when", "date"),
49
+ makeColumn("at", "datetime"),
50
+ makeColumn("clock", "time"),
51
+ ];
39
52
  return {
40
53
  getAllColumns: () => columns,
41
54
  getColumn: (id: string) => columns.find((c) => c.id === id),
@@ -144,6 +157,79 @@ describe("FilterPillEditor — snapshot rehydration", () => {
144
157
  });
145
158
  });
146
159
 
160
+ describe("FilterPillEditor — date/datetime/time", () => {
161
+ it("rehydrates a date between snapshot with the range picker", () => {
162
+ renderWithProviders(
163
+ <FilterPillEditor
164
+ snapshot={{
165
+ columnId: "when",
166
+ value: Filter.date({
167
+ operator: "between",
168
+ min: new Date("2024-01-01T00:00:00Z"),
169
+ max: new Date("2024-06-01T00:00:00Z"),
170
+ }),
171
+ }}
172
+ table={mockTable()}
173
+ onClose={vi.fn()}
174
+ />,
175
+ );
176
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
177
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
178
+ });
179
+
180
+ it("rehydrates a datetime <= snapshot with a single value picker", () => {
181
+ renderWithProviders(
182
+ <FilterPillEditor
183
+ snapshot={{
184
+ columnId: "at",
185
+ value: Filter.datetime({
186
+ operator: "<=",
187
+ value: new Date("2024-06-01T12:00:00Z"),
188
+ }),
189
+ }}
190
+ table={mockTable()}
191
+ onClose={vi.fn()}
192
+ />,
193
+ );
194
+ expect(screen.getByLabelText("value")).toBeInTheDocument();
195
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
196
+ });
197
+
198
+ it("renders min/max TimeFields for time between", () => {
199
+ renderWithProviders(
200
+ <FilterPillEditor
201
+ snapshot={{
202
+ columnId: "clock",
203
+ value: Filter.time({
204
+ operator: "between",
205
+ min: new Date("2024-01-01T08:00:00Z"),
206
+ max: new Date("2024-01-01T17:00:00Z"),
207
+ }),
208
+ }}
209
+ table={mockTable()}
210
+ onClose={vi.fn()}
211
+ />,
212
+ );
213
+ expect(screen.getByLabelText("min")).toBeInTheDocument();
214
+ expect(screen.getByLabelText("max")).toBeInTheDocument();
215
+ });
216
+
217
+ it("hides the value slot for date is_null", () => {
218
+ renderWithProviders(
219
+ <FilterPillEditor
220
+ snapshot={{
221
+ columnId: "when",
222
+ value: Filter.date({ operator: "is_null" }),
223
+ }}
224
+ table={mockTable()}
225
+ onClose={vi.fn()}
226
+ />,
227
+ );
228
+ expect(screen.queryByText("Value")).not.toBeInTheDocument();
229
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
230
+ });
231
+ });
232
+
147
233
  describe("FilterPillEditor — apply", () => {
148
234
  it("commits a number > filter via setColumnFilters", () => {
149
235
  const table = mockTable();
@@ -215,22 +215,93 @@ describe("filterToFilterCondition", () => {
215
215
  ]);
216
216
  });
217
217
 
218
- it("handles date filter with min and max", () => {
219
- const min = new Date("2024-01-01");
220
- const max = new Date("2024-12-31");
218
+ it("handles date between filter", () => {
219
+ const min = new Date(2024, 0, 1);
220
+ const max = new Date(2024, 11, 31);
221
221
  const result = filterToFilterCondition(
222
222
  "created",
223
- Filter.date({ min, max }),
223
+ Filter.date({ operator: "between", min, max }),
224
224
  );
225
- expect(result).toHaveLength(2);
226
- expect(result[0]).toMatchObject({
227
- operator: ">=",
228
- value: min.toISOString(),
229
- });
230
- expect(result[1]).toMatchObject({
231
- operator: "<=",
232
- value: max.toISOString(),
233
- });
225
+ expect(result).toEqual([
226
+ {
227
+ column_id: "created",
228
+ operator: "between",
229
+ value: { min: "2024-01-01", max: "2024-12-31" },
230
+ type: "condition",
231
+ negate: false,
232
+ },
233
+ ]);
234
+ });
235
+
236
+ it("handles date comparison filter", () => {
237
+ const value = new Date(2024, 5, 15);
238
+ const result = filterToFilterCondition(
239
+ "created",
240
+ Filter.date({ operator: ">=", value }),
241
+ );
242
+ expect(result).toEqual([
243
+ {
244
+ column_id: "created",
245
+ operator: ">=",
246
+ value: "2024-06-15",
247
+ type: "condition",
248
+ negate: false,
249
+ },
250
+ ]);
251
+ });
252
+
253
+ it("handles datetime between filter as local ISO string without TZ", () => {
254
+ const min = new Date(2024, 0, 1, 0, 0, 0);
255
+ const max = new Date(2024, 11, 31, 23, 59, 59);
256
+ const result = filterToFilterCondition(
257
+ "created",
258
+ Filter.datetime({ operator: "between", min, max }),
259
+ );
260
+ expect(result).toEqual([
261
+ {
262
+ column_id: "created",
263
+ operator: "between",
264
+ value: {
265
+ min: "2024-01-01T00:00:00",
266
+ max: "2024-12-31T23:59:59",
267
+ },
268
+ type: "condition",
269
+ negate: false,
270
+ },
271
+ ]);
272
+ });
273
+
274
+ it("handles time between filter as HH:MM:SS", () => {
275
+ const min = new Date(2024, 0, 1, 9, 30, 0);
276
+ const max = new Date(2024, 0, 1, 17, 45, 15);
277
+ const result = filterToFilterCondition(
278
+ "start",
279
+ Filter.time({ operator: "between", min, max }),
280
+ );
281
+ expect(result).toEqual([
282
+ {
283
+ column_id: "start",
284
+ operator: "between",
285
+ value: { min: "09:30:00", max: "17:45:15" },
286
+ type: "condition",
287
+ negate: false,
288
+ },
289
+ ]);
290
+ });
291
+
292
+ it("handles date is_null filter", () => {
293
+ const result = filterToFilterCondition(
294
+ "created",
295
+ Filter.date({ operator: "is_null" }),
296
+ );
297
+ expect(result).toEqual([
298
+ {
299
+ column_id: "created",
300
+ operator: "is_null",
301
+ type: "condition",
302
+ negate: false,
303
+ },
304
+ ]);
234
305
  });
235
306
 
236
307
  it("every condition has type and negate fields", () => {
@@ -44,14 +44,19 @@ import { OPERATOR_LABELS } from "./operator-labels";
44
44
  import {
45
45
  type ColumnFilterForType,
46
46
  type ColumnFilterValue,
47
+ DATETIME_OPS,
47
48
  Filter,
48
- NUMBER_COMPARISON_OPS,
49
- type NumberComparisonOp,
49
+ isDatetimeComparisonOp,
50
+ isNumberComparisonOp,
51
+ isTextScalarOp,
50
52
  NUMBER_OPS,
51
53
  TEXT_OPS,
52
- TEXT_SCALAR_OPS,
53
- type TextScalarOp,
54
54
  } from "./filters";
55
+ import {
56
+ type DateLikeFilterType,
57
+ DateLikeInput,
58
+ DateLikeRangeInput,
59
+ } from "./date-filter-inputs";
55
60
  import {
56
61
  ClearFilterMenuItem,
57
62
  FilterButtons,
@@ -283,19 +288,21 @@ export function renderMenuItemFilter<TData, TValue>(
283
288
  return null;
284
289
  }
285
290
 
286
- if (filterType === "time") {
287
- // Not implemented
288
- return null;
289
- }
290
-
291
- if (filterType === "datetime") {
292
- // Not implemented
293
- return null;
294
- }
295
-
296
- if (filterType === "date") {
297
- // Not implemented
298
- return null;
291
+ if (
292
+ filterType === "date" ||
293
+ filterType === "datetime" ||
294
+ filterType === "time"
295
+ ) {
296
+ return (
297
+ <DropdownMenuSub>
298
+ {filterMenuItem}
299
+ <DropdownMenuPortal>
300
+ <DropdownMenuSubContent>
301
+ <DateFilterMenu column={column} filterType={filterType} />
302
+ </DropdownMenuSubContent>
303
+ </DropdownMenuPortal>
304
+ </DropdownMenuSub>
305
+ );
299
306
  }
300
307
 
301
308
  logNever(filterType);
@@ -369,12 +376,6 @@ const BooleanFilter = <TData, TValue>({
369
376
  );
370
377
  };
371
378
 
372
- const NUMBER_COMPARISON_SET: ReadonlySet<OperatorType> = new Set(
373
- NUMBER_COMPARISON_OPS,
374
- );
375
- const isNumberComparisonOp = (op: OperatorType): op is NumberComparisonOp =>
376
- NUMBER_COMPARISON_SET.has(op);
377
-
378
379
  type NumberComparisonFilter = Extract<
379
380
  ColumnFilterForType<"number">,
380
381
  { value: number }
@@ -485,9 +486,134 @@ export const NumberFilterMenu = <TData, TValue>({
485
486
  );
486
487
  };
487
488
 
488
- const TEXT_SCALAR_SET: ReadonlySet<OperatorType> = new Set(TEXT_SCALAR_OPS);
489
- const isTextScalarOp = (op: OperatorType): op is TextScalarOp =>
490
- TEXT_SCALAR_SET.has(op);
489
+ type DateComparisonFilter = Extract<
490
+ ColumnFilterForType<DateLikeFilterType>,
491
+ { value: Date }
492
+ >;
493
+ const isDateComparisonFilter = (
494
+ filter: ColumnFilterForType<DateLikeFilterType>,
495
+ ): filter is DateComparisonFilter => isDatetimeComparisonOp(filter.operator);
496
+
497
+ export const DateFilterMenu = <TData, TValue>({
498
+ column,
499
+ filterType,
500
+ }: {
501
+ column: Column<TData, TValue>;
502
+ filterType: DateLikeFilterType;
503
+ }) => {
504
+ const currentFilter = column.getFilterValue() as
505
+ | ColumnFilterForType<DateLikeFilterType>
506
+ | undefined;
507
+ const hasFilter = currentFilter !== undefined;
508
+
509
+ const [operator, setOperator] = useState<OperatorType>(
510
+ currentFilter?.operator ?? "between",
511
+ );
512
+ const [min, setMin] = useState<Date | undefined>(
513
+ currentFilter?.operator === "between" ? currentFilter.min : undefined,
514
+ );
515
+ const [max, setMax] = useState<Date | undefined>(
516
+ currentFilter?.operator === "between" ? currentFilter.max : undefined,
517
+ );
518
+ const [value, setValue] = useState<Date | undefined>(
519
+ currentFilter !== undefined && isDateComparisonFilter(currentFilter)
520
+ ? currentFilter.value
521
+ : undefined,
522
+ );
523
+
524
+ const isComparison = isDatetimeComparisonOp(operator);
525
+ const isNullish = operator === "is_null" || operator === "is_not_null";
526
+
527
+ const applyDisabled =
528
+ (operator === "between" && (min === undefined || max === undefined)) ||
529
+ (isComparison && value === undefined);
530
+
531
+ const buildFilter = (
532
+ opts: Parameters<typeof Filter.date>[0],
533
+ ): ColumnFilterForType<DateLikeFilterType> => {
534
+ switch (filterType) {
535
+ case "date":
536
+ return Filter.date(opts);
537
+ case "datetime":
538
+ return Filter.datetime(opts);
539
+ case "time":
540
+ return Filter.time(opts);
541
+ }
542
+ };
543
+
544
+ const handleApply = () => {
545
+ if (isNullish) {
546
+ column.setFilterValue(buildFilter({ operator }));
547
+ return;
548
+ }
549
+ if (operator === "between" && min !== undefined && max !== undefined) {
550
+ column.setFilterValue(buildFilter({ operator: "between", min, max }));
551
+ return;
552
+ }
553
+ if (isComparison && value !== undefined) {
554
+ column.setFilterValue(buildFilter({ operator, value }));
555
+ }
556
+ };
557
+
558
+ const [resetKey, setResetKey] = useState(0);
559
+ const handleClear = () => {
560
+ setMin(undefined);
561
+ setMax(undefined);
562
+ setValue(undefined);
563
+ setResetKey((k) => k + 1);
564
+ column.setFilterValue(undefined);
565
+ };
566
+
567
+ const handleOperatorChange = (next: OperatorType) => {
568
+ setOperator(next);
569
+ };
570
+
571
+ return (
572
+ <div
573
+ className="flex flex-col gap-1 pt-3 px-2"
574
+ onKeyDownCapture={(e) => {
575
+ if (e.key === "Tab") {
576
+ e.stopPropagation();
577
+ }
578
+ }}
579
+ >
580
+ <OperatorSelect
581
+ operator={operator}
582
+ options={DATETIME_OPS}
583
+ onChange={handleOperatorChange}
584
+ />
585
+ {operator === "between" && (
586
+ <DateLikeRangeInput
587
+ key={`${filterType}-${resetKey}`}
588
+ filterType={filterType}
589
+ min={min}
590
+ max={max}
591
+ onRangeChange={(nextMin, nextMax) => {
592
+ setMin(nextMin);
593
+ setMax(nextMax);
594
+ }}
595
+ className="shadow-none! border-border hover:shadow-none!"
596
+ />
597
+ )}
598
+ {isComparison && (
599
+ <DateLikeInput
600
+ key={`${filterType}-${resetKey}`}
601
+ filterType={filterType}
602
+ value={value}
603
+ onChange={setValue}
604
+ aria-label="value"
605
+ className="shadow-none! border-border hover:shadow-none!"
606
+ />
607
+ )}
608
+ <FilterButtons
609
+ onApply={handleApply}
610
+ onClear={handleClear}
611
+ clearButtonDisabled={!hasFilter}
612
+ applyButtonDisabled={applyDisabled}
613
+ />
614
+ </div>
615
+ );
616
+ };
491
617
 
492
618
  export const TextFilterMenu = <TData, TValue>({
493
619
  column,