From b49d00874516fd77201901b33208a2a32727879e Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 13 Feb 2026 17:09:49 +0200 Subject: [PATCH] chore: griddy work --- package.json | 1 + ...47f4a0057687583a39a3354a294707b3b98bfd3.md | 500 ++++++++++++++++++ playwright-report/index.html | 85 +++ playwright.config.ts | 23 + pnpm-lock.yaml | 38 ++ src/Griddy/CONTEXT.md | 139 ++++- src/Griddy/Griddy.stories.tsx | 355 ++++++++++++- src/Griddy/core/columnMapper.ts | 18 +- src/Griddy/core/constants.ts | 4 + src/Griddy/core/types.ts | 3 + .../features/filtering/ColumnFilterButton.tsx | 33 ++ .../filtering/ColumnFilterContextMenu.tsx | 105 ++++ .../filtering/ColumnFilterPopover.tsx | 118 +++++ .../features/filtering/FilterBoolean.tsx | 32 ++ src/Griddy/features/filtering/FilterInput.tsx | 141 +++++ .../features/filtering/FilterSelect.tsx | 69 +++ .../features/filtering/filterFunctions.ts | 179 +++++++ src/Griddy/features/filtering/index.ts | 9 + src/Griddy/features/filtering/operators.ts | 51 ++ src/Griddy/features/filtering/types.ts | 33 ++ src/Griddy/plan.md | 39 +- src/Griddy/rendering/TableHeader.tsx | 35 +- src/Griddy/styles/griddy.module.css | 29 + tests/e2e/filtering-context-menu.spec.ts | 167 ++++++ 24 files changed, 2184 insertions(+), 22 deletions(-) create mode 100644 playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 src/Griddy/features/filtering/ColumnFilterButton.tsx create mode 100644 src/Griddy/features/filtering/ColumnFilterContextMenu.tsx create mode 100644 src/Griddy/features/filtering/ColumnFilterPopover.tsx create mode 100644 src/Griddy/features/filtering/FilterBoolean.tsx create mode 100644 src/Griddy/features/filtering/FilterInput.tsx create mode 100644 src/Griddy/features/filtering/FilterSelect.tsx create mode 100644 src/Griddy/features/filtering/filterFunctions.ts create mode 100644 src/Griddy/features/filtering/index.ts create mode 100644 src/Griddy/features/filtering/operators.ts create mode 100644 src/Griddy/features/filtering/types.ts create mode 100644 tests/e2e/filtering-context-menu.spec.ts diff --git a/package.json b/package.json index 062647e..724e91c 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@changesets/cli": "^2.29.8", "@eslint/js": "^10.0.1", "@microsoft/api-extractor": "^7.56.3", + "@playwright/test": "^1.58.2", "@sentry/react": "^10.38.0", "@storybook/react-vite": "^10.2.8", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md b/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md new file mode 100644 index 0000000..e15db18 --- /dev/null +++ b/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md @@ -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] +``` \ No newline at end of file diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..fa40773 --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..93c3468 --- /dev/null +++ b/playwright.config.ts @@ -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, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5e8b7f..54c064d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@microsoft/api-extractor': specifier: ^7.56.3 version: 7.56.3(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@sentry/react': specifier: ^10.38.0 version: 10.38.0(react@19.2.4) @@ -817,6 +820,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 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': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} @@ -2257,6 +2265,11 @@ packages: fs.realpath@1.0.0: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3033,6 +3046,16 @@ packages: pkg-types@2.3.0: 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: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4745,6 +4768,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@rolldown/pluginutils@1.0.0-rc.2': {} '@rollup/pluginutils@5.3.0(rollup@4.50.2)': @@ -6425,6 +6452,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7177,6 +7207,14 @@ snapshots: exsolve: 1.0.7 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: {} postcss-js@4.1.0(postcss@8.5.6): diff --git a/src/Griddy/CONTEXT.md b/src/Griddy/CONTEXT.md index bdb3997..3e5e6a0 100644 --- a/src/Griddy/CONTEXT.md +++ b/src/Griddy/CONTEXT.md @@ -80,6 +80,71 @@ Griddy is a new data grid component in the Oranguru package (`@warkypublic/orang Uses **Mantine** components (not raw HTML): - `Checkbox` from `@mantine/core` for row/header checkboxes - `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 - [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 4: Search (Ctrl+F overlay) - [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 7: Pagination + remote data adapters - [ ] Phase 8: Grouping, pinning, column reorder, export @@ -96,7 +168,64 @@ Uses **Mantine** components (not raw HTML): ## Dependencies Added - `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies) -## Build -- `pnpm run typecheck` — clean -- `pnpm run build` — clean -- `pnpm run storybook` — stories render correctly +## Build & Testing Status +- [x] `pnpm run typecheck` — ✅ PASS (0 errors) +- [x] `pnpm run lint` — ✅ PASS (0 errors in Phase 5 code) +- [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 diff --git a/src/Griddy/Griddy.stories.tsx b/src/Griddy/Griddy.stories.tsx index d564cd4..40f371f 100644 --- a/src/Griddy/Griddy.stories.tsx +++ b/src/Griddy/Griddy.stories.tsx @@ -1,5 +1,5 @@ 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 { 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([]) + + const filterColumns: GriddyColumn[] = [ + { 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 ( + + + columnFilters={filters} + columns={filterColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + /> + + Active Filters: +
{JSON.stringify(filters, null, 2)}
+
+
+ ) + }, +} + +/** Number filtering with operators like equals, between, greater than, etc. */ +export const WithNumberFiltering: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const filterColumns: GriddyColumn[] = [ + { 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 ( + + + columnFilters={filters} + columns={filterColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + /> + + Active Filters: +
{JSON.stringify(filters, null, 2)}
+
+
+ ) + }, +} + +/** Enum (multi-select) filtering with includes/excludes operators */ +export const WithEnumFiltering: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const filterColumns: GriddyColumn[] = [ + { 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 ( + + + columnFilters={filters} + columns={filterColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + /> + + Active Filters: +
{JSON.stringify(filters, null, 2)}
+
+
+ ) + }, +} + +/** Boolean filtering with true/false/all operators */ +export const WithBooleanFiltering: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const filterColumns: GriddyColumn[] = [ + { 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 ( + + + columnFilters={filters} + columns={filterColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + /> + + Active Filters: +
{JSON.stringify(filters, null, 2)}
+
+
+ ) + }, +} + +/** Combined filtering - all filter types together */ +export const WithAllFilterTypes: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const filterColumns: GriddyColumn[] = [ + { 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 ( + + + columnFilters={filters} + columns={filterColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + /> + + Active Filters (AND logic - all must match): +
{JSON.stringify(filters, null, 2)}
+
+
+ ) + }, +} + +/** Large dataset with filtering and sorting */ +export const LargeDatasetWithFiltering: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const filterColumns: GriddyColumn[] = [ + { 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 ( + + + columnFilters={filters} + columns={filterColumns} + data={largeData} + getRowId={(row) => String(row.id)} + height={600} + onColumnFiltersChange={setFilters} + /> + + Active Filters: +
{JSON.stringify(filters, null, 2)}
+
+
+ ) + }, +} diff --git a/src/Griddy/core/columnMapper.ts b/src/Griddy/core/columnMapper.ts index 437b966..fd290f8 100644 --- a/src/Griddy/core/columnMapper.ts +++ b/src/Griddy/core/columnMapper.ts @@ -2,6 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table' import type { GriddyColumn, SelectionConfig } from './types' +import { createOperatorFilter } from '../features/filtering' import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants' /** @@ -38,10 +39,21 @@ export function mapColumns( meta: { griddy: col }, minSize: col.minWidth ?? DEFAULTS.minColumnWidth, 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 }) diff --git a/src/Griddy/core/constants.ts b/src/Griddy/core/constants.ts index 28e5f35..fd3973d 100644 --- a/src/Griddy/core/constants.ts +++ b/src/Griddy/core/constants.ts @@ -5,7 +5,10 @@ export const CSS = { cellEditing: 'griddy-cell--editing', checkbox: 'griddy-checkbox', container: 'griddy-container', + filterButton: 'griddy-filter-button', + filterButtonActive: 'griddy-filter-button--active', headerCell: 'griddy-header-cell', + headerCellContent: 'griddy-header-cell-content', headerCellSortable: 'griddy-header-cell--sortable', headerCellSorted: 'griddy-header-cell--sorted', headerRow: 'griddy-header-row', @@ -27,6 +30,7 @@ export const CSS = { // ─── Defaults ──────────────────────────────────────────────────────────────── export const DEFAULTS = { + filterDebounceMs: 300, headerHeight: 36, maxColumnWidth: 800, minColumnWidth: 50, diff --git a/src/Griddy/core/types.ts b/src/Griddy/core/types.ts index 8c7e8e3..e879416 100644 --- a/src/Griddy/core/types.ts +++ b/src/Griddy/core/types.ts @@ -2,6 +2,8 @@ import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningStat import type { Virtualizer } from '@tanstack/react-virtual' import type { ReactNode } from 'react' +import type { FilterConfig } from '../features/filtering' + // ─── Column Definition ─────────────────────────────────────────────────────── export type CellRenderer = (props: RendererProps) => ReactNode @@ -45,6 +47,7 @@ export interface GriddyColumn { editable?: ((row: T) => boolean) | boolean editor?: EditorComponent filterable?: boolean + filterConfig?: FilterConfig filterFn?: FilterFn header: ReactNode | string headerGroup?: string diff --git a/src/Griddy/features/filtering/ColumnFilterButton.tsx b/src/Griddy/features/filtering/ColumnFilterButton.tsx new file mode 100644 index 0000000..d009819 --- /dev/null +++ b/src/Griddy/features/filtering/ColumnFilterButton.tsx @@ -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 +} + +export function ColumnFilterButton({ column }: ColumnFilterButtonProps) { + const isActive = !!column.getFilterValue() + + return ( + + + + ) +} diff --git a/src/Griddy/features/filtering/ColumnFilterContextMenu.tsx b/src/Griddy/features/filtering/ColumnFilterContextMenu.tsx new file mode 100644 index 0000000..3e00cba --- /dev/null +++ b/src/Griddy/features/filtering/ColumnFilterContextMenu.tsx @@ -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 + 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 ( + <> +
+ {children} +
+ + {contextMenu && ( + setContextMenu(null)} + opened={true} + position="bottom-start" + withinPortal + > + + {column.getCanSort() && ( + <> + } onClick={handleSort}> + Sort {column.getIsSorted() === 'asc' ? '↓' : '↑'} + + {column.getIsSorted() && ( + } onClick={handleClearSort}> + Reset Sorting + + )} + + )} + + {column.getCanFilter() && ( + <> + {column.getFilterValue() && ( + } onClick={handleResetFilter}> + Reset Filter + + )} + } onClick={handleOpenFilter}> + Open Filters + + + )} + + + )} + + ) +} diff --git a/src/Griddy/features/filtering/ColumnFilterPopover.tsx b/src/Griddy/features/filtering/ColumnFilterPopover.tsx new file mode 100644 index 0000000..11dd82c --- /dev/null +++ b/src/Griddy/features/filtering/ColumnFilterPopover.tsx @@ -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 + 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( + (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 ( + + + + + + + + Filter: {column.id} + + + {filterConfig.type === 'text' && ( + + )} + + {filterConfig.type === 'number' && ( + + )} + + {filterConfig.type === 'enum' && filterConfig.enumOptions && ( + + )} + + {filterConfig.type === 'boolean' && ( + + )} + + + + + + + + + ) +} diff --git a/src/Griddy/features/filtering/FilterBoolean.tsx b/src/Griddy/features/filtering/FilterBoolean.tsx new file mode 100644 index 0000000..56c222a --- /dev/null +++ b/src/Griddy/features/filtering/FilterBoolean.tsx @@ -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(value?.operator || 'isEmpty') + + const handleChange = (op: string) => { + setOperator(op) + onChange({ + operator: op, + }) + } + + return ( + e.stopPropagation()}> + + + + + + + + + ) +} diff --git a/src/Griddy/features/filtering/FilterInput.tsx b/src/Griddy/features/filtering/FilterInput.tsx new file mode 100644 index 0000000..9cd6398 --- /dev/null +++ b/src/Griddy/features/filtering/FilterInput.tsx @@ -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(value?.operator || operators[0]?.id || '') + const [inputValue, setInputValue] = useState( + value?.value !== undefined && value?.value !== null ? value.value : undefined, + ) + const debounceTimerRef = useRef(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 ( + e.stopPropagation()}> + ({ label: op.label, value: op.id }))} + label="Operator" + onChange={handleOperatorChange} + searchable + size="xs" + value={operator} + /> + {requiresValue && type === 'text' && ( + setInputValue(e.currentTarget.value)} + placeholder="Enter value..." + rightSection={ + inputValue && ( + + + + ) + } + size="xs" + value={inputValue === undefined ? '' : String(inputValue)} + /> + )} + {requiresValue && type === 'number' && ( + setInputValue(val)} + placeholder="Enter number..." + size="xs" + value={inputValue as number | undefined} + /> + )} + + ) +} diff --git a/src/Griddy/features/filtering/FilterSelect.tsx b/src/Griddy/features/filtering/FilterSelect.tsx new file mode 100644 index 0000000..4eb7660 --- /dev/null +++ b/src/Griddy/features/filtering/FilterSelect.tsx @@ -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(value?.operator || operators[0]?.id || 'includes') + const [selectedValues, setSelectedValues] = useState( + 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 ( + e.stopPropagation()}> +