feat(tree): add flat and lazy modes for tree data handling
* Implement flat mode to transform flat data with parentId to nested structure. * Introduce lazy mode for on-demand loading of children. * Update tree rendering logic to accommodate new modes. * Enhance tree cell expansion logic to support lazy loading. * Add dark mode styles for improved UI experience. * Create comprehensive end-to-end tests for tree functionality.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,4 +25,5 @@ dist-ssr
|
|||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
test-results/
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { PreviewDecorator } from './previewDecorator';
|
|||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
decorators: [PreviewDecorator],
|
decorators: [PreviewDecorator],
|
||||||
|
globalTypes: {
|
||||||
|
colorScheme: {
|
||||||
|
description: 'Mantine color scheme',
|
||||||
|
toolbar: {
|
||||||
|
dynamicTitle: true,
|
||||||
|
icon: 'paintbrush',
|
||||||
|
items: [
|
||||||
|
{ icon: 'sun', title: 'Light', value: 'light' },
|
||||||
|
{ icon: 'moon', title: 'Dark', value: 'dark' },
|
||||||
|
],
|
||||||
|
title: 'Color Scheme',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
initialGlobals: {
|
||||||
|
colorScheme: 'light',
|
||||||
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
controls: {
|
controls: {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { ModalsProvider } from '@mantine/modals';
|
|||||||
import { GlobalStateStoreProvider } from '../src/GlobalStateStore';
|
import { GlobalStateStoreProvider } from '../src/GlobalStateStore';
|
||||||
|
|
||||||
export const PreviewDecorator: Decorator = (Story, context) => {
|
export const PreviewDecorator: Decorator = (Story, context) => {
|
||||||
const { parameters } = context;
|
const { parameters, globals } = context;
|
||||||
|
const colorScheme = globals.colorScheme as 'light' | 'dark';
|
||||||
|
|
||||||
// Allow stories to opt-out of GlobalStateStore provider
|
// Allow stories to opt-out of GlobalStateStore provider
|
||||||
const useGlobalStore = parameters.globalStore !== false;
|
const useGlobalStore = parameters.globalStore !== false;
|
||||||
@@ -21,7 +22,7 @@ export const PreviewDecorator: Decorator = (Story, context) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider forceColorScheme={colorScheme}>
|
||||||
|
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
{useGlobalStore ? (
|
{useGlobalStore ? (
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [ref=e3]:
|
|
||||||
- banner "Storybook" [ref=e6]:
|
|
||||||
- heading "Storybook" [level=1] [ref=e7]
|
|
||||||
- img
|
|
||||||
- generic [ref=e11]:
|
|
||||||
- generic [ref=e12]:
|
|
||||||
- generic [ref=e13]:
|
|
||||||
- link "Skip to content" [ref=e14] [cursor=pointer]:
|
|
||||||
- /url: "#storybook-preview-wrapper"
|
|
||||||
- link "Storybook" [ref=e16] [cursor=pointer]:
|
|
||||||
- /url: ./
|
|
||||||
- img "Storybook" [ref=e17]
|
|
||||||
- switch "Settings" [ref=e22] [cursor=pointer]:
|
|
||||||
- img [ref=e23]
|
|
||||||
- generic [ref=e28]:
|
|
||||||
- generic [ref=e30] [cursor=pointer]:
|
|
||||||
- button "Open onboarding guide" [ref=e34]:
|
|
||||||
- img [ref=e36]
|
|
||||||
- strong [ref=e38]: Get started
|
|
||||||
- generic [ref=e39]:
|
|
||||||
- button "Collapse onboarding checklist" [expanded] [ref=e40]:
|
|
||||||
- img [ref=e41]
|
|
||||||
- button "25% completed" [ref=e43]:
|
|
||||||
- generic [ref=e44]:
|
|
||||||
- img [ref=e45]
|
|
||||||
- img [ref=e47]
|
|
||||||
- generic [ref=e50]: 25%
|
|
||||||
- list [ref=e52]:
|
|
||||||
- listitem [ref=e53]:
|
|
||||||
- button "Open onboarding guide for See what's new" [ref=e54] [cursor=pointer]:
|
|
||||||
- img [ref=e56]
|
|
||||||
- generic [ref=e59]: See what's new
|
|
||||||
- button "Go"
|
|
||||||
- listitem [ref=e60]:
|
|
||||||
- button "Open onboarding guide for Change a story with Controls" [ref=e61] [cursor=pointer]:
|
|
||||||
- img [ref=e63]
|
|
||||||
- generic [ref=e66]: Change a story with Controls
|
|
||||||
- listitem [ref=e67]:
|
|
||||||
- button "Open onboarding guide for Install Vitest addon" [ref=e68] [cursor=pointer]:
|
|
||||||
- img [ref=e70]
|
|
||||||
- generic [ref=e73]: Install Vitest addon
|
|
||||||
- generic [ref=e74]: Search for components
|
|
||||||
- search [ref=e75]:
|
|
||||||
- combobox "Search for components" [ref=e76]:
|
|
||||||
- generic:
|
|
||||||
- img
|
|
||||||
- searchbox "Search for components" [ref=e77]
|
|
||||||
- code: ⌃ K
|
|
||||||
- button "Tag filters" [ref=e79] [cursor=pointer]:
|
|
||||||
- img [ref=e80]
|
|
||||||
- button "Create a new story" [ref=e82] [cursor=pointer]:
|
|
||||||
- img [ref=e83]
|
|
||||||
- navigation "Stories" [ref=e86]:
|
|
||||||
- heading "Stories" [level=2] [ref=e87]
|
|
||||||
- generic [ref=e89]:
|
|
||||||
- generic [ref=e90]:
|
|
||||||
- button "Collapse" [expanded] [ref=e91] [cursor=pointer]:
|
|
||||||
- img [ref=e93]
|
|
||||||
- text: Components
|
|
||||||
- button "Expand all" [ref=e95] [cursor=pointer]:
|
|
||||||
- img [ref=e96]
|
|
||||||
- button "Boxer" [ref=e99] [cursor=pointer]:
|
|
||||||
- generic [ref=e100]:
|
|
||||||
- img [ref=e102]
|
|
||||||
- img [ref=e104]
|
|
||||||
- text: Boxer
|
|
||||||
- button "Griddy" [expanded] [ref=e107] [cursor=pointer]:
|
|
||||||
- generic [ref=e108]:
|
|
||||||
- img [ref=e110]
|
|
||||||
- img [ref=e112]
|
|
||||||
- text: Griddy
|
|
||||||
- link "Basic" [ref=e115] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--basic
|
|
||||||
- img [ref=e117]
|
|
||||||
- text: Basic
|
|
||||||
- link "Large Dataset" [ref=e120] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--large-dataset
|
|
||||||
- img [ref=e122]
|
|
||||||
- text: Large Dataset
|
|
||||||
- link "Single Selection" [ref=e125] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--single-selection
|
|
||||||
- img [ref=e127]
|
|
||||||
- text: Single Selection
|
|
||||||
- link "Multi Selection" [ref=e130] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--multi-selection
|
|
||||||
- img [ref=e132]
|
|
||||||
- text: Multi Selection
|
|
||||||
- link "Large Multi Selection" [ref=e135] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--large-multi-selection
|
|
||||||
- img [ref=e137]
|
|
||||||
- text: Large Multi Selection
|
|
||||||
- link "With Search" [ref=e140] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--with-search
|
|
||||||
- img [ref=e142]
|
|
||||||
- text: With Search
|
|
||||||
- link "Keyboard Navigation" [ref=e145] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--keyboard-navigation
|
|
||||||
- img [ref=e147]
|
|
||||||
- text: Keyboard Navigation
|
|
||||||
- generic [ref=e149]:
|
|
||||||
- link "With Text Filtering" [ref=e150] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--with-text-filtering
|
|
||||||
- img [ref=e152]
|
|
||||||
- text: With Text Filtering
|
|
||||||
- link "Skip to content" [ref=e154] [cursor=pointer]:
|
|
||||||
- /url: "#storybook-preview-wrapper"
|
|
||||||
- link "With Number Filtering" [ref=e156] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--with-number-filtering
|
|
||||||
- img [ref=e158]
|
|
||||||
- text: With Number Filtering
|
|
||||||
- link "With Enum Filtering" [ref=e161] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--with-enum-filtering
|
|
||||||
- img [ref=e163]
|
|
||||||
- text: With Enum Filtering
|
|
||||||
- link "With Boolean Filtering" [ref=e166] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--with-boolean-filtering
|
|
||||||
- img [ref=e168]
|
|
||||||
- text: With Boolean Filtering
|
|
||||||
- link "With All Filter Types" [ref=e171] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--with-all-filter-types
|
|
||||||
- img [ref=e173]
|
|
||||||
- text: With All Filter Types
|
|
||||||
- link "Large Dataset With Filtering" [ref=e176] [cursor=pointer]:
|
|
||||||
- /url: /?path=/story/components-griddy--large-dataset-with-filtering
|
|
||||||
- img [ref=e178]
|
|
||||||
- text: Large Dataset With Filtering
|
|
||||||
- generic [ref=e180]:
|
|
||||||
- button "Collapse" [expanded] [ref=e181] [cursor=pointer]:
|
|
||||||
- img [ref=e183]
|
|
||||||
- text: Former
|
|
||||||
- button "Expand all" [ref=e185] [cursor=pointer]:
|
|
||||||
- img [ref=e186]
|
|
||||||
- button "Former Basic" [ref=e189] [cursor=pointer]:
|
|
||||||
- generic [ref=e190]:
|
|
||||||
- img [ref=e192]
|
|
||||||
- img [ref=e194]
|
|
||||||
- text: Former Basic
|
|
||||||
- button "Controls Basic" [ref=e197] [cursor=pointer]:
|
|
||||||
- generic [ref=e198]:
|
|
||||||
- img [ref=e200]
|
|
||||||
- img [ref=e202]
|
|
||||||
- text: Controls Basic
|
|
||||||
- generic [ref=e204]:
|
|
||||||
- button "Collapse" [expanded] [ref=e205] [cursor=pointer]:
|
|
||||||
- img [ref=e207]
|
|
||||||
- text: State
|
|
||||||
- button "Expand all" [ref=e209] [cursor=pointer]:
|
|
||||||
- img [ref=e210]
|
|
||||||
- button "GlobalStateStore" [ref=e213] [cursor=pointer]:
|
|
||||||
- generic [ref=e214]:
|
|
||||||
- img [ref=e216]
|
|
||||||
- img [ref=e218]
|
|
||||||
- text: GlobalStateStore
|
|
||||||
- generic [ref=e220]:
|
|
||||||
- button "Collapse" [expanded] [ref=e221] [cursor=pointer]:
|
|
||||||
- img [ref=e223]
|
|
||||||
- text: Grid
|
|
||||||
- button "Expand all" [ref=e225] [cursor=pointer]:
|
|
||||||
- img [ref=e226]
|
|
||||||
- button "Gridler API" [ref=e229] [cursor=pointer]:
|
|
||||||
- generic [ref=e230]:
|
|
||||||
- img [ref=e232]
|
|
||||||
- img [ref=e234]
|
|
||||||
- text: Gridler API
|
|
||||||
- button "Gridler Local" [ref=e237] [cursor=pointer]:
|
|
||||||
- generic [ref=e238]:
|
|
||||||
- img [ref=e240]
|
|
||||||
- img [ref=e242]
|
|
||||||
- text: Gridler Local
|
|
||||||
- generic [ref=e244]:
|
|
||||||
- button "Collapse" [expanded] [ref=e245] [cursor=pointer]:
|
|
||||||
- img [ref=e247]
|
|
||||||
- text: UI
|
|
||||||
- button "Expand all" [ref=e249] [cursor=pointer]:
|
|
||||||
- img [ref=e250]
|
|
||||||
- button "Mantine Better Menu" [ref=e253] [cursor=pointer]:
|
|
||||||
- generic [ref=e254]:
|
|
||||||
- img [ref=e256]
|
|
||||||
- img [ref=e258]
|
|
||||||
- text: Mantine Better Menu
|
|
||||||
- generic [ref=e261]:
|
|
||||||
- region "Toolbar" [ref=e262]:
|
|
||||||
- heading "Toolbar" [level=2] [ref=e263]
|
|
||||||
- toolbar [ref=e264]:
|
|
||||||
- generic [ref=e265]:
|
|
||||||
- button "Reload story" [ref=e266] [cursor=pointer]:
|
|
||||||
- img [ref=e267]
|
|
||||||
- switch "Grid visibility" [ref=e269] [cursor=pointer]:
|
|
||||||
- img [ref=e270]
|
|
||||||
- button "Preview background" [ref=e272] [cursor=pointer]:
|
|
||||||
- img [ref=e273]
|
|
||||||
- switch "Measure tool" [ref=e276] [cursor=pointer]:
|
|
||||||
- img [ref=e277]
|
|
||||||
- switch "Outline tool" [ref=e280] [cursor=pointer]:
|
|
||||||
- img [ref=e281]
|
|
||||||
- button "Viewport size" [ref=e283] [cursor=pointer]:
|
|
||||||
- img [ref=e284]
|
|
||||||
- generic [ref=e288]:
|
|
||||||
- switch "Change zoom level" [ref=e289] [cursor=pointer]: 100%
|
|
||||||
- button "Enter full screen" [ref=e290] [cursor=pointer]:
|
|
||||||
- img [ref=e291]
|
|
||||||
- button "Share" [ref=e293] [cursor=pointer]:
|
|
||||||
- img [ref=e294]
|
|
||||||
- button "Open in editor" [ref=e297] [cursor=pointer]:
|
|
||||||
- img [ref=e298]
|
|
||||||
- main "Main preview area" [ref=e301]:
|
|
||||||
- heading "Main preview area" [level=2] [ref=e302]
|
|
||||||
- generic [ref=e304]:
|
|
||||||
- link "Skip to sidebar" [ref=e305] [cursor=pointer]:
|
|
||||||
- /url: "#components-griddy--with-text-filtering"
|
|
||||||
- iframe [ref=e309]:
|
|
||||||
- generic [ref=f1e4]:
|
|
||||||
- grid "Data grid" [ref=f1e5]:
|
|
||||||
- generic [ref=f1e6]:
|
|
||||||
- rowgroup [ref=f1e7]:
|
|
||||||
- row "ID First Name Filter status indicator Last Name Filter status indicator Email Age Department Salary Start Date Active" [ref=f1e8]:
|
|
||||||
- columnheader "ID" [ref=f1e9] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e11]: ID
|
|
||||||
- columnheader "First Name Filter status indicator" [ref=f1e13] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e15]:
|
|
||||||
- text: First Name
|
|
||||||
- button "Filter status indicator" [disabled] [ref=f1e16]:
|
|
||||||
- img [ref=f1e18]
|
|
||||||
- columnheader "Last Name Filter status indicator" [ref=f1e21] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e23]:
|
|
||||||
- text: Last Name
|
|
||||||
- button "Filter status indicator" [disabled] [ref=f1e24]:
|
|
||||||
- img [ref=f1e26]
|
|
||||||
- columnheader "Email" [ref=f1e29] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e31]: Email
|
|
||||||
- columnheader "Age" [ref=f1e33] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e35]: Age
|
|
||||||
- columnheader "Department" [ref=f1e37] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e39]: Department
|
|
||||||
- columnheader "Salary" [ref=f1e41] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e43]: Salary
|
|
||||||
- columnheader "Start Date" [ref=f1e45] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e47]: Start Date
|
|
||||||
- columnheader "Active" [ref=f1e49] [cursor=pointer]:
|
|
||||||
- generic [ref=f1e51]: Active
|
|
||||||
- rowgroup [ref=f1e53]:
|
|
||||||
- row "1 Alice Smith alice.smith@example.com 22 Engineering $40,000 2020-01-01 No" [ref=f1e54]:
|
|
||||||
- gridcell "1" [ref=f1e55]
|
|
||||||
- gridcell "Alice" [ref=f1e56]
|
|
||||||
- gridcell "Smith" [ref=f1e57]
|
|
||||||
- gridcell "alice.smith@example.com" [ref=f1e58]
|
|
||||||
- gridcell "22" [ref=f1e59]
|
|
||||||
- gridcell "Engineering" [ref=f1e60]
|
|
||||||
- gridcell "$40,000" [ref=f1e61]
|
|
||||||
- gridcell "2020-01-01" [ref=f1e62]
|
|
||||||
- gridcell "No" [ref=f1e63]
|
|
||||||
- row "2 Bob Johnson bob.johnson@example.com 23 Marketing $41,234 2021-02-02 Yes" [ref=f1e64]:
|
|
||||||
- gridcell "2" [ref=f1e65]
|
|
||||||
- gridcell "Bob" [ref=f1e66]
|
|
||||||
- gridcell "Johnson" [ref=f1e67]
|
|
||||||
- gridcell "bob.johnson@example.com" [ref=f1e68]
|
|
||||||
- gridcell "23" [ref=f1e69]
|
|
||||||
- gridcell "Marketing" [ref=f1e70]
|
|
||||||
- gridcell "$41,234" [ref=f1e71]
|
|
||||||
- gridcell "2021-02-02" [ref=f1e72]
|
|
||||||
- gridcell "Yes" [ref=f1e73]
|
|
||||||
- row "3 Charlie Williams charlie.williams@example.com 24 Sales $42,468 2022-03-03 Yes" [ref=f1e74]:
|
|
||||||
- gridcell "3" [ref=f1e75]
|
|
||||||
- gridcell "Charlie" [ref=f1e76]
|
|
||||||
- gridcell "Williams" [ref=f1e77]
|
|
||||||
- gridcell "charlie.williams@example.com" [ref=f1e78]
|
|
||||||
- gridcell "24" [ref=f1e79]
|
|
||||||
- gridcell "Sales" [ref=f1e80]
|
|
||||||
- gridcell "$42,468" [ref=f1e81]
|
|
||||||
- gridcell "2022-03-03" [ref=f1e82]
|
|
||||||
- gridcell "Yes" [ref=f1e83]
|
|
||||||
- row "4 Diana Brown diana.brown@example.com 25 HR $43,702 2023-04-04 No" [ref=f1e84]:
|
|
||||||
- gridcell "4" [ref=f1e85]
|
|
||||||
- gridcell "Diana" [ref=f1e86]
|
|
||||||
- gridcell "Brown" [ref=f1e87]
|
|
||||||
- gridcell "diana.brown@example.com" [ref=f1e88]
|
|
||||||
- gridcell "25" [ref=f1e89]
|
|
||||||
- gridcell "HR" [ref=f1e90]
|
|
||||||
- gridcell "$43,702" [ref=f1e91]
|
|
||||||
- gridcell "2023-04-04" [ref=f1e92]
|
|
||||||
- gridcell "No" [ref=f1e93]
|
|
||||||
- row "5 Eve Jones eve.jones@example.com 26 Finance $44,936 2024-05-05 Yes" [ref=f1e94]:
|
|
||||||
- gridcell "5" [ref=f1e95]
|
|
||||||
- gridcell "Eve" [ref=f1e96]
|
|
||||||
- gridcell "Jones" [ref=f1e97]
|
|
||||||
- gridcell "eve.jones@example.com" [ref=f1e98]
|
|
||||||
- gridcell "26" [ref=f1e99]
|
|
||||||
- gridcell "Finance" [ref=f1e100]
|
|
||||||
- gridcell "$44,936" [ref=f1e101]
|
|
||||||
- gridcell "2024-05-05" [ref=f1e102]
|
|
||||||
- gridcell "Yes" [ref=f1e103]
|
|
||||||
- row "6 Frank Garcia frank.garcia@example.com 27 Design $46,170 2020-06-06 Yes" [ref=f1e104]:
|
|
||||||
- gridcell "6" [ref=f1e105]
|
|
||||||
- gridcell "Frank" [ref=f1e106]
|
|
||||||
- gridcell "Garcia" [ref=f1e107]
|
|
||||||
- gridcell "frank.garcia@example.com" [ref=f1e108]
|
|
||||||
- gridcell "27" [ref=f1e109]
|
|
||||||
- gridcell "Design" [ref=f1e110]
|
|
||||||
- gridcell "$46,170" [ref=f1e111]
|
|
||||||
- gridcell "2020-06-06" [ref=f1e112]
|
|
||||||
- gridcell "Yes" [ref=f1e113]
|
|
||||||
- row "7 Grace Miller grace.miller@example.com 28 Legal $47,404 2021-07-07 No" [ref=f1e114]:
|
|
||||||
- gridcell "7" [ref=f1e115]
|
|
||||||
- gridcell "Grace" [ref=f1e116]
|
|
||||||
- gridcell "Miller" [ref=f1e117]
|
|
||||||
- gridcell "grace.miller@example.com" [ref=f1e118]
|
|
||||||
- gridcell "28" [ref=f1e119]
|
|
||||||
- gridcell "Legal" [ref=f1e120]
|
|
||||||
- gridcell "$47,404" [ref=f1e121]
|
|
||||||
- gridcell "2021-07-07" [ref=f1e122]
|
|
||||||
- gridcell "No" [ref=f1e123]
|
|
||||||
- row "8 Henry Davis henry.davis@example.com 29 Support $48,638 2022-08-08 Yes" [ref=f1e124]:
|
|
||||||
- gridcell "8" [ref=f1e125]
|
|
||||||
- gridcell "Henry" [ref=f1e126]
|
|
||||||
- gridcell "Davis" [ref=f1e127]
|
|
||||||
- gridcell "henry.davis@example.com" [ref=f1e128]
|
|
||||||
- gridcell "29" [ref=f1e129]
|
|
||||||
- gridcell "Support" [ref=f1e130]
|
|
||||||
- gridcell "$48,638" [ref=f1e131]
|
|
||||||
- gridcell "2022-08-08" [ref=f1e132]
|
|
||||||
- gridcell "Yes" [ref=f1e133]
|
|
||||||
- row "9 Ivy Martinez ivy.martinez@example.com 30 Engineering $49,872 2023-09-09 Yes" [ref=f1e134]:
|
|
||||||
- gridcell "9" [ref=f1e135]
|
|
||||||
- gridcell "Ivy" [ref=f1e136]
|
|
||||||
- gridcell "Martinez" [ref=f1e137]
|
|
||||||
- gridcell "ivy.martinez@example.com" [ref=f1e138]
|
|
||||||
- gridcell "30" [ref=f1e139]
|
|
||||||
- gridcell "Engineering" [ref=f1e140]
|
|
||||||
- gridcell "$49,872" [ref=f1e141]
|
|
||||||
- gridcell "2023-09-09" [ref=f1e142]
|
|
||||||
- gridcell "Yes" [ref=f1e143]
|
|
||||||
- row "10 Jack Anderson jack.anderson@example.com 31 Marketing $51,106 2024-10-10 No" [ref=f1e144]:
|
|
||||||
- gridcell "10" [ref=f1e145]
|
|
||||||
- gridcell "Jack" [ref=f1e146]
|
|
||||||
- gridcell "Anderson" [ref=f1e147]
|
|
||||||
- gridcell "jack.anderson@example.com" [ref=f1e148]
|
|
||||||
- gridcell "31" [ref=f1e149]
|
|
||||||
- gridcell "Marketing" [ref=f1e150]
|
|
||||||
- gridcell "$51,106" [ref=f1e151]
|
|
||||||
- gridcell "2024-10-10" [ref=f1e152]
|
|
||||||
- gridcell "No" [ref=f1e153]
|
|
||||||
- row "11 Karen Taylor karen.taylor@example.com 32 Sales $52,340 2020-11-11 Yes" [ref=f1e154]:
|
|
||||||
- gridcell "11" [ref=f1e155]
|
|
||||||
- gridcell "Karen" [ref=f1e156]
|
|
||||||
- gridcell "Taylor" [ref=f1e157]
|
|
||||||
- gridcell "karen.taylor@example.com" [ref=f1e158]
|
|
||||||
- gridcell "32" [ref=f1e159]
|
|
||||||
- gridcell "Sales" [ref=f1e160]
|
|
||||||
- gridcell "$52,340" [ref=f1e161]
|
|
||||||
- gridcell "2020-11-11" [ref=f1e162]
|
|
||||||
- gridcell "Yes" [ref=f1e163]
|
|
||||||
- row "12 Leo Thomas leo.thomas@example.com 33 HR $53,574 2021-12-12 Yes" [ref=f1e164]:
|
|
||||||
- gridcell "12" [ref=f1e165]
|
|
||||||
- gridcell "Leo" [ref=f1e166]
|
|
||||||
- gridcell "Thomas" [ref=f1e167]
|
|
||||||
- gridcell "leo.thomas@example.com" [ref=f1e168]
|
|
||||||
- gridcell "33" [ref=f1e169]
|
|
||||||
- gridcell "HR" [ref=f1e170]
|
|
||||||
- gridcell "$53,574" [ref=f1e171]
|
|
||||||
- gridcell "2021-12-12" [ref=f1e172]
|
|
||||||
- gridcell "Yes" [ref=f1e173]
|
|
||||||
- row "13 Mia Hernandez mia.hernandez@example.com 34 Finance $54,808 2022-01-13 No" [ref=f1e174]:
|
|
||||||
- gridcell "13" [ref=f1e175]
|
|
||||||
- gridcell "Mia" [ref=f1e176]
|
|
||||||
- gridcell "Hernandez" [ref=f1e177]
|
|
||||||
- gridcell "mia.hernandez@example.com" [ref=f1e178]
|
|
||||||
- gridcell "34" [ref=f1e179]
|
|
||||||
- gridcell "Finance" [ref=f1e180]
|
|
||||||
- gridcell "$54,808" [ref=f1e181]
|
|
||||||
- gridcell "2022-01-13" [ref=f1e182]
|
|
||||||
- gridcell "No" [ref=f1e183]
|
|
||||||
- row "14 Nick Moore nick.moore@example.com 35 Design $56,042 2023-02-14 Yes" [ref=f1e184]:
|
|
||||||
- gridcell "14" [ref=f1e185]
|
|
||||||
- gridcell "Nick" [ref=f1e186]
|
|
||||||
- gridcell "Moore" [ref=f1e187]
|
|
||||||
- gridcell "nick.moore@example.com" [ref=f1e188]
|
|
||||||
- gridcell "35" [ref=f1e189]
|
|
||||||
- gridcell "Design" [ref=f1e190]
|
|
||||||
- gridcell "$56,042" [ref=f1e191]
|
|
||||||
- gridcell "2023-02-14" [ref=f1e192]
|
|
||||||
- gridcell "Yes" [ref=f1e193]
|
|
||||||
- row "15 Olivia Martin olivia.martin@example.com 36 Legal $57,276 2024-03-15 Yes" [ref=f1e194]:
|
|
||||||
- gridcell "15" [ref=f1e195]
|
|
||||||
- gridcell "Olivia" [ref=f1e196]
|
|
||||||
- gridcell "Martin" [ref=f1e197]
|
|
||||||
- gridcell "olivia.martin@example.com" [ref=f1e198]
|
|
||||||
- gridcell "36" [ref=f1e199]
|
|
||||||
- gridcell "Legal" [ref=f1e200]
|
|
||||||
- gridcell "$57,276" [ref=f1e201]
|
|
||||||
- gridcell "2024-03-15" [ref=f1e202]
|
|
||||||
- gridcell "Yes" [ref=f1e203]
|
|
||||||
- row "16 Paul Jackson paul.jackson@example.com 37 Support $58,510 2020-04-16 No" [ref=f1e204]:
|
|
||||||
- gridcell "16" [ref=f1e205]
|
|
||||||
- gridcell "Paul" [ref=f1e206]
|
|
||||||
- gridcell "Jackson" [ref=f1e207]
|
|
||||||
- gridcell "paul.jackson@example.com" [ref=f1e208]
|
|
||||||
- gridcell "37" [ref=f1e209]
|
|
||||||
- gridcell "Support" [ref=f1e210]
|
|
||||||
- gridcell "$58,510" [ref=f1e211]
|
|
||||||
- gridcell "2020-04-16" [ref=f1e212]
|
|
||||||
- gridcell "No" [ref=f1e213]
|
|
||||||
- row "17 Quinn Thompson quinn.thompson@example.com 38 Engineering $59,744 2021-05-17 Yes" [ref=f1e214]:
|
|
||||||
- gridcell "17" [ref=f1e215]
|
|
||||||
- gridcell "Quinn" [ref=f1e216]
|
|
||||||
- gridcell "Thompson" [ref=f1e217]
|
|
||||||
- gridcell "quinn.thompson@example.com" [ref=f1e218]
|
|
||||||
- gridcell "38" [ref=f1e219]
|
|
||||||
- gridcell "Engineering" [ref=f1e220]
|
|
||||||
- gridcell "$59,744" [ref=f1e221]
|
|
||||||
- gridcell "2021-05-17" [ref=f1e222]
|
|
||||||
- gridcell "Yes" [ref=f1e223]
|
|
||||||
- row "18 Rose White rose.white@example.com 39 Marketing $60,978 2022-06-18 Yes" [ref=f1e224]:
|
|
||||||
- gridcell "18" [ref=f1e225]
|
|
||||||
- gridcell "Rose" [ref=f1e226]
|
|
||||||
- gridcell "White" [ref=f1e227]
|
|
||||||
- gridcell "rose.white@example.com" [ref=f1e228]
|
|
||||||
- gridcell "39" [ref=f1e229]
|
|
||||||
- gridcell "Marketing" [ref=f1e230]
|
|
||||||
- gridcell "$60,978" [ref=f1e231]
|
|
||||||
- gridcell "2022-06-18" [ref=f1e232]
|
|
||||||
- gridcell "Yes" [ref=f1e233]
|
|
||||||
- row "19 Sam Lopez sam.lopez@example.com 40 Sales $62,212 2023-07-19 No" [ref=f1e234]:
|
|
||||||
- gridcell "19" [ref=f1e235]
|
|
||||||
- gridcell "Sam" [ref=f1e236]
|
|
||||||
- gridcell "Lopez" [ref=f1e237]
|
|
||||||
- gridcell "sam.lopez@example.com" [ref=f1e238]
|
|
||||||
- gridcell "40" [ref=f1e239]
|
|
||||||
- gridcell "Sales" [ref=f1e240]
|
|
||||||
- gridcell "$62,212" [ref=f1e241]
|
|
||||||
- gridcell "2023-07-19" [ref=f1e242]
|
|
||||||
- gridcell "No" [ref=f1e243]
|
|
||||||
- row "20 Tina Lee tina.lee@example.com 41 HR $63,446 2024-08-20 Yes" [ref=f1e244]:
|
|
||||||
- gridcell "20" [ref=f1e245]
|
|
||||||
- gridcell "Tina" [ref=f1e246]
|
|
||||||
- gridcell "Lee" [ref=f1e247]
|
|
||||||
- gridcell "tina.lee@example.com" [ref=f1e248]
|
|
||||||
- gridcell "41" [ref=f1e249]
|
|
||||||
- gridcell "HR" [ref=f1e250]
|
|
||||||
- gridcell "$63,446" [ref=f1e251]
|
|
||||||
- gridcell "2024-08-20" [ref=f1e252]
|
|
||||||
- gridcell "Yes" [ref=f1e253]
|
|
||||||
- generic [ref=f1e254]:
|
|
||||||
- strong [ref=f1e255]: "Active Filters:"
|
|
||||||
- generic [ref=f1e256]: "[]"
|
|
||||||
- region "Addon panel" [ref=e312]:
|
|
||||||
- heading "Addon panel" [level=2] [ref=e313]
|
|
||||||
- generic [ref=e314]:
|
|
||||||
- generic [ref=e315]:
|
|
||||||
- generic [ref=e316]:
|
|
||||||
- button "Move addon panel to right" [ref=e317] [cursor=pointer]:
|
|
||||||
- img [ref=e318]
|
|
||||||
- button "Hide addon panel" [ref=e321] [cursor=pointer]:
|
|
||||||
- img [ref=e322]
|
|
||||||
- tablist "Available addons" [ref=e327]:
|
|
||||||
- tab "Controls" [selected] [ref=e328] [cursor=pointer]:
|
|
||||||
- generic [ref=e330]: Controls
|
|
||||||
- tab "Actions" [ref=e331] [cursor=pointer]:
|
|
||||||
- generic [ref=e333]: Actions
|
|
||||||
- tab "Interactions" [ref=e334] [cursor=pointer]:
|
|
||||||
- generic [ref=e336]: Interactions
|
|
||||||
- tabpanel "Controls" [ref=e337]:
|
|
||||||
- generic [ref=e344]:
|
|
||||||
- button "Reset controls" [ref=e346] [cursor=pointer]:
|
|
||||||
- img [ref=e347]
|
|
||||||
- table [ref=e349]:
|
|
||||||
- rowgroup [ref=e350]:
|
|
||||||
- row "Name Description Default Control" [ref=e351]:
|
|
||||||
- columnheader "Name" [ref=e352]
|
|
||||||
- columnheader "Description" [ref=e353]
|
|
||||||
- columnheader "Default" [ref=e354]
|
|
||||||
- columnheader "Control" [ref=e355]
|
|
||||||
- rowgroup [ref=e356]:
|
|
||||||
- row "columns array - -" [ref=e357]:
|
|
||||||
- cell "columns" [ref=e358]
|
|
||||||
- cell "array" [ref=e359]:
|
|
||||||
- generic [ref=e362]: array
|
|
||||||
- cell "-" [ref=e363]
|
|
||||||
- cell "-" [ref=e364]
|
|
||||||
- row "data array - -" [ref=e365]:
|
|
||||||
- cell "data" [ref=e366]
|
|
||||||
- cell "array" [ref=e367]:
|
|
||||||
- generic [ref=e370]: array
|
|
||||||
- cell "-" [ref=e371]
|
|
||||||
- cell "-" [ref=e372]
|
|
||||||
- row "getRowId function - -" [ref=e373]:
|
|
||||||
- cell "getRowId" [ref=e374]
|
|
||||||
- cell "function" [ref=e375]:
|
|
||||||
- generic [ref=e378]: function
|
|
||||||
- cell "-" [ref=e379]
|
|
||||||
- cell "-" [ref=e380]
|
|
||||||
- row "height number - -" [ref=e381]:
|
|
||||||
- cell "height" [ref=e382]
|
|
||||||
- cell "number" [ref=e383]:
|
|
||||||
- generic [ref=e386]: number
|
|
||||||
- cell "-" [ref=e387]
|
|
||||||
- cell "-" [ref=e388]
|
|
||||||
```
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -2088,12 +2088,12 @@ export const WithSearchHistory: Story = {
|
|||||||
// ─── Tree/Hierarchical Data Stories ──────────────────────────────────────────
|
// ─── Tree/Hierarchical Data Stories ──────────────────────────────────────────
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
|
children?: TreeNode[];
|
||||||
|
email?: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'department' | 'team' | 'person';
|
|
||||||
email?: string;
|
|
||||||
role?: string;
|
role?: string;
|
||||||
children?: TreeNode[];
|
type: 'department' | 'person' | 'team';
|
||||||
}
|
}
|
||||||
|
|
||||||
const treeData: TreeNode[] = [
|
const treeData: TreeNode[] = [
|
||||||
@@ -2203,11 +2203,11 @@ export const TreeNestedMode: Story = {
|
|||||||
export const TreeFlatMode: Story = {
|
export const TreeFlatMode: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
interface FlatNode {
|
interface FlatNode {
|
||||||
id: string;
|
|
||||||
parentId?: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
email?: string;
|
email?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId?: string;
|
||||||
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flatData: FlatNode[] = [
|
const flatData: FlatNode[] = [
|
||||||
@@ -2219,7 +2219,7 @@ export const TreeFlatMode: Story = {
|
|||||||
{ email: 'charlie@example.com', id: 'p3', name: 'Charlie Brown', parentId: 't2', type: 'person' },
|
{ email: 'charlie@example.com', id: 'p3', name: 'Charlie Brown', parentId: 't2', type: 'person' },
|
||||||
{ id: 'd2', name: 'Design', type: 'department' },
|
{ id: 'd2', name: 'Design', type: 'department' },
|
||||||
{ id: 't3', name: 'Product Design', parentId: 'd2', type: 'team' },
|
{ id: 't3', name: 'Product Design', parentId: 'd2', type: 'team' },
|
||||||
{ email: 'diana@example.com', id: 'p4', name: 'Diana Prince', parentId: 't3', type: 'person' },
|
{ email: 'frank@example.com', id: 'p4', name: 'Frank Miller', parentId: 't3', type: 'person' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const flatColumns: GriddyColumn<FlatNode>[] = [
|
const flatColumns: GriddyColumn<FlatNode>[] = [
|
||||||
@@ -2263,9 +2263,9 @@ export const TreeFlatMode: Story = {
|
|||||||
export const TreeLazyMode: Story = {
|
export const TreeLazyMode: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
interface LazyNode {
|
interface LazyNode {
|
||||||
|
hasChildren: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
hasChildren: boolean;
|
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,13 +209,16 @@ export const ResolveSpec: Story = {
|
|||||||
export const HeaderSpec: Story = {
|
export const HeaderSpec: Story = {
|
||||||
args: {
|
args: {
|
||||||
baseUrl: 'https://utils.btsys.tech/api',
|
baseUrl: 'https://utils.btsys.tech/api',
|
||||||
|
|
||||||
columnMap: {
|
columnMap: {
|
||||||
active: 'inactive',
|
active: 'inactive',
|
||||||
department: 'department',
|
department: 'department',
|
||||||
email: 'email',
|
email: 'logmessage',
|
||||||
name: 'name',
|
name: 'type',
|
||||||
},
|
},
|
||||||
|
|
||||||
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
|
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
|
||||||
|
entity: 'synclog',
|
||||||
},
|
},
|
||||||
|
|
||||||
render: (args) => <HeaderSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
|
render: (args) => <HeaderSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
|
||||||
@@ -394,8 +397,15 @@ export const WithOffsetPagination: Story = {
|
|||||||
export const HeaderSpecInfiniteScroll: Story = {
|
export const HeaderSpecInfiniteScroll: Story = {
|
||||||
args: {
|
args: {
|
||||||
baseUrl: 'https://utils.btsys.tech/api',
|
baseUrl: 'https://utils.btsys.tech/api',
|
||||||
columnMap: {},
|
|
||||||
|
columnMap: {
|
||||||
|
name: 'logtype',
|
||||||
|
email: 'logmessage',
|
||||||
|
},
|
||||||
|
|
||||||
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
|
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
|
||||||
|
entity: 'synclog',
|
||||||
|
cursorField: 'id',
|
||||||
},
|
},
|
||||||
|
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
|||||||
// Tree support: configure getSubRows for TanStack Table
|
// Tree support: configure getSubRows for TanStack Table
|
||||||
...(tree?.enabled
|
...(tree?.enabled
|
||||||
? {
|
? {
|
||||||
|
filterFromLeafRows: true,
|
||||||
getSubRows: (row: any) => {
|
getSubRows: (row: any) => {
|
||||||
const childrenField = (tree.childrenField as string) || 'children';
|
const childrenField = (tree.childrenField as string) || 'children';
|
||||||
if (childrenField !== 'subRows' && row[childrenField]) {
|
if (childrenField !== 'subRows' && row[childrenField]) {
|
||||||
@@ -274,6 +275,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
|||||||
// Lazy tree expansion
|
// Lazy tree expansion
|
||||||
useLazyTreeExpansion({
|
useLazyTreeExpansion({
|
||||||
data: transformedData,
|
data: transformedData,
|
||||||
|
expanded,
|
||||||
setData,
|
setData,
|
||||||
setTreeChildrenCache,
|
setTreeChildrenCache,
|
||||||
setTreeLoadingNode,
|
setTreeLoadingNode,
|
||||||
@@ -284,6 +286,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
|||||||
|
|
||||||
// Auto-expand on search
|
// Auto-expand on search
|
||||||
useAutoExpandOnSearch({
|
useAutoExpandOnSearch({
|
||||||
|
globalFilter,
|
||||||
table,
|
table,
|
||||||
tree,
|
tree,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,16 +82,16 @@ export interface GriddyStoreState extends GriddyUIState {
|
|||||||
setScrollRef: (el: HTMLDivElement | null) => void;
|
setScrollRef: (el: HTMLDivElement | null) => void;
|
||||||
// ─── Internal ref setters ───
|
// ─── Internal ref setters ───
|
||||||
setTable: (table: Table<any>) => void;
|
setTable: (table: Table<any>) => void;
|
||||||
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
|
setTreeChildrenCache: (nodeId: string, children: any[]) => void;
|
||||||
|
|
||||||
|
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
|
||||||
|
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
|
||||||
showToolbar?: boolean;
|
showToolbar?: boolean;
|
||||||
sorting?: SortingState;
|
sorting?: SortingState;
|
||||||
// ─── Tree/Hierarchical Data ───
|
// ─── Tree/Hierarchical Data ───
|
||||||
tree?: TreeConfig<any>;
|
tree?: TreeConfig<any>;
|
||||||
treeLoadingNodes: Set<string>;
|
|
||||||
treeChildrenCache: Map<string, any[]>;
|
treeChildrenCache: Map<string, any[]>;
|
||||||
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
|
treeLoadingNodes: Set<string>;
|
||||||
setTreeChildrenCache: (nodeId: string, children: any[]) => void;
|
|
||||||
// ─── Synced from GriddyProps (written by $sync) ───
|
// ─── Synced from GriddyProps (written by $sync) ───
|
||||||
uniqueId?: string;
|
uniqueId?: string;
|
||||||
}
|
}
|
||||||
@@ -150,10 +150,12 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
|
|||||||
setTable: (table) => set({ _table: table }),
|
setTable: (table) => set({ _table: table }),
|
||||||
|
|
||||||
setTotalRows: (count) => set({ totalRows: count }),
|
setTotalRows: (count) => set({ totalRows: count }),
|
||||||
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
|
setTreeChildrenCache: (nodeId, children) =>
|
||||||
// ─── Tree State ───
|
set((state) => {
|
||||||
treeLoadingNodes: new Set(),
|
const newMap = new Map(state.treeChildrenCache);
|
||||||
treeChildrenCache: new Map(),
|
newMap.set(nodeId, children);
|
||||||
|
return { treeChildrenCache: newMap };
|
||||||
|
}),
|
||||||
setTreeLoadingNode: (nodeId, loading) =>
|
setTreeLoadingNode: (nodeId, loading) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newSet = new Set(state.treeLoadingNodes);
|
const newSet = new Set(state.treeLoadingNodes);
|
||||||
@@ -164,12 +166,10 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
|
|||||||
}
|
}
|
||||||
return { treeLoadingNodes: newSet };
|
return { treeLoadingNodes: newSet };
|
||||||
}),
|
}),
|
||||||
setTreeChildrenCache: (nodeId, children) =>
|
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
|
||||||
set((state) => {
|
|
||||||
const newMap = new Map(state.treeChildrenCache);
|
|
||||||
newMap.set(nodeId, children);
|
|
||||||
return { treeChildrenCache: newMap };
|
|
||||||
}),
|
|
||||||
// ─── Row Count ───
|
// ─── Row Count ───
|
||||||
totalRows: 0,
|
totalRows: 0,
|
||||||
|
treeChildrenCache: new Map(),
|
||||||
|
// ─── Tree State ───
|
||||||
|
treeLoadingNodes: new Set(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -305,49 +305,49 @@ export interface SelectionConfig {
|
|||||||
// ─── Tree/Hierarchical Data ──────────────────────────────────────────────────
|
// ─── Tree/Hierarchical Data ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TreeConfig<T> {
|
export interface TreeConfig<T> {
|
||||||
/** Enable tree/hierarchical data mode */
|
// ─── UI Configuration ───
|
||||||
enabled: boolean;
|
/** Auto-expand parent nodes when search matches children. Default: true */
|
||||||
|
autoExpandOnSearch?: boolean;
|
||||||
/** Tree data mode. Default: 'nested' */
|
|
||||||
mode?: 'flat' | 'lazy' | 'nested';
|
|
||||||
|
|
||||||
// ─── Flat Mode ───
|
|
||||||
/** Field name for parent ID in flat mode. Default: 'parentId' */
|
|
||||||
parentIdField?: keyof T | string;
|
|
||||||
|
|
||||||
// ─── Nested Mode ───
|
// ─── Nested Mode ───
|
||||||
/** Field name for children array in nested mode. Default: 'children' */
|
/** Field name for children array in nested mode. Default: 'children' */
|
||||||
childrenField?: keyof T | string;
|
childrenField?: keyof T | string;
|
||||||
|
|
||||||
// ─── Lazy Mode ───
|
|
||||||
/** Async function to fetch children for a parent node */
|
|
||||||
getChildren?: (parent: T) => Promise<T[]> | T[];
|
|
||||||
/** Function to determine if a node has children (for lazy mode) */
|
|
||||||
hasChildren?: (row: T) => boolean;
|
|
||||||
|
|
||||||
// ─── Expansion State ───
|
// ─── Expansion State ───
|
||||||
/** Default expanded state (record or array of IDs) */
|
/** Default expanded state (record or array of IDs) */
|
||||||
defaultExpanded?: Record<string, boolean> | string[];
|
defaultExpanded?: Record<string, boolean> | string[];
|
||||||
|
|
||||||
|
/** Enable tree/hierarchical data mode */
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
/** Controlled expanded state */
|
/** Controlled expanded state */
|
||||||
expanded?: Record<string, boolean>;
|
expanded?: Record<string, boolean>;
|
||||||
/** Callback when expanded state changes */
|
// ─── Lazy Mode ───
|
||||||
onExpandedChange?: (expanded: Record<string, boolean>) => void;
|
/** Async function to fetch children for a parent node */
|
||||||
|
getChildren?: (parent: T) => Promise<T[]> | T[];
|
||||||
|
|
||||||
// ─── UI Configuration ───
|
/** Function to determine if a node has children (for lazy mode) */
|
||||||
/** Auto-expand parent nodes when search matches children. Default: true */
|
hasChildren?: (row: T) => boolean;
|
||||||
autoExpandOnSearch?: boolean;
|
|
||||||
/** Indentation size per depth level in pixels. Default: 20 */
|
|
||||||
indentSize?: number;
|
|
||||||
/** Maximum tree depth to render. Default: Infinity */
|
|
||||||
maxDepth?: number;
|
|
||||||
/** Show expand/collapse icon. Default: true */
|
|
||||||
showExpandIcon?: boolean;
|
|
||||||
/** Custom icons for tree states */
|
/** Custom icons for tree states */
|
||||||
icons?: {
|
icons?: {
|
||||||
collapsed?: ReactNode;
|
collapsed?: ReactNode;
|
||||||
expanded?: ReactNode;
|
expanded?: ReactNode;
|
||||||
leaf?: ReactNode;
|
leaf?: ReactNode;
|
||||||
};
|
};
|
||||||
|
/** Indentation size per depth level in pixels. Default: 20 */
|
||||||
|
indentSize?: number;
|
||||||
|
|
||||||
|
/** Maximum tree depth to render. Default: Infinity */
|
||||||
|
maxDepth?: number;
|
||||||
|
/** Tree data mode. Default: 'nested' */
|
||||||
|
mode?: 'flat' | 'lazy' | 'nested';
|
||||||
|
/** Callback when expanded state changes */
|
||||||
|
onExpandedChange?: (expanded: Record<string, boolean>) => void;
|
||||||
|
// ─── Flat Mode ───
|
||||||
|
/** Field name for parent ID in flat mode. Default: 'parentId' */
|
||||||
|
parentIdField?: keyof T | string;
|
||||||
|
/** Show expand/collapse icon. Default: true */
|
||||||
|
showExpandIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Re-exports for convenience ──────────────────────────────────────────────
|
// ─── Re-exports for convenience ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -16,23 +16,6 @@ interface UseKeyboardNavigationOptions<TData = unknown> {
|
|||||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to find parent row in tree structure
|
|
||||||
*/
|
|
||||||
function findParentRow<TData>(rows: any[], childRow: any): any | null {
|
|
||||||
const childIndex = rows.findIndex((r) => r.id === childRow.id);
|
|
||||||
if (childIndex === -1) return null;
|
|
||||||
|
|
||||||
const targetDepth = childRow.depth - 1;
|
|
||||||
// Search backwards from child position
|
|
||||||
for (let i = childIndex - 1; i >= 0; i--) {
|
|
||||||
if (rows[i].depth === targetDepth) {
|
|
||||||
return rows[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardNavigation<TData = unknown>({
|
export function useKeyboardNavigation<TData = unknown>({
|
||||||
editingEnabled,
|
editingEnabled,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
@@ -307,3 +290,20 @@ export function useKeyboardNavigation<TData = unknown>({
|
|||||||
return () => el.removeEventListener('keydown', handleKeyDown)
|
return () => el.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [handleKeyDown, scrollRef])
|
}, [handleKeyDown, scrollRef])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to find parent row in tree structure
|
||||||
|
*/
|
||||||
|
function findParentRow<TData>(rows: any[], childRow: any): any | null {
|
||||||
|
const childIndex = rows.findIndex((r) => r.id === childRow.id);
|
||||||
|
if (childIndex === -1) return null;
|
||||||
|
|
||||||
|
const targetDepth = childRow.depth - 1;
|
||||||
|
// Search backwards from child position
|
||||||
|
for (let i = childIndex - 1; i >= 0; i--) {
|
||||||
|
if (rows[i].depth === targetDepth) {
|
||||||
|
return rows[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function GridToolbar<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="xs" justify="flex-end" p="xs" style={{ borderBottom: '1px solid #e0e0e0' }}>
|
<Group gap="xs" justify="flex-end" p="xs" style={{ borderBottom: '1px solid var(--griddy-border-color, #e0e0e0)' }}>
|
||||||
{filterPresets && (
|
{filterPresets && (
|
||||||
<FilterPresetsMenu persistenceKey={persistenceKey} table={table} />
|
<FilterPresetsMenu persistenceKey={persistenceKey} table={table} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Loader } from '@mantine/core';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Loader } from '@mantine/core';
|
||||||
|
|
||||||
import styles from '../../styles/griddy.module.css';
|
import styles from '../../styles/griddy.module.css';
|
||||||
|
|
||||||
interface TreeExpandButtonProps {
|
interface TreeExpandButtonProps {
|
||||||
canExpand: boolean;
|
canExpand: boolean;
|
||||||
isExpanded: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
icons?: {
|
icons?: {
|
||||||
collapsed?: ReactNode;
|
collapsed?: ReactNode;
|
||||||
expanded?: ReactNode;
|
expanded?: ReactNode;
|
||||||
leaf?: ReactNode;
|
leaf?: ReactNode;
|
||||||
};
|
};
|
||||||
|
isExpanded: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS = {
|
const DEFAULT_ICONS = {
|
||||||
@@ -23,10 +24,10 @@ const DEFAULT_ICONS = {
|
|||||||
|
|
||||||
export function TreeExpandButton({
|
export function TreeExpandButton({
|
||||||
canExpand,
|
canExpand,
|
||||||
|
icons = DEFAULT_ICONS,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onToggle,
|
onToggle,
|
||||||
icons = DEFAULT_ICONS,
|
|
||||||
}: TreeExpandButtonProps) {
|
}: TreeExpandButtonProps) {
|
||||||
const displayIcons = { ...DEFAULT_ICONS, ...icons };
|
const displayIcons = { ...DEFAULT_ICONS, ...icons };
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export { TreeExpandButton } from './TreeExpandButton';
|
|
||||||
export {
|
export {
|
||||||
hasChildren,
|
hasChildren,
|
||||||
insertChildrenIntoData,
|
insertChildrenIntoData,
|
||||||
transformFlatToNested,
|
transformFlatToNested,
|
||||||
} from './transformTreeData';
|
} from './transformTreeData';
|
||||||
|
export { TreeExpandButton } from './TreeExpandButton';
|
||||||
export { useAutoExpandOnSearch } from './useAutoExpandOnSearch';
|
export { useAutoExpandOnSearch } from './useAutoExpandOnSearch';
|
||||||
export { useLazyTreeExpansion } from './useLazyTreeExpansion';
|
export { useLazyTreeExpansion } from './useLazyTreeExpansion';
|
||||||
export { useTreeData } from './useTreeData';
|
export { useTreeData } from './useTreeData';
|
||||||
|
|||||||
@@ -1,74 +1,5 @@
|
|||||||
import type { TreeConfig } from '../../core/types';
|
import type { TreeConfig } from '../../core/types';
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms flat data with parentId references into nested tree structure
|
|
||||||
* @param data - Flat array of data with parent references
|
|
||||||
* @param parentIdField - Field name containing parent ID (default: 'parentId')
|
|
||||||
* @param idField - Field name containing node ID (default: 'id')
|
|
||||||
* @param maxDepth - Maximum tree depth to build (default: Infinity)
|
|
||||||
* @returns Array of root nodes with subRows property
|
|
||||||
*/
|
|
||||||
export function transformFlatToNested<T extends Record<string, any>>(
|
|
||||||
data: T[],
|
|
||||||
parentIdField: keyof T | string = 'parentId',
|
|
||||||
idField: keyof T | string = 'id',
|
|
||||||
maxDepth = Infinity,
|
|
||||||
): T[] {
|
|
||||||
// Build a map of id -> node for quick lookups
|
|
||||||
const nodeMap = new Map<any, T & { subRows?: T[] }>();
|
|
||||||
const roots: (T & { subRows?: T[] })[] = [];
|
|
||||||
|
|
||||||
// First pass: create map of all nodes
|
|
||||||
data.forEach((item) => {
|
|
||||||
nodeMap.set(item[idField], { ...item, subRows: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second pass: build tree structure
|
|
||||||
data.forEach((item) => {
|
|
||||||
const node = nodeMap.get(item[idField])!;
|
|
||||||
const parentId = item[parentIdField];
|
|
||||||
|
|
||||||
if (parentId == null || parentId === '') {
|
|
||||||
// Root node (no parent or empty parent)
|
|
||||||
roots.push(node);
|
|
||||||
} else {
|
|
||||||
const parent = nodeMap.get(parentId);
|
|
||||||
if (parent) {
|
|
||||||
// Add to parent's children
|
|
||||||
if (!parent.subRows) {
|
|
||||||
parent.subRows = [];
|
|
||||||
}
|
|
||||||
parent.subRows.push(node);
|
|
||||||
} else {
|
|
||||||
// Orphaned node (parent doesn't exist) - treat as root
|
|
||||||
roots.push(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enforce max depth by removing children beyond the limit
|
|
||||||
if (maxDepth !== Infinity) {
|
|
||||||
const enforceDepth = (nodes: (T & { subRows?: T[] })[], currentDepth: number) => {
|
|
||||||
if (currentDepth >= maxDepth) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
if (node.subRows && node.subRows.length > 0) {
|
|
||||||
if (currentDepth + 1 >= maxDepth) {
|
|
||||||
// Remove children at max depth
|
|
||||||
delete node.subRows;
|
|
||||||
} else {
|
|
||||||
enforceDepth(node.subRows, currentDepth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
enforceDepth(roots, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a node has children (can be expanded)
|
* Determines if a node has children (can be expanded)
|
||||||
* @param row - The data row
|
* @param row - The data row
|
||||||
@@ -136,3 +67,72 @@ export function insertChildrenIntoData<T extends Record<string, any>>(
|
|||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms flat data with parentId references into nested tree structure
|
||||||
|
* @param data - Flat array of data with parent references
|
||||||
|
* @param parentIdField - Field name containing parent ID (default: 'parentId')
|
||||||
|
* @param idField - Field name containing node ID (default: 'id')
|
||||||
|
* @param maxDepth - Maximum tree depth to build (default: Infinity)
|
||||||
|
* @returns Array of root nodes with subRows property
|
||||||
|
*/
|
||||||
|
export function transformFlatToNested<T extends Record<string, any>>(
|
||||||
|
data: T[],
|
||||||
|
parentIdField: keyof T | string = 'parentId',
|
||||||
|
idField: keyof T | string = 'id',
|
||||||
|
maxDepth = Infinity,
|
||||||
|
): T[] {
|
||||||
|
// Build a map of id -> node for quick lookups
|
||||||
|
const nodeMap = new Map<any, { subRows?: T[] } & T>();
|
||||||
|
const roots: ({ subRows?: T[] } & T)[] = [];
|
||||||
|
|
||||||
|
// First pass: create map of all nodes
|
||||||
|
data.forEach((item) => {
|
||||||
|
nodeMap.set(item[idField], { ...item, subRows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second pass: build tree structure
|
||||||
|
data.forEach((item) => {
|
||||||
|
const node = nodeMap.get(item[idField])!;
|
||||||
|
const parentId = item[parentIdField];
|
||||||
|
|
||||||
|
if (parentId == null || parentId === '') {
|
||||||
|
// Root node (no parent or empty parent)
|
||||||
|
roots.push(node);
|
||||||
|
} else {
|
||||||
|
const parent = nodeMap.get(parentId);
|
||||||
|
if (parent) {
|
||||||
|
// Add to parent's children
|
||||||
|
if (!parent.subRows) {
|
||||||
|
parent.subRows = [];
|
||||||
|
}
|
||||||
|
parent.subRows.push(node);
|
||||||
|
} else {
|
||||||
|
// Orphaned node (parent doesn't exist) - treat as root
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enforce max depth by removing children beyond the limit
|
||||||
|
if (maxDepth !== Infinity) {
|
||||||
|
const enforceDepth = (nodes: ({ subRows?: T[] } & T)[], currentDepth: number) => {
|
||||||
|
if (currentDepth >= maxDepth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.subRows && node.subRows.length > 0) {
|
||||||
|
if (currentDepth + 1 >= maxDepth) {
|
||||||
|
// Remove children at max depth
|
||||||
|
delete node.subRows;
|
||||||
|
} else {
|
||||||
|
enforceDepth(node.subRows, currentDepth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
enforceDepth(roots, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,40 +1,22 @@
|
|||||||
import type { Table } from '@tanstack/react-table';
|
import type { Table } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import type { TreeConfig } from '../../core/types';
|
import type { TreeConfig } from '../../core/types';
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to find all ancestor rows for a given row
|
|
||||||
*/
|
|
||||||
function findAncestors<TData>(rows: any[], targetRow: any): any[] {
|
|
||||||
const ancestors: any[] = [];
|
|
||||||
let currentDepth = targetRow.depth;
|
|
||||||
const targetIndex = rows.findIndex((r) => r.id === targetRow.id);
|
|
||||||
|
|
||||||
if (targetIndex === -1) return ancestors;
|
|
||||||
|
|
||||||
// Walk backwards to find all ancestors
|
|
||||||
for (let i = targetIndex - 1; i >= 0 && currentDepth > 0; i--) {
|
|
||||||
if (rows[i].depth === currentDepth - 1) {
|
|
||||||
ancestors.unshift(rows[i]);
|
|
||||||
currentDepth = rows[i].depth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ancestors;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAutoExpandOnSearchOptions<TData> {
|
interface UseAutoExpandOnSearchOptions<TData> {
|
||||||
tree?: TreeConfig<TData>;
|
globalFilter?: string;
|
||||||
table: Table<TData>;
|
table: Table<TData>;
|
||||||
|
tree?: TreeConfig<TData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to auto-expand parent nodes when search matches child nodes
|
* Hook to auto-expand parent nodes when search matches child nodes
|
||||||
*/
|
*/
|
||||||
export function useAutoExpandOnSearch<TData>({
|
export function useAutoExpandOnSearch<TData>({
|
||||||
tree,
|
globalFilter: globalFilterProp,
|
||||||
table,
|
table,
|
||||||
|
tree,
|
||||||
}: UseAutoExpandOnSearchOptions<TData>) {
|
}: UseAutoExpandOnSearchOptions<TData>) {
|
||||||
const previousFilterRef = useRef<string | undefined>(undefined);
|
const previousFilterRef = useRef<string | undefined>(undefined);
|
||||||
const previousExpandedRef = useRef<Record<string, boolean>>({});
|
const previousExpandedRef = useRef<Record<string, boolean>>({});
|
||||||
@@ -53,8 +35,7 @@ export function useAutoExpandOnSearch<TData>({
|
|||||||
|
|
||||||
// If filter was cleared, optionally restore previous expanded state
|
// If filter was cleared, optionally restore previous expanded state
|
||||||
if (!globalFilter && previousFilter) {
|
if (!globalFilter && previousFilter) {
|
||||||
// Filter was cleared - could restore previous state here if config option added
|
// Filter was cleared - leave expanded state as-is
|
||||||
// For now, just leave expanded state as-is
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,23 +44,30 @@ export function useAutoExpandOnSearch<TData>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get filtered rows
|
// Use flatRows to get all rows at all depths in the filtered model
|
||||||
const filteredRows = table.getFilteredRowModel().rows;
|
const filteredFlatRows = table.getFilteredRowModel().flatRows;
|
||||||
|
|
||||||
if (filteredRows.length === 0) {
|
if (filteredFlatRows.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build set of all ancestors that should be expanded
|
// Build set of all ancestors that should be expanded
|
||||||
const toExpand: Record<string, boolean> = {};
|
const toExpand: Record<string, boolean> = {};
|
||||||
|
|
||||||
filteredRows.forEach((row) => {
|
// Build a lookup map from flatRows for fast parent resolution
|
||||||
// If row has depth > 0, it's a child node - expand all ancestors
|
const rowById = new Map<string, (typeof filteredFlatRows)[0]>();
|
||||||
|
filteredFlatRows.forEach((row) => rowById.set(row.id, row));
|
||||||
|
|
||||||
|
filteredFlatRows.forEach((row) => {
|
||||||
|
// If row has depth > 0, walk up parent chain and expand all ancestors
|
||||||
if (row.depth > 0) {
|
if (row.depth > 0) {
|
||||||
const ancestors = findAncestors(table.getRowModel().rows, row);
|
let current = row;
|
||||||
ancestors.forEach((ancestor) => {
|
while (current.parentId) {
|
||||||
toExpand[ancestor.id] = true;
|
toExpand[current.parentId] = true;
|
||||||
});
|
const parent = rowById.get(current.parentId);
|
||||||
|
if (!parent) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,5 +83,5 @@ export function useAutoExpandOnSearch<TData>({
|
|||||||
}
|
}
|
||||||
table.setExpanded(newExpanded);
|
table.setExpanded(newExpanded);
|
||||||
}
|
}
|
||||||
}, [tree, table]);
|
}, [tree, table, globalFilterProp]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Table } from '@tanstack/react-table';
|
import type { Table } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import type { TreeConfig } from '../../core/types';
|
import type { TreeConfig } from '../../core/types';
|
||||||
@@ -6,12 +7,13 @@ import type { TreeConfig } from '../../core/types';
|
|||||||
import { insertChildrenIntoData } from './transformTreeData';
|
import { insertChildrenIntoData } from './transformTreeData';
|
||||||
|
|
||||||
interface UseLazyTreeExpansionOptions<T> {
|
interface UseLazyTreeExpansionOptions<T> {
|
||||||
tree?: TreeConfig<T>;
|
|
||||||
table: Table<T>;
|
|
||||||
data: T[];
|
data: T[];
|
||||||
|
expanded: Record<string, boolean> | true;
|
||||||
setData: (data: T[]) => void;
|
setData: (data: T[]) => void;
|
||||||
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
|
|
||||||
setTreeChildrenCache: (nodeId: string, children: T[]) => void;
|
setTreeChildrenCache: (nodeId: string, children: T[]) => void;
|
||||||
|
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
|
||||||
|
table: Table<T>;
|
||||||
|
tree?: TreeConfig<T>;
|
||||||
treeChildrenCache: Map<string, T[]>;
|
treeChildrenCache: Map<string, T[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,12 +21,13 @@ interface UseLazyTreeExpansionOptions<T> {
|
|||||||
* Hook to handle lazy loading of tree children when nodes are expanded
|
* Hook to handle lazy loading of tree children when nodes are expanded
|
||||||
*/
|
*/
|
||||||
export function useLazyTreeExpansion<T extends Record<string, any>>({
|
export function useLazyTreeExpansion<T extends Record<string, any>>({
|
||||||
tree,
|
|
||||||
table,
|
|
||||||
data,
|
data,
|
||||||
|
expanded: expandedState,
|
||||||
setData,
|
setData,
|
||||||
setTreeLoadingNode,
|
|
||||||
setTreeChildrenCache,
|
setTreeChildrenCache,
|
||||||
|
setTreeLoadingNode,
|
||||||
|
table,
|
||||||
|
tree,
|
||||||
treeChildrenCache,
|
treeChildrenCache,
|
||||||
}: UseLazyTreeExpansionOptions<T>) {
|
}: UseLazyTreeExpansionOptions<T>) {
|
||||||
const expandedRef = useRef<Record<string, boolean>>({});
|
const expandedRef = useRef<Record<string, boolean>>({});
|
||||||
@@ -35,7 +38,7 @@ export function useLazyTreeExpansion<T extends Record<string, any>>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expanded = table.getState().expanded;
|
const expanded = typeof expandedState === 'object' ? expandedState : {};
|
||||||
const previousExpanded = expandedRef.current;
|
const previousExpanded = expandedRef.current;
|
||||||
|
|
||||||
// Find newly expanded nodes
|
// Find newly expanded nodes
|
||||||
@@ -91,6 +94,7 @@ export function useLazyTreeExpansion<T extends Record<string, any>>({
|
|||||||
}, [
|
}, [
|
||||||
tree,
|
tree,
|
||||||
table,
|
table,
|
||||||
|
expandedState,
|
||||||
data,
|
data,
|
||||||
setData,
|
setData,
|
||||||
setTreeLoadingNode,
|
setTreeLoadingNode,
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ export function useTreeData<T extends Record<string, any>>(
|
|||||||
const mode = tree.mode || 'nested';
|
const mode = tree.mode || 'nested';
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
case 'flat': {
|
||||||
|
// Transform flat data with parentId to nested structure
|
||||||
|
const parentIdField = tree.parentIdField || 'parentId';
|
||||||
|
const idField = 'id'; // Assume 'id' field exists
|
||||||
|
const maxDepth = tree.maxDepth || Infinity;
|
||||||
|
return transformFlatToNested(data, parentIdField, idField, maxDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'lazy': {
|
||||||
|
// Lazy mode: data is already structured, children loaded on-demand
|
||||||
|
// Just return data as-is, lazy loading hook will handle expansion
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
case 'nested': {
|
case 'nested': {
|
||||||
// If childrenField is not 'subRows', map it
|
// If childrenField is not 'subRows', map it
|
||||||
const childrenField = (tree.childrenField as string) || 'children';
|
const childrenField = (tree.childrenField as string) || 'children';
|
||||||
@@ -43,20 +57,6 @@ export function useTreeData<T extends Record<string, any>>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'flat': {
|
|
||||||
// Transform flat data with parentId to nested structure
|
|
||||||
const parentIdField = tree.parentIdField || 'parentId';
|
|
||||||
const idField = 'id'; // Assume 'id' field exists
|
|
||||||
const maxDepth = tree.maxDepth || Infinity;
|
|
||||||
return transformFlatToNested(data, parentIdField, idField, maxDepth);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'lazy': {
|
|
||||||
// Lazy mode: data is already structured, children loaded on-demand
|
|
||||||
// Just return data as-is, lazy loading hook will handle expansion
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1261,6 +1261,12 @@ The grid follows WAI-ARIA grid pattern:
|
|||||||
|
|
||||||
## Phase 10: Future Enhancements
|
## Phase 10: Future Enhancements
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- [ ] **Unique Row ID** - Add a unique row ID to the data if no uniqueId is provided.
|
||||||
|
- [ ] **Tree row selection** - The tree row selection breaks, it selects the same item. Suspect the uniqueId
|
||||||
|
- [ ] **Infinite scroll** - Header Spec infinite scroll server side filtering and sorting not working
|
||||||
|
|
||||||
### Data & State Management
|
### Data & State Management
|
||||||
|
|
||||||
- [ ] **Column layout persistence** - Save/restore column order, widths, visibility to localStorage
|
- [ ] **Column layout persistence** - Save/restore column order, widths, visibility to localStorage
|
||||||
|
|||||||
@@ -65,9 +65,12 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
|
|||||||
|
|
||||||
// Tree support
|
// Tree support
|
||||||
const depth = cell.row.depth;
|
const depth = cell.row.depth;
|
||||||
const canExpand = cell.row.getCanExpand();
|
const canExpand =
|
||||||
|
cell.row.getCanExpand() ||
|
||||||
|
(tree?.enabled && tree?.mode === 'lazy' && tree?.hasChildren?.(cell.row.original as any)) ||
|
||||||
|
false;
|
||||||
const isExpanded = cell.row.getIsExpanded();
|
const isExpanded = cell.row.getIsExpanded();
|
||||||
const hasSelection = selection?.mode !== 'none';
|
const hasSelection = selection != null && selection.mode !== 'none';
|
||||||
const columnIndex = cell.column.getIndex();
|
const columnIndex = cell.column.getIndex();
|
||||||
// First content column is index 0 if no selection, or index 1 if selection enabled
|
// First content column is index 0 if no selection, or index 1 if selection enabled
|
||||||
const isFirstColumn = hasSelection ? columnIndex === 1 : columnIndex === 0;
|
const isFirstColumn = hasSelection ? columnIndex === 1 : columnIndex === 0;
|
||||||
|
|||||||
@@ -581,3 +581,74 @@
|
|||||||
.griddy-row--tree-depth-4 .griddy-cell:first-child {
|
.griddy-row--tree-depth-4 .griddy-cell:first-child {
|
||||||
border-left: 2px solid var(--mantine-color-grape-3, #da77f2);
|
border-left: 2px solid var(--mantine-color-grape-3, #da77f2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Dark Mode ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy {
|
||||||
|
--griddy-border-color: #373a40;
|
||||||
|
--griddy-header-bg: #25262b;
|
||||||
|
--griddy-header-color: #c1c2c5;
|
||||||
|
--griddy-row-bg: #1a1b1e;
|
||||||
|
--griddy-row-hover-bg: #25262b;
|
||||||
|
--griddy-row-even-bg: #1a1b1e;
|
||||||
|
--griddy-focus-color: #339af0;
|
||||||
|
--griddy-selection-bg: rgba(51, 154, 240, 0.15);
|
||||||
|
--griddy-search-bg: #25262b;
|
||||||
|
--griddy-search-border: #373a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--sortable:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-row--selected:hover {
|
||||||
|
background-color: rgba(51, 154, 240, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-search-overlay {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-search-input:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(51, 154, 240, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-error {
|
||||||
|
background: #2c2e33;
|
||||||
|
border-color: #e03131;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-error-message {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-error-detail {
|
||||||
|
color: #909296;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-skeleton-bar {
|
||||||
|
background: linear-gradient(90deg, #373a40 25%, #2c2e33 50%, #373a40 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-loading-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-renderer-progress {
|
||||||
|
background: #373a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-renderer-progress-label {
|
||||||
|
color: #c1c2c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--pinned-left,
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-cell--pinned-left {
|
||||||
|
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--pinned-right,
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .griddy-cell--pinned-right {
|
||||||
|
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|||||||
815
tests/e2e/griddy-core.spec.ts
Normal file
815
tests/e2e/griddy-core.spec.ts
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// Helper to navigate to a story inside the Storybook iframe
|
||||||
|
async function gotoStory(page: any, storyId: string) {
|
||||||
|
await page.goto(`/iframe.html?id=components-griddy--${storyId}&viewMode=story`);
|
||||||
|
await page.waitForSelector('[role="grid"]', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 1. Basic Rendering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Basic Rendering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'basic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the grid with rows and columns', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="grid"]')).toBeVisible();
|
||||||
|
// Header row should exist
|
||||||
|
const headers = page.locator('[role="columnheader"]');
|
||||||
|
await expect(headers.first()).toBeVisible();
|
||||||
|
// Should have all 9 column headers
|
||||||
|
await expect(headers).toHaveCount(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders correct column headers', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders data rows', async ({ page }) => {
|
||||||
|
// Should render multiple data rows (20 in small dataset)
|
||||||
|
const rows = page.locator('[role="row"]');
|
||||||
|
// At least header + some data rows
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a column header sorts data', async ({ page }) => {
|
||||||
|
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
|
||||||
|
await idHeader.click();
|
||||||
|
|
||||||
|
// After clicking, should have a sort indicator
|
||||||
|
const sortAttr = await idHeader.getAttribute('aria-sort');
|
||||||
|
expect(sortAttr).toBeTruthy();
|
||||||
|
expect(['ascending', 'descending']).toContain(sortAttr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking column header twice reverses sort', async ({ page }) => {
|
||||||
|
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
|
||||||
|
await idHeader.click();
|
||||||
|
const firstSort = await idHeader.getAttribute('aria-sort');
|
||||||
|
|
||||||
|
await idHeader.click();
|
||||||
|
const secondSort = await idHeader.getAttribute('aria-sort');
|
||||||
|
|
||||||
|
expect(firstSort).not.toEqual(secondSort);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 2. Large Dataset (Virtualization) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Large Dataset', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'large-dataset');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without crashing on 10k rows', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="grid"]')).toBeVisible();
|
||||||
|
const rows = page.locator('[role="row"]');
|
||||||
|
const count = await rows.count();
|
||||||
|
// Virtualized: should render far fewer rows than 10,000
|
||||||
|
expect(count).toBeGreaterThan(1);
|
||||||
|
expect(count).toBeLessThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid aria-rowcount reflects total data size', async ({ page }) => {
|
||||||
|
const grid = page.locator('[role="grid"]');
|
||||||
|
const rowCount = await grid.getAttribute('aria-rowcount');
|
||||||
|
expect(Number(rowCount)).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 3. Single Selection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Single Selection', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'single-selection');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders checkboxes in each row', async ({ page }) => {
|
||||||
|
const checkboxes = page.locator('[role="row"] input[type="checkbox"]');
|
||||||
|
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
const count = await checkboxes.count();
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a row selects it', async ({ page }) => {
|
||||||
|
const firstDataRow = page.locator('[role="row"]').nth(1);
|
||||||
|
await firstDataRow.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Selection state should show in the debug output
|
||||||
|
const selectionText = page.locator('text=Selected:');
|
||||||
|
await expect(selectionText).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking another row deselects previous in single mode', async ({ page }) => {
|
||||||
|
// Click first data row
|
||||||
|
const firstDataRow = page.locator('[role="row"]').nth(1);
|
||||||
|
await firstDataRow.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Click second data row
|
||||||
|
const secondDataRow = page.locator('[role="row"]').nth(2);
|
||||||
|
await secondDataRow.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// In single selection, only one should be selected
|
||||||
|
const checkboxes = page.locator('[role="row"] input[type="checkbox"]:checked');
|
||||||
|
const checkedCount = await checkboxes.count();
|
||||||
|
expect(checkedCount).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 4. Multi Selection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Multi Selection', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'multi-selection');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders select-all checkbox in header', async ({ page }) => {
|
||||||
|
const selectAllCheckbox = page.locator('[aria-label="Select all rows"]');
|
||||||
|
await expect(selectAllCheckbox).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting multiple rows shows count', async ({ page }) => {
|
||||||
|
// Click first data row
|
||||||
|
await page.locator('[role="row"]').nth(1).click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Shift-click third row to extend selection
|
||||||
|
await page.locator('[role="row"]').nth(3).click({ modifiers: ['Shift'] });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// The selected count should show in the debug output
|
||||||
|
const countText = page.locator('text=/Selected \\(\\d+ rows\\)/');
|
||||||
|
await expect(countText).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select-all checkbox selects all rows', async ({ page }) => {
|
||||||
|
const selectAll = page.locator('[aria-label="Select all rows"]');
|
||||||
|
await selectAll.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Should show all 20 rows selected
|
||||||
|
await expect(page.locator('text=/Selected \\(20 rows\\)/')).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 5. Search ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Search', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-search');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+F opens search overlay', async ({ page }) => {
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
await page.keyboard.press('Control+f');
|
||||||
|
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search filters rows', async ({ page }) => {
|
||||||
|
const initialRowCount = await page.locator('[role="row"]').count();
|
||||||
|
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
await page.keyboard.press('Control+f');
|
||||||
|
const searchInput = page.locator('[aria-label="Search grid"]');
|
||||||
|
await searchInput.fill('Alice');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const filteredRowCount = await page.locator('[role="row"]').count();
|
||||||
|
expect(filteredRowCount).toBeLessThan(initialRowCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape closes search overlay', async ({ page }) => {
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
await page.keyboard.press('Control+f');
|
||||||
|
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('[aria-label="Search grid"]')).not.toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing search restores all rows', async ({ page }) => {
|
||||||
|
const initialRowCount = await page.locator('[role="row"]').count();
|
||||||
|
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
await page.keyboard.press('Control+f');
|
||||||
|
const searchInput = page.locator('[aria-label="Search grid"]');
|
||||||
|
await searchInput.fill('Alice');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Clear the search
|
||||||
|
await searchInput.fill('');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const restoredRowCount = await page.locator('[role="row"]').count();
|
||||||
|
expect(restoredRowCount).toBeGreaterThanOrEqual(initialRowCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 6. Keyboard Navigation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Keyboard Navigation', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'keyboard-navigation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowDown moves focus to next row', async ({ page }) => {
|
||||||
|
// Focus the grid container
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
|
||||||
|
// Grid should still have focus (not lost)
|
||||||
|
await expect(page.locator('[role="grid"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Space toggles row selection', async ({ page }) => {
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
// Move to first data row
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Should show "Selected: 1 rows" in the debug text
|
||||||
|
await expect(page.locator('text=/Selected: \\d+ rows/')).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+A selects all rows', async ({ page }) => {
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
await page.keyboard.press('Control+a');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(page.locator('text=/Selected: 20 rows/')).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+F opens search from keyboard nav story', async ({ page }) => {
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
|
await page.keyboard.press('Control+f');
|
||||||
|
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 7. Inline Editing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Inline Editing', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-inline-editing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('double-click on editable cell enters edit mode', async ({ page }) => {
|
||||||
|
// Find a First Name cell (column index 1, since ID is 0)
|
||||||
|
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
|
||||||
|
await firstNameCell.dblclick();
|
||||||
|
|
||||||
|
// An input should appear
|
||||||
|
const input = page.locator('[role="row"]').nth(1).locator('input');
|
||||||
|
await expect(input).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enter commits the edit', async ({ page }) => {
|
||||||
|
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
|
||||||
|
const originalText = await firstNameCell.innerText();
|
||||||
|
|
||||||
|
await firstNameCell.dblclick();
|
||||||
|
const input = page.locator('[role="row"]').nth(1).locator('input').first();
|
||||||
|
await expect(input).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
await input.fill('TestName');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// The cell text should now show the new value
|
||||||
|
const updatedText = await firstNameCell.innerText();
|
||||||
|
expect(updatedText).toContain('TestName');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape cancels the edit', async ({ page }) => {
|
||||||
|
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
|
||||||
|
const originalText = await firstNameCell.innerText();
|
||||||
|
|
||||||
|
await firstNameCell.dblclick();
|
||||||
|
const input = page.locator('[role="row"]').nth(1).locator('input').first();
|
||||||
|
await expect(input).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
await input.fill('CancelledValue');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// The cell should revert to original value
|
||||||
|
const restoredText = await firstNameCell.innerText();
|
||||||
|
expect(restoredText).toBe(originalText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 8. Client-Side Pagination ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Client-Side Pagination', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-client-side-pagination');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders pagination controls', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Page 1 of')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('text=Rows per page:')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows correct initial page info', async ({ page }) => {
|
||||||
|
// 10,000 rows / 25 per page = 400 pages
|
||||||
|
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('next page button navigates forward', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the "next page" button (single chevron right)
|
||||||
|
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
|
||||||
|
await nextPageBtn.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(page.locator('text=Page 2 of 400')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('last page button navigates to end', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the "last page" button (double chevron right)
|
||||||
|
const lastPageBtn = page.locator('[class*="griddy-pagination"] button').nth(3);
|
||||||
|
await lastPageBtn.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
await expect(page.locator('text=Page 400 of 400')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first page button is disabled on first page', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// First page button (double chevron left) should be disabled
|
||||||
|
const firstPageBtn = page.locator('[class*="griddy-pagination"] button').first();
|
||||||
|
await expect(firstPageBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page size selector changes rows per page', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the page size select (Mantine Select renders as textbox)
|
||||||
|
const pageSizeSelect = page.getByRole('textbox');
|
||||||
|
await pageSizeSelect.click();
|
||||||
|
|
||||||
|
// Select 50 rows per page
|
||||||
|
await page.locator('[role="option"]', { hasText: '50' }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// 10,000 / 50 = 200 pages
|
||||||
|
await expect(page.locator('text=Page 1 of 200')).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 9. Server-Side Pagination ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Server-Side Pagination', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-server-side-pagination');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders data after initial load', async ({ page }) => {
|
||||||
|
// Wait for server-side data to load (300ms delay)
|
||||||
|
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible({ timeout: 5000 });
|
||||||
|
const rows = page.locator('[role="row"]');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows server state info', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Total Rows: 10000')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating pages updates server state', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Current Page: 1')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Navigate to next page
|
||||||
|
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
|
||||||
|
await nextPageBtn.click();
|
||||||
|
|
||||||
|
await expect(page.locator('text=Current Page: 2')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 10. Toolbar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Toolbar', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-toolbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders export button', async ({ page }) => {
|
||||||
|
const exportBtn = page.locator('[aria-label="Export to CSV"]');
|
||||||
|
await expect(exportBtn).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders column toggle button', async ({ page }) => {
|
||||||
|
const columnToggleBtn = page.locator('[aria-label="Toggle columns"]');
|
||||||
|
await expect(columnToggleBtn).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('column toggle menu shows all columns', async ({ page }) => {
|
||||||
|
await page.locator('[aria-label="Toggle columns"]').click();
|
||||||
|
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Should show checkboxes for each column
|
||||||
|
const checkboxes = page.locator('.mantine-Menu-dropdown input[type="checkbox"]');
|
||||||
|
const count = await checkboxes.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggling column visibility hides a column', async ({ page }) => {
|
||||||
|
// Verify "Email" header is initially visible
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Open column toggle menu
|
||||||
|
await page.locator('[aria-label="Toggle columns"]').click();
|
||||||
|
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Uncheck the "Email" column
|
||||||
|
const emailCheckbox = page.locator('.mantine-Checkbox-root', { hasText: 'Email' }).locator('input[type="checkbox"]');
|
||||||
|
await emailCheckbox.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Close the menu by clicking elsewhere
|
||||||
|
await page.locator('[role="grid"]').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Email header should now be hidden
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 11. Column Pinning ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Column Pinning', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-column-pinning');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders grid with pinned columns', async ({ page }) => {
|
||||||
|
// ID and First Name should be visible (pinned left)
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
|
||||||
|
// Active should be visible (pinned right)
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all column headers render', async ({ page }) => {
|
||||||
|
const headers = page.locator('[role="columnheader"]');
|
||||||
|
const count = await headers.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 12. Header Grouping ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Header Grouping', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-header-grouping');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders group headers', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Personal Info' })).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Contact' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Employment' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders child column headers under groups', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has multiple header rows', async ({ page }) => {
|
||||||
|
// Should have at least 2 header rows (group + column)
|
||||||
|
const headerRows = page.locator('[role="row"]').filter({ has: page.locator('[role="columnheader"]') });
|
||||||
|
const count = await headerRows.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 13. Data Grouping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Data Grouping', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-data-grouping');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders grouped rows by department', async ({ page }) => {
|
||||||
|
// Should show department names as group headers
|
||||||
|
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('[role="row"]', { hasText: 'Marketing' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expanding a group shows its members', async ({ page }) => {
|
||||||
|
// Click on Engineering group to expand it
|
||||||
|
const engineeringRow = page.locator('[role="row"]', { hasText: 'Engineering' });
|
||||||
|
await engineeringRow.locator('button').first().click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Should show individual people from Engineering department
|
||||||
|
// From generateData: index 0 is Engineering (Alice Smith)
|
||||||
|
const rows = page.locator('[role="row"]');
|
||||||
|
const count = await rows.count();
|
||||||
|
// After expanding one group, should have more rows visible
|
||||||
|
expect(count).toBeGreaterThan(8); // 8 department groups
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 14. Column Reordering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Column Reordering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-column-reordering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders all column headers', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
|
||||||
|
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('column headers have draggable attribute', async ({ page }) => {
|
||||||
|
// Column headers should be draggable for reordering
|
||||||
|
const headers = page.locator('[role="columnheader"][draggable="true"]');
|
||||||
|
const count = await headers.count();
|
||||||
|
// At least some headers should be draggable (selection column is excluded)
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 15. Infinite Scroll ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Infinite Scroll', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-infinite-scroll');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders initial batch of rows', async ({ page }) => {
|
||||||
|
// Initial batch is 50 rows
|
||||||
|
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolling to bottom loads more data', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Scroll to bottom of the grid container
|
||||||
|
const container = page.locator('[role="grid"] [tabindex="0"]');
|
||||||
|
await container.click();
|
||||||
|
|
||||||
|
// Use End key to jump to last row, triggering infinite scroll
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.waitForTimeout(2000); // Wait for the 1000ms load delay + buffer
|
||||||
|
|
||||||
|
// Should now have more than 50 rows
|
||||||
|
await expect(page.locator('text=Current: 100 rows')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 16. Text Filtering ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Text Filtering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-text-filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterable columns show filter icon', async ({ page }) => {
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
|
||||||
|
await expect(firstNameHeader).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening filter popover shows text filter UI', async ({ page }) => {
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
|
||||||
|
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await filterIcon.click();
|
||||||
|
|
||||||
|
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-filterable columns have no filter icon', async ({ page }) => {
|
||||||
|
const idHeader = page.locator('[role="columnheader"]').filter({ hasText: 'ID' });
|
||||||
|
await expect(idHeader).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const filterIcon = idHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 17. Number Filtering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Number Filtering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-number-filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('age column has filter icon', async ({ page }) => {
|
||||||
|
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
|
||||||
|
await expect(ageHeader).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('salary column has filter icon', async ({ page }) => {
|
||||||
|
const salaryHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Salary' });
|
||||||
|
await expect(salaryHeader).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const filterIcon = salaryHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening number filter shows numeric filter UI', async ({ page }) => {
|
||||||
|
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
|
||||||
|
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await filterIcon.click();
|
||||||
|
|
||||||
|
await expect(page.locator('text=Filter: age')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 18. Enum Filtering ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Enum Filtering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-enum-filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('department column has filter icon', async ({ page }) => {
|
||||||
|
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
|
||||||
|
await expect(deptHeader).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening enum filter shows the filter popover', async ({ page }) => {
|
||||||
|
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
|
||||||
|
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await filterIcon.click();
|
||||||
|
|
||||||
|
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 19. Boolean Filtering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Boolean Filtering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-boolean-filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active column has filter icon', async ({ page }) => {
|
||||||
|
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
|
||||||
|
await expect(activeHeader).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening boolean filter shows the filter popover', async ({ page }) => {
|
||||||
|
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
|
||||||
|
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await filterIcon.click();
|
||||||
|
|
||||||
|
await expect(page.locator('text=Filter: active')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 20. Date Filtering ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Date Filtering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-date-filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start date column has filter icon', async ({ page }) => {
|
||||||
|
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
|
||||||
|
await expect(dateHeader).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening date filter shows the filter popover', async ({ page }) => {
|
||||||
|
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
|
||||||
|
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
|
||||||
|
await filterIcon.click();
|
||||||
|
|
||||||
|
await expect(page.locator('text=Filter: startDate')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 21. All Filter Types ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('All Filter Types', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'with-all-filter-types');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all filterable columns show filter icons', async ({ page }) => {
|
||||||
|
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
|
||||||
|
|
||||||
|
for (const colName of filterableColumns) {
|
||||||
|
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
|
||||||
|
await expect(header).toBeVisible({ timeout: 5000 });
|
||||||
|
const filterIcon = header.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-filterable columns have no filter icon', async ({ page }) => {
|
||||||
|
const nonFilterableColumns = ['ID', 'Email', 'Salary'];
|
||||||
|
|
||||||
|
for (const colName of nonFilterableColumns) {
|
||||||
|
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
|
||||||
|
await expect(header).toBeVisible({ timeout: 5000 });
|
||||||
|
const filterIcon = header.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).not.toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 22. Server-Side Filtering & Sorting ────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Server-Side Filtering & Sorting', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'server-side-filtering-sorting');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders data from simulated server', async ({ page }) => {
|
||||||
|
// Wait for server data to load (300ms simulated delay)
|
||||||
|
await expect(page.locator('text=Loading: false')).toBeVisible({ timeout: 5000 });
|
||||||
|
const rows = page.locator('[role="row"]');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows server-side mode indicator', async ({ page }) => {
|
||||||
|
await expect(page.locator('text=Server-Side Mode:')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sorting updates server state', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
// Wait for initial load
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Click First Name header to sort
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]', { hasText: 'First Name' });
|
||||||
|
await firstNameHeader.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Active Sorting section should now show sorting state
|
||||||
|
await expect(page.locator('text=Active Sorting:')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 23. Large Dataset with Filtering ───────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Large Dataset with Filtering', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoStory(page, 'large-dataset-with-filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders large dataset with filter columns', async ({ page }) => {
|
||||||
|
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
const rows = page.locator('[role="row"]');
|
||||||
|
const count = await rows.count();
|
||||||
|
expect(count).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all expected filter columns have filter icons', async ({ page }) => {
|
||||||
|
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
|
||||||
|
|
||||||
|
for (const colName of filterableColumns) {
|
||||||
|
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
|
||||||
|
await expect(header).toBeVisible({ timeout: 5000 });
|
||||||
|
const filterIcon = header.locator('[aria-label="Open column filter"]');
|
||||||
|
await expect(filterIcon).toBeVisible({ timeout: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -211,11 +211,14 @@ test.describe('Tree with Search Auto-Expand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Ctrl+F opens search overlay', async ({ page }) => {
|
test('Ctrl+F opens search overlay', async ({ page }) => {
|
||||||
|
// Focus the grid scroll container before pressing keyboard shortcut
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
await page.keyboard.press('Control+f');
|
await page.keyboard.press('Control+f');
|
||||||
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
|
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('searching for a leaf node auto-expands ancestors', async ({ page }) => {
|
test('searching for a leaf node auto-expands ancestors', async ({ page }) => {
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
await page.keyboard.press('Control+f');
|
await page.keyboard.press('Control+f');
|
||||||
const searchInput = page.locator('[aria-label="Search grid"]');
|
const searchInput = page.locator('[aria-label="Search grid"]');
|
||||||
await searchInput.fill('Alice');
|
await searchInput.fill('Alice');
|
||||||
@@ -230,6 +233,7 @@ test.describe('Tree with Search Auto-Expand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('clearing search preserves expanded state', async ({ page }) => {
|
test('clearing search preserves expanded state', async ({ page }) => {
|
||||||
|
await page.locator('[role="grid"] [tabindex="0"]').click();
|
||||||
await page.keyboard.press('Control+f');
|
await page.keyboard.press('Control+f');
|
||||||
const searchInput = page.locator('[aria-label="Search grid"]');
|
const searchInput = page.locator('[aria-label="Search grid"]');
|
||||||
await searchInput.fill('Alice');
|
await searchInput.fill('Alice');
|
||||||
|
|||||||
Reference in New Issue
Block a user