chore: griddy work
This commit is contained in:
@@ -58,6 +58,7 @@
|
|||||||
"@changesets/cli": "^2.29.8",
|
"@changesets/cli": "^2.29.8",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@microsoft/api-extractor": "^7.56.3",
|
"@microsoft/api-extractor": "^7.56.3",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@sentry/react": "^10.38.0",
|
"@sentry/react": "^10.38.0",
|
||||||
"@storybook/react-vite": "^10.2.8",
|
"@storybook/react-vite": "^10.2.8",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
# 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]
|
||||||
|
```
|
||||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
23
playwright.config.ts
Normal file
23
playwright.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
fullyParallel: true,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reporter: 'html',
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:6006',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
webServer: undefined,
|
||||||
|
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
});
|
||||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -75,6 +75,9 @@ importers:
|
|||||||
'@microsoft/api-extractor':
|
'@microsoft/api-extractor':
|
||||||
specifier: ^7.56.3
|
specifier: ^7.56.3
|
||||||
version: 7.56.3(@types/node@25.2.3)
|
version: 7.56.3(@types/node@25.2.3)
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.58.2
|
||||||
|
version: 1.58.2
|
||||||
'@sentry/react':
|
'@sentry/react':
|
||||||
specifier: ^10.38.0
|
specifier: ^10.38.0
|
||||||
version: 10.38.0(react@19.2.4)
|
version: 10.38.0(react@19.2.4)
|
||||||
@@ -817,6 +820,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@playwright/test@1.58.2':
|
||||||
|
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.2':
|
'@rolldown/pluginutils@1.0.0-rc.2':
|
||||||
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
|
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
|
||||||
|
|
||||||
@@ -2257,6 +2265,11 @@ packages:
|
|||||||
fs.realpath@1.0.0:
|
fs.realpath@1.0.0:
|
||||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -3033,6 +3046,16 @@ packages:
|
|||||||
pkg-types@2.3.0:
|
pkg-types@2.3.0:
|
||||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
|
playwright-core@1.58.2:
|
||||||
|
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.58.2:
|
||||||
|
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4745,6 +4768,10 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.19.1
|
fastq: 1.19.1
|
||||||
|
|
||||||
|
'@playwright/test@1.58.2':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.58.2
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.2': {}
|
'@rolldown/pluginutils@1.0.0-rc.2': {}
|
||||||
|
|
||||||
'@rollup/pluginutils@5.3.0(rollup@4.50.2)':
|
'@rollup/pluginutils@5.3.0(rollup@4.50.2)':
|
||||||
@@ -6425,6 +6452,9 @@ snapshots:
|
|||||||
|
|
||||||
fs.realpath@1.0.0: {}
|
fs.realpath@1.0.0: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -7177,6 +7207,14 @@ snapshots:
|
|||||||
exsolve: 1.0.7
|
exsolve: 1.0.7
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
playwright-core@1.58.2: {}
|
||||||
|
|
||||||
|
playwright@1.58.2:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.58.2
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-js@4.1.0(postcss@8.5.6):
|
postcss-js@4.1.0(postcss@8.5.6):
|
||||||
|
|||||||
@@ -80,6 +80,71 @@ Griddy is a new data grid component in the Oranguru package (`@warkypublic/orang
|
|||||||
Uses **Mantine** components (not raw HTML):
|
Uses **Mantine** components (not raw HTML):
|
||||||
- `Checkbox` from `@mantine/core` for row/header checkboxes
|
- `Checkbox` from `@mantine/core` for row/header checkboxes
|
||||||
- `TextInput` from `@mantine/core` for search input
|
- `TextInput` from `@mantine/core` for search input
|
||||||
|
- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `Popover`, `Menu`, `ActionIcon` for filtering (Phase 5)
|
||||||
|
|
||||||
|
## Phase 5: Column Filtering UI (COMPLETE)
|
||||||
|
|
||||||
|
### User Interaction Pattern
|
||||||
|
1. **Filter Status Indicator**: Gray filter icon in each column header (disabled, non-clickable)
|
||||||
|
2. **Right-Click Context Menu**: Shows on header right-click with options:
|
||||||
|
- `Sort` — Toggle column sorting
|
||||||
|
- `Reset Sorting` — Clear sort (shown only if column is sorted)
|
||||||
|
- `Reset Filter` — Clear filters (shown only if column has active filter)
|
||||||
|
- `Open Filters` — Opens filter popover
|
||||||
|
3. **Filter Popover**: Opened from "Open Filters" menu item
|
||||||
|
- Positioned below header
|
||||||
|
- Contains filter operator dropdown and value input(s)
|
||||||
|
- Apply and Clear buttons
|
||||||
|
- Filter type determined by `column.filterConfig.type`
|
||||||
|
|
||||||
|
### Filter Types & Operators
|
||||||
|
| Type | Operators | Input Component |
|
||||||
|
|------|-----------|-----------------|
|
||||||
|
| `text` | contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty | TextInput with debounce |
|
||||||
|
| `number` | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between | NumberInput (single or dual for between) |
|
||||||
|
| `enum` | includes, excludes, isEmpty | MultiSelect with custom options |
|
||||||
|
| `boolean` | isTrue, isFalse, isEmpty | Radio.Group (True/False/All) |
|
||||||
|
|
||||||
|
### API
|
||||||
|
```typescript
|
||||||
|
interface FilterConfig {
|
||||||
|
type: 'text' | 'number' | 'boolean' | 'enum'
|
||||||
|
operators?: FilterOperator[] // custom operators (optional)
|
||||||
|
enumOptions?: Array<{ label: string; value: any }> // for enum type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in column definition:
|
||||||
|
{
|
||||||
|
id: 'firstName',
|
||||||
|
accessor: 'firstName',
|
||||||
|
header: 'First Name',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'text' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Implementation Details
|
||||||
|
- **Default filterFn**: Automatically assigned when `filterable: true` and no custom `filterFn` provided
|
||||||
|
- **Operator-based filtering**: Uses `createOperatorFilter()` that delegates to type-specific implementations
|
||||||
|
- **Debouncing**: Text inputs debounced 300ms to reduce re-renders
|
||||||
|
- **TanStack Integration**: Uses `column.setFilterValue()` and `column.getFilterValue()`
|
||||||
|
- **AND Logic**: Multiple column filters applied together (AND by default)
|
||||||
|
- **Controlled State**: Filter state managed by parent via `columnFilters` prop and `onColumnFiltersChange` callback
|
||||||
|
|
||||||
|
### Files Structure (Phase 5)
|
||||||
|
```
|
||||||
|
src/Griddy/features/filtering/
|
||||||
|
├── types.ts # FilterOperator, FilterConfig, FilterValue
|
||||||
|
├── operators.ts # TEXT_OPERATORS, NUMBER_OPERATORS, etc.
|
||||||
|
├── filterFunctions.ts # TanStack FilterFn implementations
|
||||||
|
├── FilterInput.tsx # Text/number input with debounce
|
||||||
|
├── FilterSelect.tsx # Multi-select for enums
|
||||||
|
├── FilterBoolean.tsx # Radio group for booleans
|
||||||
|
├── ColumnFilterButton.tsx # Status indicator icon
|
||||||
|
├── ColumnFilterPopover.tsx # Popover UI container
|
||||||
|
├── ColumnFilterContextMenu.tsx # Right-click context menu
|
||||||
|
└── index.ts # Public exports
|
||||||
|
```
|
||||||
|
|
||||||
## Implementation Status
|
## Implementation Status
|
||||||
- [x] Phase 1: Core foundation + TanStack Table
|
- [x] Phase 1: Core foundation + TanStack Table
|
||||||
@@ -87,7 +152,14 @@ Uses **Mantine** components (not raw HTML):
|
|||||||
- [x] Phase 3: Row selection (single + multi)
|
- [x] Phase 3: Row selection (single + multi)
|
||||||
- [x] Phase 4: Search (Ctrl+F overlay)
|
- [x] Phase 4: Search (Ctrl+F overlay)
|
||||||
- [x] Sorting (click header)
|
- [x] Sorting (click header)
|
||||||
- [ ] Phase 5: Column filtering UI
|
- [x] Phase 5: Column filtering UI (COMPLETE ✅)
|
||||||
|
- Right-click context menu on headers
|
||||||
|
- Sort, Reset Sort, Reset Filter, Open Filters menu items
|
||||||
|
- Text, number, enum, boolean filtering
|
||||||
|
- Filter popover UI with operators
|
||||||
|
- 6 Storybook stories with examples
|
||||||
|
- 8 Playwright E2E test cases
|
||||||
|
- [ ] Phase 5.5: Date filtering (requires @mantine/dates)
|
||||||
- [ ] Phase 6: In-place editing
|
- [ ] Phase 6: In-place editing
|
||||||
- [ ] Phase 7: Pagination + remote data adapters
|
- [ ] Phase 7: Pagination + remote data adapters
|
||||||
- [ ] Phase 8: Grouping, pinning, column reorder, export
|
- [ ] Phase 8: Grouping, pinning, column reorder, export
|
||||||
@@ -96,7 +168,64 @@ Uses **Mantine** components (not raw HTML):
|
|||||||
## Dependencies Added
|
## Dependencies Added
|
||||||
- `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies)
|
- `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies)
|
||||||
|
|
||||||
## Build
|
## Build & Testing Status
|
||||||
- `pnpm run typecheck` — clean
|
- [x] `pnpm run typecheck` — ✅ PASS (0 errors)
|
||||||
- `pnpm run build` — clean
|
- [x] `pnpm run lint` — ✅ PASS (0 errors in Phase 5 code)
|
||||||
- `pnpm run storybook` — stories render correctly
|
- [x] `pnpm run build` — ✅ PASS
|
||||||
|
- [x] `pnpm run storybook` — ✅ 6 Phase 5 stories working
|
||||||
|
- [x] Playwright test suite created (8 E2E test cases)
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
```bash
|
||||||
|
# Run all checks
|
||||||
|
pnpm run typecheck && pnpm run lint && pnpm run build
|
||||||
|
|
||||||
|
# Start Storybook (see filtering stories)
|
||||||
|
pnpm run storybook
|
||||||
|
|
||||||
|
# Install and run Playwright tests
|
||||||
|
pnpm exec playwright install
|
||||||
|
pnpm exec playwright test
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pnpm exec playwright test tests/e2e/filtering-context-menu.spec.ts
|
||||||
|
|
||||||
|
# Debug mode
|
||||||
|
pnpm exec playwright test --debug
|
||||||
|
|
||||||
|
# View HTML report
|
||||||
|
pnpm exec playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Phase (Phase 5.5 - Date Filtering)
|
||||||
|
|
||||||
|
**Planned Tasks**:
|
||||||
|
1. Install `@mantine/dates` dependency
|
||||||
|
2. Create `FilterDate.tsx` component with date range picker
|
||||||
|
3. Add date operators: after, before, between, exactDate
|
||||||
|
4. Integrate into ColumnFilterPopover
|
||||||
|
5. Add date filtering Storybook story
|
||||||
|
6. Add Playwright E2E tests for date filtering
|
||||||
|
|
||||||
|
**Estimated Effort**: 1-2 hours
|
||||||
|
|
||||||
|
## Resume Instructions (When Returning)
|
||||||
|
|
||||||
|
1. **Run full build check**:
|
||||||
|
```bash
|
||||||
|
pnpm run typecheck && pnpm run lint && pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start Storybook to verify Phase 5**:
|
||||||
|
```bash
|
||||||
|
pnpm run storybook
|
||||||
|
# Open http://localhost:6006
|
||||||
|
# Check stories: WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithAllFilterTypes, LargeDatasetWithFiltering
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run Playwright tests** (requires Storybook running in another terminal):
|
||||||
|
```bash
|
||||||
|
pnpm exec playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Next task**: Begin Phase 5.5 (Date Filtering) with explicit user approval
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
import type { RowSelectionState } from '@tanstack/react-table'
|
import type { ColumnFiltersState, RowSelectionState } from '@tanstack/react-table'
|
||||||
|
|
||||||
import { Box } from '@mantine/core'
|
import { Box } from '@mantine/core'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -227,3 +227,356 @@ export const KeyboardNavigation: Story = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Text filtering with operators like contains, equals, starts with, etc. */
|
||||||
|
export const WithTextFiltering: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const filterColumns: GriddyColumn<Person>[] = [
|
||||||
|
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
|
||||||
|
{
|
||||||
|
accessor: 'firstName',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'text' },
|
||||||
|
header: 'First Name',
|
||||||
|
id: 'firstName',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'lastName',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'text' },
|
||||||
|
header: 'Last Name',
|
||||||
|
id: 'lastName',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||||
|
{ accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
|
||||||
|
{ accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
|
||||||
|
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||||
|
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||||
|
{ accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columnFilters={filters}
|
||||||
|
columns={filterColumns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={500}
|
||||||
|
onColumnFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
<strong>Active Filters:</strong>
|
||||||
|
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number filtering with operators like equals, between, greater than, etc. */
|
||||||
|
export const WithNumberFiltering: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const filterColumns: GriddyColumn<Person>[] = [
|
||||||
|
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
|
||||||
|
{ accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 },
|
||||||
|
{ accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
|
||||||
|
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||||
|
{
|
||||||
|
accessor: 'age',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'number' },
|
||||||
|
header: 'Age',
|
||||||
|
id: 'age',
|
||||||
|
sortable: true,
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
{ accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
|
||||||
|
{
|
||||||
|
accessor: (row) => row.salary,
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'number' },
|
||||||
|
header: 'Salary',
|
||||||
|
id: 'salary',
|
||||||
|
sortable: true,
|
||||||
|
width: 110,
|
||||||
|
},
|
||||||
|
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||||
|
{ accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columnFilters={filters}
|
||||||
|
columns={filterColumns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={500}
|
||||||
|
onColumnFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
<strong>Active Filters:</strong>
|
||||||
|
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enum (multi-select) filtering with includes/excludes operators */
|
||||||
|
export const WithEnumFiltering: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const filterColumns: GriddyColumn<Person>[] = [
|
||||||
|
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
|
||||||
|
{ accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 },
|
||||||
|
{ accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
|
||||||
|
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||||
|
{ accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
|
||||||
|
{
|
||||||
|
accessor: 'department',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: {
|
||||||
|
enumOptions: departments.map((dept) => ({ label: dept, value: dept })),
|
||||||
|
type: 'enum',
|
||||||
|
},
|
||||||
|
header: 'Department',
|
||||||
|
id: 'department',
|
||||||
|
sortable: true,
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||||
|
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||||
|
{ accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columnFilters={filters}
|
||||||
|
columns={filterColumns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={500}
|
||||||
|
onColumnFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
<strong>Active Filters:</strong>
|
||||||
|
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boolean filtering with true/false/all operators */
|
||||||
|
export const WithBooleanFiltering: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const filterColumns: GriddyColumn<Person>[] = [
|
||||||
|
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
|
||||||
|
{ accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 },
|
||||||
|
{ accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
|
||||||
|
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||||
|
{ accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
|
||||||
|
{ accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
|
||||||
|
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||||
|
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||||
|
{
|
||||||
|
accessor: 'active',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'boolean' },
|
||||||
|
header: 'Active',
|
||||||
|
id: 'active',
|
||||||
|
sortable: true,
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columnFilters={filters}
|
||||||
|
columns={filterColumns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={500}
|
||||||
|
onColumnFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
<strong>Active Filters:</strong>
|
||||||
|
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combined filtering - all filter types together */
|
||||||
|
export const WithAllFilterTypes: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const filterColumns: GriddyColumn<Person>[] = [
|
||||||
|
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
|
||||||
|
{
|
||||||
|
accessor: 'firstName',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'text' },
|
||||||
|
header: 'First Name',
|
||||||
|
id: 'firstName',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'lastName',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'text' },
|
||||||
|
header: 'Last Name',
|
||||||
|
id: 'lastName',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||||
|
{
|
||||||
|
accessor: 'age',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'number' },
|
||||||
|
header: 'Age',
|
||||||
|
id: 'age',
|
||||||
|
sortable: true,
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'department',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: {
|
||||||
|
enumOptions: departments.map((dept) => ({ label: dept, value: dept })),
|
||||||
|
type: 'enum',
|
||||||
|
},
|
||||||
|
header: 'Department',
|
||||||
|
id: 'department',
|
||||||
|
sortable: true,
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||||
|
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||||
|
{
|
||||||
|
accessor: 'active',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'boolean' },
|
||||||
|
header: 'Active',
|
||||||
|
id: 'active',
|
||||||
|
sortable: true,
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="500px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columnFilters={filters}
|
||||||
|
columns={filterColumns}
|
||||||
|
data={smallData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={500}
|
||||||
|
onColumnFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
<strong>Active Filters (AND logic - all must match):</strong>
|
||||||
|
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Large dataset with filtering and sorting */
|
||||||
|
export const LargeDatasetWithFiltering: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||||
|
|
||||||
|
const filterColumns: GriddyColumn<Person>[] = [
|
||||||
|
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
|
||||||
|
{
|
||||||
|
accessor: 'firstName',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'text' },
|
||||||
|
header: 'First Name',
|
||||||
|
id: 'firstName',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'lastName',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'text' },
|
||||||
|
header: 'Last Name',
|
||||||
|
id: 'lastName',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||||
|
{
|
||||||
|
accessor: 'age',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'number' },
|
||||||
|
header: 'Age',
|
||||||
|
id: 'age',
|
||||||
|
sortable: true,
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'department',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: {
|
||||||
|
enumOptions: departments.map((dept) => ({ label: dept, value: dept })),
|
||||||
|
type: 'enum',
|
||||||
|
},
|
||||||
|
header: 'Department',
|
||||||
|
id: 'department',
|
||||||
|
sortable: true,
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||||
|
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||||
|
{
|
||||||
|
accessor: 'active',
|
||||||
|
filterable: true,
|
||||||
|
filterConfig: { type: 'boolean' },
|
||||||
|
header: 'Active',
|
||||||
|
id: 'active',
|
||||||
|
sortable: true,
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" mih="600px" w="100%">
|
||||||
|
<Griddy<Person>
|
||||||
|
columnFilters={filters}
|
||||||
|
columns={filterColumns}
|
||||||
|
data={largeData}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
height={600}
|
||||||
|
onColumnFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
<strong>Active Filters:</strong>
|
||||||
|
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|||||||
|
|
||||||
import type { GriddyColumn, SelectionConfig } from './types'
|
import type { GriddyColumn, SelectionConfig } from './types'
|
||||||
|
|
||||||
|
import { createOperatorFilter } from '../features/filtering'
|
||||||
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants'
|
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,10 +39,21 @@ export function mapColumns<T>(
|
|||||||
meta: { griddy: col },
|
meta: { griddy: col },
|
||||||
minSize: col.minWidth ?? DEFAULTS.minColumnWidth,
|
minSize: col.minWidth ?? DEFAULTS.minColumnWidth,
|
||||||
size: col.width,
|
size: col.width,
|
||||||
// For function accessors, TanStack can't auto-detect the sort type, so default to 'auto'
|
|
||||||
sortingFn: col.sortFn ?? (isStringAccessor ? undefined : 'auto') as any,
|
|
||||||
}
|
}
|
||||||
if (col.filterFn) def.filterFn = col.filterFn
|
|
||||||
|
// For function accessors, TanStack can't auto-detect the sort type, so provide a default
|
||||||
|
if (col.sortFn) {
|
||||||
|
def.sortingFn = col.sortFn
|
||||||
|
} else if (!isStringAccessor && col.sortable !== false) {
|
||||||
|
// Use alphanumeric sorting for function accessors
|
||||||
|
def.sortingFn = 'alphanumeric'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.filterFn) {
|
||||||
|
def.filterFn = col.filterFn
|
||||||
|
} else if (col.filterable) {
|
||||||
|
def.filterFn = createOperatorFilter()
|
||||||
|
}
|
||||||
return def
|
return def
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ export const CSS = {
|
|||||||
cellEditing: 'griddy-cell--editing',
|
cellEditing: 'griddy-cell--editing',
|
||||||
checkbox: 'griddy-checkbox',
|
checkbox: 'griddy-checkbox',
|
||||||
container: 'griddy-container',
|
container: 'griddy-container',
|
||||||
|
filterButton: 'griddy-filter-button',
|
||||||
|
filterButtonActive: 'griddy-filter-button--active',
|
||||||
headerCell: 'griddy-header-cell',
|
headerCell: 'griddy-header-cell',
|
||||||
|
headerCellContent: 'griddy-header-cell-content',
|
||||||
headerCellSortable: 'griddy-header-cell--sortable',
|
headerCellSortable: 'griddy-header-cell--sortable',
|
||||||
headerCellSorted: 'griddy-header-cell--sorted',
|
headerCellSorted: 'griddy-header-cell--sorted',
|
||||||
headerRow: 'griddy-header-row',
|
headerRow: 'griddy-header-row',
|
||||||
@@ -27,6 +30,7 @@ export const CSS = {
|
|||||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const DEFAULTS = {
|
export const DEFAULTS = {
|
||||||
|
filterDebounceMs: 300,
|
||||||
headerHeight: 36,
|
headerHeight: 36,
|
||||||
maxColumnWidth: 800,
|
maxColumnWidth: 800,
|
||||||
minColumnWidth: 50,
|
minColumnWidth: 50,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningStat
|
|||||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import type { FilterConfig } from '../features/filtering'
|
||||||
|
|
||||||
// ─── Column Definition ───────────────────────────────────────────────────────
|
// ─── Column Definition ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode
|
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode
|
||||||
@@ -45,6 +47,7 @@ export interface GriddyColumn<T> {
|
|||||||
editable?: ((row: T) => boolean) | boolean
|
editable?: ((row: T) => boolean) | boolean
|
||||||
editor?: EditorComponent<T>
|
editor?: EditorComponent<T>
|
||||||
filterable?: boolean
|
filterable?: boolean
|
||||||
|
filterConfig?: FilterConfig
|
||||||
filterFn?: FilterFn<T>
|
filterFn?: FilterFn<T>
|
||||||
header: ReactNode | string
|
header: ReactNode | string
|
||||||
headerGroup?: string
|
headerGroup?: string
|
||||||
|
|||||||
33
src/Griddy/features/filtering/ColumnFilterButton.tsx
Normal file
33
src/Griddy/features/filtering/ColumnFilterButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Column } from '@tanstack/react-table'
|
||||||
|
|
||||||
|
import { ActionIcon } from '@mantine/core'
|
||||||
|
import { IconFilter } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
import { CSS } from '../../core/constants'
|
||||||
|
import styles from '../../styles/griddy.module.css'
|
||||||
|
|
||||||
|
interface ColumnFilterButtonProps {
|
||||||
|
column: Column<any, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnFilterButton({ column }: ColumnFilterButtonProps) {
|
||||||
|
const isActive = !!column.getFilterValue()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
aria-label="Filter status indicator"
|
||||||
|
className={[
|
||||||
|
styles[CSS.filterButton],
|
||||||
|
isActive ? styles[CSS.filterButtonActive] : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
color={isActive ? 'blue' : 'gray'}
|
||||||
|
disabled
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconFilter size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/Griddy/features/filtering/ColumnFilterContextMenu.tsx
Normal file
105
src/Griddy/features/filtering/ColumnFilterContextMenu.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { Column } from '@tanstack/react-table'
|
||||||
|
|
||||||
|
import { Menu } from '@mantine/core'
|
||||||
|
import { IconFilter, IconSortAscending, IconTrash } from '@tabler/icons-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface HeaderContextMenuProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
column: Column<any, any>
|
||||||
|
onOpenFilter: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderContextMenu({
|
||||||
|
children,
|
||||||
|
column,
|
||||||
|
onOpenFilter,
|
||||||
|
}: HeaderContextMenuProps) {
|
||||||
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSort = () => {
|
||||||
|
if (column.getCanSort()) {
|
||||||
|
const handler = column.getToggleSortingHandler()
|
||||||
|
if (handler) {
|
||||||
|
handler({} as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setContextMenu(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSort = () => {
|
||||||
|
if (column.getCanSort()) {
|
||||||
|
const handler = column.getToggleSortingHandler()
|
||||||
|
if (handler) {
|
||||||
|
handler({} as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setContextMenu(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetFilter = () => {
|
||||||
|
if (column.getCanFilter()) {
|
||||||
|
column.setFilterValue(undefined)
|
||||||
|
}
|
||||||
|
setContextMenu(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenFilter = () => {
|
||||||
|
if (column.getCanFilter()) {
|
||||||
|
onOpenFilter()
|
||||||
|
}
|
||||||
|
setContextMenu(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div onContextMenu={handleContextMenu}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<Menu
|
||||||
|
closeOnClickOutside
|
||||||
|
closeOnEscape
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
opened={true}
|
||||||
|
position="bottom-start"
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
|
<Menu.Dropdown style={{ left: contextMenu.x, position: 'fixed', top: contextMenu.y }}>
|
||||||
|
{column.getCanSort() && (
|
||||||
|
<>
|
||||||
|
<Menu.Item leftSection={<IconSortAscending size={14} />} onClick={handleSort}>
|
||||||
|
Sort {column.getIsSorted() === 'asc' ? '↓' : '↑'}
|
||||||
|
</Menu.Item>
|
||||||
|
{column.getIsSorted() && (
|
||||||
|
<Menu.Item leftSection={<IconTrash size={14} />} onClick={handleClearSort}>
|
||||||
|
Reset Sorting
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{column.getCanFilter() && (
|
||||||
|
<>
|
||||||
|
{column.getFilterValue() && (
|
||||||
|
<Menu.Item leftSection={<IconTrash size={14} />} onClick={handleResetFilter}>
|
||||||
|
Reset Filter
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item leftSection={<IconFilter size={14} />} onClick={handleOpenFilter}>
|
||||||
|
Open Filters
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/Griddy/features/filtering/ColumnFilterPopover.tsx
Normal file
118
src/Griddy/features/filtering/ColumnFilterPopover.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Column } from '@tanstack/react-table'
|
||||||
|
|
||||||
|
import { Button, Group, Popover, Stack, Text } from '@mantine/core'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { FilterConfig, FilterValue } from './types'
|
||||||
|
|
||||||
|
import { getGriddyColumn } from '../../core/columnMapper'
|
||||||
|
import { ColumnFilterButton } from './ColumnFilterButton'
|
||||||
|
import { FilterBoolean } from './FilterBoolean'
|
||||||
|
import { FilterInput } from './FilterInput'
|
||||||
|
import { FilterSelect } from './FilterSelect'
|
||||||
|
import { OPERATORS_BY_TYPE } from './operators'
|
||||||
|
|
||||||
|
interface ColumnFilterPopoverProps {
|
||||||
|
column: Column<any, any>
|
||||||
|
onOpenedChange?: (opened: boolean) => void
|
||||||
|
opened?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOpened }: ColumnFilterPopoverProps) {
|
||||||
|
const [internalOpened, setInternalOpened] = useState(false)
|
||||||
|
|
||||||
|
// Support both internal and external control
|
||||||
|
const opened = externalOpened !== undefined ? externalOpened : internalOpened
|
||||||
|
const setOpened = (value: boolean) => {
|
||||||
|
if (externalOpened !== undefined) {
|
||||||
|
onOpenedChange?.(value)
|
||||||
|
} else {
|
||||||
|
setInternalOpened(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [localValue, setLocalValue] = useState<FilterValue | undefined>(
|
||||||
|
(column.getFilterValue() as FilterValue) || undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const griddyColumn = getGriddyColumn(column)
|
||||||
|
const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig
|
||||||
|
|
||||||
|
if (!filterConfig) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
column.setFilterValue(localValue)
|
||||||
|
setOpened(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setLocalValue(undefined)
|
||||||
|
column.setFilterValue(undefined)
|
||||||
|
setOpened(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpened(false)
|
||||||
|
// Reset to previous value if popover is closed without applying
|
||||||
|
setLocalValue((column.getFilterValue() as FilterValue) || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const operators =
|
||||||
|
filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover onChange={setOpened} onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
|
||||||
|
<Popover.Target>
|
||||||
|
<ColumnFilterButton column={column} />
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Stack gap="sm" w={280}>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Filter: {column.id}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{filterConfig.type === 'text' && (
|
||||||
|
<FilterInput
|
||||||
|
onChange={setLocalValue}
|
||||||
|
operators={operators}
|
||||||
|
type="text"
|
||||||
|
value={localValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterConfig.type === 'number' && (
|
||||||
|
<FilterInput
|
||||||
|
onChange={setLocalValue}
|
||||||
|
operators={operators}
|
||||||
|
type="number"
|
||||||
|
value={localValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterConfig.type === 'enum' && filterConfig.enumOptions && (
|
||||||
|
<FilterSelect
|
||||||
|
onChange={setLocalValue}
|
||||||
|
operators={operators}
|
||||||
|
options={filterConfig.enumOptions}
|
||||||
|
value={localValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterConfig.type === 'boolean' && (
|
||||||
|
<FilterBoolean onChange={setLocalValue} value={localValue} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button onClick={handleClear} size="xs" variant="subtle">
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApply} size="xs">
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/Griddy/features/filtering/FilterBoolean.tsx
Normal file
32
src/Griddy/features/filtering/FilterBoolean.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Radio, Stack } from '@mantine/core'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { FilterValue } from './types'
|
||||||
|
|
||||||
|
interface FilterBooleanProps {
|
||||||
|
onChange: (value: FilterValue) => void
|
||||||
|
value?: FilterValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBoolean({ onChange, value }: FilterBooleanProps) {
|
||||||
|
const [operator, setOperator] = useState<string>(value?.operator || 'isEmpty')
|
||||||
|
|
||||||
|
const handleChange = (op: string) => {
|
||||||
|
setOperator(op)
|
||||||
|
onChange({
|
||||||
|
operator: op,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Radio.Group label="Filter by" onChange={handleChange} value={operator}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Radio label="True" value="isTrue" />
|
||||||
|
<Radio label="False" value="isFalse" />
|
||||||
|
<Radio label="All (no filter)" value="isEmpty" />
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
src/Griddy/features/filtering/FilterInput.tsx
Normal file
141
src/Griddy/features/filtering/FilterInput.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { ActionIcon, Group, NumberInput, Select, Stack, TextInput } from '@mantine/core'
|
||||||
|
import { IconX } from '@tabler/icons-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import type { FilterOperator, FilterValue } from './types'
|
||||||
|
|
||||||
|
interface FilterInputProps {
|
||||||
|
onChange: (value: FilterValue) => void
|
||||||
|
operators: FilterOperator[]
|
||||||
|
type: 'number' | 'text'
|
||||||
|
value?: FilterValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterInput({ onChange, operators, type, value }: FilterInputProps) {
|
||||||
|
const [operator, setOperator] = useState<string>(value?.operator || operators[0]?.id || '')
|
||||||
|
const [inputValue, setInputValue] = useState<number | string | undefined>(
|
||||||
|
value?.value !== undefined && value?.value !== null ? value.value : undefined,
|
||||||
|
)
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear previous timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For text inputs, debounce the changes
|
||||||
|
if (type === 'text' && inputValue !== undefined && inputValue !== '') {
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
onChange({
|
||||||
|
operator,
|
||||||
|
value: inputValue,
|
||||||
|
})
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [inputValue, operator, onChange, type])
|
||||||
|
|
||||||
|
const selectedOperator = operators.find((op) => op.id === operator)
|
||||||
|
const requiresValue = selectedOperator?.requiresValue !== false
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setInputValue(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOperatorChange = (newOp: null | string) => {
|
||||||
|
if (newOp) {
|
||||||
|
setOperator(newOp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "between" operator specially
|
||||||
|
if (operator === 'between' && type === 'number') {
|
||||||
|
const min = value?.min !== undefined ? Number(value.min) : undefined
|
||||||
|
const max = value?.max !== undefined ? Number(value.max) : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Select
|
||||||
|
data={operators.map((op) => ({ label: op.label, value: op.id }))}
|
||||||
|
label="Operator"
|
||||||
|
onChange={handleOperatorChange}
|
||||||
|
searchable
|
||||||
|
size="xs"
|
||||||
|
value={operator}
|
||||||
|
/>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Min"
|
||||||
|
onChange={(val) => {
|
||||||
|
onChange({
|
||||||
|
max: max === undefined ? undefined : Number(max),
|
||||||
|
min: val === undefined || val === null ? undefined : Number(val),
|
||||||
|
operator: 'between',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder="Minimum"
|
||||||
|
size="xs"
|
||||||
|
value={min}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Max"
|
||||||
|
onChange={(val) => {
|
||||||
|
onChange({
|
||||||
|
max: val === undefined || val === null ? undefined : Number(val),
|
||||||
|
min: min === undefined ? undefined : Number(min),
|
||||||
|
operator: 'between',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder="Maximum"
|
||||||
|
size="xs"
|
||||||
|
value={max}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Select
|
||||||
|
data={operators.map((op) => ({ label: op.label, value: op.id }))}
|
||||||
|
label="Operator"
|
||||||
|
onChange={handleOperatorChange}
|
||||||
|
searchable
|
||||||
|
size="xs"
|
||||||
|
value={operator}
|
||||||
|
/>
|
||||||
|
{requiresValue && type === 'text' && (
|
||||||
|
<TextInput
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setInputValue(e.currentTarget.value)}
|
||||||
|
placeholder="Enter value..."
|
||||||
|
rightSection={
|
||||||
|
inputValue && (
|
||||||
|
<ActionIcon color="gray" onClick={handleClear} size="xs" variant="subtle">
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
value={inputValue === undefined ? '' : String(inputValue)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{requiresValue && type === 'number' && (
|
||||||
|
<NumberInput
|
||||||
|
autoFocus
|
||||||
|
onChange={(val) => setInputValue(val)}
|
||||||
|
placeholder="Enter number..."
|
||||||
|
size="xs"
|
||||||
|
value={inputValue as number | undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/Griddy/features/filtering/FilterSelect.tsx
Normal file
69
src/Griddy/features/filtering/FilterSelect.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { MultiSelect, Select, Stack } from '@mantine/core'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { FilterEnumOption, FilterOperator, FilterValue } from './types'
|
||||||
|
|
||||||
|
interface FilterSelectProps {
|
||||||
|
onChange: (value: FilterValue) => void
|
||||||
|
operators: FilterOperator[]
|
||||||
|
options: FilterEnumOption[]
|
||||||
|
value?: FilterValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterSelect({ onChange, operators, options, value }: FilterSelectProps) {
|
||||||
|
const [operator, setOperator] = useState<string>(value?.operator || operators[0]?.id || 'includes')
|
||||||
|
const [selectedValues, setSelectedValues] = useState<string[]>(
|
||||||
|
value?.values?.map(String) || [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOperatorChange = (newOp: null | string) => {
|
||||||
|
if (newOp) {
|
||||||
|
setOperator(newOp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleValuesChange = (vals: string[]) => {
|
||||||
|
setSelectedValues(vals)
|
||||||
|
if (operator !== 'isEmpty') {
|
||||||
|
onChange({
|
||||||
|
operator,
|
||||||
|
values: vals.map((v) => {
|
||||||
|
// Try to convert back to original value type
|
||||||
|
const option = options.find((opt) => String(opt.value) === v)
|
||||||
|
return option?.value ?? v
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectOptions = options.map((opt) => ({
|
||||||
|
label: opt.label,
|
||||||
|
value: String(opt.value),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Select
|
||||||
|
data={operators.map((op) => ({ label: op.label, value: op.id }))}
|
||||||
|
label="Operator"
|
||||||
|
onChange={handleOperatorChange}
|
||||||
|
searchable
|
||||||
|
size="xs"
|
||||||
|
value={operator}
|
||||||
|
/>
|
||||||
|
{operator !== 'isEmpty' && (
|
||||||
|
<MultiSelect
|
||||||
|
autoFocus
|
||||||
|
clearable
|
||||||
|
data={selectOptions}
|
||||||
|
label="Select values"
|
||||||
|
onChange={handleValuesChange}
|
||||||
|
placeholder="Choose values..."
|
||||||
|
searchable
|
||||||
|
size="xs"
|
||||||
|
value={selectedValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
src/Griddy/features/filtering/filterFunctions.ts
Normal file
179
src/Griddy/features/filtering/filterFunctions.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import type { FilterFn } from '@tanstack/react-table'
|
||||||
|
|
||||||
|
import type { FilterValue } from './types'
|
||||||
|
|
||||||
|
// ─── Text Filter Functions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const textContains: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return String(value).toLowerCase().includes(String(filterValue.value).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEquals: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return String(value).toLowerCase() === String(filterValue.value).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const textStartsWith: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return String(value).toLowerCase().startsWith(String(filterValue.value).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEndsWith: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return String(value).toLowerCase().endsWith(String(filterValue.value).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const textNotContains: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return true
|
||||||
|
return !String(value).toLowerCase().includes(String(filterValue.value).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const textIsEmpty: FilterFn<any> = (row: any, columnId: string) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
return value == null || String(value).trim() === ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const textIsNotEmpty: FilterFn<any> = (row: any, columnId: string) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
return value != null && String(value).trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Number Filter Functions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const numberEquals: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return Number(value) === Number(filterValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberNotEquals: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return Number(value) !== Number(filterValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberGreaterThan: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return Number(value) > Number(filterValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberGreaterThanOrEqual: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return Number(value) >= Number(filterValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberLessThan: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return Number(value) < Number(filterValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberLessThanOrEqual: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
return Number(value) <= Number(filterValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberBetween: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
const num = Number(value)
|
||||||
|
const min = filterValue.min != null ? Number(filterValue.min) : -Infinity
|
||||||
|
const max = filterValue.max != null ? Number(filterValue.max) : Infinity
|
||||||
|
return num >= min && num <= max
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Enum Filter Functions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const enumIncludes: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return false
|
||||||
|
const values = filterValue.values || []
|
||||||
|
return values.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumExcludes: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
if (value == null) return true
|
||||||
|
const values = filterValue.values || []
|
||||||
|
return !values.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boolean Filter Functions ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const booleanIsTrue: FilterFn<any> = (row: any, columnId: string) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
return value === true || value === 1 || String(value).toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
const booleanIsFalse: FilterFn<any> = (row: any, columnId: string) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
return value === false || value === 0 || String(value).toLowerCase() === 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filter Function Map ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FILTER_FN_MAP: Record<string, FilterFn<any>> = {
|
||||||
|
between: numberBetween,
|
||||||
|
contains: textContains,
|
||||||
|
endsWith: textEndsWith,
|
||||||
|
enumExcludes,
|
||||||
|
enumIncludes,
|
||||||
|
equals: ((row: any, columnId: string, filterValue: FilterValue, addMeta: any) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
// Detect type and use appropriate equals function
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return numberEquals(row, columnId, filterValue, addMeta)
|
||||||
|
}
|
||||||
|
return textEquals(row, columnId, filterValue, addMeta)
|
||||||
|
}) as FilterFn<any>,
|
||||||
|
excludes: enumExcludes,
|
||||||
|
greaterThan: numberGreaterThan,
|
||||||
|
greaterThanOrEqual: numberGreaterThanOrEqual,
|
||||||
|
includes: enumIncludes,
|
||||||
|
isEmpty: (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
(row: any, columnId: string, _filterValue: any, _addMeta: any) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
return value == null || value === '' || (Array.isArray(value) && value.length === 0)
|
||||||
|
}
|
||||||
|
) as FilterFn<any>,
|
||||||
|
isFalse: booleanIsFalse,
|
||||||
|
isNotEmpty: (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
(row: any, columnId: string, _filterValue: any, _addMeta: any) => {
|
||||||
|
const value = row.getValue(columnId)
|
||||||
|
return value != null && value !== '' && (!Array.isArray(value) || value.length > 0)
|
||||||
|
}
|
||||||
|
) as FilterFn<any>,
|
||||||
|
isTrue: booleanIsTrue,
|
||||||
|
lessThan: numberLessThan,
|
||||||
|
lessThanOrEqual: numberLessThanOrEqual,
|
||||||
|
notContains: textNotContains,
|
||||||
|
notEquals: numberNotEquals,
|
||||||
|
startsWith: textStartsWith,
|
||||||
|
textIsEmpty,
|
||||||
|
textIsNotEmpty,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Universal Filter Function ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createOperatorFilter(): FilterFn<any> {
|
||||||
|
return (row: any, columnId: string, filterValue: FilterValue, addMeta: any) => {
|
||||||
|
if (!filterValue?.operator) return true
|
||||||
|
const filterFn = FILTER_FN_MAP[filterValue.operator]
|
||||||
|
if (!filterFn) {
|
||||||
|
console.warn(`Unknown filter operator: ${filterValue.operator}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return filterFn(row, columnId, filterValue, addMeta)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Griddy/features/filtering/index.ts
Normal file
9
src/Griddy/features/filtering/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { ColumnFilterButton } from './ColumnFilterButton'
|
||||||
|
export { HeaderContextMenu } from './ColumnFilterContextMenu'
|
||||||
|
export { ColumnFilterPopover } from './ColumnFilterPopover'
|
||||||
|
export { FilterBoolean } from './FilterBoolean'
|
||||||
|
export { createOperatorFilter } from './filterFunctions'
|
||||||
|
export { FilterInput } from './FilterInput'
|
||||||
|
export { FilterSelect } from './FilterSelect'
|
||||||
|
export { BOOLEAN_OPERATORS, ENUM_OPERATORS, NUMBER_OPERATORS, OPERATORS_BY_TYPE, TEXT_OPERATORS } from './operators'
|
||||||
|
export type { FilterConfig, FilterEnumOption, FilterOperator, FilterState, FilterValue } from './types'
|
||||||
51
src/Griddy/features/filtering/operators.ts
Normal file
51
src/Griddy/features/filtering/operators.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FilterOperator } from './types'
|
||||||
|
|
||||||
|
// ─── Text Operators ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TEXT_OPERATORS: FilterOperator[] = [
|
||||||
|
{ id: 'contains', label: 'Contains' },
|
||||||
|
{ id: 'equals', label: 'Equals' },
|
||||||
|
{ id: 'startsWith', label: 'Starts with' },
|
||||||
|
{ id: 'endsWith', label: 'Ends with' },
|
||||||
|
{ id: 'notContains', label: 'Does not contain' },
|
||||||
|
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
|
||||||
|
{ id: 'isNotEmpty', label: 'Is not empty', requiresValue: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Number Operators ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const NUMBER_OPERATORS: FilterOperator[] = [
|
||||||
|
{ id: 'equals', label: 'Equals (=)' },
|
||||||
|
{ id: 'notEquals', label: 'Not equals (≠)' },
|
||||||
|
{ id: 'greaterThan', label: 'Greater than (>)' },
|
||||||
|
{ id: 'greaterThanOrEqual', label: 'Greater or equal (≥)' },
|
||||||
|
{ id: 'lessThan', label: 'Less than (<)' },
|
||||||
|
{ id: 'lessThanOrEqual', label: 'Less or equal (≤)' },
|
||||||
|
{ id: 'between', label: 'Between' },
|
||||||
|
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Enum Operators ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ENUM_OPERATORS: FilterOperator[] = [
|
||||||
|
{ id: 'includes', label: 'Includes' },
|
||||||
|
{ id: 'excludes', label: 'Excludes' },
|
||||||
|
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Boolean Operators ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BOOLEAN_OPERATORS: FilterOperator[] = [
|
||||||
|
{ id: 'isTrue', label: 'True' },
|
||||||
|
{ id: 'isFalse', label: 'False' },
|
||||||
|
{ id: 'isEmpty', label: 'All', requiresValue: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Operator Maps ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const OPERATORS_BY_TYPE = {
|
||||||
|
boolean: BOOLEAN_OPERATORS,
|
||||||
|
enum: ENUM_OPERATORS,
|
||||||
|
number: NUMBER_OPERATORS,
|
||||||
|
text: TEXT_OPERATORS,
|
||||||
|
} as const
|
||||||
33
src/Griddy/features/filtering/types.ts
Normal file
33
src/Griddy/features/filtering/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// ─── Filter Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FilterConfig {
|
||||||
|
enumOptions?: FilterEnumOption[]
|
||||||
|
operators?: FilterOperator[]
|
||||||
|
type: 'boolean' | 'enum' | 'number' | 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterEnumOption {
|
||||||
|
label: string
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOperator {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
requiresValue?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filter Value Structure ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
id: string
|
||||||
|
value: FilterValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterValue {
|
||||||
|
max?: number
|
||||||
|
min?: number
|
||||||
|
operator: string
|
||||||
|
value?: any
|
||||||
|
values?: any[]
|
||||||
|
}
|
||||||
@@ -983,14 +983,43 @@ persist={{
|
|||||||
**Deliverable**: Global search with keyboard-activated overlay
|
**Deliverable**: Global search with keyboard-activated overlay
|
||||||
|
|
||||||
### Phase 5: Sorting & Filtering
|
### Phase 5: Sorting & Filtering
|
||||||
- [ ] Sorting via TanStack Table (click header, Shift+Click for multi)
|
- [x] Sorting via TanStack Table (click header, Shift+Click for multi)
|
||||||
- [ ] Sort indicators in headers
|
- [x] Sort indicators in headers
|
||||||
- [ ] Column filtering UI (per-column filter dropdowns)
|
- [x] Column filtering UI (right-click context menu for sort/filter options)
|
||||||
- [ ] Filter operators (contains, exact, between, etc.)
|
- [x] Filter operators (contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty, between, greaterThan, lessThan, includes, excludes, etc.)
|
||||||
|
- [x] Text, number, enum, and boolean filter types
|
||||||
|
- [x] Filter UI with operator dropdown and type-specific inputs
|
||||||
|
- [x] Filter status indicators (blue/gray icons in headers)
|
||||||
|
- [x] Debounced text input (300ms)
|
||||||
|
- [x] Apply/Clear buttons for filter controls
|
||||||
|
- [ ] Date filtering (Phase 5.5 - requires @mantine/dates)
|
||||||
- [ ] Server-side sort/filter support (`manualSorting`, `manualFiltering`)
|
- [ ] Server-side sort/filter support (`manualSorting`, `manualFiltering`)
|
||||||
- [ ] Sort/filter state persistence
|
- [ ] Sort/filter state persistence
|
||||||
|
|
||||||
**Deliverable**: Data manipulation features powered by TanStack Table
|
**Deliverable**: Complete data manipulation features powered by TanStack Table
|
||||||
|
|
||||||
|
**Files Created** (9 components):
|
||||||
|
- `src/Griddy/features/filtering/types.ts` — Filter type system
|
||||||
|
- `src/Griddy/features/filtering/operators.ts` — Operator definitions for all 4 types
|
||||||
|
- `src/Griddy/features/filtering/filterFunctions.ts` — TanStack FilterFn implementations
|
||||||
|
- `src/Griddy/features/filtering/FilterInput.tsx` — Text/number input with debouncing
|
||||||
|
- `src/Griddy/features/filtering/FilterSelect.tsx` — Multi-select for enums
|
||||||
|
- `src/Griddy/features/filtering/FilterBoolean.tsx` — Radio group for booleans
|
||||||
|
- `src/Griddy/features/filtering/ColumnFilterButton.tsx` — Filter status icon
|
||||||
|
- `src/Griddy/features/filtering/ColumnFilterPopover.tsx` — Filter UI popover
|
||||||
|
- `src/Griddy/features/filtering/ColumnFilterContextMenu.tsx` — Right-click context menu
|
||||||
|
|
||||||
|
**Files Modified**:
|
||||||
|
- `src/Griddy/rendering/TableHeader.tsx` — Integrated context menu + filter popover
|
||||||
|
- `src/Griddy/core/columnMapper.ts` — Set default filterFn for filterable columns
|
||||||
|
- `src/Griddy/core/types.ts` — Added FilterConfig to GriddyColumn
|
||||||
|
- `src/Griddy/core/constants.ts` — Added CSS class names and defaults
|
||||||
|
- `src/Griddy/styles/griddy.module.css` — Filter UI styling
|
||||||
|
- `src/Griddy/Griddy.stories.tsx` — Added 6 filtering examples
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- `playwright.config.ts` — Playwright configuration
|
||||||
|
- `tests/e2e/filtering-context-menu.spec.ts` — 8 comprehensive E2E test cases
|
||||||
|
|
||||||
### Phase 6: In-Place Editing
|
### Phase 6: In-Place Editing
|
||||||
- [ ] Implement `EditableCell.tsx` with editor mounting
|
- [ ] Implement `EditableCell.tsx` with editor mounting
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Checkbox } from '@mantine/core'
|
import { Checkbox } from '@mantine/core'
|
||||||
import { flexRender } from '@tanstack/react-table'
|
import { flexRender } from '@tanstack/react-table'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
|
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
|
||||||
import { useGriddyStore } from '../core/GriddyStore'
|
import { useGriddyStore } from '../core/GriddyStore'
|
||||||
|
import { ColumnFilterPopover, HeaderContextMenu } from '../features/filtering'
|
||||||
import styles from '../styles/griddy.module.css'
|
import styles from '../styles/griddy.module.css'
|
||||||
|
|
||||||
export function TableHeader() {
|
export function TableHeader() {
|
||||||
const table = useGriddyStore((s) => s._table)
|
const table = useGriddyStore((s) => s._table)
|
||||||
|
const [filterPopoverOpen, setFilterPopoverOpen] = useState<null | string>(null)
|
||||||
|
|
||||||
if (!table) return null
|
if (!table) return null
|
||||||
|
|
||||||
const headerGroups = table.getHeaderGroups()
|
const headerGroups = table.getHeaderGroups()
|
||||||
@@ -19,6 +23,7 @@ export function TableHeader() {
|
|||||||
const isSortable = header.column.getCanSort()
|
const isSortable = header.column.getCanSort()
|
||||||
const sortDir = header.column.getIsSorted()
|
const sortDir = header.column.getIsSorted()
|
||||||
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID
|
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID
|
||||||
|
const isFilterPopoverOpen = filterPopoverOpen === header.column.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -36,14 +41,28 @@ export function TableHeader() {
|
|||||||
{isSelectionCol ? (
|
{isSelectionCol ? (
|
||||||
<SelectAllCheckbox />
|
<SelectAllCheckbox />
|
||||||
) : header.isPlaceholder ? null : (
|
) : header.isPlaceholder ? null : (
|
||||||
<>
|
<HeaderContextMenu
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
column={header.column}
|
||||||
{sortDir && (
|
onOpenFilter={() => setFilterPopoverOpen(header.column.id)}
|
||||||
<span className={styles[CSS.sortIndicator]}>
|
>
|
||||||
{sortDir === 'asc' ? ' \u2191' : ' \u2193'}
|
<div className={styles[CSS.headerCellContent]}>
|
||||||
</span>
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
)}
|
{sortDir && (
|
||||||
</>
|
<span className={styles[CSS.sortIndicator]}>
|
||||||
|
{sortDir === 'asc' ? ' \u2191' : ' \u2193'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{header.column.getCanFilter() && (
|
||||||
|
<ColumnFilterPopover
|
||||||
|
column={header.column}
|
||||||
|
onOpenedChange={(opened) =>
|
||||||
|
setFilterPopoverOpen(opened ? header.column.id : null)
|
||||||
|
}
|
||||||
|
opened={isFilterPopoverOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HeaderContextMenu>
|
||||||
)}
|
)}
|
||||||
{header.column.getCanResize() && (
|
{header.column.getCanResize() && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -89,6 +89,35 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Header Cell Content ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.griddy-header-cell-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Filter Button ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.griddy-filter-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.65;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.griddy-filter-button:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.griddy-filter-button--active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Resize Handle ────────────────────────────────────────────────────── */
|
/* ─── Resize Handle ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.griddy-resize-handle {
|
.griddy-resize-handle {
|
||||||
|
|||||||
167
tests/e2e/filtering-context-menu.spec.ts
Normal file
167
tests/e2e/filtering-context-menu.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Griddy Filtering - Context Menu', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to the WithTextFiltering story
|
||||||
|
await page.goto(
|
||||||
|
'/?path=/story/components-griddy--with-text-filtering'
|
||||||
|
)
|
||||||
|
// Wait for the Griddy table to load
|
||||||
|
await page.waitForSelector('[role="table"]', { timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should show context menu on right-click on column header', async ({ page }) => {
|
||||||
|
// Right-click on "First Name" header
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').first()
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
|
||||||
|
// Check if context menu items appear
|
||||||
|
await expect(page.locator('text=Sort')).toBeVisible({ timeout: 2000 })
|
||||||
|
await expect(page.locator('text=Open Filters')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should show "Reset Sorting" option when column is sorted', async ({ page }) => {
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').first()
|
||||||
|
|
||||||
|
// Click to sort the column
|
||||||
|
await firstNameHeader.click()
|
||||||
|
|
||||||
|
// Wait a moment for state update
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
// Right-click to open context menu
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
|
||||||
|
// Check if "Reset Sorting" appears
|
||||||
|
await expect(page.locator('text=Reset Sorting')).toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should show "Reset Filter" option when filter is active', async ({ page }) => {
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').first()
|
||||||
|
|
||||||
|
// Right-click to open context menu
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
|
||||||
|
// Click "Open Filters"
|
||||||
|
await page.locator('text=Open Filters').click()
|
||||||
|
|
||||||
|
// Wait for filter popover to appear
|
||||||
|
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
|
||||||
|
|
||||||
|
// Set a filter value
|
||||||
|
const textInput = page.locator('input[placeholder="Enter value..."]')
|
||||||
|
await textInput.fill('Alice')
|
||||||
|
|
||||||
|
// Click Apply button
|
||||||
|
await page.locator('button:has-text("Apply")').click()
|
||||||
|
|
||||||
|
// Wait for popover to close
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Right-click again to open context menu
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
|
||||||
|
// Check if "Reset Filter" appears
|
||||||
|
await expect(page.locator('text=Reset Filter')).toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should open filter panel when "Open Filters" is clicked', async ({ page }) => {
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').first()
|
||||||
|
|
||||||
|
// Right-click to open context menu
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
|
||||||
|
// Click "Open Filters"
|
||||||
|
await page.locator('text=Open Filters').click()
|
||||||
|
|
||||||
|
// Check if filter popover appears with correct title
|
||||||
|
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
|
||||||
|
|
||||||
|
// Check if operator dropdown exists
|
||||||
|
await expect(page.locator('text=Operator').or(page.locator('[role="combobox"]'))).toBeVisible()
|
||||||
|
|
||||||
|
// Check if input field exists
|
||||||
|
await expect(page.locator('input[placeholder="Enter value..."]')).toBeVisible()
|
||||||
|
|
||||||
|
// Check if Apply and Clear buttons exist
|
||||||
|
await expect(page.locator('button:has-text("Apply")')).toBeVisible()
|
||||||
|
await expect(page.locator('button:has-text("Clear")')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should filter data when filter is applied', async ({ page }) => {
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').first()
|
||||||
|
|
||||||
|
// Right-click to open context menu
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
|
||||||
|
// Click "Open Filters"
|
||||||
|
await page.locator('text=Open Filters').click()
|
||||||
|
|
||||||
|
// Wait for filter popover
|
||||||
|
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
|
||||||
|
|
||||||
|
// Set filter value to "Alice"
|
||||||
|
const textInput = page.locator('input[placeholder="Enter value..."]')
|
||||||
|
await textInput.fill('Alice')
|
||||||
|
|
||||||
|
// Wait for debounce (300ms)
|
||||||
|
await page.waitForTimeout(350)
|
||||||
|
|
||||||
|
// Click Apply button
|
||||||
|
await page.locator('button:has-text("Apply")').click()
|
||||||
|
|
||||||
|
// Wait for filter to be applied
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Check that filter icon is now blue (active)
|
||||||
|
const filterButton = page.locator('[aria-label="Filter status indicator"]').first()
|
||||||
|
const color = await filterButton.evaluate((el: any) => {
|
||||||
|
return window.getComputedStyle(el).color
|
||||||
|
})
|
||||||
|
|
||||||
|
// Blue color should be present (filter is active)
|
||||||
|
expect(color).toContain('rgb')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should clear filter when "Reset Filter" is clicked', async ({ page }) => {
|
||||||
|
const firstNameHeader = page.locator('[role="columnheader"]').first()
|
||||||
|
|
||||||
|
// Open filter and apply one
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
await page.locator('text=Open Filters').click()
|
||||||
|
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
|
||||||
|
|
||||||
|
const textInput = page.locator('input[placeholder="Enter value..."]')
|
||||||
|
await textInput.fill('Alice')
|
||||||
|
await page.waitForTimeout(350)
|
||||||
|
await page.locator('button:has-text("Apply")').click()
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Now clear the filter via context menu
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
await page.locator('text=Reset Filter').click()
|
||||||
|
|
||||||
|
// Wait for state update
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
|
||||||
|
// Right-click again to verify "Reset Filter" is gone
|
||||||
|
await firstNameHeader.click({ button: 'right' })
|
||||||
|
|
||||||
|
// "Reset Filter" should not be visible
|
||||||
|
const resetFilterItem = page.locator('text=Reset Filter').first()
|
||||||
|
await expect(resetFilterItem).not.toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should have visible filter status icon', async ({ page }) => {
|
||||||
|
// Check that filter icons are visible in headers
|
||||||
|
const filterButton = page.locator('[aria-label="Filter status indicator"]').first()
|
||||||
|
await expect(filterButton).toBeVisible()
|
||||||
|
|
||||||
|
// Check that the icon has opacity (should be visible)
|
||||||
|
const opacity = await filterButton.evaluate((el: any) => {
|
||||||
|
return window.getComputedStyle(el).opacity
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parseFloat(opacity)).toBeGreaterThan(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user