@redsift/ds-mcp-server 12.4.0 → 12.5.0-muiv6
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/data/demos/patterns/_shared/StateDebugPanel.tsx +153 -0
- package/data/demos/patterns/_shared/columns.tsx +3 -3
- package/data/demos/patterns/_shared/defaults.ts +1 -1
- package/data/demos/patterns/_shared/filter-helpers.ts +1 -1
- package/data/demos/patterns/_shared/helpers.tsx +1 -1
- package/data/demos/patterns/_shared/server-logic.ts +1 -1
- package/data/demos/patterns/_shared/story-helpers.ts +560 -10
- package/data/demos/patterns/_shared/use-router-adapter.ts +51 -0
- package/data/demos/patterns/crossfiltered-datagrid-server-side/example.tsx +11 -11
- package/data/demos/patterns/drilldowned-datagrid-client-side/example.tsx +1 -1
- package/data/demos/patterns/drilldowned-datagrid-server-side/example.tsx +1 -1
- package/data/demos/patterns/single-datagrid-client-side/SingleDatagridClientSide.interaction.stories.tsx +380 -167
- package/data/demos/patterns/single-datagrid-client-side/example.tsx +5 -5
- package/data/demos/patterns/single-datagrid-client-side/with-empty-state.tsx +1 -1
- package/data/demos/patterns/single-datagrid-client-side/with-error.tsx +1 -1
- package/data/demos/patterns/single-datagrid-client-side/with-loading.tsx +1 -1
- package/data/demos/patterns/single-datagrid-server-side/SingleDatagridServerSide.interaction.stories.tsx +424 -52
- package/data/demos/patterns/single-datagrid-server-side/example.tsx +5 -4
- package/data/demos/patterns/stateful-single-datagrid-client-side/StatefulSingleDatagridClientSide.interaction.stories.tsx +671 -0
- package/data/demos/patterns/stateful-single-datagrid-client-side/example.tsx +55 -0
- package/data/demos/patterns/stateful-single-datagrid-client-side/with-empty-state.tsx +27 -0
- package/data/demos/patterns/stateful-single-datagrid-client-side/with-error.tsx +39 -0
- package/data/demos/patterns/stateful-single-datagrid-client-side/with-loading.tsx +25 -0
- package/data/demos/patterns/stateful-single-datagrid-server-side/StatefulSingleDatagridServerSide.interaction.stories.tsx +690 -0
- package/data/demos/patterns/stateful-single-datagrid-server-side/example.tsx +108 -0
- package/data/demos/patterns/stateful-single-datagrid-server-side/with-empty-state.tsx +31 -0
- package/data/demos/patterns/stateful-single-datagrid-server-side/with-error.tsx +43 -0
- package/data/demos/patterns/stateful-single-datagrid-server-side/with-loading.tsx +29 -0
- package/data/demos/patterns/summary-dashboard/SummaryDashboard.interaction.stories.tsx +42 -22
- package/data/demos/patterns/summary-dashboard/example.tsx +66 -28
- package/data/demos/patterns/summary-dashboard/with-loading.tsx +12 -3
- package/data/demos/patterns/tabbed-datagrid-server-side/example.tsx +1 -1
- package/data/docs/components/dashboard/Dashboard.json +2 -2
- package/data/docs/components/table/DataGrid.json +7 -7
- package/data/docs/components/table/GridToolbarFilterSemanticField.json +1 -1
- package/data/docs/components/table/StatefulDataGrid.json +7 -7
- package/data/docs/components/table/Toolbar.json +2 -8
- package/data/docs/components.json +21 -27
- package/data/docs/llms-full.txt +805 -56
- package/data/docs/llms.txt +18 -4
- package/data/docs/patterns-catalog.md +25 -24
- package/data/docs/patterns.json +82 -8
- package/data/metadata.json +2 -2
- package/data/patterns/crossfiltered-datagrid-server-side.mdx +1 -1
- package/data/patterns/drilldowned-datagrid-client-side.mdx +1 -1
- package/data/patterns/drilldowned-datagrid-server-side.mdx +1 -1
- package/data/patterns/single-datagrid-client-side.mdx +7 -7
- package/data/patterns/single-datagrid-server-side.mdx +6 -6
- package/data/patterns/stateful-single-datagrid-client-side.mdx +304 -0
- package/data/patterns/stateful-single-datagrid-server-side.mdx +347 -0
- package/data/patterns/summary-dashboard.mdx +47 -7
- package/data/patterns/tabbed-datagrid-client-side.mdx +72 -2
- package/data/patterns/tabbed-datagrid-server-side.mdx +105 -2
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { within, waitFor, userEvent, fireEvent } from '@storybook/testing-library';
|
|
2
3
|
import { expect } from '@storybook/jest';
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
@@ -284,12 +285,15 @@ export const getRetryButton = (canvas: ReturnType<typeof within>): HTMLElement =
|
|
|
284
285
|
|
|
285
286
|
/** Assert the bulk action bar is visible and shows the expected count. */
|
|
286
287
|
export const assertBulkActionBarVisible = async (canvasElement: HTMLElement, expectedCount: number) => {
|
|
287
|
-
await waitFor(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
288
|
+
await waitFor(
|
|
289
|
+
() => {
|
|
290
|
+
const pills = Array.from(canvasElement.querySelectorAll('.redsift-pill'));
|
|
291
|
+
const selectionPill = pills.find((p) => p.textContent?.includes('selected'));
|
|
292
|
+
expect(selectionPill).toBeTruthy();
|
|
293
|
+
expect(selectionPill!.textContent).toContain(`${expectedCount} selected`);
|
|
294
|
+
},
|
|
295
|
+
{ timeout: 5000 }
|
|
296
|
+
);
|
|
293
297
|
};
|
|
294
298
|
|
|
295
299
|
/** Assert the bulk action bar is hidden (no selection pill visible). */
|
|
@@ -307,9 +311,23 @@ export const assertBulkActionBarHidden = async (canvasElement: HTMLElement) => {
|
|
|
307
311
|
|
|
308
312
|
/** Click the header checkbox to select/deselect all visible rows. */
|
|
309
313
|
export const clickHeaderCheckbox = async (canvasElement: HTMLElement) => {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
await
|
|
314
|
+
// Wait for the grid to be stable: rows present, no loading, checkbox exists.
|
|
315
|
+
// This prevents clicking during a mid-render where the grid ignores the click.
|
|
316
|
+
await waitFor(
|
|
317
|
+
() => {
|
|
318
|
+
const rows = canvasElement.querySelectorAll('.MuiDataGrid-row');
|
|
319
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
320
|
+
const headerCheckbox = canvasElement.querySelector('.MuiDataGrid-columnHeaderCheckbox input[type="checkbox"]');
|
|
321
|
+
expect(headerCheckbox).toBeTruthy();
|
|
322
|
+
},
|
|
323
|
+
{ timeout: 3000 }
|
|
324
|
+
);
|
|
325
|
+
// Small yield to let React finish any pending microtask re-renders
|
|
326
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
327
|
+
const headerCheckbox = canvasElement.querySelector('.MuiDataGrid-columnHeaderCheckbox input[type="checkbox"]')!;
|
|
328
|
+
fireEvent.click(headerCheckbox);
|
|
329
|
+
// Wait for React and MUI DataGrid to process the selection change
|
|
330
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
313
331
|
};
|
|
314
332
|
|
|
315
333
|
/** Click a row checkbox by row index (0-based, within the visible page). */
|
|
@@ -322,6 +340,91 @@ export const clickRowCheckbox = async (canvasElement: HTMLElement, rowIndex: num
|
|
|
322
340
|
const checkbox = rows[rowIndex].querySelector('input[type="checkbox"]');
|
|
323
341
|
if (!checkbox) throw new Error(`Could not find checkbox in row ${rowIndex}`);
|
|
324
342
|
await userEvent.click(checkbox);
|
|
343
|
+
// Wait for React and MUI DataGrid to process the selection change
|
|
344
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Checkbox state assertions
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/** Get the header checkbox input element. */
|
|
352
|
+
const getHeaderCheckboxInput = (canvasElement: HTMLElement): HTMLInputElement => {
|
|
353
|
+
const el = canvasElement.querySelector('.MuiDataGrid-columnHeaderCheckbox input[type="checkbox"]');
|
|
354
|
+
if (!el) throw new Error('Could not find header checkbox');
|
|
355
|
+
return el as HTMLInputElement;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Assert the header checkbox is in the expected state.
|
|
360
|
+
* The DS Checkbox component uses `aria-checked` to communicate state:
|
|
361
|
+
* - checked: aria-checked="true"
|
|
362
|
+
* - unchecked: aria-checked="false"
|
|
363
|
+
* - indeterminate: aria-checked="mixed"
|
|
364
|
+
* Note: `input.indeterminate` DOM property is NOT set by the DS Checkbox.
|
|
365
|
+
*/
|
|
366
|
+
export const assertHeaderCheckboxState = async (
|
|
367
|
+
canvasElement: HTMLElement,
|
|
368
|
+
expected: 'checked' | 'unchecked' | 'indeterminate'
|
|
369
|
+
) => {
|
|
370
|
+
await waitFor(
|
|
371
|
+
() => {
|
|
372
|
+
const input = getHeaderCheckboxInput(canvasElement);
|
|
373
|
+
const ariaChecked = input.getAttribute('aria-checked');
|
|
374
|
+
switch (expected) {
|
|
375
|
+
case 'checked':
|
|
376
|
+
expect(ariaChecked).toBe('true');
|
|
377
|
+
break;
|
|
378
|
+
case 'unchecked':
|
|
379
|
+
expect(ariaChecked).toBe('false');
|
|
380
|
+
break;
|
|
381
|
+
case 'indeterminate':
|
|
382
|
+
expect(ariaChecked).toBe('mixed');
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
{ timeout: 5000 }
|
|
387
|
+
);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Assert a specific row's checkbox is checked or unchecked.
|
|
392
|
+
* Uses `aria-selected` on the MUI DataGrid row element, which is more reliable
|
|
393
|
+
* than checking `input.checked` on the custom DS Checkbox (whose native input
|
|
394
|
+
* is visually hidden and rendered asynchronously via React effects).
|
|
395
|
+
* @param rowIndex 0-based index within the visible page.
|
|
396
|
+
*/
|
|
397
|
+
export const assertRowCheckboxChecked = async (
|
|
398
|
+
canvasElement: HTMLElement,
|
|
399
|
+
rowIndex: number,
|
|
400
|
+
expectedChecked: boolean
|
|
401
|
+
) => {
|
|
402
|
+
await waitFor(
|
|
403
|
+
() => {
|
|
404
|
+
const rows = canvasElement.querySelectorAll('.MuiDataGrid-row');
|
|
405
|
+
expect(rows.length).toBeGreaterThan(rowIndex);
|
|
406
|
+
const row = rows[rowIndex];
|
|
407
|
+
const isSelected = row.getAttribute('aria-selected') === 'true';
|
|
408
|
+
expect(isSelected).toBe(expectedChecked);
|
|
409
|
+
},
|
|
410
|
+
{ timeout: 5000 }
|
|
411
|
+
);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Assert that the first data cell in a given row contains specific text.
|
|
416
|
+
* Useful to prove the page actually changed (different rows visible).
|
|
417
|
+
* Skips the checkbox column cell (first cell) and reads the second cell.
|
|
418
|
+
*/
|
|
419
|
+
export const assertFirstCellInRow = async (canvasElement: HTMLElement, rowIndex: number, expectedText: string) => {
|
|
420
|
+
await waitFor(() => {
|
|
421
|
+
const rows = canvasElement.querySelectorAll('.MuiDataGrid-row');
|
|
422
|
+
expect(rows.length).toBeGreaterThan(rowIndex);
|
|
423
|
+
const cells = rows[rowIndex].querySelectorAll('.MuiDataGrid-cell');
|
|
424
|
+
// cells[0] is checkbox column, cells[1] is first data column
|
|
425
|
+
const dataCell = cells[1] ?? cells[0];
|
|
426
|
+
expect(dataCell?.textContent).toContain(expectedText);
|
|
427
|
+
});
|
|
325
428
|
};
|
|
326
429
|
|
|
327
430
|
// ---------------------------------------------------------------------------
|
|
@@ -754,3 +857,450 @@ export const assertScreenReaderSequence = async (
|
|
|
754
857
|
);
|
|
755
858
|
}
|
|
756
859
|
};
|
|
860
|
+
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
// Pagination — page navigation and page-size switching
|
|
863
|
+
// ---------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
/** Assert the displayed rows text matches (e.g. "1–10 of 14,075"). Ignores locale thousands separators. */
|
|
866
|
+
export const assertDisplayedRowsText = async (canvasElement: HTMLElement, expected: string) => {
|
|
867
|
+
const strip = (s: string) => s.replace(/,/g, '');
|
|
868
|
+
await waitFor(() => {
|
|
869
|
+
const el = canvasElement.querySelector('.MuiTablePagination-displayedRows');
|
|
870
|
+
expect(el).toBeTruthy();
|
|
871
|
+
expect(strip(el!.textContent!)).toBe(strip(expected));
|
|
872
|
+
});
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
/** Click the "Go to next page" pagination button. */
|
|
876
|
+
export const clickNextPage = async (canvasElement: HTMLElement) => {
|
|
877
|
+
const btn = canvasElement.querySelector(
|
|
878
|
+
'.MuiTablePagination-actions button[aria-label="Go to next page"]'
|
|
879
|
+
) as HTMLElement | null;
|
|
880
|
+
if (!btn) throw new Error('Could not find next page button');
|
|
881
|
+
await userEvent.click(btn);
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
/** Click the "Go to previous page" pagination button. */
|
|
885
|
+
export const clickPrevPage = async (canvasElement: HTMLElement) => {
|
|
886
|
+
const btn = canvasElement.querySelector(
|
|
887
|
+
'.MuiTablePagination-actions button[aria-label="Go to previous page"]'
|
|
888
|
+
) as HTMLElement | null;
|
|
889
|
+
if (!btn) throw new Error('Could not find previous page button');
|
|
890
|
+
await userEvent.click(btn);
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
/** Wait for a pagination button to exist and be enabled. Use before clickPrevPage/clickNextPage in server-side stories. */
|
|
894
|
+
export const waitForPaginationEnabled = async (canvasElement: HTMLElement, direction: 'next' | 'previous') => {
|
|
895
|
+
const label = direction === 'next' ? 'Go to next page' : 'Go to previous page';
|
|
896
|
+
await waitFor(
|
|
897
|
+
() => {
|
|
898
|
+
const btn = canvasElement.querySelector(
|
|
899
|
+
`.MuiTablePagination-actions button[aria-label="${label}"]`
|
|
900
|
+
) as HTMLButtonElement | null;
|
|
901
|
+
expect(btn).toBeTruthy();
|
|
902
|
+
expect(btn!.disabled).toBe(false);
|
|
903
|
+
},
|
|
904
|
+
{ timeout: 10000 }
|
|
905
|
+
);
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Change the page size via the MUI pagination "Rows per page" select.
|
|
910
|
+
* The dropdown menu renders as a portal on document.body.
|
|
911
|
+
*/
|
|
912
|
+
export const changePageSize = async (canvasElement: HTMLElement, newSize: number) => {
|
|
913
|
+
const select = canvasElement.querySelector('.MuiTablePagination-select') as HTMLElement | null;
|
|
914
|
+
if (!select) throw new Error('Could not find page size select');
|
|
915
|
+
await userEvent.click(select);
|
|
916
|
+
// The menu renders as a MUI Popper on document.body
|
|
917
|
+
await waitFor(() => {
|
|
918
|
+
const options = document.querySelectorAll('li[role="option"]');
|
|
919
|
+
expect(options.length).toBeGreaterThan(0);
|
|
920
|
+
});
|
|
921
|
+
const options = document.querySelectorAll('li[role="option"]');
|
|
922
|
+
const target = Array.from(options).find((opt) => opt.textContent === String(newSize));
|
|
923
|
+
if (!target) throw new Error(`Could not find page size option "${newSize}"`);
|
|
924
|
+
await userEvent.click(target);
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// Column visibility — columns panel interaction
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
/** Open the columns panel via the toolbar button. */
|
|
932
|
+
export const openColumnsPanel = async (canvasElement: HTMLElement) => {
|
|
933
|
+
const btn = canvasElement.querySelector(
|
|
934
|
+
'.MuiDataGrid-toolbarContainer button[aria-label="Select columns"]'
|
|
935
|
+
) as HTMLElement | null;
|
|
936
|
+
if (!btn) throw new Error('Could not find columns toolbar button');
|
|
937
|
+
await userEvent.click(btn);
|
|
938
|
+
// Wait for the panel to appear on document.body
|
|
939
|
+
await waitFor(() => {
|
|
940
|
+
const panel = document.querySelector('.MuiDataGrid-columnsPanel');
|
|
941
|
+
expect(panel).toBeTruthy();
|
|
942
|
+
});
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
/** Toggle a column's visibility in the columns panel by its label text. */
|
|
946
|
+
export const toggleColumnInPanel = async (fieldLabel: string) => {
|
|
947
|
+
const panel = document.querySelector('.MuiDataGrid-columnsPanel');
|
|
948
|
+
if (!panel) throw new Error('Columns panel is not open');
|
|
949
|
+
const labels = Array.from(panel.querySelectorAll('.MuiFormControlLabel-root'));
|
|
950
|
+
const target = labels.find((label) => label.textContent?.includes(fieldLabel));
|
|
951
|
+
if (!target) throw new Error(`Could not find column "${fieldLabel}" in columns panel`);
|
|
952
|
+
const checkbox = target.querySelector('input[type="checkbox"]') as HTMLElement | null;
|
|
953
|
+
if (!checkbox) throw new Error(`Could not find checkbox for column "${fieldLabel}"`);
|
|
954
|
+
await userEvent.click(checkbox);
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
/** Close the columns panel by clicking elsewhere. */
|
|
958
|
+
export const closeColumnsPanel = async () => {
|
|
959
|
+
// Press Escape to close the panel
|
|
960
|
+
await userEvent.keyboard('{Escape}');
|
|
961
|
+
await waitFor(() => {
|
|
962
|
+
const panel = document.querySelector('.MuiDataGrid-columnsPanel');
|
|
963
|
+
expect(panel).toBeFalsy();
|
|
964
|
+
});
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
/** Assert a column header is visible in the grid. */
|
|
968
|
+
export const assertColumnVisible = async (canvasElement: HTMLElement, headerName: string) => {
|
|
969
|
+
await waitFor(() => {
|
|
970
|
+
const headers = Array.from(canvasElement.querySelectorAll('.MuiDataGrid-columnHeaderTitle'));
|
|
971
|
+
const found = headers.some((h) => h.textContent === headerName);
|
|
972
|
+
expect(found).toBe(true);
|
|
973
|
+
});
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
/** Assert a column header is NOT visible in the grid. */
|
|
977
|
+
export const assertColumnHidden = async (canvasElement: HTMLElement, headerName: string) => {
|
|
978
|
+
await waitFor(() => {
|
|
979
|
+
const headers = Array.from(canvasElement.querySelectorAll('.MuiDataGrid-columnHeaderTitle'));
|
|
980
|
+
const found = headers.some((h) => h.textContent === headerName);
|
|
981
|
+
expect(found).toBe(false);
|
|
982
|
+
});
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
// Density — switch and assert grid density
|
|
987
|
+
// ---------------------------------------------------------------------------
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Change the grid density via the toolbar density selector.
|
|
991
|
+
* The menu renders as a portal on document.body.
|
|
992
|
+
*/
|
|
993
|
+
export const changeDensity = async (canvasElement: HTMLElement, density: 'Compact' | 'Standard' | 'Comfortable') => {
|
|
994
|
+
const btn = canvasElement.querySelector(
|
|
995
|
+
'.MuiDataGrid-toolbarContainer button[aria-label="Density"]'
|
|
996
|
+
) as HTMLElement | null;
|
|
997
|
+
if (!btn) throw new Error('Could not find density toolbar button');
|
|
998
|
+
await userEvent.click(btn);
|
|
999
|
+
// Wait for menu to appear
|
|
1000
|
+
await waitFor(() => {
|
|
1001
|
+
const menuItems = document.querySelectorAll('li[role="menuitem"]');
|
|
1002
|
+
expect(menuItems.length).toBeGreaterThan(0);
|
|
1003
|
+
});
|
|
1004
|
+
const menuItems = document.querySelectorAll('li[role="menuitem"]');
|
|
1005
|
+
const target = Array.from(menuItems).find((item) => item.textContent === density);
|
|
1006
|
+
if (!target) throw new Error(`Could not find density option "${density}"`);
|
|
1007
|
+
await userEvent.click(target);
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Assert the grid root has the expected density CSS class.
|
|
1012
|
+
* MUI DataGrid density classes: MuiDataGrid-root--densityCompact, densityStandard, densityComfortable.
|
|
1013
|
+
*/
|
|
1014
|
+
export const assertDensity = async (canvasElement: HTMLElement, density: 'Compact' | 'Standard' | 'Comfortable') => {
|
|
1015
|
+
await waitFor(() => {
|
|
1016
|
+
const grid = canvasElement.querySelector('.MuiDataGrid-root');
|
|
1017
|
+
expect(grid).toBeTruthy();
|
|
1018
|
+
expect(grid!.classList.contains(`MuiDataGrid-root--density${density}`)).toBe(true);
|
|
1019
|
+
});
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
// Mock router — isolated URL state for StatefulDataGrid stories
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Create an isolated mock router for StatefulDataGrid interaction stories.
|
|
1028
|
+
*
|
|
1029
|
+
* Each story should create its own instance to prevent cross-story state leakage.
|
|
1030
|
+
* The mock router stores URL search params in a closure — no `window.location` involved.
|
|
1031
|
+
*
|
|
1032
|
+
* @param initialSearch - Initial URL search string (default: '')
|
|
1033
|
+
* @returns `{ useRouter, getSearch }` — pass `useRouter` to StatefulDataGrid; use `getSearch()` for assertions
|
|
1034
|
+
*/
|
|
1035
|
+
export const createMockRouter = (initialSearch = '') => {
|
|
1036
|
+
const state = { search: initialSearch };
|
|
1037
|
+
|
|
1038
|
+
const useRouter = () => ({
|
|
1039
|
+
pathname: '/test',
|
|
1040
|
+
search: state.search,
|
|
1041
|
+
historyReplace: (newSearch: string) => {
|
|
1042
|
+
state.search = newSearch.startsWith('?') ? newSearch : `?${newSearch}`;
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const getSearch = () => state.search;
|
|
1047
|
+
|
|
1048
|
+
return { useRouter, getSearch };
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* React hook wrapper around createMockRouter that persists the router instance
|
|
1053
|
+
* across re-renders. Must be called inside a component or Storybook render function.
|
|
1054
|
+
*
|
|
1055
|
+
* Unlike createMockRouter(), this hook:
|
|
1056
|
+
* 1. Persists the router state across re-renders (via useRef)
|
|
1057
|
+
* 2. Triggers a parent re-render when historyReplace is called (via useState),
|
|
1058
|
+
* mimicking the real Next.js router's behavior where router.replace() causes
|
|
1059
|
+
* the consuming component to re-render with the updated search string.
|
|
1060
|
+
*
|
|
1061
|
+
* Without this, calling createMockRouter() inside a render function resets the
|
|
1062
|
+
* URL state on every re-render, causing race conditions in interaction tests.
|
|
1063
|
+
*/
|
|
1064
|
+
export const usePersistentMockRouter = (initialSearch = '') => {
|
|
1065
|
+
const searchRef = useRef(initialSearch);
|
|
1066
|
+
const [, forceRerender] = useState(0);
|
|
1067
|
+
|
|
1068
|
+
const routerRef = useRef<ReturnType<typeof createMockRouter>>();
|
|
1069
|
+
if (!routerRef.current) {
|
|
1070
|
+
routerRef.current = {
|
|
1071
|
+
useRouter: () => ({
|
|
1072
|
+
pathname: '/test',
|
|
1073
|
+
search: searchRef.current,
|
|
1074
|
+
historyReplace: (newSearch: string) => {
|
|
1075
|
+
searchRef.current = newSearch.startsWith('?') ? newSearch : `?${newSearch}`;
|
|
1076
|
+
forceRerender((t) => t + 1);
|
|
1077
|
+
},
|
|
1078
|
+
}),
|
|
1079
|
+
getSearch: () => searchRef.current,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return routerRef.current;
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
// ---------------------------------------------------------------------------
|
|
1087
|
+
// URL assertion helpers — for StatefulDataGrid stories
|
|
1088
|
+
// ---------------------------------------------------------------------------
|
|
1089
|
+
|
|
1090
|
+
/** Assert the mock router's search string contains a substring. */
|
|
1091
|
+
export const assertSearchContains = async (getSearch: () => string, substring: string) => {
|
|
1092
|
+
await waitFor(() => {
|
|
1093
|
+
expect(getSearch()).toContain(substring);
|
|
1094
|
+
});
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
/** Assert a specific URL search param equals the expected value. */
|
|
1098
|
+
export const assertSearchParam = async (getSearch: () => string, key: string, value: string) => {
|
|
1099
|
+
await waitFor(() => {
|
|
1100
|
+
const params = new URLSearchParams(getSearch());
|
|
1101
|
+
expect(params.get(key)).toBe(value);
|
|
1102
|
+
});
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
/** Assert a URL search param's value contains a substring. */
|
|
1106
|
+
export const assertSearchParamContains = async (getSearch: () => string, key: string, substring: string) => {
|
|
1107
|
+
await waitFor(() => {
|
|
1108
|
+
const params = new URLSearchParams(getSearch());
|
|
1109
|
+
const val = params.get(key);
|
|
1110
|
+
expect(val).toBeTruthy();
|
|
1111
|
+
expect(val!).toContain(substring);
|
|
1112
|
+
});
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
/** Assert a URL search param's value does NOT contain a substring. */
|
|
1116
|
+
export const assertSearchParamMissing = async (getSearch: () => string, key: string, substring: string) => {
|
|
1117
|
+
await waitFor(() => {
|
|
1118
|
+
const params = new URLSearchParams(getSearch());
|
|
1119
|
+
const val = params.get(key) ?? '';
|
|
1120
|
+
expect(val).not.toContain(substring);
|
|
1121
|
+
});
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
// State sync assertion — verify URL ↔ localStorage consistency
|
|
1126
|
+
// ---------------------------------------------------------------------------
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Normalizes the localStorage search-model string (internal bracket format)
|
|
1130
|
+
* into display-format URL params for comparison.
|
|
1131
|
+
* e.g. `_Items[contains,string]=e` → `Items.contains=e`
|
|
1132
|
+
*
|
|
1133
|
+
* Since localStorage stores the raw internal format and URL uses display format,
|
|
1134
|
+
* we compare the *semantic content* rather than the raw strings.
|
|
1135
|
+
*/
|
|
1136
|
+
|
|
1137
|
+
interface SyncAssertionOptions {
|
|
1138
|
+
/** getSearch() from createMockRouter */
|
|
1139
|
+
getSearch: () => string;
|
|
1140
|
+
/** pathname used for localStorage keys (from createMockRouter, typically '/test') */
|
|
1141
|
+
pathname?: string;
|
|
1142
|
+
/** localStorage version */
|
|
1143
|
+
localStorageVersion?: number;
|
|
1144
|
+
/** Whether to check filter sync */
|
|
1145
|
+
checkFilters?: boolean;
|
|
1146
|
+
/** Whether to check sort sync */
|
|
1147
|
+
checkSort?: boolean;
|
|
1148
|
+
/** Whether to check pagination sync */
|
|
1149
|
+
checkPagination?: boolean;
|
|
1150
|
+
/** Whether to check density sync */
|
|
1151
|
+
checkDensity?: boolean;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Assert that URL query params and localStorage are in sync.
|
|
1156
|
+
*
|
|
1157
|
+
* Compares the semantic content of each state dimension (filters, sort, pagination, density)
|
|
1158
|
+
* between the URL and localStorage. Both are populated by the same `updateUrl` → `historyReplace`
|
|
1159
|
+
* and `setLocalStorageX` calls, so they should always agree.
|
|
1160
|
+
*/
|
|
1161
|
+
export const assertAllStatesInSync = async ({
|
|
1162
|
+
getSearch,
|
|
1163
|
+
pathname = '/test',
|
|
1164
|
+
localStorageVersion = 1,
|
|
1165
|
+
checkFilters = true,
|
|
1166
|
+
checkSort = true,
|
|
1167
|
+
checkPagination = true,
|
|
1168
|
+
checkDensity = true,
|
|
1169
|
+
}: SyncAssertionOptions) => {
|
|
1170
|
+
await waitFor(
|
|
1171
|
+
() => {
|
|
1172
|
+
const search = getSearch();
|
|
1173
|
+
const url = new URLSearchParams(search);
|
|
1174
|
+
|
|
1175
|
+
// --- Filters ---
|
|
1176
|
+
if (checkFilters) {
|
|
1177
|
+
const lsKey = `${pathname}:${localStorageVersion}:searchModel`;
|
|
1178
|
+
const lsRaw = localStorage.getItem(lsKey);
|
|
1179
|
+
const lsFilters = lsRaw ? JSON.parse(lsRaw) : '';
|
|
1180
|
+
|
|
1181
|
+
// URL has filter params in display format: `Items.contains=e`
|
|
1182
|
+
// localStorage has internal format: `_Items[contains,string]=e&_logicOperator=and`
|
|
1183
|
+
// Both should represent the same filters. We verify by checking that every
|
|
1184
|
+
// filter field.operator from the URL also exists in localStorage (and vice versa).
|
|
1185
|
+
if (lsFilters) {
|
|
1186
|
+
const lsParams = new URLSearchParams(lsFilters);
|
|
1187
|
+
// Check each internal filter key maps to a URL key
|
|
1188
|
+
for (const [key] of lsParams) {
|
|
1189
|
+
if (key === '_logicOperator' || key === '_quickFilterValues') continue;
|
|
1190
|
+
// Internal: _Field[operator,type] → Display: Field.operator
|
|
1191
|
+
const match = key.match(/^_([^[]+)\[([^,]+)/);
|
|
1192
|
+
if (match) {
|
|
1193
|
+
const [, field, operator] = match;
|
|
1194
|
+
const displayKey = `${field}.${operator}`;
|
|
1195
|
+
// The URL (display format) should contain this key
|
|
1196
|
+
expect(search).toContain(displayKey);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// --- Sort ---
|
|
1203
|
+
if (checkSort) {
|
|
1204
|
+
const lsKey = `${pathname}:${localStorageVersion}:sortModel`;
|
|
1205
|
+
const lsRaw = localStorage.getItem(lsKey);
|
|
1206
|
+
const lsSorting = lsRaw ? JSON.parse(lsRaw) : '';
|
|
1207
|
+
|
|
1208
|
+
const urlSort = url.get('_sortColumn');
|
|
1209
|
+
if (lsSorting) {
|
|
1210
|
+
// localStorage: `_sortColumn=[DateTime,desc]`
|
|
1211
|
+
// URL: `_sortColumn=DateTime.desc`
|
|
1212
|
+
const lsParams = new URLSearchParams(lsSorting);
|
|
1213
|
+
const lsSortValue = lsParams.get('_sortColumn') ?? '';
|
|
1214
|
+
// Extract field and direction from both
|
|
1215
|
+
const lsMatch = lsSortValue.match(/^\[(\w+),(\w+)\]$/);
|
|
1216
|
+
if (lsMatch && urlSort) {
|
|
1217
|
+
const [, lsField, lsDir] = lsMatch;
|
|
1218
|
+
expect(urlSort).toBe(`${lsField}.${lsDir}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// --- Pagination ---
|
|
1224
|
+
if (checkPagination) {
|
|
1225
|
+
const lsKey = `${pathname}:${localStorageVersion}:paginationModel`;
|
|
1226
|
+
const lsRaw = localStorage.getItem(lsKey);
|
|
1227
|
+
const lsPagination = lsRaw ? JSON.parse(lsRaw) : '';
|
|
1228
|
+
|
|
1229
|
+
const urlPagination = url.get('_pagination');
|
|
1230
|
+
if (lsPagination && urlPagination) {
|
|
1231
|
+
// localStorage: `_pagination=[0,10,next]`
|
|
1232
|
+
// URL: `_pagination=0.10.next`
|
|
1233
|
+
const lsParams = new URLSearchParams(lsPagination);
|
|
1234
|
+
const lsPaginationValue = lsParams.get('_pagination') ?? '';
|
|
1235
|
+
const lsMatch = lsPaginationValue.match(/^\[(\d+),(\d+),(\w+)\]$/);
|
|
1236
|
+
if (lsMatch) {
|
|
1237
|
+
const [, page, size, dir] = lsMatch;
|
|
1238
|
+
expect(urlPagination).toBe(`${page}.${size}.${dir}`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// --- Density ---
|
|
1244
|
+
if (checkDensity) {
|
|
1245
|
+
const lsKey = `${pathname}:${localStorageVersion}:densityModel`;
|
|
1246
|
+
const lsRaw = localStorage.getItem(lsKey);
|
|
1247
|
+
const lsDensity = lsRaw ? JSON.parse(lsRaw) : '';
|
|
1248
|
+
|
|
1249
|
+
const urlDensity = url.get('_density');
|
|
1250
|
+
if (lsDensity && urlDensity) {
|
|
1251
|
+
// localStorage: `_density=compact`
|
|
1252
|
+
// URL: `_density=compact`
|
|
1253
|
+
const lsParams = new URLSearchParams(lsDensity);
|
|
1254
|
+
const lsDensityValue = lsParams.get('_density') ?? '';
|
|
1255
|
+
expect(urlDensity).toBe(lsDensityValue);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
},
|
|
1259
|
+
{ timeout: 5000 }
|
|
1260
|
+
);
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
// ---------------------------------------------------------------------------
|
|
1264
|
+
// Sorting — click column header and assert sort direction
|
|
1265
|
+
// ---------------------------------------------------------------------------
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Click a column header to cycle its sort direction (unsorted → asc → desc → unsorted).
|
|
1269
|
+
* Finds the header by its displayed title text.
|
|
1270
|
+
*/
|
|
1271
|
+
export const clickColumnHeader = async (canvasElement: HTMLElement, headerName: string) => {
|
|
1272
|
+
const headers = Array.from(canvasElement.querySelectorAll('.MuiDataGrid-columnHeader'));
|
|
1273
|
+
const target = headers.find((h) => {
|
|
1274
|
+
const title = h.querySelector('.MuiDataGrid-columnHeaderTitle');
|
|
1275
|
+
return title?.textContent === headerName;
|
|
1276
|
+
});
|
|
1277
|
+
if (!target) throw new Error(`Could not find column header "${headerName}"`);
|
|
1278
|
+
const titleEl = target.querySelector('.MuiDataGrid-columnHeaderTitle') as HTMLElement;
|
|
1279
|
+
await userEvent.click(titleEl);
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Assert a column header has the expected sort direction.
|
|
1284
|
+
* MUI DataGrid sets `aria-sort="ascending" | "descending"` on the column header element.
|
|
1285
|
+
* When unsorted, the attribute is absent.
|
|
1286
|
+
*/
|
|
1287
|
+
export const assertColumnSorted = async (
|
|
1288
|
+
canvasElement: HTMLElement,
|
|
1289
|
+
headerName: string,
|
|
1290
|
+
direction: 'ascending' | 'descending' | 'none'
|
|
1291
|
+
) => {
|
|
1292
|
+
await waitFor(() => {
|
|
1293
|
+
const headers = Array.from(canvasElement.querySelectorAll('.MuiDataGrid-columnHeader'));
|
|
1294
|
+
const target = headers.find((h) => {
|
|
1295
|
+
const title = h.querySelector('.MuiDataGrid-columnHeaderTitle');
|
|
1296
|
+
return title?.textContent === headerName;
|
|
1297
|
+
});
|
|
1298
|
+
expect(target).toBeTruthy();
|
|
1299
|
+
if (direction === 'none') {
|
|
1300
|
+
const val = target!.getAttribute('aria-sort');
|
|
1301
|
+
expect(val === null || val === 'none').toBe(true);
|
|
1302
|
+
} else {
|
|
1303
|
+
expect(target!.getAttribute('aria-sort')).toBe(direction);
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router adapter for StatefulDataGrid — plain browser API implementation.
|
|
3
|
+
*
|
|
4
|
+
* StatefulDataGrid requires a `useRouter` hook that returns:
|
|
5
|
+
* - `pathname` — current URL path (used as localStorage key)
|
|
6
|
+
* - `search` — current query string (URL state source)
|
|
7
|
+
* - `historyReplace` — update the URL without triggering navigation
|
|
8
|
+
*
|
|
9
|
+
* This adapter uses `window.location` and `window.history.replaceState`
|
|
10
|
+
* directly. It works in any browser environment without a routing framework.
|
|
11
|
+
*
|
|
12
|
+
* In real products, replace this with your framework's router:
|
|
13
|
+
*
|
|
14
|
+
* react-router:
|
|
15
|
+
* import { useLocation, useNavigate } from 'react-router-dom';
|
|
16
|
+
* export const useRouter = () => {
|
|
17
|
+
* const { pathname, search } = useLocation();
|
|
18
|
+
* const navigate = useNavigate();
|
|
19
|
+
* return {
|
|
20
|
+
* pathname,
|
|
21
|
+
* search,
|
|
22
|
+
* historyReplace: (newSearch: string) => {
|
|
23
|
+
* navigate(`${pathname}?${newSearch}`, { replace: true });
|
|
24
|
+
* },
|
|
25
|
+
* };
|
|
26
|
+
* };
|
|
27
|
+
*
|
|
28
|
+
* Next.js:
|
|
29
|
+
* import { useRouter as useNextRouter } from 'next/router';
|
|
30
|
+
* export const useRouter = () => {
|
|
31
|
+
* const router = useNextRouter();
|
|
32
|
+
* return {
|
|
33
|
+
* pathname: router.pathname,
|
|
34
|
+
* search: typeof window !== 'undefined' ? window.location.search : '',
|
|
35
|
+
* historyReplace: (newSearch: string) => {
|
|
36
|
+
* router.replace(`${router.pathname}?${newSearch}`, undefined, { shallow: true });
|
|
37
|
+
* },
|
|
38
|
+
* };
|
|
39
|
+
* };
|
|
40
|
+
*/
|
|
41
|
+
export const useRouterAdapter = () => ({
|
|
42
|
+
pathname: window.location.pathname,
|
|
43
|
+
search: window.location.search,
|
|
44
|
+
historyReplace: (newSearch: string) => {
|
|
45
|
+
window.history.replaceState(
|
|
46
|
+
{},
|
|
47
|
+
'',
|
|
48
|
+
decodeURIComponent(`${window.location.pathname}?${new URLSearchParams(newSearch)}`)
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
});
|