Compare commits

...

247 Commits

Author SHA1 Message Date
Joe Li
6a1c30e5e7 chore: update changelog for 6.0.0rc4 2025-12-04 10:55:36 -08:00
Felipe López
e763c96381 fix(SQLLab): most recent queries at the top in Query History without refreshing the page (#36359)
(cherry picked from commit 62d86aba14)
2025-12-03 10:17:30 -08:00
Beto Dealmeida
0753fd4c45 fix: is_column_reference check (#36382)
(cherry picked from commit d05ab91d11)
2025-12-02 13:15:39 -08:00
om pharate
3a537498f6 feat(controlPanel): add integer validation for rows per page setting (#36289)
(cherry picked from commit 01f032017f)
2025-12-01 14:46:28 -08:00
Daniel Vaz Gaspar
8f061d0c0a fix(log): remove unwanted info from logs REST API (#36269)
(cherry picked from commit bae716fa83)
2025-11-25 16:07:35 -08:00
Daniel Vaz Gaspar
e0e52086cd fix: remove unwanted info from tags REST API (#36266)
(cherry picked from commit cd36845d56)
2025-11-25 08:41:39 -08:00
Enzo Martellucci
2a21ef6dc9 fix: Columns bleeding into other cells (#36134)
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com>
Co-authored-by: Geidō <60598000+geido@users.noreply.github.com>
(cherry picked from commit 062e4a2922)
2025-11-25 08:41:01 -08:00
Enzo Martellucci
72fbaa6677 fix: Table chart types headers are offset from the columns in the table (#36190)
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com>
(cherry picked from commit ab8352ee66)
2025-11-25 08:40:15 -08:00
Enzo Martellucci
6d49d1e182 fix: Extra controls width for Area Chart on dashboards (#36133)
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com>
(cherry picked from commit cac6ffcd3c)
2025-11-25 08:39:46 -08:00
Elizabeth Thompson
5b51c7e89e fix(screenshots): Only cache thumbnails when image generation succeeds (#36126)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 08c1d03479)
2025-11-25 08:38:39 -08:00
Beto Dealmeida
74f455f076 fix: adhoc column quoting (#36215)
(cherry picked from commit e303537e0c)
2025-11-21 13:43:04 -08:00
Daniel Vaz Gaspar
a911a50680 fix(sqllab): validate results backend writes and enhance 410 diagnostics (#36222)
(cherry picked from commit 348b19cb4c)
2025-11-21 11:18:33 -08:00
Enzo Martellucci
5b820699e8 fix(dashboard): adjust vertical spacing for numerical range filter to prevent overlaps (#36167)
(cherry picked from commit 6d359161bb)
2025-11-20 09:25:55 -08:00
Joe Li
c3308f4447 chore: update changelog for 6.0.0rc3 2025-11-19 16:07:14 -08:00
Beto Dealmeida
9cfd35d43a chore: bump duckdb et al. (#36171)
(cherry picked from commit 53207302f9)
2025-11-19 13:55:45 -08:00
Evan Rusackas
785a6d0237 chore(docs): config Kapa to use logo from the repo (#36177)
(cherry picked from commit cdbd5bf4f9)
2025-11-19 13:48:28 -08:00
Richard Fogaca Nienkotter
71be38f003 fix(dashboard): ensure charts re-render when visibility state changes (#36011)
(cherry picked from commit 4582f0e8d2)
2025-11-19 13:47:44 -08:00
Enzo Martellucci
f3db80bfee fix(datasets): prevent viewport overflow in dataset creation page (#36166)
(cherry picked from commit a268232ed6)
2025-11-18 14:23:20 -08:00
SBIN2010
6cc46750b9 fix: role list edit modal height (#36123)
(cherry picked from commit 43e9e1ec36)
2025-11-18 11:00:58 -08:00
Richard Fogaca Nienkotter
c9a7f2d02e fix(cache): apply dashboard filters to non-legacy visualizations (#36109)
(cherry picked from commit 8315804c85)
2025-11-18 11:00:20 -08:00
Richard Fogaca Nienkotter
3c5868c412 fix: 'save and go to dashboard' option was disabled after changing the chart type (#36122)
(cherry picked from commit a9fd600c52)
2025-11-18 10:59:13 -08:00
PolinaFam
4112fecc93 fix(translations): Fix Russian translations for EmptyState (#34055)
Co-authored-by: Polina Fam <pfam@ptsecurity.com>
(cherry picked from commit fb325a8f24)
2025-11-18 10:51:03 -08:00
Yuvraj Singh Chauhan
ea1ab05e8d fix(tags): ensure tag creation is compatible with MySQL by avoiding Markup objects (#36075)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 28bdec2c79)
2025-11-18 10:49:04 -08:00
Beto Dealmeida
0e3092dd56 fix(datasets): prevent double time filter application in virtual datasets (#35890)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 3b226038ba)
2025-11-18 10:48:18 -08:00
Gabriel Torres Ruiz
91814f3dfe fix(navbar): some styling + components inconsistencies (#36120)
(cherry picked from commit 9bff64824b)
2025-11-17 15:06:20 -08:00
Yong Le He
c9501fddb8 fix(dashboard): ensure world map chart uses correct country code format in crossfilter (#35919)
(cherry picked from commit fb8eb2a5c3)
2025-11-17 11:38:21 -08:00
Levis Mbote
fe58baa23c fix: fix crossfilter persisting after removal (#35998)
(cherry picked from commit 85413f2a65)
2025-11-17 11:35:58 -08:00
SBIN2010
19ebeea030 fix: opacity color formating (#36101) 2025-11-14 15:53:42 -08:00
Joe Li
18ed2290bc fix(table-chart): fix missing table header IDs (#35968)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 15:53:24 -08:00
Richard Fogaca Nienkotter
79ec265a30 fix: save button was enabled even no changes were made to the dashboard (#35817) 2025-11-14 14:24:47 -08:00
Janani Gurram
aab835a096 fix(histogram): add NULL handling for histogram (#35693)
Co-authored-by: Rachel Pan <r.pan@mail.utoronto.ca>
Co-authored-by: Rachel Pan <panrrachel@gmail.com>
Co-authored-by: Janani Gurram <68124448+JG-ctrl@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit c955a5dc08)
2025-11-14 13:58:38 -08:00
Beto Dealmeida
b19372fe16 fix: RLS in virtual datasets (#36061)
(cherry picked from commit f3e620cd0f)
2025-11-14 13:58:26 -08:00
Enzo Martellucci
a083e189e2 fix(ace-editor-popover): main AntD popover closes when clicking autocomplete suggestions in Ace Editor (#35986)
(cherry picked from commit 9ef87e75d5)
2025-11-14 12:51:52 -08:00
Richard Fogaca Nienkotter
0f5918dbd9 fix(chart): align legend with chart grid in List mode for Top/Bottom orientations (#36077)
(cherry picked from commit 37d58a476c)
2025-11-14 12:24:03 -08:00
Richard Fogaca Nienkotter
3a54cade44 fix(dashboard): prevent tab content cutoff and excessive whitespace in empty tabs (#35834)
(cherry picked from commit 78f9debdd4)
2025-11-14 12:13:55 -08:00
Mehmet Salih Yavuz
cbdbc2c295 fix(dashboard): refresh tabs as they load when dashboard is refreshed (#35265)
(cherry picked from commit 74a590cb76)
2025-11-14 12:12:17 -08:00
Richard Fogaca Nienkotter
548480bd8e fix(explore): re-apply filters when 'Group remaining as Others' is enabled (#35937)
(cherry picked from commit 4a04d46118)
2025-11-13 22:12:28 -08:00
Vitor Avila
6179eb9ef4 fix: Use singlestoredb dialect for sqlglot (#36096)
(cherry picked from commit 6701d0ae0c)
2025-11-13 22:10:06 -08:00
Gabriel Torres Ruiz
15c4ee63b2 fix(navbar): Minor fixes in navbar spacings (#36091)
(cherry picked from commit 4515d18ddd)
2025-11-13 22:09:32 -08:00
Mehmet Salih Yavuz
89a36ea131 fix(sql): quote column names with spaces to prevent SQLGlot parsing errors (#35553)
(cherry picked from commit 306f4c14cf)
2025-11-13 22:08:42 -08:00
Levis Mbote
2d0017033c fix: fix tabs overflow in dashboards (#35984)
(cherry picked from commit bb2e2a5ed6)
2025-11-13 21:21:11 -08:00
Richard Fogaca Nienkotter
1a4ae81058 fix(dashboard): dashboard filter was incorrectly showing as out of scope (#35886)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
(cherry picked from commit a45c0528da)
2025-11-13 19:41:20 -08:00
Richard Fogaca Nienkotter
c9d8baf9a1 fix(dashboard): align filter bar elements vertically in horizontal mode (#36036)
(cherry picked from commit d123249bd2)
2025-11-12 10:53:18 -08:00
Richard Fogaca Nienkotter
a2fcb64ef3 fix(sqllab): prevent unwanted tab switching when autocompleting table names on SQL Lab (#35992)
(cherry picked from commit 9fbfcf0ccd)
2025-11-12 10:42:01 -08:00
Richard Fogaca Nienkotter
8a84b17270 fix: saved query preview modal not highlighting active rows (#35866)
(cherry picked from commit 4376476ec4)
2025-11-12 10:41:06 -08:00
Mehmet Salih Yavuz
992cb83088 fix(permalink): exclude edit mode from dashboard permalink (#35889)
(cherry picked from commit e2e831e322)
2025-11-12 10:40:15 -08:00
Joe Li
8c43578b70 fix(explore): show validation errors in View Query modal (#35969)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 21d585d586)
2025-11-11 17:19:26 -08:00
Kamil Gabryjelski
933ec0a918 fix: Flakiness around scrolling during taking tiled screenshots with Playwright (#36051)
(cherry picked from commit 63dfd95aa2)
2025-11-10 11:56:32 -08:00
Mehmet Salih Yavuz
70117eb55f fix(date_parser): add check for time range timeshifts (#36039)
(cherry picked from commit c9f65cf1c2)
2025-11-10 10:08:45 -08:00
Elizabeth Thompson
8c22e61ef1 fix(reports): improve error handling for report schedule execution (#35800)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit c42e3c6837)
2025-11-10 10:08:39 -08:00
Elizabeth Thompson
bed186b32f fix(filters): preserve backend metric-based sorting (#35152)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 909bd877c9)
2025-11-07 12:06:02 -08:00
Vedant Prajapati
a52cfef8b1 fix(echarts): Series style hidden for line charts (#33677)
Co-authored-by: Vedant Prajapati <vedantprajapati@geotab.com>
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 258512fef2)
2025-11-07 12:04:11 -08:00
Kamil Gabryjelski
3b7a52d1eb fix: Ensure that Playwright tile height is always positive (#36027)
(cherry picked from commit 728bc2c632)
2025-11-07 12:03:10 -08:00
amaannawab923
d8c8430ed7 fix(Context-Menu): Fixing Context Menu for Table Chart with Html Content (#33791)
(cherry picked from commit 0307c71945)
2025-11-07 12:02:20 -08:00
Gabriel Torres Ruiz
bc3f146eaf fix(UI): spacings + UI fixes (#36010)
(cherry picked from commit c11be72ead)
2025-11-07 11:18:29 -08:00
Mehmet Salih Yavuz
1245a1e26a fix(SelectFilterPlugin): clear all clears all filters including dependent ones (#35303)
(cherry picked from commit af37e12de4)
2025-11-06 19:42:21 -08:00
Enzo Martellucci
446136b46c fix(view-in-sqllab): unable to open virtual dataset after discarding chart edits (#35931)
(cherry picked from commit 392b880b52)
2025-11-06 18:05:12 -08:00
ethan-l-geotab
33e53143e6 fix(chart list): Facepile shows correct users when saving chart properties (#33392)
(cherry picked from commit 14f20e644e)
2025-11-06 17:23:56 -08:00
Enzo Martellucci
706a04be7f fix(DatabaseModal): prevent errors when pasting text into supported database select (#35916)
(cherry picked from commit 1f960d5761)
2025-11-06 17:23:23 -08:00
Mehmet Salih Yavuz
2c86d1ae9c fix(explore): Overwriting a chart updates the form_data_key (#35888)
(cherry picked from commit 3f49938b79)
2025-11-06 16:04:45 -08:00
Mehmet Salih Yavuz
8bc1f98033 fix(TimeTable): Match calculations between filtered and non filtered states (#35619)
(cherry picked from commit 04231c86db)
2025-11-06 16:00:21 -08:00
Rafael Benitez
f7ebdd71e7 fix(dashboard): fix dataset search in filter config modal (#35488)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 53687ae659)
2025-11-06 14:23:45 -08:00
Mehmet Salih Yavuz
c48b5ad65e fix(DatasourceEditor): preserve calculated column order when editing sql (#35790)
(cherry picked from commit 7265567561)
2025-11-04 11:59:56 -08:00
Joe Li
8e4efe2cc2 test(useThemeMenuItems): fix race conditions by awaiting all userEvent calls (#35918)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 5224347c39)
2025-11-03 10:42:29 -08:00
Richard Fogaca Nienkotter
e818d0b11f fix(sqllab): align refresh buttons with select input fields (#35917)
(cherry picked from commit 27011d0239)
2025-11-03 10:42:03 -08:00
Joe Li
e0a4b98351 fix(explore): formatting the SQL in "View Query" pop-up doesn't format (#35898)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit be3690c22b)
2025-11-03 10:41:36 -08:00
Yuvraj Singh Chauhan
131359c78f fix(db2): update time grain expressions for DAY to use DATE function (#35848)
(cherry picked from commit 30d584afd1)
2025-10-31 09:52:37 -07:00
Ville Brofeldt
6267856b0d fix: set pandas 2.1 as requirement (#35912)
(cherry picked from commit f6f15f58ee)
2025-10-31 09:48:04 -07:00
Beto Dealmeida
ce21b04621 chore: bump shillelagh to 1.4.3 (#35895)
(cherry picked from commit 5fc934d859)
2025-10-30 12:30:15 -07:00
SBIN2010
1fdb3210f9 fix: displaying cell bars in table (#35885)
(cherry picked from commit dd857a2c7a)
2025-10-30 11:55:11 -07:00
Amin Ghadersohi
e21cb9a6d9 fix: add utc=True to pd.to_datetime for timezone-aware datetimes (#35587)
(cherry picked from commit 5c57c9c0b2)
2025-10-29 10:52:38 -07:00
Elizabeth Thompson
3f6f53569f fix(reports): Add celery task execution ID to email notification logs (#35807)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 61c68f7b8f)
2025-10-29 10:51:43 -07:00
Mehmet Salih Yavuz
87f36d2b2f fix(echarts): fix time shift color matching functionality (#35826)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 5218b4eea2)
2025-10-29 10:51:06 -07:00
innovark
36c14f81f3 fix: update Russian translations (#35750)
(cherry picked from commit 61758c07d2)
2025-10-28 13:34:26 -07:00
Joe Li
e5ee7a0a15 fix(theme): add fontWeightStrong to allowedAntdTokens to fix bold markdown rendering (#35821)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit bf830b2dd5)
2025-10-28 13:33:41 -07:00
Martyn Gigg
78a53484fc fix(sqllab): Fix CSV export button href in SQL Lab when application root is defined (#35118)
(cherry picked from commit 6704c0aaec)
2025-10-28 12:55:36 -07:00
Mehmet Salih Yavuz
5ecd067ed3 fix(SqlLab): South pane visual changes (#35601)
(cherry picked from commit 6e60a00d69)
2025-10-28 11:39:24 -07:00
ngokturkkarli
78983a6f25 fix(native-filters): prevent circular dependencies and improve dependency handling (#35317)
(cherry picked from commit 0bf34d4d6f)
2025-10-28 10:49:54 -07:00
Levis Mbote
4d996cec5e fix(database-modal): fix issue where commas could not be typed into DB configuration. (#35289)
(cherry picked from commit 19473af401)
2025-10-28 10:48:34 -07:00
Daniel Vaz Gaspar
41ac8d8d9c fix: unpin holidays and prophet (#35771)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 51aad52e6c)
2025-10-28 10:47:02 -07:00
Elizabeth Thompson
d1feafb400 fix(dashboard): handle invalid thumbnail BytesIO objects gracefully (#35808)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 7c9720e22b)
2025-10-28 10:45:05 -07:00
Marcos Amorim
877997ceda fix(alerts): improve Slack API rate limiting for large workspaces (#35622)
(cherry picked from commit c3b8c96db6)
2025-10-28 10:44:07 -07:00
Joe Li
26177314db docs(db_engine_specs): restructure feature table for GitHub rendering (#35809)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 93cb60b24e)
2025-10-28 10:42:28 -07:00
Gabriel Torres Ruiz
a3aef76427 fix(ag-grid): fix conditional formatting theme colors and module extensibility (#35605)
(cherry picked from commit 5e4a80e5d0)
2025-10-23 16:26:54 -07:00
Richard Fogaca Nienkotter
fc222eefb7 fix: edit dataset modal visual fixes (#35799)
(cherry picked from commit 1234533c67)
2025-10-23 11:01:48 -07:00
Mehmet Salih Yavuz
36bacf2ae4 fix(ThemeController): replace fetch with SupersetClient for proper auth (#35794)
(cherry picked from commit 7f0c0aea94)
2025-10-22 11:47:27 -07:00
Mehmet Salih Yavuz
bda2d8c145 fix(security): Add active property to guest user (#35454)
(cherry picked from commit 98fba1eefe)
2025-10-22 11:45:44 -07:00
Geidō
a30b212a37 fix(Actions): Improper spacing (#35724)
(cherry picked from commit bad03b1e76)
2025-10-21 14:32:26 -07:00
Erkka Tahvanainen
b07011872b fix(playwright): Download dashboard correctly (#35484)
Co-authored-by: Erkka Tahvanainen <erkka.tahvanainen@confidently.fi>
(cherry picked from commit d089a96163)
2025-10-20 15:52:31 -07:00
yousoph
23b02963b6 fix(charts): update axis title labels to sentence case (#35694)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 9ab0a0179d)
2025-10-17 13:29:02 -07:00
Sam Firke
80fce9a662 fix(auth): redirect anonymous attempts to view dashboard with next (#35345) 2025-10-17 12:53:08 -07:00
Beto Dealmeida
3bfd4efa21 fix(dataset): render default URL description properly in settings (#35669)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit f405174fcf)
2025-10-16 16:50:46 -07:00
Gabriel Torres Ruiz
151633a1fd fix(theme-crud): enable overwrite confirmation UI for theme imports (#35558)
(cherry picked from commit de1dd53186)
2025-10-15 18:23:00 -07:00
Gabriel Torres Ruiz
fe7823cc10 fix(table-chart): fix page size label visibility and improve header control wrapping (#35648)
(cherry picked from commit 58672dfab6)
2025-10-15 18:22:44 -07:00
Rafael Benitez
f7a64f05c5 fix(theme): align "Clear local theme" option with other theme menu items (#35651)
(cherry picked from commit 4b5629d1c8)
2025-10-15 18:22:30 -07:00
Elizabeth Thompson
35861f7ec0 fix: Log Celery task failures with a signal handler (#35595)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit ccc0e3dbb2)
2025-10-15 16:06:57 -07:00
innovark
7abe5c379e fix(d3-format): call setupFormatters synchronously to apply D3 format… (#35529)
(cherry picked from commit c38ba1daa8)
2025-10-14 14:22:24 -07:00
Damian Pendrak
36f23a003f fix(deckgl): scatterplot fix categorical color (#35537) 2025-10-14 14:19:36 -07:00
Luiz Otavio
412d9c8cbc fix(csv upload): Correctly casting to string numbers with floating points (e+) (#35586)
(cherry picked from commit 17ebbdd966)
2025-10-10 14:32:26 -07:00
Elizabeth Thompson
c799f5cedd fix(alerts): log execution_id instead of report schedule name in query timing (#35592)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit e437ae1f2f)
2025-10-10 12:30:56 -07:00
Mehmet Salih Yavuz
1d6d12cfb4 fix(tables): Dark mode scrollbar styles for webkit (#35338)
(cherry picked from commit 412587ad41)
2025-10-10 12:26:03 -07:00
Mehmet Salih Yavuz
b41ad89474 fix(Alerts): Correct icon sizes (#35572)
(cherry picked from commit 5a15c632ad)
2025-10-09 09:38:41 -07:00
Daniel Vaz Gaspar
52993e68e1 fix: dataset update with invalid SQL query (#35543)
(cherry picked from commit 50a5854b25)
2025-10-08 17:13:41 -07:00
Gabriel Torres Ruiz
2a474e017a fix(charts): fix legend theming and hollow symbols in dark mode (#35123)
(cherry picked from commit 7fd5a7668b)
2025-10-08 15:08:16 -07:00
Rafael Benitez
8d4a1cfc05 fix(chart): Fixes BigNumber gradient appearing blackish in light mode (#35527)
(cherry picked from commit f7b9d7a64b)
2025-10-07 10:56:17 -07:00
Mehmet Salih Yavuz
6c82d480a7 fix(explore): Include chart canvases in the screenshot (#35491)
(cherry picked from commit 89932fa0b2)
2025-10-07 10:55:47 -07:00
Daniel Vaz Gaspar
f6b050d270 fix: update chart with dashboards validation (#35523)
(cherry picked from commit 9d50f1b8a2)
2025-10-07 10:35:43 -07:00
Amin Ghadersohi
22c8551d64 fix(webdriver): add missing options object to WebDriver initialization (#35504)
(cherry picked from commit 77c3146829)
2025-10-06 13:43:08 -07:00
Vitor Avila
37e280f5bd fix: Support metric macro for embedded users (#35508)
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit 4545d55d30)
2025-10-06 13:42:40 -07:00
Rafael Benitez
bc1fda5a4a fix(explore): correct search icon in dashboard submenu (#35489)
(cherry picked from commit a7b158c7fa)
2025-10-06 13:42:07 -07:00
Mehmet Salih Yavuz
0a6b3de884 fix(Select): Prevent closing the select when clicking on a tag (#35487)
(cherry picked from commit d39c55e941)
2025-10-06 13:39:57 -07:00
Elizabeth Thompson
012765cd0b fix(loading): improve loading screen theming for dark mode support (#35129)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-06 11:44:32 -07:00
Tran Ngoc Tuan
b633bc5577 fix(security-manager): switch from deprecated get_session to session attribute (#35290)
(cherry picked from commit 04b1a45416)
2025-10-03 16:33:53 -07:00
Beto Dealmeida
d5fa2d86f0 fix(sqlglot): adhoc expressions (#35482)
(cherry picked from commit 139b5ae20c)
2025-10-03 11:28:20 -07:00
Mehmet Salih Yavuz
8dbbbbf136 fix(dashboard): Navigate to new dashboard when saved as a new one (#35339)
(cherry picked from commit 891f826143)
2025-10-03 11:27:41 -07:00
Mehmet Salih Yavuz
e61a0dd619 fix(theming): CRUD view padding (#35321)
(cherry picked from commit 0e2fb1d1a3)
2025-10-03 11:26:00 -07:00
amaannawab923
24a84a23f2 fix(ag-grid-table): remove enterprise features to use community version (#35453)
(cherry picked from commit 96170e43c0)
2025-10-03 11:25:32 -07:00
Beto Dealmeida
e4cbc5db4c fix(cache): ensure SQL is sanitized before cache key generation (#35419)
(cherry picked from commit 62dc5c0306)
2025-10-02 10:59:44 -07:00
Beto Dealmeida
e0e8d1d177 fix(pinot): more functions (#35451)
(cherry picked from commit 3202ff4b3f)
2025-10-02 10:57:56 -07:00
Gabriel Torres Ruiz
b47dc64cd5 fix(dashboard): exit markdown edit mode when clicking outside of element (#35336)
(cherry picked from commit 553204e613)
2025-10-02 10:57:35 -07:00
Rafael Benitez
61bf39f0d5 fix(dataset): sort by database in Dataset and Saved queries Issue (#35277)
(cherry picked from commit fe8348c03a)
2025-10-02 10:57:19 -07:00
Beto Dealmeida
efbfcd737d fix(pinot): SUBSTR function (#35427)
(cherry picked from commit 30021f8ede)
2025-10-02 10:57:00 -07:00
Beto Dealmeida
4c60bd1392 fix(pinot): DATE_SUB function (#35426)
(cherry picked from commit f3349388d0)
2025-10-02 10:56:46 -07:00
Antonio Rivero
6420d06fc0 fix(slice): Fix using isdigit when id passed as int (#35452)
(cherry picked from commit 449a89c214)
2025-10-02 10:56:23 -07:00
Beto Dealmeida
51396f0b94 fix(pinot): DATE_ADD function (#35424)
(cherry picked from commit 5428376662)
2025-10-02 10:56:08 -07:00
Beto Dealmeida
6bb13ef3b4 fix(pinot): dialect date truncation (#35420)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
(cherry picked from commit aa97d2fe03)
2025-10-01 13:15:17 -07:00
Beto Dealmeida
b842eeb893 fix: table quoting in DBs with supports_cross_catalog_queries=True (#35350)
(cherry picked from commit 13a164dd63)
2025-10-01 13:14:35 -07:00
Rafael Benitez
50e2a06306 fix(explore): close unsaved changes modal when discarding changes (#35307)
(cherry picked from commit d8688cf8b1)
2025-10-01 13:14:08 -07:00
Geidō
e80f44716c fix(SqlLab): Hit tableschemaview with a valid queryEditorId (#35341)
(cherry picked from commit a66c230058)
2025-10-01 13:13:29 -07:00
Beto Dealmeida
94d10af733 fix(pinot): restrict types in dialect (#35337)
(cherry picked from commit bf88d9bb1c)
2025-09-30 13:52:42 -07:00
Beto Dealmeida
8d49999af6 fix: adhoc orderby in explore (#35342)
(cherry picked from commit d51b35f61b)
2025-09-30 13:52:30 -07:00
Beto Dealmeida
927c94306e feat: sqlglot dialect for Pinot (#35333)
(cherry picked from commit 4e093a8e2a)
2025-09-30 10:55:26 -07:00
Mehmet Salih Yavuz
5074ee0af8 fix(doris): Don't set supports_cross_catalog_queries to true (#35332)
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit ef78d2af06)
2025-09-30 10:12:35 -07:00
Geidō
7ba76a85f4 fix: AceEditor Autocomplete Highlight (#35316)
(cherry picked from commit 90f281f585)
2025-09-29 16:14:53 -07:00
Elizabeth Thompson
d9646dedd9 fix(DatasourceModal): replace imperative modal updates with declarative state (#35256)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 82e2bc6181)
2025-09-29 14:17:28 -07:00
Gabriel Torres Ruiz
ac582322b8 fix(sqllab): fix blank bottom section in SQL Lab left panel (#35309)
(cherry picked from commit 784ff82847)
2025-09-29 09:45:29 -07:00
Mehmet Salih Yavuz
d2d99d4698 fix(DateFilterControl): remove modal overlay style to fix z-index issues (#35292)
(cherry picked from commit 027b25e6b8)
2025-09-29 09:42:26 -07:00
SBIN2010
76d13176a1 fix(table): New ad-hoc columns retain the name of previous columns (#35274)
(cherry picked from commit b652fab042)
2025-09-29 09:42:01 -07:00
Geidō
f6209e1ca9 fix: Cosmetic issues (#35122) 2025-09-25 16:34:27 -07:00
Mehmet Salih Yavuz
97649d7290 fix(BuilderComponentPane): navigation tabs padding (#35213)
(cherry picked from commit 7a9dbfe879)
2025-09-25 10:38:10 -07:00
Giulio Piccolo
df0f6f6ec3 fix(deck.gl): ensure min/max values are included in polygon map legend breakpoints (#35033)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
(cherry picked from commit 0de78d8203)
2025-09-25 10:38:00 -07:00
Beto Dealmeida
0a89b306e5 fix(SQL Lab): syncTable on new tabs (#35216)
(cherry picked from commit 94686ddfbe)
2025-09-24 14:10:52 -07:00
SBIN2010
7263133c52 fix(Mixed Chart): Tooltip incorrectly displays numbers with optional Y-axis format and showQueryIdentifiers set to true (#35224)
(cherry picked from commit ec322dfd8d)
2025-09-24 14:10:37 -07:00
Elizabeth Thompson
a4e3d21176 fix(dashboard): update header border to use colorBorder token (#35199)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit b6f6b75348)
2025-09-24 13:44:06 -07:00
Mehmet Salih Yavuz
485ff97b0f fix(ConditionalFormattingControl): icon color in dark mode (#35243)
(cherry picked from commit c601341520)
2025-09-23 12:01:22 -07:00
Levis Mbote
1621c70b68 fix(table-chart): fix cell bar visibility in dark theme (#35211)
(cherry picked from commit ce55cc7dd7)
2025-09-23 12:00:51 -07:00
Gabriel Torres Ruiz
13b7bbe9a4 feat(bug): defensive code to avoid accesing attribute of a NoneType object (#35219)
(cherry picked from commit 48e1b1ff2c)
2025-09-23 12:00:17 -07:00
Joe Li
886f525545 chore: Adds RC2 data to CHANGELOG.md 2025-09-22 12:30:01 -07:00
Beto Dealmeida
b76a0e1d2d chore: bump sqlglot to 27.15.2 (#35176)
(cherry picked from commit 5ec8f9d886)
2025-09-22 10:23:01 -07:00
Mehmet Salih Yavuz
542efcdb11 fix(SQLPopover): Use correct component (#35212)
(cherry picked from commit 076e477fd4)
2025-09-22 10:22:49 -07:00
SBIN2010
0ac7464649 fix: bug in tooltip timeseries chart in calculated total with annotation layer (#35179)
(cherry picked from commit 1e4bc6ee78)
2025-09-21 18:30:22 -07:00
Pat Buxton
e5bb8bf0ff fix: Bump pandas to 2.1.4 for python 3.12 (#34999)
(cherry picked from commit db178cf527)
2025-09-21 18:30:12 -07:00
SBIN2010
8306b66515 fix(Funnel): onInit overridden row_limit to default value on save chart (#35076)
(cherry picked from commit 23bb4f88c0)
2025-09-21 18:29:59 -07:00
Levis Mbote
9a0dc23755 fix(gantt-chart): fix Y-axis label visibility in dark theme (#35189)
(cherry picked from commit 4130b92966)
2025-09-21 18:29:28 -07:00
marun
5561f529f2 fix(CrudThemeProvider): Optimized theme loading logic (#35155)
(cherry picked from commit 1bf112a57a)
2025-09-21 18:29:03 -07:00
marun
b0ceb2a162 fix(embedded): resolve theme context error in Loading component (#35168)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 1f530d45cb)
2025-09-21 18:28:36 -07:00
Maxime Beauchemin
7fde43b476 fix(viz): resolve dark mode compatibility issues in BigNumber and Heatmap (#35151)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 05c6a1bf20)
2025-09-16 13:49:36 -07:00
SBIN2010
99519cd4ce fix: import bug template params (#35144)
(cherry picked from commit c193d6d6a1)
2025-09-16 13:49:05 -07:00
Joe Li
9525742b56 fix(deck.gl): restore legend display for Polygon charts with linear palette and fixed color schemes (#35142)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit fb840b8e71)
2025-09-16 13:48:25 -07:00
Elizabeth Thompson
ad7acecbf2 fix: Remove emotion-rgba from dependencies and codebase (#35124)
(cherry picked from commit 19ddcb7e5c)
2025-09-15 12:00:14 -07:00
Gabriel Torres Ruiz
a423f8ecda fix(ListView): implement AntD pagination for ListView component (#35057)
(cherry picked from commit 36daa2dc3f)
2025-09-12 18:56:26 -07:00
Mehmet Salih Yavuz
1265b3d3e5 fix(theming): Lighter text colors on dark mode (#35114)
(cherry picked from commit 95333e34b1)
2025-09-12 17:09:19 -07:00
Daniel Vaz Gaspar
b2fd9e2fb1 fix: Bump FAB to 5.X (#33055)
Co-authored-by: Joe Li <joe@preset.io>
(cherry picked from commit a9fb853e3e)
2025-09-12 17:07:53 -07:00
Michael S. Molina
f3163e1c27 fix: SQL Lab tab events (#35105)
(cherry picked from commit e729b2dbb4)
2025-09-12 16:18:10 -07:00
SBIN2010
8c1fdcb179 fix: page size options 'all' correct in table and remove PAGE_SIZE_OPTIONS in handlebars (#35095)
(cherry picked from commit 06261f262b)
2025-09-12 15:43:06 -07:00
Priyanshu Kumar
eb2b4bfc30 fix(pie): fixes pie chart other click error (#35086)
(cherry picked from commit b42060c880)
2025-09-12 15:42:54 -07:00
Gabriel Torres Ruiz
5baee67df7 fix(theming): replace error color with bolt icon for local themes (#35090)
(cherry picked from commit 7bf16d805d)
2025-09-12 15:42:44 -07:00
Rafael Benitez
e8562bc641 fix(templates): Restores templates files accidentally removed (#35094)
(cherry picked from commit 529adebe1b)
2025-09-12 15:41:52 -07:00
Rafael Benitez
dedc10065e fix(settingsMenu): Version (#35096)
(cherry picked from commit 5a2411fa64)
2025-09-12 15:40:53 -07:00
LisaHusband
4dedfac238 fix(drill-to-detail): ensure axis label filters map to original column names (#34694)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
(cherry picked from commit a7d349a5c6)
2025-09-10 10:05:24 -07:00
Mehmet Salih Yavuz
2c870e8528 fix(timeshifts): Add missing feature flag to enum (#35072)
(cherry picked from commit 912ed2ba80)
2025-09-10 10:04:45 -07:00
Nicolas
baee1ab82b fix(Table Chart): render null dates properly (#34558)
(cherry picked from commit 65376c7baf)
2025-09-10 10:04:05 -07:00
SBIN2010
a8cf0981fa fix(table): table search input placeholder (#35064)
(cherry picked from commit c5f220a9ff)
2025-09-09 10:08:09 -07:00
catpineapple
8768a3f55a fix(tests): one of integration test in TestSqlaTableModel does not support MySQL "concat" (#35007)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
(cherry picked from commit 9efb80dbf4)
2025-09-08 14:12:21 -07:00
Luiz Otavio
cf1902a4cc fix: Upload CSV as Dataset (#34763)
(cherry picked from commit 1c2b9db4f0)
2025-09-08 14:12:10 -07:00
Gabriel Torres Ruiz
d94c92db01 fix(dashboard): normalize spacings and background colors (#35001)
(cherry picked from commit 0fce5ecfa5)
2025-09-08 12:01:18 -07:00
Rafael Benitez
acec8743c0 fix(theming): Icons in ExecutionLogList and Country map chart tooltip theme consistency (#34828)
(cherry picked from commit bef1f4d045)
2025-09-08 12:01:06 -07:00
SBIN2010
9918670315 fix: mixed timeseries chart add legend margin (#35036)
(cherry picked from commit 5a3182ce21)
2025-09-08 11:55:31 -07:00
Damian Pendrak
997e000f6b fix(chart): change "No query." to "Query cannot be loaded" in Multi Layer Deck.gl Chart (#34973)
(cherry picked from commit c65cb284e6)
2025-09-05 11:17:17 -07:00
JUST.in DO IT
428c97a1d6 fix(echarts): rename time series shifted for isTimeComparisonValue (#35022)
(cherry picked from commit bc54b7970a)
2025-09-05 11:15:50 -07:00
SBIN2010
7996359719 fix: display legend mixed timeseries chart (#35005)
(cherry picked from commit 031fb4b5a8)
2025-09-05 11:04:15 -07:00
Evan Rusackas
444b98b95e fix(sql): Add Impala dialect support to sqlglot parser (#34662)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
(cherry picked from commit 7fb7ac8bef)
2025-09-04 16:25:30 -07:00
Mehmet Salih Yavuz
b51eda51ce fix(theming): more visual bugs (#34987)
(cherry picked from commit 569a7b33a5)
2025-09-04 16:25:10 -07:00
Mehmet Salih Yavuz
8a3dcadf87 fix(RoleListEditModal): display user's other properties in table (#35017)
(cherry picked from commit 59df0d6f15)
2025-09-04 16:22:36 -07:00
catpineapple
07fd60fe20 fix: doris genericDataType modify (#35011)
(cherry picked from commit 2e51d02806)
2025-09-04 16:20:40 -07:00
sha174n
06ada5472e fix(deps): expand pyarrow version range to <19 (#34870)
(cherry picked from commit 8406a827dd)
2025-09-03 22:39:53 -07:00
Joe Li
22826ba876 fix(tests): resolve AlertReportModal checkmark test failures (#34995) 2025-09-03 22:29:11 -07:00
JUST.in DO IT
dbe0845dc0 fix(ui-core): Invalid postTransform process (#34874)
(cherry picked from commit 448a28545b)
2025-09-03 18:12:41 -07:00
JUST.in DO IT
0c045127e4 fix(sqllab): autocomplete and delete tabs (#34781)
(cherry picked from commit cefd046ea0)
2025-09-03 18:09:12 -07:00
Gabriel Torres Ruiz
f69bdf5475 fix(error-handling): jinja2 error handling improvements (#34803) 2025-09-03 17:52:08 -07:00
Vitor Avila
d5a523db25 fix(databricks): string escaper v2 (#34991)
(cherry picked from commit 0de5b28716)
2025-09-03 11:40:44 -07:00
Evan Rusackas
38b456bc8a fix(charts): Handle virtual dataset names without schema prefix correctly (#34760)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit b5ae402c12)
2025-09-02 13:41:41 -07:00
Evan Rusackas
e1efc87fdc fix(echarts): Display NULL values in categorical x-axis for bar charts (#34761)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 682cdcc3e0)
2025-09-02 13:41:09 -07:00
Mehmet Salih Yavuz
e84bdfaa6d fix(ChartCreation): Translate chart description (#34918)
(cherry picked from commit 5dba59b6a4)
2025-09-02 13:40:35 -07:00
Daniel Vaz Gaspar
930038d763 chore: bump FAB to 4.8.1 (#34838)
(cherry picked from commit 54af1cb2c8)
2025-09-02 13:37:46 -07:00
Daniel Vaz Gaspar
d30cd5dc2a fix: playwright feature flag evaluation (#34978)
(cherry picked from commit b2f8803486)
2025-09-02 11:23:27 -07:00
Gabriel Torres Ruiz
714a03e007 fix(TimeTable): use type-only export for TableChartProps to resolve webpack warnings (#34989)
(cherry picked from commit 744fa1f54c)
2025-09-02 11:22:43 -07:00
Gabriel Torres Ruiz
026e016720 fix(dashboard): table charts render correctly after tab switch and refresh (#34975)
(cherry picked from commit 6a4b1df3a2)
2025-09-02 11:22:01 -07:00
Beto Dealmeida
5442521e18 fix: Athena quoting (#34895)
(cherry picked from commit fad3cb3162)
2025-09-02 10:11:58 -07:00
Maxime Beauchemin
b8426b92c7 fix: revert mistake setting TALISMAN_ENABLED=False (#34909)
(cherry picked from commit bc9ec6ac63)
2025-09-02 10:10:03 -07:00
Gabriel Torres Ruiz
2f086475f8 fix(theming): fix TimeTable chart issues (#34868)
(cherry picked from commit d183969744)
2025-09-02 10:03:36 -07:00
Maxime Beauchemin
1c95ea5ab8 fix: complete theme management system import/export (#34850)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 4695be5cc5)
2025-09-02 10:03:08 -07:00
Kamil Gabryjelski
a3a2c494cc fix: Improve table layout and column sizing (#34887)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 175835138c)
2025-09-02 10:02:35 -07:00
Sam Firke
d6cc324798 fix(drilling): drill by pagination works with MSSQL data source, cont. (#34724)
(cherry picked from commit c5a84c0985)
2025-09-02 10:00:40 -07:00
Kamil Gabryjelski
5756d25a7c fix: Filter bar orientation submenu should not be highlighted (#34900)
(cherry picked from commit e463743fcf)
2025-08-29 10:28:36 -07:00
Joe Li
b16d6ed224 fix(ConfirmStatusChange): remove deprecated event.persist() to fix headless browser crashes (#34864)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit ebfb14c353)
2025-08-28 09:52:25 -07:00
Joe Li
18e8b064de fix(tests): Improve MessageChannel mocking to prevent worker force exits (#34878)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 7946ec003f)
2025-08-28 09:52:03 -07:00
Kamil Gabryjelski
0c0dfc0601 fix: SelectControl default sort numeric choices by value (#34858)
(cherry picked from commit 665a11f821)
2025-08-28 09:51:15 -07:00
Kamil Gabryjelski
c80b8fea85 fix: Undefined error when viewing query in Explore + visual fixes (#34869)
(cherry picked from commit 5566eb8dd6)
2025-08-28 09:50:37 -07:00
Joe Li
160a8fe16c fix(tests): Mock MessageChannel to prevent Jest hanging from rc-overflow (#34871)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 836540e8c9)
2025-08-27 23:19:01 -07:00
Kamil Gabryjelski
fc80861f47 fix: Remove the underline from the right section of main menu (#34855)
(cherry picked from commit b74a244950)
2025-08-27 12:01:38 -07:00
Kamil Gabryjelski
7169d9f2bd fix: DB icon sizes in database add modal (#34854)
(cherry picked from commit ab58b0a8a3)
2025-08-27 12:00:55 -07:00
Kamil Gabryjelski
37347525e7 fix(dashboard): Anchor link positions (#34843)
(cherry picked from commit 97b35a4640)
2025-08-27 12:00:10 -07:00
JUST.in DO IT
b6aa68dbfc fix(sqllab): Missing executed sql value in the result table (#34846)
(cherry picked from commit b89b0bdf5d)
2025-08-27 11:58:49 -07:00
Vitor Avila
e4f371b126 fix: Avoid dataset drill request if no perm (#34665)
(cherry picked from commit 9c9588cce6)
2025-08-27 11:58:18 -07:00
Vitor Avila
d0d816047c fix: Add dataset ID to file name on exports (#34782)
(cherry picked from commit 471d9fe737)
2025-08-27 11:57:07 -07:00
Rafael Benitez
2e28e22596 fix(theming): explore chart type style fixes, nav right menu spacing fixed (#34795)
(cherry picked from commit b381992a75)
2025-08-25 10:24:11 -07:00
Beto Dealmeida
a475d68693 fix: make get_image() always return BytesIO (#34801)
(cherry picked from commit 1204507d68)
2025-08-25 10:23:57 -07:00
Kamil Gabryjelski
dfd36f5a54 chore: Add instruction for LLMs to use antd theme tokens (#34800)
(cherry picked from commit c7779578f9)
2025-08-25 10:23:40 -07:00
Kamil Gabryjelski
ea5ebd2ec9 fix: Unexpected overflow ellipsis dots after status icon in Dashboard list (#34798)
(cherry picked from commit b225432c55)
2025-08-25 10:23:27 -07:00
Kamil Gabryjelski
d454a22f1c fix(echarts): Series labels hard to read in dark mode (#34815)
(cherry picked from commit 547f297171)
2025-08-25 10:23:14 -07:00
Joe Li
d01934d9d8 fix(Icons): Add missing data-test and aria-label attributes to custom icons (#34809)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 5c3c2599db)
2025-08-25 10:22:58 -07:00
Michael S. Molina
25775504b9 fix: User-provided Jinja template parameters causing SQL parsing errors (#34802)
(cherry picked from commit e1234b2264)
2025-08-23 11:17:15 -07:00
JUST.in DO IT
4cc6984ebf fix: customize column description limit size in db_engine_spec (#34808)
(cherry picked from commit 75af53dc3d)
2025-08-23 11:16:43 -07:00
Mehmet Salih Yavuz
548d9e6f7b fix(DetailsPanel): Applied filters colors (#34790)
(cherry picked from commit 2b2cc96f11)
2025-08-23 11:15:57 -07:00
Kamil Gabryjelski
1adaf20ccb fix(native-filters): Low contrast of empty state in dark mode (#34812)
(cherry picked from commit 59c01e016d)
2025-08-23 11:15:20 -07:00
Kamil Gabryjelski
7e9658fad6 fix: Low contrast in viz creator selected tag in dark mode (#34811)
(cherry picked from commit 3895b8b127)
2025-08-23 11:14:53 -07:00
Kamil Gabryjelski
5ff6679804 fix: Remove border around textarea in dashboard edit mode (#34814)
(cherry picked from commit da8c0f94e6)
2025-08-23 11:14:25 -07:00
Kamil Gabryjelski
0899496ca5 fix: Misaligned global controls in Table chart (#34799)
(cherry picked from commit 6908a733a0)
2025-08-23 11:13:35 -07:00
Gabriel Torres Ruiz
e2a469c32d fix(dashboard): enable undo/redo buttons for layout changes (#34777)
(cherry picked from commit ff1f7b64e2)
2025-08-23 11:11:46 -07:00
Maxime Beauchemin
2ffc1b95ba fix: Check migration status before initializing database-dependent features (#34679)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit c568d463b9)
2025-08-21 14:05:27 -07:00
Tomáš Karela Procházka
3b3aa1e302 fix: default value in run-server.sh (#34719)
(cherry picked from commit 179a6f2cfe)
2025-08-21 14:04:59 -07:00
Elizabeth Thompson
958b29acbc fix: catch no table error (#32640)
(cherry picked from commit 695a20d009)
2025-08-21 14:04:08 -07:00
Mehmet Salih Yavuz
f0cfd17dc5 fix(PivotExcelExport): select correct chart for export (#34793)
(cherry picked from commit e908775fb2)
2025-08-21 14:03:38 -07:00
Joe Li
ebfddb2b39 fix(tests): make SingleStore test_adjust_engine_params version-agnostic (#34780)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit af05396227)
2025-08-21 09:43:34 -07:00
amaannawab923
e10b6e8ae9 fix(webpack): Bump webpack dev-server to handle Errors on Firefox where error object is not defined (#34791)
(cherry picked from commit 277f03c207)
2025-08-21 09:42:35 -07:00
Evan Rusackas
18d4acdee9 fix(sqllab): Fix save query modal closing prematurely on new tabs (#34765)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit 48699a7194)
2025-08-21 09:41:48 -07:00
PolinaFam
c199213e8e fix(translations): Fix translation of time-related strings like "7 seconds ago", "a minute ago", etc (#34051)
Co-authored-by: Polina Fam <pfam@ptsecurity.com>
(cherry picked from commit b45141b2a1)
2025-08-21 09:40:38 -07:00
Joe Li
91834bbede fix: Fix TypeError in Slice.get() method when using filter_by() with BinaryExpression (#34769)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 9de1706baa)
2025-08-20 11:24:12 -07:00
Evan Rusackas
7253c79959 fix(duckdb): Add support for DuckDB-specific numeric types (#34743)
Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit a42185cd3b)
2025-08-20 11:04:06 -07:00
JUST.in DO IT
04738c716c fix(sqllab): Invisible grid table due to the invalid height (#34683)
(cherry picked from commit 89eb7b207c)
2025-08-20 11:03:42 -07:00
Michael S. Molina
928dbe43e0 fix: Users can't skip column sync when saving virtual datasets (#34757)
(cherry picked from commit f99022b242)
2025-08-19 09:51:22 -07:00
JUST.in DO IT
3f597a6551 fix(sqllab): Reduce flushing caused by ID updates (#34720)
(cherry picked from commit f8b9e3ace4)
2025-08-19 09:50:52 -07:00
JUST.in DO IT
3661482eb3 chore(saved_query): Copy link to clipboard before redirect to edit (#34567) (#34758)
(cherry picked from commit 9cbe5a90b8)
2025-08-19 09:50:25 -07:00
Mehmet Salih Yavuz
9443fe8b78 fix(RightMenu): Move RightMenu carets to the right side (#34756)
(cherry picked from commit f7fe617f4c)
2025-08-19 09:50:02 -07:00
Kamil Gabryjelski
453241eb33 fix: Highlight outline of numerical range and time range filters (#34705)
(cherry picked from commit 6969f2cf7a)
2025-08-19 09:49:32 -07:00
Joe Li
a5f7d236ac chore: Adds RC1 data to CHANGELOG.md and UPDATING.md 2025-08-18 14:38:54 -07:00
591 changed files with 31415 additions and 8801 deletions

View File

@@ -44,4 +44,8 @@ under the License.
- [4.0.1](./CHANGELOG/4.0.1.md)
- [4.0.2](./CHANGELOG/4.0.2.md)
- [4.1.0](./CHANGELOG/4.1.0.md)
- [4.1.1](./CHANGELOG/4.1.1.md)
- [4.1.2](./CHANGELOG/4.1.2.md)
- [4.1.3](./CHANGELOG/4.1.3.md)
- [5.0.0](./CHANGELOG/5.0.0.md)
- [6.0.0](./CHANGELOG/6.0.0.md)

1062
CHANGELOG/6.0.0.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,9 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
### Frontend Modernization
- **NO `any` types** - Use proper TypeScript types
- **NO JavaScript files** - Convert to TypeScript (.ts/.tsx)
- **Use @superset-ui/core** - Don't import Ant Design directly
- **Use @superset-ui/core** - Don't import Ant Design directly, prefer Ant Design component wrappers from @superset-ui/core/components
- **Use antd theming tokens** - Prefer antd tokens over legacy theming tokens
- **Avoid custom css and styles** - Follow antd best practices and avoid styling and custom CSS whenever possible
### Testing Strategy Migration
- **Prefer unit tests** over integration tests

View File

@@ -22,7 +22,10 @@ under the License.
This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
## Next
## 6.0.0
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
- [34782](https://github.com/apache/superset/pull/34782): Dataset exports now include the dataset ID in their file name (similar to charts and dashboards). If managing assets as code, make sure to rename existing dataset YAMLs to include the ID (and avoid duplicated files).
- [34536](https://github.com/apache/superset/pull/34536): The `ENVIRONMENT_TAG_CONFIG` color values have changed to support only Ant Design semantic colors. Update your `superset_config.py`:
- Change `"error.base"` to just `"error"` after this PR
- Change any hex color values to one of: `"success"`, `"processing"`, `"error"`, `"warning"`, `"default"`

View File

@@ -26,7 +26,7 @@ gunicorn \
--workers ${SERVER_WORKER_AMOUNT:-1} \
--worker-class ${SERVER_WORKER_CLASS:-gthread} \
--threads ${SERVER_THREADS_AMOUNT:-20} \
--log-level "${GUNICORN_LOGLEVEL:info}" \
--log-level "${GUNICORN_LOGLEVEL:-info}" \
--timeout ${GUNICORN_TIMEOUT:-60} \
--keep-alive ${GUNICORN_KEEPALIVE:-2} \
--max-requests ${WORKER_MAX_REQUESTS:-0} \

View File

@@ -67,6 +67,22 @@ To send alerts and reports to Slack channels, you need to create a new Slack App
Note: when you configure an alert or a report, the Slack channel list takes channel names without the leading '#' e.g. use `alerts` instead of `#alerts`.
#### Large Slack Workspaces (10k+ channels)
For workspaces with many channels, fetching the complete channel list can take several minutes and may encounter Slack API rate limits. Add the following to your `superset_config.py`:
```python
from datetime import timedelta
# Increase cache timeout to reduce API calls
# Default: 1 day (86400 seconds)
SLACK_CACHE_TIMEOUT = int(timedelta(days=2).total_seconds())
# Increase retry count for rate limit errors
# Default: 2
SLACK_API_RATE_LIMIT_RETRY_COUNT = 5
```
### Kubernetes-specific
- You must have a `celery beat` pod running. If you're using the chart included in the GitHub repository under [helm/superset](https://github.com/apache/superset/tree/master/helm/superset), you need to put `supersetCeleryBeat.enabled = true` in your values override.

View File

@@ -363,110 +363,6 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
]
```
### Keycloak-Specific Configuration using Flask-OIDC
If you are using Keycloak as OpenID Connect 1.0 Provider, the above configuration based on [`Authlib`](https://authlib.org/) might not work. In this case using [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is a viable option.
Make sure the pip package [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is installed on the webserver. This was successfully tested using version 2.2.0. This package requires [`Flask-OpenID`](https://pypi.org/project/Flask-OpenID/) as a dependency.
The following code defines a new security manager. Add it to a new file named `keycloak_security_manager.py`, placed in the same directory as your `superset_config.py` file.
```python
from flask_appbuilder.security.manager import AUTH_OID
from superset.security import SupersetSecurityManager
from flask_oidc import OpenIDConnect
from flask_appbuilder.security.views import AuthOIDView
from flask_login import login_user
from urllib.parse import quote
from flask_appbuilder.views import ModelView, SimpleFormView, expose
from flask import (
redirect,
request
)
import logging
class OIDCSecurityManager(SupersetSecurityManager):
def __init__(self, appbuilder):
super(OIDCSecurityManager, self).__init__(appbuilder)
if self.auth_type == AUTH_OID:
self.oid = OpenIDConnect(self.appbuilder.get_app)
self.authoidview = AuthOIDCView
class AuthOIDCView(AuthOIDView):
@expose('/login/', methods=['GET', 'POST'])
def login(self, flag=True):
sm = self.appbuilder.sm
oidc = sm.oid
@self.appbuilder.sm.oid.require_login
def handle_login():
user = sm.auth_user_oid(oidc.user_getfield('email'))
if user is None:
info = oidc.user_getinfo(['preferred_username', 'given_name', 'family_name', 'email'])
user = sm.add_user(info.get('preferred_username'), info.get('given_name'), info.get('family_name'),
info.get('email'), sm.find_role('Gamma'))
login_user(user, remember=False)
return redirect(self.appbuilder.get_url_for_index)
return handle_login()
@expose('/logout/', methods=['GET', 'POST'])
def logout(self):
oidc = self.appbuilder.sm.oid
oidc.logout()
super(AuthOIDCView, self).logout()
redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
return redirect(
oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url))
```
Then add to your `superset_config.py` file:
```python
from keycloak_security_manager import OIDCSecurityManager
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
import os
AUTH_TYPE = AUTH_OID
SECRET_KEY: 'SomethingNotEntirelySecret'
OIDC_CLIENT_SECRETS = '/path/to/client_secret.json'
OIDC_ID_TOKEN_COOKIE_SECURE = False
OIDC_OPENID_REALM: '<myRealm>'
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
# Will allow user self registration, allowing to create Flask users from Authorized User
AUTH_USER_REGISTRATION = True
# The default user self registration role
AUTH_USER_REGISTRATION_ROLE = 'Public'
```
Store your client-specific OpenID information in a file called `client_secret.json`. Create this file in the same directory as `superset_config.py`:
```json
{
"<myOpenIDProvider>": {
"issuer": "https://<myKeycloakDomain>/realms/<myRealm>",
"auth_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/auth",
"client_id": "https://<myKeycloakDomain>",
"client_secret": "<myClientSecret>",
"redirect_uris": [
"https://<SupersetWebserver>/oauth-authorized/<myOpenIDProvider>"
],
"userinfo_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/userinfo",
"token_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token",
"token_introspection_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token/introspect"
}
}
```
## LDAP Authentication
FAB supports authenticating user credentials against an LDAP server.

View File

@@ -747,6 +747,26 @@ To run a single test file:
npm run test -- path/to/file.js
```
#### Known Issues and Workarounds
**Jest Test Hanging (MessageChannel Issue)**
If Jest tests hang with "Jest did not exit one second after the test run has completed", this is likely due to the MessageChannel issue from rc-overflow (Ant Design v5 components).
**Root Cause**: `rc-overflow@1.4.1` creates MessageChannel handles for responsive overflow detection that remain open after test completion.
**Current Workaround**: MessageChannel is mocked as undefined in `spec/helpers/jsDomWithFetchAPI.ts`, forcing rc-overflow to use requestAnimationFrame fallback.
**To verify if still needed**: Remove the MessageChannel mocking lines and run `npm test -- --shard=4/8`. If tests hang, the workaround is still required.
**Future removal conditions**: This workaround can be removed when:
- rc-overflow updates to properly clean up MessagePorts in test environments
- Jest updates to handle MessageChannel/MessagePort cleanup better
- Ant Design switches away from rc-overflow
- We switch away from Ant Design v5
**See**: [PR #34871](https://github.com/apache/superset/pull/34871) for full technical details.
### Debugging Server App
#### Local

View File

@@ -344,7 +344,7 @@ const config: Config = {
'data-project-name': 'Apache Superset',
'data-project-color': '#FFFFFF',
'data-project-logo':
'https://images.seeklogo.com/logo-png/50/2/superset-icon-logo-png_seeklogo-500354.png',
'https://superset.apache.org/img/superset-logo-icon-only.png',
'data-modal-override-open-id': 'ask-ai-input',
'data-modal-override-open-class': 'search-input',
'data-modal-disclaimer':

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -46,7 +46,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=4.8.0, <5.0.0",
"flask-appbuilder>=5.0.0,<6",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -58,8 +58,8 @@ dependencies = [
"greenlet>=3.0.3, <=3.1.1",
"gunicorn>=22.0.0; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# known issue with holidays 0.26.0 and above related to prophet lib #25017
"holidays>=0.25, <0.26",
# holidays>=0.45 required for security fix
"holidays>=0.45, <1",
"humanize",
"isodate",
"jsonpath-ng>=1.6.1, <2",
@@ -73,7 +73,7 @@ dependencies = [
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
"pandas[excel]>=2.0.3, <2.1",
"pandas[excel]>=2.1.4, <2.2",
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",
@@ -85,18 +85,18 @@ dependencies = [
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
"python-geohash",
"pyarrow>=16.1.0, <17", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyarrow>=16.1.0, <19", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyyaml>=6.0.0, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=4.6.0, <5.0",
"selenium>=4.14.0, <5.0",
"shillelagh[gsheetsapi]>=1.2.18, <2.0",
"shillelagh[gsheetsapi]>=1.4.3, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=3.15.0",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.3, <0.39",
"sqlglot>=27.3.0, <28",
"sqlglot>=27.15.2, <28",
# newer pandas needs 0.9+
"tabulate>=0.9.0, <1.0",
"typing-extensions>=4, <5",
@@ -128,7 +128,7 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.4, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb-engine>=0.12.1, <0.13"]
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
@@ -137,7 +137,7 @@ excel = ["xlrd>=1.2.0, <1.3"]
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
gsheets = ["shillelagh[gsheetsapi]>=1.2.18, <2"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.3, <2"]
hana = ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
@@ -165,10 +165,10 @@ playwright = ["playwright>=1.37.0, <2"]
postgres = ["psycopg2-binary==2.9.6"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.328.0"]
prophet = ["prophet>=1.1.5, <2"]
prophet = ["prophet>=1.1.6, <2"]
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
risingwave = ["sqlalchemy-risingwave"]
shillelagh = ["shillelagh[all]>=1.2.18, <2"]
shillelagh = ["shillelagh[all]>=1.4.3, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.1.1, <2"]
snowflake = ["snowflake-sqlalchemy>=1.2.4, <2"]
spark = [

View File

@@ -112,9 +112,9 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==4.8.0
flask-appbuilder==5.0.0
# via apache-superset (pyproject.toml)
flask-babel==2.0.0
flask-babel==3.1.0
# via flask-appbuilder
flask-caching==2.3.1
# via apache-superset (pyproject.toml)
@@ -154,13 +154,14 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
hashids==1.3.1
# via apache-superset (pyproject.toml)
holidays==0.25
holidays==0.82
# via apache-superset (pyproject.toml)
humanize==4.12.3
# via apache-superset (pyproject.toml)
@@ -192,8 +193,6 @@ jsonschema-specifications==2025.4.1
# openapi-schema-validator
kombu==5.5.3
# via celery
korean-lunar-calendar==0.3.1
# via holidays
limits==5.1.0
# via flask-limiter
mako==1.3.10
@@ -256,7 +255,7 @@ packaging==25.0
# limits
# marshmallow
# shillelagh
pandas==2.0.3
pandas==2.1.4
# via apache-superset (pyproject.toml)
paramiko==3.5.1
# via
@@ -351,7 +350,7 @@ rsa==4.9.1
# via google-auth
selenium==4.32.0
# via apache-superset (pyproject.toml)
shillelagh==1.3.5
shillelagh==1.4.3
# via apache-superset (pyproject.toml)
simplejson==3.20.1
# via apache-superset (pyproject.toml)
@@ -371,6 +370,7 @@ sqlalchemy==1.4.54
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# flask-appbuilder
# flask-sqlalchemy
# marshmallow-sqlalchemy
@@ -379,9 +379,12 @@ sqlalchemy==1.4.54
sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==27.3.0
# via apache-superset (pyproject.toml)
sqlglot==27.15.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
@@ -396,6 +399,7 @@ typing-extensions==4.14.0
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# cattrs
# limits
# pyopenssl

View File

@@ -195,11 +195,11 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==4.8.0
flask-appbuilder==5.0.0
# via
# -c requirements/base.txt
# apache-superset
flask-babel==2.0.0
flask-babel==3.1.0
# via
# -c requirements/base.txt
# flask-appbuilder
@@ -313,6 +313,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -332,7 +333,7 @@ hashids==1.3.1
# via
# -c requirements/base.txt
# apache-superset
holidays==0.25
holidays==0.82
# via
# -c requirements/base.txt
# apache-superset
@@ -393,10 +394,6 @@ kombu==5.5.3
# via
# -c requirements/base.txt
# celery
korean-lunar-calendar==0.3.1
# via
# -c requirements/base.txt
# holidays
lazy-object-proxy==1.10.0
# via openapi-spec-validator
limits==5.1.0
@@ -469,6 +466,7 @@ numpy==1.26.4
# pandas
# pandas-gbq
# prophet
# pyarrow
oauthlib==3.2.2
# via requests-oauthlib
odfpy==1.4.1
@@ -510,7 +508,7 @@ packaging==25.0
# pytest
# shillelagh
# sqlalchemy-bigquery
pandas==2.0.3
pandas==2.1.4
# via
# -c requirements/base.txt
# apache-superset
@@ -539,6 +537,7 @@ pgsanity==0.2.9
# apache-superset
pillow==11.3.0
# via
# -c requirements/base.txt
# apache-superset
# matplotlib
pip==25.1.1
@@ -571,7 +570,7 @@ prompt-toolkit==3.0.51
# via
# -c requirements/base.txt
# click-repl
prophet==1.1.5
prophet==1.2.0
# via apache-superset
proto-plus==1.25.0
# via
@@ -759,7 +758,7 @@ setuptools==80.7.1
# pydata-google-auth
# zope-event
# zope-interface
shillelagh==1.3.5
shillelagh==1.4.3
# via
# -c requirements/base.txt
# apache-superset
@@ -804,7 +803,7 @@ sqlalchemy-utils==0.38.3
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
sqlglot==27.3.0
sqlglot==27.15.2
# via
# -c requirements/base.txt
# apache-superset

View File

@@ -1,766 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { Interception } from 'cypress/types/net-stubbing';
import { waitForChartLoad } from 'cypress/utils';
import { SUPPORTED_CHARTS_DASHBOARD } from 'cypress/utils/urls';
import {
openTopLevelTab,
SUPPORTED_TIER1_CHARTS,
SUPPORTED_TIER2_CHARTS,
} from './utils';
import {
interceptExploreJson,
interceptV1ChartData,
interceptFormDataKey,
} from '../explore/utils';
const interceptDrillInfo = () => {
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
statusCode: 200,
body: {
result: {
id: 1,
changed_on_humanized: '2 days ago',
created_on_humanized: 'a week ago',
table_name: 'birth_names',
changed_by: {
first_name: 'Admin',
last_name: 'User',
},
created_by: {
first_name: 'Admin',
last_name: 'User',
},
owners: [
{
first_name: 'Admin',
last_name: 'User',
},
],
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'state',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{
column_name: 'ds',
verbose_name: null,
},
],
},
},
}).as('drillInfo');
};
const closeModal = () => {
cy.get('body').then($body => {
if ($body.find('[data-test="close-drill-by-modal"]').length) {
cy.getBySel('close-drill-by-modal').click({ force: true });
}
});
};
const openTableContextMenu = (
cellContent: string,
tableSelector = "[data-test-viz-type='table']",
) => {
cy.get(tableSelector).scrollIntoView();
cy.get(tableSelector).contains(cellContent).first().rightclick();
};
const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
if (isLegacy) {
interceptExploreJson('legacyData');
} else {
interceptV1ChartData();
}
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)', { timeout: 15000 })
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill by$/)
.trigger('mouseover', { force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
{ timeout: 15000 },
)
.should('be.visible')
.find('[role="menuitem"]')
.contains(new RegExp(`^${targetDrillByColumn}$`))
.click();
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).should('not.exist');
if (isLegacy) {
return cy.wait('@legacyData');
}
return cy.wait('@v1Data');
};
const verifyExpectedFormData = (
interceptedRequest: Interception,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectedFormData: Record<string, any>,
) => {
const actualFormData = interceptedRequest.request.body?.form_data;
Object.entries(expectedFormData).forEach(([key, val]) => {
expect(actualFormData?.[key]).to.eql(val);
});
};
const testEchart = (
vizType: string,
chartName: string,
drillClickCoordinates: [[number, number], [number, number]],
furtherDrillDimension = 'name',
) => {
cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel(`"Drill by: ${chartName}-modal"`).as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', chartName);
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click 'other'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
drillBy(furtherDrillDimension).then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: [furtherDrillDimension],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'other',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (other)')
.and('contain', furtherDrillDimension)
.contains('state (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (other)')
.and('not.contain', furtherDrillDimension)
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
};
describe('Drill by modal', () => {
beforeEach(() => {
closeModal();
});
before(() => {
interceptDrillInfo();
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
});
describe('Modal actions + Table', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it.only('opens the modal from the context menu', () => {
openTableContextMenu('boy');
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Table');
cy.get('@drillByModal')
.find('[data-test="metadata-bar"]')
.should('be.visible');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'sum__num');
// further drilling
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
interceptFormDataKey();
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (CA)')
.and('contain', 'name')
.contains('state (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'name')
.and('contain', 'state')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (CA)')
.and('not.contain', 'name')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-display-toggle"]')
.contains('Table')
.click();
cy.getBySel('drill-by-chart').should('not.exist');
cy.get('@drillByModal')
.find('[data-test="drill-by-results-table"]')
.should('be.visible');
cy.wait('@formDataKey').then(intercept => {
cy.get('@drillByModal')
.contains('Edit chart')
.should('have.attr', 'href')
.and(
'contain',
`/explore/?form_data_key=${intercept.response?.body?.key}`,
);
});
});
});
describe('Tier 1 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it('Pivot Table', () => {
openTableContextMenu('boy', "[data-test-viz-type='pivot_table_v2']");
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Pivot Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Pivot Table');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num')
.and('not.contain', 'Gender');
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('ds').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyColumns: ['name'],
groupbyRows: ['ds'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'name')
.and('contain', 'ds')
.and('contain', 'sum__num')
.and('not.contain', 'state');
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (CA)')
.and('contain', 'ds')
.contains('name (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'ds')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (CA)')
.and('not.contain', 'ds')
.and('contain', 'name');
});
it('Line chart', () => {
testEchart('echarts_timeseries_line', 'Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Area Chart', () => {
testEchart('echarts_area', 'Area Chart', [
[85, 93],
[85, 93],
]);
});
it('Scatter Chart', () => {
testEchart('echarts_timeseries_scatter', 'Scatter Chart', [
[85, 93],
[85, 93],
]);
});
it.skip('Bar Chart', () => {
testEchart('echarts_timeseries_bar', 'Bar Chart', [
[85, 94],
[490, 68],
]);
});
it('Pie Chart', () => {
testEchart('pie', 'Pie Chart', [
[243, 167],
[534, 248],
]);
});
});
describe('Tier 2 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 2');
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
});
it('Box Plot Chart', () => {
testEchart(
'box_plot',
'Box Plot Chart',
[
[139, 277],
[787, 441],
],
'ds',
);
});
it('Generic Chart', () => {
testEchart('echarts_timeseries', 'Generic Chart', [
[85, 93],
[85, 93],
]);
});
it('Smooth Line Chart', () => {
testEchart('echarts_timeseries_smooth', 'Smooth Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Step Line Chart', () => {
testEchart('echarts_timeseries_step', 'Step Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Funnel Chart', () => {
testEchart('funnel', 'Funnel Chart', [
[154, 80],
[421, 39],
]);
});
it('Gauge Chart', () => {
testEchart('gauge_chart', 'Gauge Chart', [
[151, 95],
[300, 143],
]);
});
it.skip('Radar Chart', () => {
testEchart('radar', 'Radar Chart', [
[182, 49],
[423, 91],
]);
});
it('Treemap V2 Chart', () => {
testEchart('treemap_v2', 'Treemap V2 Chart', [
[145, 84],
[220, 105],
]);
});
it.skip('Mixed Chart', () => {
cy.get('[data-test-viz-type="mixed_timeseries"] canvas').then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 85, 93);
cy.wrap($canvas).rightclick(85, 93);
drillBy('name').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.getBySel('"Drill by: Mixed Chart-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Mixed Chart');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click second query
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 261, 114);
cy.wrap($canvas).rightclick(261, 114);
drillBy('ds').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['ds']);
expect(queries[1].filters).to.eql([
{ col: 'state', op: '==', val: 'other' },
]);
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (other)')
.and('contain', 'ds')
.contains('name (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (other)')
.and('not.contain', 'ds')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
});
});
});

View File

@@ -179,13 +179,13 @@ describe.skip('Drill to detail modal', () => {
cy.on('uncaught:exception', () => false);
cy.wait('@samples');
cy.get('.virtual-table-cell').should($rows => {
expect($rows).to.contain('Kelly');
expect($rows).to.contain('Kimberly');
});
// verify scroll top on pagination
cy.getBySelLike('Number-modal').find('.virtual-grid').scrollTo(0, 200);
cy.get('.virtual-grid').contains('Juan').should('not.be.visible');
cy.get('.virtual-grid').contains('Kim').should('not.be.visible');
cy.get('.ant-pagination-item').eq(0).click();

View File

@@ -160,6 +160,57 @@ describe('Native filters', () => {
);
});
it('user cannot create bi-directional dependencies between filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },
{ name: 'country_name', column: 'country_name', datasetId: 2 },
{ name: 'country_code', column: 'country_code', datasetId: 2 },
{ name: 'year', column: 'year', datasetId: 2 },
]);
enterNativeFilterEditModal();
// First, make country_name dependent on region
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// Second, make country_code dependent on country_name
selectFilter(2);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumn);
// Now select region filter and try to add dependency
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
// Verify that only 'year' is available as dependency for region
// 'country_name' and 'country_code' should not be available (would create circular dependency)
cy.get('input[aria-label^="Limit type"]').click({ force: true });
cy.get('[role="listbox"]').should('be.visible');
cy.get('[role="listbox"]').should('contain', 'year');
cy.get('[role="listbox"]').should('not.contain', 'country_name');
cy.get('[role="listbox"]').should('not.contain', 'country_code');
cy.get('[role="listbox"]').contains('year').click();
},
);
});
it('Dependent filter selects first item based on parent filter selection', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },

View File

@@ -1,12 +1,12 @@
{
"name": "superset",
"version": "0.0.0-dev",
"version": "6.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "superset",
"version": "0.0.0-dev",
"version": "6.0.0",
"license": "Apache-2.0",
"workspaces": [
"packages/*",
@@ -62,7 +62,6 @@
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -187,7 +186,6 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
@@ -265,7 +263,7 @@
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.1",
"webpack-dev-server": "^5.2.2",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.3.3",
"webpack-visualizer-plugin2": "^1.2.0"
@@ -16104,16 +16102,6 @@
"@types/react": "*"
}
},
"node_modules/@types/react-ultimate-pagination": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/react-ultimate-pagination/-/react-ultimate-pagination-1.2.4.tgz",
"integrity": "sha512-1y9jLt3KEFGzFD+99qVpJUI/Eu4cEx48sClB957eGoepWRLVVi+r1UBj0157Mg7HYZcIF4I1/qGZYaBBQWhaqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz",
@@ -24501,12 +24489,6 @@
"node": ">= 4"
}
},
"node_modules/emotion-rgba": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.12.tgz",
"integrity": "sha512-lvtZ52BWisYDtis+HctQMkxcHwmFbzTiZhgMJGFfWXLsBYEzthfKE7nlysOiUwmmAdTM/8YBAPfwQ4MEDwiaWw==",
"license": "MIT"
},
"node_modules/encodable": {
"version": "0.7.8",
"resolved": "https://registry.npmjs.org/encodable/-/encodable-0.7.8.tgz",
@@ -57102,9 +57084,9 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
"integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz",
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -57124,7 +57106,7 @@
"connect-history-api-fallback": "^2.0.0",
"express": "^4.21.2",
"graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.7",
"http-proxy-middleware": "^2.0.9",
"ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1",
"open": "^10.0.3",

View File

@@ -1,6 +1,6 @@
{
"name": "superset",
"version": "0.0.0-dev",
"version": "6.0.0",
"description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.",
"keywords": [
"big",
@@ -130,7 +130,6 @@
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -255,7 +254,6 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
@@ -333,7 +331,7 @@
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.1",
"webpack-dev-server": "^5.2.2",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.3.3",
"webpack-visualizer-plugin2": "^1.2.0"

View File

@@ -16,9 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { Popover, type PopoverProps } from '@superset-ui/core/components';
import type ReactAce from 'react-ace';
import {
Popover,
type PopoverProps,
SQLEditor,
} from '@superset-ui/core/components';
import { CalculatorOutlined } from '@ant-design/icons';
import { css, styled, useTheme, t } from '@superset-ui/core';
@@ -35,24 +37,10 @@ const StyledCalculatorIcon = styled(CalculatorOutlined)`
export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
const theme = useTheme();
const [AceEditor, setAceEditor] = useState<typeof ReactAce | null>(null);
useEffect(() => {
Promise.all([
import('react-ace'),
import('ace-builds/src-min-noconflict/mode-sql'),
]).then(([reactAceModule]) => {
setAceEditor(() => reactAceModule.default);
});
}, []);
if (!AceEditor) {
return null;
}
return (
<Popover
content={
<AceEditor
mode="sql"
<SQLEditor
value={props.sqlExpression}
editorProps={{ $blockScrolling: true }}
setOptions={{
@@ -65,7 +53,6 @@ export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
wrapEnabled
style={{
border: `1px solid ${theme.colorBorder}`,
background: theme.colorPrimaryBg,
maxWidth: theme.sizeUnit * 100,
}}
/>

View File

@@ -43,15 +43,17 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
// remove or rename top level of column name(metric name) in the MultiIndex when
// 1) at least 1 metric
// 2) dimension exist or multiple time shift metrics exist
// 3) xAxis exist
// 4) truncate_metric in form_data and truncate_metric is true
// 2) xAxis exist
// 3a) isTimeComparisonValue
// 3b-1) dimension exist or multiple time shift metrics exist
// 3b-2) truncate_metric in form_data and truncate_metric is true
if (
metrics.length > 0 &&
(columns.length > 0 || timeOffsets.length > 1) &&
xAxisLabel &&
truncate_metric !== undefined &&
!!truncate_metric
(isTimeComparisonValue ||
((columns.length > 0 || timeOffsets.length > 1) &&
truncate_metric !== undefined &&
!!truncate_metric))
) {
const renamePairs: [string, string | null][] = [];
if (

View File

@@ -32,21 +32,30 @@ const MIN_OPACITY_BOUNDED = 0.05;
const MIN_OPACITY_UNBOUNDED = 0;
const MAX_OPACITY = 1;
export const getOpacity = (
value: number,
cutoffPoint: number,
extremeValue: number,
value: number | string,
cutoffPoint: number | string,
extremeValue: number | string,
minOpacity = MIN_OPACITY_BOUNDED,
maxOpacity = MAX_OPACITY,
) => {
if (extremeValue === cutoffPoint) {
if (extremeValue === cutoffPoint || typeof value !== 'number') {
return maxOpacity;
}
const numCutoffPoint =
typeof cutoffPoint === 'string' ? parseFloat(cutoffPoint) : cutoffPoint;
const numExtremeValue =
typeof extremeValue === 'string' ? parseFloat(extremeValue) : extremeValue;
if (Number.isNaN(numCutoffPoint) || Number.isNaN(numExtremeValue)) {
return maxOpacity;
}
return Math.min(
maxOpacity,
round(
Math.abs(
((maxOpacity - minOpacity) / (extremeValue - cutoffPoint)) *
(value - cutoffPoint),
((maxOpacity - minOpacity) / (numExtremeValue - numCutoffPoint)) *
(value - numCutoffPoint),
) + minOpacity,
2,
),
@@ -191,10 +200,21 @@ export const getColorFormatters = memoizeOne(
(
columnConfig: ConditionalFormattingConfig[] | undefined,
data: DataRecord[],
theme?: Record<string, any>,
alpha?: boolean,
) =>
columnConfig?.reduce(
(acc: ColorFormatters, config: ConditionalFormattingConfig) => {
let resolvedColorScheme = config.colorScheme;
if (
theme &&
typeof config.colorScheme === 'string' &&
config.colorScheme.startsWith('color') &&
theme[config.colorScheme]
) {
resolvedColorScheme = theme[config.colorScheme] as string;
}
if (
config?.column !== undefined &&
(config?.operator === Comparator.None ||
@@ -207,7 +227,7 @@ export const getColorFormatters = memoizeOne(
acc.push({
column: config?.column,
getColorFromValue: getColorFunction(
config,
{ ...config, colorScheme: resolvedColorScheme },
data.map(row => row[config.column!] as number),
alpha,
),

View File

@@ -160,6 +160,44 @@ test('should add renameOperator if exists derived metrics', () => {
});
});
test('should add renameOperator if isTimeComparisonValue without columns', () => {
[
ComparisonType.Difference,
ComparisonType.Ratio,
ComparisonType.Percentage,
].forEach(type => {
expect(
renameOperator(
{
...formData,
...{
comparison_type: type,
time_compare: ['1 year ago'],
},
},
{
...queryObject,
...{
columns: [],
metrics: ['sum(val)', 'avg(val2)'],
},
},
),
).toEqual({
operation: 'rename',
options: {
columns: {
[`${type}__avg(val2)__avg(val2)__1 year ago`]:
'avg(val2), 1 year ago',
[`${type}__sum(val)__sum(val)__1 year ago`]: 'sum(val), 1 year ago',
},
inplace: true,
level: 0,
},
});
});
});
test('should add renameOperator if x_axis does not exist', () => {
expect(
renameOperator(

View File

@@ -32,360 +32,373 @@ const mockData = [
];
const countValues = mockData.map(row => row.count);
describe('round', () => {
it('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
});
test('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
});
describe('getOpacity', () => {
it('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
});
test('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
expect(getOpacity('100', 100, 100)).toEqual(1);
expect(getOpacity('75', 50, 100)).toEqual(1);
expect(getOpacity('50', '100', '100')).toEqual(1);
expect(getOpacity('50', '75', '100')).toEqual(1);
expect(getOpacity('50', NaN, '100')).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, 100)).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, NaN)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(100, 50, 100)).toEqual(1);
expect(getOpacity(75, '50', 100)).toEqual(0.53);
expect(getOpacity(75, 50, '100')).toEqual(0.53);
expect(getOpacity(75, '50', '100')).toEqual(0.53);
expect(getOpacity(50, NaN, NaN)).toEqual(1);
expect(getOpacity(50, NaN, 100)).toEqual(1);
expect(getOpacity(50, NaN, '100')).toEqual(1);
expect(getOpacity(50, '75', NaN)).toEqual(1);
expect(getOpacity(50, 75, NaN)).toEqual(1);
});
describe('getColorFunction()', () => {
it('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
it('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
it('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
it('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
describe('getColorFormatters()', () => {
it('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
it('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
});
test('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
test('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
test('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
test('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
test('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
});

View File

@@ -0,0 +1,90 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from '@testing-library/react';
import {
ChartPlugin,
ChartMetadata,
DatasourceType,
getChartComponentRegistry,
} from '@superset-ui/core';
import SuperChartCore from './SuperChartCore';
const props = {
chartType: 'line',
};
const FakeChart = () => <span>test</span>;
beforeEach(() => {
const metadata = new ChartMetadata({
name: 'test-chart',
thumbnail: '',
});
const buildQuery = () => ({
datasource: { id: 1, type: DatasourceType.Table },
queries: [{ granularity: 'day' }],
force: false,
result_format: 'json',
result_type: 'full',
});
const controlPanel = { abc: 1 };
const plugin = new ChartPlugin({
metadata,
Chart: FakeChart,
buildQuery,
controlPanel,
});
plugin.configure({ key: props.chartType }).register();
});
test('should return the result from cache unless transformProps has changed', async () => {
const pre = jest.fn(x => x);
const transform = jest.fn(x => x);
const post = jest.fn(x => x);
expect(getChartComponentRegistry().get(props.chartType)).toBe(FakeChart);
expect(pre).toHaveBeenCalledTimes(0);
const { rerender } = render(
<SuperChartCore
{...props}
preTransformProps={pre}
overrideTransformProps={transform}
postTransformProps={post}
/>,
);
await waitFor(() => expect(pre).toHaveBeenCalledTimes(1));
expect(transform).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
const updatedPost = jest.fn(x => x);
rerender(
<SuperChartCore
{...props}
preTransformProps={pre}
overrideTransformProps={transform}
postTransformProps={updatedPost}
/>,
);
await waitFor(() => expect(updatedPost).toHaveBeenCalledTimes(1));
expect(transform).toHaveBeenCalledTimes(1);
expect(pre).toHaveBeenCalledTimes(1);
});

View File

@@ -85,31 +85,74 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
container?: HTMLElement | null;
/**
* memoized function so it will not recompute
* and return previous value
* memoized function so it will not recompute and return previous value
* unless one of
* - preTransformProps
* - transformProps
* - postTransformProps
* - chartProps
* is changed.
*/
processChartProps = createSelector(
preSelector = createSelector(
[
(input: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) => input.chartProps,
input => input.preTransformProps,
],
(chartProps, pre = IDENTITY) => pre(chartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
transformSelector = createSelector(
[
(input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
input.chartProps,
input => input.transformProps,
],
(preprocessedChartProps, transform = IDENTITY) =>
transform(preprocessedChartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
postSelector = createSelector(
[
(input: {
chartProps: ChartProps;
postTransformProps?: PostTransformProps;
}) => input.chartProps,
input => input.postTransformProps,
],
(chartProps, pre = IDENTITY, transform = IDENTITY, post = IDENTITY) =>
post(transform(pre(chartProps))),
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
);
/**
* Using each memoized function to retrieve the computed chartProps
*/
processChartProps = ({
chartProps,
preTransformProps,
transformProps,
postTransformProps,
}: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) =>
this.postSelector({
chartProps: this.transformSelector({
chartProps: this.preSelector({ chartProps, preTransformProps }),
transformProps,
}),
postTransformProps,
});
/**
* memoized function so it will not recompute
* and return previous value

View File

@@ -0,0 +1,108 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Icons } from '@superset-ui/core/components/Icons';
import { ActionButton } from '.';
const defaultProps = {
label: 'test-action',
icon: <Icons.EditOutlined />,
onClick: jest.fn(),
};
test('renders action button with icon', () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('data-test', 'test-action');
expect(button).toHaveClass('action-button');
});
test('calls onClick when clicked', async () => {
const onClick = jest.fn();
render(<ActionButton {...defaultProps} onClick={onClick} />);
const button = screen.getByRole('button');
userEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
test('renders with tooltip when tooltip prop is provided', async () => {
const tooltipText = 'This is a tooltip';
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveTextContent(tooltipText);
});
test('renders without tooltip when tooltip prop is not provided', async () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = screen.queryByRole('tooltip');
expect(tooltip).not.toBeInTheDocument();
});
test('supports ReactElement tooltip', async () => {
const tooltipElement = <div>Custom tooltip content</div>;
render(<ActionButton {...defaultProps} tooltip={tooltipElement} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveTextContent('Custom tooltip content');
});
test('renders different icons correctly', () => {
render(<ActionButton {...defaultProps} icon={<Icons.DeleteOutlined />} />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
test('renders with custom placement for tooltip', async () => {
const tooltipText = 'Tooltip with custom placement';
render(
<ActionButton {...defaultProps} tooltip={tooltipText} placement="bottom" />,
);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
test('has proper accessibility attributes', () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('tabIndex', '0');
expect(button).toHaveAttribute('role', 'button');
});

View File

@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactElement } from 'react';
import {
Tooltip,
type TooltipPlacement,
type IconType,
} from '@superset-ui/core/components';
import { css, useTheme } from '@superset-ui/core';
export interface ActionProps {
label: string;
tooltip?: string | ReactElement;
placement?: TooltipPlacement;
icon: IconType;
onClick: () => void;
}
export const ActionButton = ({
label,
tooltip,
placement,
icon,
onClick,
}: ActionProps) => {
const theme = useTheme();
const actionButton = (
<span
role="button"
tabIndex={0}
css={css`
cursor: pointer;
color: ${theme.colorIcon};
margin-right: ${theme.sizeUnit}px;
&:hover {
path {
fill: ${theme.colorPrimary};
}
}
`}
className="action-button"
data-test={label}
onClick={onClick}
>
{icon}
</span>
);
const tooltipId = `${label.replaceAll(' ', '-').toLowerCase()}-tooltip`;
return tooltip ? (
<Tooltip id={tooltipId} title={tooltip} placement={placement}>
{actionButton}
</Tooltip>
) : (
actionButton
);
};

View File

@@ -16,7 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef } from 'react';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import type AceEditor from 'react-ace';
import {
AsyncAceEditor,
SQLEditor,
@@ -99,3 +101,259 @@ test('renders a custom placeholder', () => {
expect(screen.getByRole('paragraph')).toBeInTheDocument();
});
test('registers afterExec event listener for command handling', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Verify the commands object has the 'on' method (confirms event listener capability)
expect(editorInstance.commands).toHaveProperty('on');
expect(typeof editorInstance.commands.on).toBe('function');
});
test('moves autocomplete popup to parent container when triggered', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the editor container
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with insertstring command using _emit
// Note: Using _emit is necessary here to test internal event handling behavior
// since there's no public API to trigger the afterExec event directly
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
// eslint-disable-next-line no-underscore-dangle
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'insertstring' },
args: ['SELECT'],
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('moves autocomplete popup on startAutocomplete command event', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with startAutocomplete command
// Note: Using _emit is necessary here to test internal event handling behavior
// since there's no public API to trigger the afterExec event directly
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
// eslint-disable-next-line no-underscore-dangle
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'startAutocomplete' },
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('does not move autocomplete popup on unrelated commands', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the body
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
const originalParent = mockAutocompletePopup.parentElement;
// Simulate an unrelated command (e.g., 'selectall')
editorInstance.commands.exec('selectall', editorInstance, {});
// Wait a bit to ensure no movement happens
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should remain in its original location
expect(mockAutocompletePopup.parentElement).toBe(originalParent);
// Cleanup
document.body.removeChild(mockAutocompletePopup);
});
test('revalidates cached autocomplete popup when detached from DOM', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create first autocomplete popup
const firstPopup = document.createElement('div');
firstPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(firstPopup);
// Trigger command to cache the first popup
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await waitFor(() => {
expect(firstPopup.dataset.aceAutocomplete).toBe('true');
});
// Remove the first popup from DOM (simulating ACE editor replacing it)
firstPopup.remove();
// Create a new autocomplete popup
const secondPopup = document.createElement('div');
secondPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(secondPopup);
// Trigger command again - should find and move the new popup
editorInstance.commands.exec('insertstring', editorInstance, ' ');
await waitFor(() => {
expect(secondPopup.dataset.aceAutocomplete).toBe('true');
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
expect(parentContainer?.contains(secondPopup)).toBe(true);
});
});
test('cleans up event listeners on unmount', async () => {
const ref = createRef<AceEditor>();
const { container, unmount } = render(
<SQLEditor ref={ref as React.Ref<never>} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Spy on the commands.off method
const offSpy = jest.spyOn(editorInstance.commands, 'off');
// Unmount the component
unmount();
// Verify that the event listener was removed
expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
offSpy.mockRestore();
});
test('does not move autocomplete popup if target container is document.body', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
// Mock the closest method to return null (simulating no #ace-editor parent)
const originalClosest = editorInstance.container?.closest;
if (editorInstance.container) {
editorInstance.container.closest = jest.fn(() => null);
}
// Mock parentElement to be document.body
Object.defineProperty(editorInstance.container, 'parentElement', {
value: document.body,
configurable: true,
});
const initialParent = mockAutocompletePopup.parentElement;
// Trigger command
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should NOT be moved because target container is document.body
expect(mockAutocompletePopup.parentElement).toBe(initialParent);
// Cleanup
if (editorInstance.container && originalClosest) {
editorInstance.container.closest = originalClosest;
}
document.body.removeChild(mockAutocompletePopup);
});

View File

@@ -26,6 +26,7 @@ import type {
} from 'brace';
import type AceEditor from 'react-ace';
import type { IAceEditorProps } from 'react-ace';
import type { Ace } from 'ace-builds';
import {
AsyncEsmComponent,
@@ -195,6 +196,70 @@ export function AsyncAceEditor(
}
}, [keywords, setCompleters]);
// Move autocomplete popup to the nearest parent container with data-ace-container
useEffect(() => {
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
?.editor;
if (!editorInstance) return undefined;
const editorContainer = editorInstance.container;
if (!editorContainer) return undefined;
// Cache DOM elements to avoid repeated queries on every command execution
let cachedAutocompletePopup: HTMLElement | null = null;
let cachedTargetContainer: Element | null = null;
const moveAutocompleteToContainer = () => {
// Revalidate cached popup if missing or detached from DOM
if (
!cachedAutocompletePopup ||
!document.body.contains(cachedAutocompletePopup)
) {
cachedAutocompletePopup =
editorContainer.querySelector<HTMLElement>(
'.ace_autocomplete',
) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
}
// Revalidate cached container if missing or detached
if (
!cachedTargetContainer ||
!document.body.contains(cachedTargetContainer)
) {
cachedTargetContainer =
editorContainer.closest('#ace-editor') ??
editorContainer.parentElement;
}
if (
cachedAutocompletePopup &&
cachedTargetContainer &&
cachedTargetContainer !== document.body
) {
cachedTargetContainer.appendChild(cachedAutocompletePopup);
cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
}
};
const handleAfterExec = (e: Ace.Operation) => {
const name: string | undefined = e?.command?.name;
if (name === 'insertstring' || name === 'startAutocomplete') {
moveAutocompleteToContainer();
}
};
const { commands } = editorInstance;
commands.on('afterExec', handleAfterExec);
// Cleanup function to remove event listener and clear cached references
return () => {
commands.off('afterExec', handleAfterExec);
// Clear cached references to avoid memory leaks
cachedAutocompletePopup = null;
cachedTargetContainer = null;
};
}, [ref]);
return (
<>
<Global
@@ -276,14 +341,24 @@ export function AsyncAceEditor(
border: 1px solid ${token.colorBorderSecondary};
box-shadow: ${token.boxShadow};
border-radius: ${token.borderRadius}px;
padding: ${token.paddingXS}px ${token.paddingXS}px;
}
& .tooltip-detail {
.ace_tooltip.ace_doc-tooltip {
display: flex !important;
}
&&& .tooltip-detail {
display: flex;
justify-content: center;
flex-direction: row;
gap: ${token.paddingXXS}px;
align-items: center;
background-color: ${token.colorBgContainer};
white-space: pre-wrap;
word-break: break-all;
min-width: ${token.sizeXXL * 5}px;
max-width: ${token.sizeXXL * 10}px;
font-size: ${token.fontSize}px;
& .tooltip-detail-head {
background-color: ${token.colorBgElevated};
@@ -306,7 +381,9 @@ export function AsyncAceEditor(
& .tooltip-detail-head,
& .tooltip-detail-body {
padding: ${token.padding}px ${token.paddingLG}px;
background-color: ${token.colorBgLayout};
padding: 0px ${token.paddingXXS}px;
border: 1px ${token.colorSplit} solid;
}
& .tooltip-detail-footer {
@@ -393,10 +470,7 @@ export const FullSQLEditor = AsyncAceEditor(
},
);
export const MarkdownEditor = AsyncAceEditor([
'mode/markdown',
'theme/textmate',
]);
export const MarkdownEditor = AsyncAceEditor(['mode/markdown', 'theme/github']);
export const TextAreaEditor = AsyncAceEditor([
'mode/markdown',

View File

@@ -48,10 +48,7 @@ export const CollapseLabelInModal: React.FC<CollapseLabelInModalProps> = ({
{title}{' '}
{validateCheckStatus !== undefined &&
(validateCheckStatus ? (
<Icons.CheckCircleOutlined
iconColor={theme.colorSuccess}
aria-label="check-circle"
/>
<Icons.CheckCircleOutlined iconColor={theme.colorSuccess} />
) : (
<span
css={css`

View File

@@ -1,66 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render, waitFor } from '@superset-ui/core/spec';
import { Button } from '../Button';
import { ConfirmStatusChange } from '.';
const mockedProps = {
title: 'please confirm',
description: 'are you sure?',
onConfirm: jest.fn(),
};
test('opens a confirm modal', () => {
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<>
<Button data-test="btn1" onClick={confirm} />
</>
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('btn1'));
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('calls the function on confirm', async () => {
const { getByTestId, getByRole } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<>
<Button data-test="btn1" onClick={() => confirm('foo')} />
</>
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('btn1'));
const confirmInput = getByTestId('delete-modal-input');
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
const confirmButton = getByRole('button', { name: 'Delete' });
fireEvent.click(confirmButton);
await waitFor(() => expect(mockedProps.onConfirm).toHaveBeenCalledTimes(1));
expect(mockedProps.onConfirm).toHaveBeenCalledWith('foo');
});

View File

@@ -0,0 +1,177 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render, waitFor } from '@superset-ui/core/spec';
import { Button } from '../Button';
import { ConfirmStatusChange } from '.';
import type { ConfirmStatusChangeProps } from './types';
const mockedProps: Omit<ConfirmStatusChangeProps, 'children'> = {
title: 'please confirm',
description: 'are you sure?',
onConfirm: jest.fn(),
};
test('renders children with showConfirm function', () => {
const childrenSpy = jest.fn().mockReturnValue(<div>test content</div>);
render(
<ConfirmStatusChange {...mockedProps}>{childrenSpy}</ConfirmStatusChange>,
);
expect(childrenSpy).toHaveBeenCalledWith(expect.any(Function));
});
test('opens modal when showConfirm is called', () => {
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => <Button data-test="trigger" onClick={confirm} />}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('stores and passes arguments to onConfirm callback', async () => {
const testArgs = ['arg1', { data: 'test' }, 42];
const { getByTestId, getByRole } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button data-test="trigger" onClick={() => confirm(...testArgs)} />
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
const confirmInput = getByTestId('delete-modal-input');
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
const confirmButton = getByRole('button', { name: 'Delete' });
fireEvent.click(confirmButton);
await waitFor(() => expect(mockedProps.onConfirm).toHaveBeenCalledTimes(1));
expect(mockedProps.onConfirm).toHaveBeenCalledWith(...testArgs);
});
test('calls preventDefault on event-like arguments', () => {
const mockEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
};
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button data-test="trigger" onClick={() => confirm(mockEvent)} />
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.stopPropagation).toHaveBeenCalled();
});
test('skips event handling on non-event arguments', () => {
const regularArg = { someData: 'value' };
const mockFunc = jest.fn();
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button
data-test="trigger"
onClick={() => confirm(regularArg, mockFunc)}
/>
)}
</ConfirmStatusChange>,
);
// Should not throw when processing non-event arguments
expect(() => {
fireEvent.click(getByTestId('trigger'));
}).not.toThrow();
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('ignores null and undefined arguments', () => {
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button
data-test="trigger"
onClick={() => confirm(null, undefined, 'valid')}
/>
)}
</ConfirmStatusChange>,
);
expect(() => {
fireEvent.click(getByTestId('trigger'));
}).not.toThrow();
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('handles partial event objects gracefully', () => {
const partialEvent1 = { preventDefault: jest.fn() }; // Only preventDefault
const partialEvent2 = { stopPropagation: jest.fn() }; // Only stopPropagation
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button
data-test="trigger"
onClick={() => confirm(partialEvent1, partialEvent2)}
/>
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
expect(partialEvent1.preventDefault).toHaveBeenCalled();
expect(partialEvent2.stopPropagation).toHaveBeenCalled();
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('closes modal when onHide is called', () => {
const { getByTestId, getByRole } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => <Button data-test="trigger" onClick={confirm} />}
</ConfirmStatusChange>,
);
// Open modal
fireEvent.click(getByTestId('trigger'));
const modal = getByTestId(`${mockedProps.title}-modal`);
expect(modal).toBeInTheDocument();
expect(modal).toBeVisible();
// Close modal
const cancelButton = getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
// Modal should be hidden (not visible)
expect(modal).not.toBeVisible();
});

View File

@@ -31,14 +31,11 @@ export function ConfirmStatusChange({
const [currentCallbackArgs, setCurrentCallbackArgs] = useState<any[]>([]);
const showConfirm = (...callbackArgs: any[]) => {
// check if any args are DOM events, if so, call persist
// check if any args are DOM events, if so, handle them
callbackArgs.forEach(arg => {
if (!arg) {
return;
}
if (typeof arg.persist === 'function') {
arg.persist();
}
if (typeof arg.preventDefault === 'function') {
arg.preventDefault();
}

View File

@@ -34,8 +34,10 @@ const StyledEditableTitle = styled.span<{
canEdit: boolean;
}>`
&.editable-title {
display: inline-block;
width: 100%;
display: inline;
&.editable-title--editing {
width: 100%;
}
input,
textarea {
@@ -52,7 +54,6 @@ const StyledEditableTitle = styled.span<{
input[type='text'],
textarea {
border: 1px solid ${({ theme }) => theme.colorSplit};
color: ${({ theme }) => theme.colorTextTertiary};
border-radius: ${({ theme }) => theme.sizeUnit}px;
font-size: ${({ theme }) => theme.fontSizeLG}px;

View File

@@ -60,7 +60,7 @@ const EmptyStateContainer = styled.div`
flex-direction: column;
width: 100%;
height: 100%;
color: ${theme.colorTextQuaternary};
color: ${theme.colorTextTertiary};
align-items: center;
justify-content: center;
padding: ${theme.sizeUnit * 4}px;
@@ -84,7 +84,7 @@ const EmptyStateContainer = styled.div`
const Title = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSizeLG : theme.fontSize}px;
color: ${theme.colorTextQuaternary};
color: ${theme.colorTextTertiary};
margin-top: ${size === 'large' ? theme.sizeUnit * 4 : theme.sizeUnit * 2}px;
font-weight: ${theme.fontWeightStrong};
`}
@@ -93,7 +93,7 @@ const Title = styled.p<{ size: EmptyStateSize }>`
const Description = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSize : theme.fontSizeSM}px;
color: ${theme.colorTextQuaternary};
color: ${theme.colorTextTertiary};
margin-top: ${theme.sizeUnit * 2}px;
`}
`;

View File

@@ -50,17 +50,7 @@ const IconButton: React.FC<IconButtonProps> = ({
};
const renderIcon = () => {
const iconContent = icon ? (
<img
src={icon as string}
alt={altText || buttonText}
css={css`
width: 100%;
object-fit: contain;
height: 100px;
`}
/>
) : (
const iconContent = (
<div
css={css`
display: flex;
@@ -69,12 +59,19 @@ const IconButton: React.FC<IconButtonProps> = ({
height: 100px;
`}
>
<Icons.DatabaseOutlined
css={css`
font-size: 48px;
`}
aria-label="default-icon"
/>
{icon ? (
<img
src={icon as string}
alt={altText || buttonText}
css={css`
width: 100%;
object-fit: contain;
height: 48px;
`}
/>
) : (
<Icons.DatabaseOutlined iconSize="xxl" aria-label="default-icon" />
)}
</div>
);

View File

@@ -27,6 +27,8 @@ export const IconTooltip = ({
placement = 'top',
style = {},
tooltip = null,
mouseEnterDelay = 0.3,
mouseLeaveDelay = 0.15,
}: IconTooltipProps) => {
const iconTooltip = (
<Button
@@ -47,8 +49,8 @@ export const IconTooltip = ({
id="tooltip"
title={tooltip}
placement={placement}
mouseEnterDelay={0.3}
mouseLeaveDelay={0.15}
mouseEnterDelay={mouseEnterDelay}
mouseLeaveDelay={mouseLeaveDelay}
>
{iconTooltip}
</Tooltip>

View File

@@ -37,4 +37,6 @@ export interface IconTooltipProps {
| 'rightBottom';
style?: object;
tooltip?: string | null;
mouseEnterDelay?: number;
mouseLeaveDelay?: number;
}

View File

@@ -146,6 +146,7 @@ import {
ExportOutlined,
CompressOutlined,
HistoryOutlined,
SlackOutlined,
} from '@ant-design/icons';
import { FC } from 'react';
import { IconType } from './types';
@@ -281,6 +282,7 @@ const AntdIcons = {
ExportOutlined,
CompressOutlined,
HistoryOutlined,
SlackOutlined,
} as const;
type AntdIconNames = keyof typeof AntdIcons;

View File

@@ -25,7 +25,8 @@ import { BaseIconComponent } from './BaseIcon';
const AsyncIcon = (props: IconType) => {
const [, setLoaded] = useState(false);
const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>>>();
const { fileName, ...restProps } = props;
const { fileName, customIcons, iconSize, iconColor, viewBox, ...restProps } =
props;
useEffect(() => {
let cancelled = false;
@@ -46,6 +47,11 @@ const AsyncIcon = (props: IconType) => {
return (
<BaseIconComponent
component={ImportedSVG.current || TransparentIcon}
fileName={fileName}
customIcons={customIcons}
iconSize={iconSize}
iconColor={iconColor}
viewBox={viewBox}
{...restProps}
/>
);

View File

@@ -22,7 +22,7 @@ import { AntdIconType, BaseIconProps, CustomIconType, IconType } from './types';
const genAriaLabel = (fileName: string) => {
const name = fileName.replace(/_/g, '-'); // Replace underscores with dashes
const words = name.split(/(?=[A-Z])/); // Split at uppercase letters
const words = name.split(/(?<=[a-z])(?=[A-Z])/); // Split at lowercase-to-uppercase transitions
if (words.length === 2) {
return words[0].toLowerCase();

View File

@@ -40,7 +40,14 @@ export const PublishedLabel: React.FC<PublishedLabelProps> = ({
const labelType = isPublished ? 'success' : 'primary';
return (
<Label type={labelType} icon={icon} onClick={onClick}>
<Label
type={labelType}
icon={icon}
onClick={onClick}
style={{
color: isPublished ? theme.colorSuccessText : theme.colorPrimaryText,
}}
>
{label}
</Label>
);

View File

@@ -34,8 +34,9 @@ const LoaderImg = styled.img`
}
&.inline-centered {
margin: 0 auto;
width: 30px;
display: block;
display: flex;
align-items: center;
justify-content: center;
}
&.floating {
padding: 0;

View File

@@ -1,37 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Ellipsis } from './Ellipsis';
test('Ellipsis - click when the button is enabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Ellipsis - click when the button is disabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -1,47 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Item } from './Item';
test('Item - click when the item is not active', async () => {
const click = jest.fn();
render(
<Item onClick={click}>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Item - click when the item is active', async () => {
const click = jest.fn();
render(
<Item onClick={click} active>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
expect(screen.getByTestId('test')).toBeInTheDocument();
});

View File

@@ -1,37 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Prev } from './Prev';
test('Prev - click when the button is enabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Prev - click when the button is disabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -1,38 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
export function Prev({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
«
</span>
</li>
);
}

View File

@@ -1,75 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, cleanup } from '@superset-ui/core/spec';
import Wrapper from './Wrapper';
// Add cleanup after each test
afterEach(async () => {
cleanup();
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
jest.mock('./Next', () => ({
Next: () => <div data-test="next" />,
}));
jest.mock('./Prev', () => ({
Prev: () => <div data-test="prev" />,
}));
jest.mock('./Item', () => ({
Item: () => <div data-test="item" />,
}));
jest.mock('./Ellipsis', () => ({
Ellipsis: () => <div data-test="ellipsis" />,
}));
test('Pagination rendering correctly', async () => {
render(
<Wrapper>
<li data-test="test" />
</Wrapper>,
);
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Next attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Prev attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Item attribute', async () => {
render(
<Wrapper.Item onClick={jest.fn()}>
<></>
</Wrapper.Item>,
);
expect(screen.getByTestId('item')).toBeInTheDocument();
});
test('Ellipsis attribute', async () => {
render(<Wrapper.Ellipsis onClick={jest.fn()} />);
expect(screen.getByTestId('ellipsis')).toBeInTheDocument();
});

View File

@@ -1,90 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { styled } from '@superset-ui/core';
import { Next } from './Next';
import { Prev } from './Prev';
import { Item } from './Item';
import { Ellipsis } from './Ellipsis';
interface PaginationProps {
children: JSX.Element | JSX.Element[];
}
const PaginationList = styled.ul`
${({ theme }) => `
display: inline-block;
padding: ${theme.sizeUnit * 3}px;
li {
display: inline;
margin: 0 4px;
> span {
padding: 8px 12px;
text-decoration: none;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
color: ${theme.colorText};
&:hover,
&:focus {
z-index: 2;
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
}
}
&.disabled {
span {
background-color: transparent;
cursor: default;
&:focus {
outline: none;
}
}
}
&.active {
span {
z-index: 3;
color: ${theme.colorBgLayout};
cursor: default;
background-color: ${theme.colorPrimary};
&:focus {
outline: none;
}
}
}
}
`}
`;
function Pagination({ children }: PaginationProps) {
return <PaginationList role="navigation">{children}</PaginationList>;
}
Pagination.Next = Next;
Pagination.Prev = Prev;
Pagination.Item = Item;
Pagination.Ellipsis = Ellipsis;
export default Pagination;

View File

@@ -1,47 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Pagination from '@superset-ui/core/components/Pagination/Wrapper';
import {
createUltimatePagination,
ITEM_TYPES,
} from 'react-ultimate-pagination';
const ListViewPagination = createUltimatePagination({
WrapperComponent: Pagination,
itemTypeToComponent: {
[ITEM_TYPES.PAGE]: ({ value, isActive, onClick }) => (
<Pagination.Item active={isActive} onClick={onClick}>
{value}
</Pagination.Item>
),
[ITEM_TYPES.ELLIPSIS]: ({ isActive, onClick }) => (
<Pagination.Ellipsis disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.PREVIOUS_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Prev disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.NEXT_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Next disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.FIRST_PAGE_LINK]: () => null,
[ITEM_TYPES.LAST_PAGE_LINK]: () => null,
},
});
export default ListViewPagination;

View File

@@ -779,6 +779,38 @@ test('Renders only an overflow tag if dropdown is open in oneLine mode', async (
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
// Test for checking the issue described in: https://github.com/apache/superset/issues/35132
test('Maintains stable maxTagCount to prevent click target disappearing in oneLine mode', async () => {
render(
<Select
{...defaultProps}
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
mode="multiple"
oneLine
/>,
);
const withinSelector = within(getElementByClassName('.ant-select-selector'));
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
await userEvent.click(getSelect());
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
await waitFor(() => {
expect(
withinSelector.queryByText(OPTIONS[0].label),
).not.toBeInTheDocument();
expect(withinSelector.getByText('+ 3 ...')).toBeVisible();
});
// Close dropdown
await type('{esc}');
expect(await withinSelector.findByText(OPTIONS[0].label)).toBeVisible();
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
test('does not render "Select all" when there are 0 or 1 options', async () => {
const { rerender } = render(
<Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,

View File

@@ -24,6 +24,7 @@ import {
useMemo,
useState,
useCallback,
useRef,
ClipboardEvent,
Ref,
ReactElement,
@@ -147,6 +148,32 @@ const Select = forwardRef(
}
}, [isDropdownVisible, oneLine]);
// Prevent maxTagCount change during click events to avoid click target disappearing
const [stableMaxTagCount, setStableMaxTagCount] = useState(maxTagCount);
const isOpeningRef = useRef(false);
useEffect(() => {
if (oneLine) {
if (isDropdownVisible && !isOpeningRef.current) {
// Mark that we're in the opening process
isOpeningRef.current = true;
// Use requestAnimationFrame to ensure DOM has settled after the click
requestAnimationFrame(() => {
setStableMaxTagCount(0);
isOpeningRef.current = false;
});
return;
}
if (!isDropdownVisible) {
// When closing, immediately show the first tag
setStableMaxTagCount(1);
isOpeningRef.current = false;
}
return;
}
setStableMaxTagCount(maxTagCount);
}, [maxTagCount, isDropdownVisible, oneLine]);
const mappedMode = isSingleMode ? undefined : 'multiple';
const sortSelectedFirst = useCallback(
@@ -607,16 +634,16 @@ const Select = forwardRef(
const omittedCount = useMemo(() => {
const num_selected = ensureIsArray(selectValue).length;
const num_shown = maxTagCount as number;
const num_shown = stableMaxTagCount as number;
return num_selected - num_shown - (selectAllMode ? 1 : 0);
}, [maxTagCount, selectAllMode, selectValue]);
}, [stableMaxTagCount, selectAllMode, selectValue]);
const customMaxTagPlaceholder = () =>
`+ ${omittedCount > 0 ? omittedCount : 1} ...`;
// We can't remove the + tag so when Select All
// is the only item omitted, we subtract one from maxTagCount
let actualMaxTagCount = maxTagCount;
let actualMaxTagCount = stableMaxTagCount;
if (
actualMaxTagCount !== 'responsive' &&
omittedCount === 0 &&

View File

@@ -28,6 +28,7 @@ export const StyledHeader = styled.span<{ headerPosition: string }>`
text-overflow: ellipsis;
white-space: nowrap;
margin-right: ${headerPosition === 'left' ? theme.sizeUnit * 2 : 0}px;
font-size: ${theme.fontSizeSM}px;
`}
`;

View File

@@ -167,6 +167,33 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
.ant-table-body {
overflow: auto;
height: ${height ? `${height}px` : undefined};
/* Chrome/Safari/Edge webkit scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: ${theme.colorFillQuaternary};
}
&::-webkit-scrollbar-thumb {
background: ${theme.colorFillSecondary};
border-radius: ${theme.borderRadiusSM}px;
&:hover {
background: ${theme.colorFillTertiary};
}
}
&::-webkit-scrollbar-corner {
background: ${theme.colorFillQuaternary};
}
/* Firefox scrollbar styling */
scrollbar-width: thin;
scrollbar-color: ${theme.colorFillSecondary} ${theme.colorFillQuaternary};
}
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
@@ -180,6 +207,10 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
text-overflow: ellipsis;
}
td.ant-table-cell.no-ellipsis {
text-overflow: unset;
}
.ant-table-tbody > tr > td {
white-space: nowrap;
overflow: hidden;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '@superset-ui/core/spec';
import { render, screen, fireEvent } from '@superset-ui/core/spec';
import { renderHook } from '@testing-library/react-hooks';
import { TableInstance, useTable } from 'react-table';
import TableCollection from '.';
@@ -36,19 +36,28 @@ beforeEach(() => {
accessor: 'col2',
id: 'col2',
},
{
Header: 'Nested Field',
accessor: 'parent.child',
id: 'parent.child',
dataIndex: ['parent', 'child'],
},
];
const data = [
{
col1: 'Line 01 - Col 01',
col2: 'Line 01 - Col 02',
parent: { child: 'Nested Value 1' },
},
{
col1: 'Line 02 - Col 01',
col2: 'Line 02 - Col 02',
parent: { child: 'Nested Value 2' },
},
{
col1: 'Line 03 - Col 01',
col2: 'Line 03 - Col 02',
parent: { child: 'Nested Value 3' },
},
];
// @ts-ignore
@@ -100,3 +109,134 @@ test('Should the loading-indicator be visible during loading', () => {
expect(screen.getByTestId('loading-indicator')).toBeVisible();
});
test('Pagination controls should be rendered when pageSize is provided', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...paginationProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination should call onPageChange when page is changed', async () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Simulate pagination change
await screen.findByTitle('Next Page');
// Verify onPageChange would be called with correct arguments
// The actual AntD pagination will handle the click internally
expect(onPageChange).toBeDefined();
// Verify that re-rendering with new pageIndex works
rerender(<TableCollection {...paginationProps} pageIndex={1} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination callback should be stable across re-renders', () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Re-render with same props
rerender(<TableCollection {...paginationProps} />);
// onPageChange should not have been called during re-render
expect(onPageChange).not.toHaveBeenCalled();
});
test('Should display correct page info when showRowCount is true', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: true,
};
render(<TableCollection {...paginationProps} />);
// AntD pagination shows page info
expect(screen.getByText('1-2 of 3')).toBeInTheDocument();
});
test('Should not display page info when showRowCount is false', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: false,
};
render(<TableCollection {...paginationProps} />);
// Page info should not be shown
expect(screen.queryByText('1-2 of 3')).not.toBeInTheDocument();
});
test('Bulk selection should work with pagination', () => {
const toggleRowSelected = jest.fn();
const toggleAllRowsSelected = jest.fn();
const selectionProps = {
...defaultProps,
bulkSelectEnabled: true,
selectedFlatRows: [],
toggleRowSelected,
toggleAllRowsSelected,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...selectionProps} />);
// Check that selection checkboxes are rendered
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});
test('should call setSortBy when clicking sortable column header', () => {
const setSortBy = jest.fn();
const sortingProps = {
...defaultProps,
setSortBy,
};
render(<TableCollection {...sortingProps} />);
// Target the nested field column (the column that needs the array-to-dot conversion)
const nestedFieldHeader = screen.getByText('Nested Field');
expect(nestedFieldHeader).toBeInTheDocument();
// Click on the nested field column header to trigger sorting
fireEvent.click(nestedFieldHeader);
// Verify setSortBy was called with the correct arguments and dot notation conversion
expect(setSortBy).toHaveBeenCalledWith([
{
id: 'parent.child',
desc: expect.any(Boolean),
},
]);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { HTMLAttributes, memo, useMemo } from 'react';
import { HTMLAttributes, memo, useMemo, useCallback } from 'react';
import {
ColumnInstance,
HeaderGroup,
@@ -47,15 +47,25 @@ interface TableCollectionProps<T extends object> {
toggleAllRowsSelected?: (value?: boolean) => void;
sticky?: boolean;
size?: TableSize;
pageIndex?: number;
pageSize?: number;
totalCount?: number;
onPageChange?: (page: number, pageSize: number) => void;
isPaginationSticky?: boolean;
showRowCount?: boolean;
}
const StyledTable = styled(Table)`
${({ theme }) => `
const StyledTable = styled(Table)<{
isPaginationSticky?: boolean;
showRowCount?: boolean;
}>`
${({ theme, isPaginationSticky, showRowCount }) => `
th.ant-column-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
opacity: 0;
font-size: ${theme.fontSizeXL}px;
@@ -72,16 +82,25 @@ const StyledTable = styled(Table)`
}
}
}
.ant-table-column-title {
line-height: initial;
}
.ant-table-row:hover {
.actions {
opacity: 1;
transition: opacity ease-in ${theme.motionDurationMid};
}
}
.ant-table-row.table-row-highlighted > td.ant-table-cell,
.ant-table-row.table-row-highlighted > td.ant-table-cell.ant-table-cell-row-hover {
background-color: ${theme.colorPrimaryBg};
}
.ant-table-cell {
max-width: 320px;
font-feature-settings: 'tnum' 1;
text-overflow: ellipsis;
overflow: hidden;
@@ -90,9 +109,50 @@ const StyledTable = styled(Table)`
padding-left: ${theme.sizeUnit * 4}px;
white-space: nowrap;
}
.ant-table-tbody > tr > td {
height: ${theme.sizeUnit * 12}px;
}
.ant-table-tbody > tr > td.ant-table-cell:has(.ant-avatar-group) {
padding-top: ${theme.sizeUnit}px;
padding-bottom: ${theme.sizeUnit}px;
}
.ant-table-placeholder .ant-table-cell {
border-bottom: 0;
}
&.ant-table-wrapper .ant-table-pagination.ant-pagination {
display: flex;
justify-content: center;
margin: ${showRowCount ? theme.sizeUnit * 4 : 0}px 0 ${showRowCount ? theme.sizeUnit * 14 : 0}px 0;
position: relative;
.ant-pagination-total-text {
color: ${theme.colorTextBase};
margin-inline-end: 0;
position: absolute;
top: ${theme.sizeUnit * 12}px;
}
${
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
z-index: 1;
background-color: ${theme.colorBgElevated};
padding: ${theme.sizeUnit * 2}px 0;
`
}
}
// Hotfix - antd doesn't apply background color to overflowing cells
& table {
background-color: ${theme.colorBgContainer};
}
`}
`;
@@ -100,6 +160,7 @@ function TableCollection<T extends object>({
columns,
rows,
loading,
highlightRowId,
setSortBy,
headerGroups,
columnsForWrapText,
@@ -110,13 +171,22 @@ function TableCollection<T extends object>({
prepareRow,
sticky,
size = TableSize.Middle,
pageIndex = 0,
pageSize = 25,
totalCount = 0,
onPageChange,
isPaginationSticky = false,
showRowCount = true,
}: TableCollectionProps<T>) {
const mappedColumns = mapColumns<T>(
columns,
headerGroups,
columnsForWrapText,
const mappedColumns = useMemo(
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
[columns, headerGroups, columnsForWrapText],
);
const mappedRows = useMemo(
() => mapRows(rows, prepareRow),
[rows, prepareRow],
);
const mappedRows = mapRows(rows, prepareRow);
const selectedRowKeys = useMemo(
() => selectedFlatRows?.map(row => row.id) || [],
@@ -141,6 +211,79 @@ function TableCollection<T extends object>({
toggleRowSelected,
toggleAllRowsSelected,
]);
const handlePaginationChange = useCallback(
(page: number, size: number) => {
const validPage = Math.max(0, (page || 1) - 1);
const validSize = size || pageSize;
onPageChange?.(validPage, validSize);
},
[pageSize, onPageChange],
);
const showTotalFunc = useCallback(
(total: number, range: [number, number]) =>
`${range[0]}-${range[1]} of ${total}`,
[],
);
const handleTableChange = useCallback(
(_pagination: any, _filters: any, sorter: SorterResult) => {
if (sorter && sorter.field) {
// Convert array field back to dot notation for nested fields
const fieldId = Array.isArray(sorter.field)
? sorter.field.join('.')
: sorter.field;
setSortBy?.([
{
id: fieldId,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}
},
[setSortBy],
);
const paginationConfig = useMemo(() => {
if (totalCount === 0) return false;
const config: any = {
pageSize,
size: 'default' as const,
showSizeChanger: false,
showQuickJumper: false,
align: 'center' as const,
showTotal: showRowCount ? showTotalFunc : undefined,
};
if (onPageChange) {
config.current = pageIndex + 1;
config.total = totalCount;
config.onChange = handlePaginationChange;
} else {
if (pageIndex > 0) config.defaultCurrent = pageIndex + 1;
config.total = totalCount;
}
return config;
}, [
pageSize,
totalCount,
showRowCount,
showTotalFunc,
pageIndex,
handlePaginationChange,
onPageChange,
]);
const getRowClassName = useCallback(
(record: Record<string, unknown>) =>
record?.id === highlightRowId ? 'table-row-highlighted' : '',
[highlightRowId],
);
return (
<StyledTable
loading={loading}
@@ -149,12 +292,16 @@ function TableCollection<T extends object>({
data={mappedRows}
size={size}
data-test="listview-table"
pagination={false}
tableLayout="fixed"
pagination={paginationConfig}
scroll={{ x: 'max-content' }}
tableLayout="auto"
rowKey="rowId"
rowSelection={rowSelection}
locale={{ emptyText: null }}
sortDirections={['ascend', 'descend', 'ascend']}
isPaginationSticky={isPaginationSticky}
showRowCount={showRowCount}
rowClassName={getRowClassName}
components={{
header: {
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
@@ -170,14 +317,7 @@ function TableCollection<T extends object>({
),
},
}}
onChange={(_pagination, _filters, sorter: SorterResult) => {
setSortBy?.([
{
id: sorter.field,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}}
onChange={handleTableChange}
/>
);
}

View File

@@ -56,6 +56,7 @@ type EnhancedColumnInstance<T extends object = any> = RTColumnInstance<T> &
Partial<UseResizeColumnsColumnProps<T>> & {
hidden?: boolean;
size?: keyof typeof COLUMN_SIZE_MAP;
className?: string;
};
type EnhancedHeaderGroup<T extends object = any> = RTHeaderGroup<T> & {
@@ -94,7 +95,7 @@ export function mapColumns<T extends object>(
dataIndex: column.id?.includes('.') ? column.id.split('.') : column.id,
hidden: column.hidden,
key: column.id,
width: column.size ? COLUMN_SIZE_MAP[column.size] : COLUMN_SIZE_MAP.md,
width: column.size ? COLUMN_SIZE_MAP[column.size] : undefined,
ellipsis: !columnsForWrapText?.includes(column.id),
defaultSortOrder: (isSorted
? isSortedDesc
@@ -122,6 +123,7 @@ export function mapColumns<T extends object>(
}
return val;
},
className: column.className,
};
});
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
import { TableView, TableViewProps } from '.';
const mockedProps: TableViewProps = {
@@ -30,6 +30,7 @@ const mockedProps: TableViewProps = {
{
accessor: 'age',
Header: 'Age',
sortable: true,
id: 'age',
},
{
@@ -78,10 +79,10 @@ test('should render the cells', () => {
test('should render the pagination', () => {
render(<TableView {...mockedProps} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(4);
expect(screen.getByText('«')).toBeInTheDocument();
expect(screen.getByText('»')).toBeInTheDocument();
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getByTitle('Previous Page')).toBeInTheDocument();
expect(screen.getByTitle('Next Page')).toBeInTheDocument();
});
test('should show the row count by default', () => {
@@ -104,45 +105,63 @@ test('should NOT render the pagination when disabled', () => {
withPagination: false,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should NOT render the pagination when fewer rows than page size', () => {
test('should render the pagination even when fewer rows than page size', () => {
const withoutPaginationProps = {
...mockedProps,
pageSize: 3,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('should change page when « and » buttons are clicked', async () => {
test('should change page when pagination is clicked', async () => {
render(<TableView {...mockedProps} />);
const nextBtn = screen.getByText('»');
const prevBtn = screen.getByText('«');
await userEvent.click(nextBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
await userEvent.click(prevBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
});
const page1 = screen.getByRole('listitem', { name: '1' });
await userEvent.click(page1);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
});
});
test('should sort by age', async () => {
render(<TableView {...mockedProps} />);
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
});
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
});
});
test('should sort by initialSortBy DESC', () => {
@@ -208,3 +227,146 @@ test('should render the right wrap content text by columnsForWrapText', () => {
'ant-table-cell-ellipsis',
);
});
test('should handle server-side pagination', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
render(<TableView {...serverPaginationProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 1,
});
});
});
test('should handle server-side sorting', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
};
render(<TableView {...serverPaginationProps} />);
// Click on sortable column
await userEvent.click(screen.getAllByTestId('sort-header')[0]);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 0,
sortBy: [{ id: 'id', desc: false }],
});
});
});
test('pagination callbacks should be stable across re-renders', () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
const { rerender } = render(<TableView {...serverPaginationProps} />);
// Re-render with same props
rerender(<TableView {...serverPaginationProps} />);
// onServerPagination should not have been called during re-render
expect(onServerPagination).not.toHaveBeenCalled();
});
test('should scroll to top when scrollTopOnPagination is true', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: true,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
scrollToSpy.mockRestore();
});
test('should NOT scroll to top when scrollTopOnPagination is false', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: false,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getByText('321')).toBeInTheDocument();
});
expect(scrollToSpy).not.toHaveBeenCalled();
scrollToSpy.mockRestore();
});
test('should handle totalCount of 0 correctly', () => {
const emptyProps = {
...mockedProps,
data: [],
totalCount: 0,
};
render(<TableView {...emptyProps} />);
// Pagination should not be shown when totalCount is 0
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should handle large datasets with pagination', () => {
const largeDataset = Array.from({ length: 100 }, (_, i) => ({
id: i,
age: 20 + i,
name: `Person ${i}`,
}));
const largeDataProps = {
...mockedProps,
data: largeDataset,
pageSize: 10,
};
render(<TableView {...largeDataProps} />);
// Should show only first page (10 items)
expect(screen.getAllByTestId('table-row')).toHaveLength(10);
// Should show pagination with correct page count
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
});

View File

@@ -16,16 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useEffect, useRef } from 'react';
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import { isEqual } from 'lodash';
import { styled, t } from '@superset-ui/core';
import { styled } from '@superset-ui/core';
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
import { Empty } from '@superset-ui/core/components';
import Pagination from '@superset-ui/core/components/Pagination';
import TableCollection from '@superset-ui/core/components/TableCollection';
import { TableSize } from '@superset-ui/core/components/Table';
import { SortByType, ServerPagination } from './types';
const NOOP_SERVER_PAGINATION = () => {};
const DEFAULT_PAGE_SIZE = 10;
export enum EmptyWrapperType {
@@ -96,29 +97,6 @@ const TableViewStyles = styled.div<{
}
`;
const PaginationStyles = styled.div<{
isPaginationSticky?: boolean;
}>`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.colorBgElevated};
${({ isPaginationSticky }) =>
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
`};
.row-count-container {
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
color: ${({ theme }) => theme.colorText};
}
`;
const RawTableView = ({
columns,
data,
@@ -133,16 +111,21 @@ const RawTableView = ({
showRowCount = true,
serverPagination = false,
columnsForWrapText,
onServerPagination = () => {},
scrollTopOnPagination = false,
onServerPagination = NOOP_SERVER_PAGINATION,
scrollTopOnPagination = true,
size = TableSize.Middle,
...props
}: TableViewProps) => {
const initialState = {
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
};
const tableRef = useRef<HTMLTableElement>(null);
const initialState = useMemo(
() => ({
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
}),
[initialPageSize, initialPageIndex, initialSortBy],
);
const {
getTableProps,
@@ -151,10 +134,9 @@ const RawTableView = ({
page,
rows,
prepareRow,
pageCount,
gotoPage,
setSortBy,
state: { pageIndex, pageSize, sortBy },
state: { pageIndex, sortBy },
} = useTable(
{
columns,
@@ -162,36 +144,94 @@ const RawTableView = ({
initialState,
manualPagination: serverPagination,
manualSortBy: serverPagination,
pageCount: Math.ceil(totalCount / initialState.pageSize),
pageCount: serverPagination
? Math.ceil(totalCount / initialState.pageSize)
: undefined,
autoResetSortBy: false,
},
useFilters,
useSortBy,
usePagination,
...(withPagination ? [usePagination] : []),
);
const content = withPagination ? page : rows;
let EmptyWrapperComponent;
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
EmptyWrapperComponent = ({ children }: any) => <>{children}</>;
break;
case EmptyWrapperType.Default:
default:
EmptyWrapperComponent = ({ children }: any) => (
<EmptyWrapper>{children}</EmptyWrapper>
);
}
const isEmpty = !loading && content.length === 0;
const hasPagination = pageCount > 1 && withPagination;
const tableRef = useRef<HTMLTableElement>(null);
const handleGotoPage = (p: number) => {
if (scrollTopOnPagination) {
tableRef?.current?.scroll(0, 0);
const EmptyWrapperComponent = useMemo(() => {
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
return ({ children }: any) => <>{children}</>;
case EmptyWrapperType.Default:
default:
return ({ children }: any) => <EmptyWrapper>{children}</EmptyWrapper>;
}
gotoPage(p);
};
}, [emptyWrapperType]);
const content = useMemo(
() => (withPagination ? page : rows),
[withPagination, page, rows],
);
const isEmpty = useMemo(
() => !loading && content.length === 0,
[loading, content.length],
);
const handleScrollToTop = useCallback(() => {
if (scrollTopOnPagination) {
if (tableRef?.current) {
if (typeof tableRef.current.scrollTo === 'function') {
tableRef.current.scrollTo({ top: 0, behavior: 'smooth' });
} else if (typeof tableRef.current.scroll === 'function') {
tableRef.current.scroll(0, 0);
}
}
if (typeof window !== 'undefined' && window.scrollTo)
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [scrollTopOnPagination]);
const handlePageChange = useCallback(
(p: number) => {
if (scrollTopOnPagination) handleScrollToTop();
gotoPage(p);
},
[scrollTopOnPagination, handleScrollToTop, gotoPage],
);
const paginationProps = useMemo(() => {
if (!withPagination) {
return {
pageIndex: 0,
pageSize: data.length,
totalCount: 0,
onPageChange: undefined,
};
}
if (serverPagination) {
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount,
onPageChange: handlePageChange,
};
}
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount: data.length,
onPageChange: handlePageChange,
};
}, [
withPagination,
serverPagination,
pageIndex,
initialPageSize,
totalCount,
data.length,
handlePageChange,
]);
useEffect(() => {
if (serverPagination && pageIndex !== initialState.pageIndex) {
@@ -199,7 +239,7 @@ const RawTableView = ({
pageIndex,
});
}
}, [pageIndex]);
}, [initialState.pageIndex, onServerPagination, pageIndex, serverPagination]);
useEffect(() => {
if (serverPagination && !isEqual(sortBy, initialState.sortBy)) {
@@ -208,61 +248,38 @@ const RawTableView = ({
sortBy,
});
}
}, [sortBy]);
}, [initialState.sortBy, onServerPagination, serverPagination, sortBy]);
return (
<>
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</EmptyWrapperComponent>
)}
</TableViewStyles>
{hasPagination && (
<PaginationStyles
className="pagination-container"
isPaginationSticky={props.isPaginationSticky}
>
<Pagination
totalPages={pageCount || 0}
currentPage={pageCount ? pageIndex + 1 : 0}
onChange={(p: number) => handleGotoPage(p - 1)}
hideFirstAndLastPageLinks
/>
{showRowCount && (
<div className="row-count-container">
{!loading &&
t(
'%s-%s of %s',
pageSize * pageIndex + (page.length && 1),
pageSize * pageIndex + page.length,
totalCount,
)}
</div>
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
isPaginationSticky={props.isPaginationSticky}
showRowCount={showRowCount}
{...paginationProps}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</PaginationStyles>
</EmptyWrapperComponent>
)}
</>
</TableViewStyles>
);
};

View File

@@ -0,0 +1,377 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render } from '@superset-ui/core/spec';
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
const defaultItems = [
{
key: '1',
label: 'Tab 1',
children: <div data-testid="tab1-content">Tab 1 content</div>,
},
{
key: '2',
label: 'Tab 2',
children: <div data-testid="tab2-content">Tab 2 content</div>,
},
{
key: '3',
label: 'Tab 3',
children: <div data-testid="tab3-content">Tab 3 content</div>,
},
];
describe('Tabs', () => {
describe('Basic Tabs', () => {
it('should render tabs with default props', () => {
const { getByText, container } = render(<Tabs items={defaultItems} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
expect(getByText('Tab 3')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active',
);
expect(activeTabContent).toBeDefined();
expect(
activeTabContent?.querySelector('[data-testid="tab1-content"]'),
).toBeDefined();
});
it('should render tabs component structure', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
const tabsNav = container.querySelector('.ant-tabs-nav');
const tabsContent = container.querySelector('.ant-tabs-content-holder');
expect(tabsElement).toBeDefined();
expect(tabsNav).toBeDefined();
expect(tabsContent).toBeDefined();
});
it('should apply default tabBarStyle with padding', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
// Check that tabBarStyle is applied (default padding is added)
expect(tabsNav?.style?.paddingLeft).toBeDefined();
});
it('should merge custom tabBarStyle with defaults', () => {
const customStyle = { paddingRight: '20px', backgroundColor: 'red' };
const { container } = render(
<Tabs items={defaultItems} tabBarStyle={customStyle} />,
);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
expect(tabsNav?.style?.paddingLeft).toBeDefined();
expect(tabsNav?.style?.paddingRight).toBe('20px');
expect(tabsNav?.style?.backgroundColor).toBe('red');
});
it('should handle allowOverflow prop', () => {
const { container: allowContainer } = render(
<Tabs items={defaultItems} allowOverflow />,
);
const { container: disallowContainer } = render(
<Tabs items={defaultItems} allowOverflow={false} />,
);
expect(allowContainer.querySelector('.ant-tabs')).toBeDefined();
expect(disallowContainer.querySelector('.ant-tabs')).toBeDefined();
});
it('should disable animation by default', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).not.toContain('ant-tabs-animated');
});
it('should handle tab change events', () => {
const onChangeMock = jest.fn();
const { getByText } = render(
<Tabs items={defaultItems} onChange={onChangeMock} />,
);
fireEvent.click(getByText('Tab 2'));
expect(onChangeMock).toHaveBeenCalledWith('2');
});
it('should pass through additional props to Antd Tabs', () => {
const onTabClickMock = jest.fn();
const { getByText } = render(
<Tabs
items={defaultItems}
onTabClick={onTabClickMock}
size="large"
centered
/>,
);
fireEvent.click(getByText('Tab 2'));
expect(onTabClickMock).toHaveBeenCalled();
});
});
describe('EditableTabs', () => {
it('should render with editable features', () => {
const { container } = render(<EditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should handle onEdit callback for add/remove actions', () => {
const onEditMock = jest.fn();
const itemsWithRemove = defaultItems.map(item => ({
...item,
closable: true,
}));
const { container } = render(
<EditableTabs items={itemsWithRemove} onEdit={onEditMock} />,
);
const removeButton = container.querySelector('.ant-tabs-tab-remove');
expect(removeButton).toBeDefined();
fireEvent.click(removeButton!);
expect(onEditMock).toHaveBeenCalledWith(expect.any(String), 'remove');
});
it('should have default props set correctly', () => {
expect(EditableTabs.defaultProps?.type).toBe('editable-card');
expect(EditableTabs.defaultProps?.animated).toEqual({
inkBar: true,
tabPane: false,
});
});
});
describe('LineEditableTabs', () => {
it('should render as line-style editable tabs', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should render with line-specific styling', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const inkBar = container.querySelector('.ant-tabs-ink-bar');
expect(inkBar).toBeDefined();
});
});
describe('TabPane Legacy Support', () => {
it('should support TabPane component access', () => {
expect(Tabs.TabPane).toBeDefined();
expect(EditableTabs.TabPane).toBeDefined();
expect(LineEditableTabs.TabPane).toBeDefined();
});
it('should render using legacy TabPane syntax', () => {
const { getByText, container } = render(
<Tabs>
<Tabs.TabPane tab="Legacy Tab 1" key="1">
<div data-testid="legacy-content-1">Legacy content 1</div>
</Tabs.TabPane>
<Tabs.TabPane tab="Legacy Tab 2" key="2">
<div data-testid="legacy-content-2">Legacy content 2</div>
</Tabs.TabPane>
</Tabs>,
);
expect(getByText('Legacy Tab 1')).toBeInTheDocument();
expect(getByText('Legacy Tab 2')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active [data-testid="legacy-content-1"]',
);
expect(activeTabContent).toBeDefined();
expect(activeTabContent?.textContent).toBe('Legacy content 1');
});
});
describe('Edge Cases', () => {
it('should handle empty items array', () => {
const { container } = render(<Tabs items={[]} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle undefined items', () => {
const { container } = render(<Tabs />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle tabs with no content', () => {
const itemsWithoutContent = [
{ key: '1', label: 'Tab 1' },
{ key: '2', label: 'Tab 2' },
];
const { getByText } = render(<Tabs items={itemsWithoutContent} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
});
it('should handle allowOverflow default value', () => {
const { container } = render(<Tabs items={defaultItems} />);
expect(container.querySelector('.ant-tabs')).toBeDefined();
});
});
describe('Accessibility', () => {
it('should render with proper ARIA roles', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tablist = container.querySelector('[role="tablist"]');
const tabs = container.querySelectorAll('[role="tab"]');
expect(tablist).toBeDefined();
expect(tabs.length).toBe(3);
});
it('should support keyboard navigation', () => {
const { container, getByText } = render(<Tabs items={defaultItems} />);
const firstTab = container.querySelector('[role="tab"]');
const secondTab = getByText('Tab 2');
if (firstTab) {
fireEvent.keyDown(firstTab, { key: 'ArrowRight', code: 'ArrowRight' });
}
fireEvent.click(secondTab);
expect(secondTab).toBeInTheDocument();
});
});
describe('Styling Integration', () => {
it('should accept and apply custom CSS classes', () => {
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} className="custom-tabs-class" />,
);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('custom-tabs-class');
});
it('should accept and apply custom styles', () => {
const customStyle = { minHeight: '200px' };
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} style={customStyle} />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
expect(tabsElement?.style?.minHeight).toBe('200px');
});
});
});
test('fullHeight prop renders component hierarchy correctly', () => {
const { container } = render(<Tabs items={defaultItems} fullHeight />);
const tabsElement = container.querySelector('.ant-tabs');
const contentHolder = container.querySelector('.ant-tabs-content-holder');
const content = container.querySelector('.ant-tabs-content');
const tabPane = container.querySelector('.ant-tabs-tabpane');
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
expect(content).toBeInTheDocument();
expect(tabPane).toBeInTheDocument();
expect(tabsElement?.contains(contentHolder as Node)).toBe(true);
expect(contentHolder?.contains(content as Node)).toBe(true);
expect(content?.contains(tabPane as Node)).toBe(true);
});
test('fullHeight prop maintains structure when content updates', () => {
const { container, rerender } = render(
<Tabs items={defaultItems} fullHeight />,
);
const initialTabsElement = container.querySelector('.ant-tabs');
const newItems = [
...defaultItems,
{
key: '4',
label: 'Tab 4',
children: <div data-testid="tab4-content">New tab content</div>,
},
];
rerender(<Tabs items={newItems} fullHeight />);
const updatedTabsElement = container.querySelector('.ant-tabs');
const updatedContentHolder = container.querySelector(
'.ant-tabs-content-holder',
);
expect(updatedTabsElement).toBeInTheDocument();
expect(updatedContentHolder).toBeInTheDocument();
expect(initialTabsElement).toBe(updatedTabsElement);
});
test('fullHeight prop works with allowOverflow to handle tall content', () => {
const { container } = render(
<Tabs items={defaultItems} fullHeight allowOverflow />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
const contentHolder = container.querySelector(
'.ant-tabs-content-holder',
) as HTMLElement;
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
// Verify overflow handling is not restricted
const holderStyles = window.getComputedStyle(contentHolder);
expect(holderStyles.overflow).not.toBe('hidden');
});
test('fullHeight prop handles empty items array', () => {
const { container } = render(<Tabs items={[]} fullHeight />);
expect(container.querySelector('.ant-tabs')).toBeInTheDocument();
});

View File

@@ -21,27 +21,45 @@ import { css, styled, useTheme } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-imports
import { Tabs as AntdTabs, TabsProps as AntdTabsProps } from 'antd';
import { Icons } from '@superset-ui/core/components/Icons';
import type { SerializedStyles } from '@emotion/react';
export interface TabsProps extends AntdTabsProps {
allowOverflow?: boolean;
fullHeight?: boolean;
contentStyle?: SerializedStyles;
}
const StyledTabs = ({
animated = false,
allowOverflow = true,
fullHeight = false,
tabBarStyle,
contentStyle,
...props
}: TabsProps) => {
const theme = useTheme();
const defaultTabBarStyle = { paddingLeft: theme.sizeUnit * 4 };
const mergedStyle = { ...defaultTabBarStyle, ...tabBarStyle };
return (
<AntdTabs
animated={animated}
{...props}
tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }}
tabBarStyle={mergedStyle}
css={theme => css`
overflow: ${allowOverflow ? 'visible' : 'hidden'};
${fullHeight && 'height: 100%;'}
.ant-tabs-content-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${fullHeight && 'height: 100%;'}
}
.ant-tabs-content {
${fullHeight && 'height: 100%;'}
}
.ant-tabs-tabpane {
${fullHeight && 'height: 100%;'}
${contentStyle}
}
.ant-tabs-tab {
flex: 1 1 auto;
@@ -81,9 +99,10 @@ const Tabs = Object.assign(StyledTabs, {
});
const StyledEditableTabs = styled(StyledTabs)`
${({ theme }) => `
${({ theme, contentStyle }) => `
.ant-tabs-content-holder {
background: ${theme.colorBgContainer};
${contentStyle}
}
& > .ant-tabs-nav {

View File

@@ -218,3 +218,29 @@ test('handles missing theme gracefully', () => {
// Should still render without crashing
expect(screen.getByTestId('ag-grid-react')).toBeInTheDocument();
});
test('merges theme overrides with default theme parameters', () => {
const themeOverrides = {
fontSize: 16,
headerBackgroundColor: '#custom-color',
};
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
themeOverrides={themeOverrides}
/>,
);
const agGrid = screen.getByTestId('ag-grid-react');
const theme = JSON.parse(agGrid.getAttribute('data-theme') || '{}');
// Custom overrides should be applied
expect(theme.fontSize).toBe(16);
expect(theme.headerBackgroundColor).toBe('#custom-color');
// Default theme parameters should still be present
expect(theme.foregroundColor).toBeDefined();
expect(theme.borderColor).toBeDefined();
});

View File

@@ -186,5 +186,5 @@ export {
// Re-export AgGridReact for ref types
export { AgGridReact } from 'ag-grid-react';
// Export the setup function for AG-Grid modules
export { setupAGGridModules } from './setupAGGridModules';
// Export the setup function and default modules for AG-Grid
export { setupAGGridModules, defaultModules } from './setupAGGridModules';

View File

@@ -0,0 +1,116 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ModuleRegistry, type Module } from 'ag-grid-community';
import { setupAGGridModules, defaultModules } from './setupAGGridModules';
jest.mock('ag-grid-community', () => ({
ModuleRegistry: {
registerModules: jest.fn(),
},
ColumnAutoSizeModule: { moduleName: 'ColumnAutoSizeModule' },
ColumnHoverModule: { moduleName: 'ColumnHoverModule' },
RowAutoHeightModule: { moduleName: 'RowAutoHeightModule' },
RowStyleModule: { moduleName: 'RowStyleModule' },
PaginationModule: { moduleName: 'PaginationModule' },
CellStyleModule: { moduleName: 'CellStyleModule' },
TextFilterModule: { moduleName: 'TextFilterModule' },
NumberFilterModule: { moduleName: 'NumberFilterModule' },
DateFilterModule: { moduleName: 'DateFilterModule' },
ExternalFilterModule: { moduleName: 'ExternalFilterModule' },
CsvExportModule: { moduleName: 'CsvExportModule' },
ColumnApiModule: { moduleName: 'ColumnApiModule' },
RowApiModule: { moduleName: 'RowApiModule' },
CellApiModule: { moduleName: 'CellApiModule' },
RenderApiModule: { moduleName: 'RenderApiModule' },
ClientSideRowModelModule: { moduleName: 'ClientSideRowModelModule' },
CustomFilterModule: { moduleName: 'CustomFilterModule' },
}));
beforeEach(() => {
jest.clearAllMocks();
});
test('defaultModules exports an array of AG Grid modules', () => {
expect(Array.isArray(defaultModules)).toBe(true);
expect(defaultModules.length).toBeGreaterThan(0);
// Verify it contains expected modules
const moduleNames = defaultModules.map((m: any) => m.moduleName);
expect(moduleNames).toContain('ColumnAutoSizeModule');
expect(moduleNames).toContain('PaginationModule');
expect(moduleNames).toContain('ClientSideRowModelModule');
});
test('setupAGGridModules registers default modules when called without arguments', () => {
setupAGGridModules();
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
expect(ModuleRegistry.registerModules).toHaveBeenCalledWith(defaultModules);
});
test('setupAGGridModules registers default + additional modules when provided', () => {
const mockEnterpriseModule1 = {
moduleName: 'MultiFilterModule',
} as unknown as Module;
const mockEnterpriseModule2 = {
moduleName: 'PivotModule',
} as unknown as Module;
const additionalModules = [mockEnterpriseModule1, mockEnterpriseModule2];
setupAGGridModules(additionalModules);
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
const registeredModules = (ModuleRegistry.registerModules as jest.Mock).mock
.calls[0][0];
// Should contain all default modules
defaultModules.forEach(module => {
expect(registeredModules).toContain(module);
});
// Should contain additional modules
expect(registeredModules).toContain(mockEnterpriseModule1);
expect(registeredModules).toContain(mockEnterpriseModule2);
// Total length should be default + additional
expect(registeredModules.length).toBe(
defaultModules.length + additionalModules.length,
);
});
test('setupAGGridModules handles empty additional modules array', () => {
setupAGGridModules([]);
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
expect(ModuleRegistry.registerModules).toHaveBeenCalledWith(defaultModules);
});
test('setupAGGridModules does not mutate defaultModules array', () => {
const originalLength = defaultModules.length;
const mockEnterpriseModule = {
moduleName: 'EnterpriseModule',
} as unknown as Module;
setupAGGridModules([mockEnterpriseModule]);
// defaultModules should remain unchanged
expect(defaultModules.length).toBe(originalLength);
expect(defaultModules).not.toContain(mockEnterpriseModule);
});

View File

@@ -19,6 +19,7 @@
import {
ModuleRegistry,
type Module,
ColumnAutoSizeModule,
ColumnHoverModule,
RowAutoHeightModule,
@@ -39,27 +40,29 @@ import {
} from 'ag-grid-community';
/**
* Registers the AG-Grid modules required for Superset's table functionality.
* This should be called once during application initialization.
* Default AG Grid modules that are registered by default.
* These modules provide core AG Grid functionality.
*/
export const setupAGGridModules = () => {
ModuleRegistry.registerModules([
ColumnAutoSizeModule,
ColumnHoverModule,
RowAutoHeightModule,
RowStyleModule,
PaginationModule,
CellStyleModule,
TextFilterModule,
NumberFilterModule,
DateFilterModule,
ExternalFilterModule,
CsvExportModule,
ColumnApiModule,
RowApiModule,
CellApiModule,
RenderApiModule,
ClientSideRowModelModule,
CustomFilterModule,
]);
export const defaultModules: Module[] = [
ColumnAutoSizeModule,
ColumnHoverModule,
RowAutoHeightModule,
RowStyleModule,
PaginationModule,
CellStyleModule,
TextFilterModule,
NumberFilterModule,
DateFilterModule,
ExternalFilterModule,
CsvExportModule,
ColumnApiModule,
RowApiModule,
CellApiModule,
RenderApiModule,
ClientSideRowModelModule,
CustomFilterModule,
];
export const setupAGGridModules = (additionalModules: Module[] = []) => {
ModuleRegistry.registerModules([...defaultModules, ...additionalModules]);
};

View File

@@ -179,4 +179,6 @@ export {
ThemedAgGridReact,
type ThemedAgGridReactProps,
setupAGGridModules,
defaultModules,
} from './ThemedAgGridReact';
export { ActionButton, type ActionProps } from './ActionButton';

View File

@@ -67,6 +67,7 @@ export function normalizeTimeColumn(
sqlExpression: formData.x_axis,
label: formData.x_axis,
expressionType: 'SQL',
isColumnReference: true,
};
}

View File

@@ -27,6 +27,7 @@ export interface AdhocColumn {
optionName?: string;
sqlExpression: string;
expressionType: 'SQL';
isColumnReference?: boolean;
columnType?: 'BASE_AXIS' | 'SERIES';
timeGrain?: string;
datasourceWarning?: boolean;
@@ -74,6 +75,10 @@ export function isAdhocColumn(column?: any): column is AdhocColumn {
);
}
export function isAdhocColumnReference(column?: any): column is AdhocColumn {
return isAdhocColumn(column) && column?.isColumnReference === true;
}
export function isQueryFormColumn(column: any): column is QueryFormColumn {
return isPhysicalColumn(column) || isAdhocColumn(column);
}

View File

@@ -254,6 +254,7 @@ export const allowedAntdTokens = [
'controlTmpOutline',
'fontFamily',
'fontFamilyCode',
'fontWeightStrong',
'fontHeight',
'fontHeightLG',
'fontHeightSM',

View File

@@ -34,6 +34,7 @@ export enum FeatureFlag {
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardRbac = 'DASHBOARD_RBAC',
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
/** @deprecated */
DrillToDetail = 'DRILL_TO_DETAIL',
DrillBy = 'DRILL_BY',

View File

@@ -24,6 +24,7 @@ import {
removeHTMLTags,
isJsonString,
getParagraphContents,
extractTextFromHTML,
} from './html';
describe('sanitizeHtml', () => {
@@ -204,3 +205,101 @@ describe('getParagraphContents', () => {
});
});
});
describe('extractTextFromHTML', () => {
it('should extract text from HTML div tags', () => {
const htmlString = '<div>Hello World</div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Hello World');
});
it('should extract text from nested HTML tags', () => {
const htmlString = '<div><p>Hello <strong>World</strong></p></div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Hello World');
});
it('should extract text from multiple HTML elements', () => {
const htmlString = '<h1>Title</h1><p>Content</p><span>Footer</span>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('TitleContentFooter');
});
it('should return original string when input is not HTML', () => {
const plainText = 'Just plain text';
const result = extractTextFromHTML(plainText);
expect(result).toBe('Just plain text');
});
it('should return original value when input is not a string', () => {
const numberValue = 12345;
const result = extractTextFromHTML(numberValue);
expect(result).toBe(12345);
const nullValue = null;
const nullResult = extractTextFromHTML(nullValue);
expect(nullResult).toBe(null);
const booleanValue = true;
const booleanResult = extractTextFromHTML(booleanValue);
expect(booleanResult).toBe(true);
});
it('should handle empty HTML tags', () => {
const htmlString = '<div></div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('');
});
it('should handle HTML with only whitespace', () => {
const htmlString = '<div> </div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe(' ');
});
it('should extract text from HTML with attributes', () => {
const htmlString = '<div class="container" id="main">Hello World</div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Hello World');
});
it('should handle self-closing tags', () => {
const htmlString = '<img src="image.jpg" alt="Image"><br><p>Text after</p>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Text after');
});
it('should handle complex HTML structure', () => {
const htmlString = `
<html>
<head><title>Page Title</title></head>
<body>
<header><h1>Main Title</h1></header>
<main>
<p>First paragraph with <em>emphasis</em>.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</main>
</body>
</html>
`;
const result = extractTextFromHTML(htmlString);
expect(result).toContain('Page Title');
expect(result).toContain('Main Title');
expect(result).toContain('First paragraph with emphasis.');
expect(result).toContain('Item 1');
expect(result).toContain('Item 2');
});
it('should not extract text from strings that look like HTML but are not', () => {
const fakeHtmlString = '<abcdef:12345>';
const result = extractTextFromHTML(fakeHtmlString);
expect(result).toBe('<abcdef:12345>');
const mathExpression = 'x < 5 and y > 10';
const mathResult = extractTextFromHTML(mathExpression);
expect(mathResult).toBe('x < 5 and y > 10');
});
});

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { FilterXSS, getDefaultWhiteList } from 'xss';
import { DataRecordValue } from '../types';
const xssFilter = new FilterXSS({
whiteList: {
@@ -169,7 +170,10 @@ export function safeHtmlSpan(possiblyHtmlString: string) {
}
export function removeHTMLTags(str: string): string {
return str.replace(/<[^>]*>/g, '');
const doc = new DOMParser().parseFromString(str, 'text/html');
const bodyText = doc.body?.textContent || '';
const headText = doc.head?.textContent || '';
return headText + bodyText;
}
export function isJsonString(str: string): boolean {
@@ -204,3 +208,10 @@ export function getParagraphContents(
return paragraphContents;
}
export function extractTextFromHTML(value: DataRecordValue): DataRecordValue {
if (typeof value === 'string' && isProbablyHTML(value)) {
return removeHTMLTags(value);
}
return value;
}

View File

@@ -203,7 +203,13 @@ describe('SuperChartCore', () => {
);
await waitFor(() => {
expect(container).toBeEmptyDOMElement();
// Should not render any chart content, only the antd App wrapper
expect(
container.querySelector('.test-component'),
).not.toBeInTheDocument();
expect(
container.querySelector('[data-test="chart-container"]'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import '@testing-library/jest-dom';
import { render, fireEvent } from '@testing-library/react';
import { SupersetTheme, ThemeProvider } from '@superset-ui/core';
// CRITICAL: Don't import from the mocked path - import directly to avoid global mocks
import AsyncIcon from '../../../src/components/Icons/AsyncIcon';
// Mock only the SVG import to prevent dynamic import issues
jest.mock(
'!!@svgr/webpack!../../../src/assets/images/icons/slack.svg',
() => {
const MockSlackSVG = (props: any) => (
<svg {...props} viewBox="0 0 24 24" data-testid="slack-svg">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52z" />
</svg>
);
return { default: MockSlackSVG };
},
{ virtual: true },
);
// Basic theme for testing
const mockTheme: SupersetTheme = {
fontSize: 16,
sizeUnit: 4,
} as SupersetTheme;
describe('AsyncIcon Integration Tests (Real Component)', () => {
it('should have data-test and aria-label attributes with real component', () => {
const { container } = render(
<ThemeProvider theme={mockTheme}>
<AsyncIcon customIcons fileName="slack" iconSize="l" />
</ThemeProvider>,
);
// Don't wait for SVG since it's mocked - just check the span wrapper
const spanElement = container.querySelector('span');
// Test the ACTUAL component behavior (not the mock)
expect(spanElement).toHaveAttribute('aria-label', 'slack');
expect(spanElement).toHaveAttribute('role', 'img');
expect(spanElement).toHaveAttribute('data-test', 'slack');
});
it('should always have aria-label and data-test for testing', () => {
const { container } = render(
<ThemeProvider theme={mockTheme}>
<AsyncIcon customIcons fileName="slack" iconSize="l" />
</ThemeProvider>,
);
const spanElement = container.querySelector('span');
// The critical requirement: we MUST have these attributes for accessibility and testing
expect(spanElement).toHaveAttribute('aria-label');
expect(spanElement).toHaveAttribute('data-test');
// The values should be consistent
const ariaLabel = spanElement?.getAttribute('aria-label');
const dataTest = spanElement?.getAttribute('data-test');
expect(ariaLabel).toBe('slack');
expect(dataTest).toBe('slack');
});
it('should set role to button when onClick is provided in real component', () => {
const onClick = jest.fn();
const { container } = render(
<ThemeProvider theme={mockTheme}>
<AsyncIcon
customIcons
fileName="slack"
iconSize="l"
onClick={onClick}
/>
</ThemeProvider>,
);
const spanElement = container.querySelector('span');
expect(spanElement).toHaveAttribute('role', 'button');
expect(spanElement).toHaveAttribute('aria-label', 'slack');
expect(spanElement).toHaveAttribute('data-test', 'slack');
// Verify onClick handler actually works
fireEvent.click(spanElement!);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('should handle complex fileName patterns like BaseIcon', () => {
const { container } = render(
<ThemeProvider theme={mockTheme}>
<AsyncIcon customIcons fileName="slack_notification" iconSize="l" />
</ThemeProvider>,
);
const spanElement = container.querySelector('span');
// Should follow BaseIcon's genAriaLabel logic:
// fileName="slack_notification" -> name="slack-notification" -> "slack-notification" (not just "slack")
expect(spanElement).toHaveAttribute('aria-label', 'slack-notification');
expect(spanElement).toHaveAttribute('data-test', 'slack-notification');
});
});

View File

@@ -86,6 +86,7 @@ test('should support different columns for x-axis and granularity', () => {
{
timeGrain: 'P1Y',
columnType: 'BASE_AXIS',
isColumnReference: true,
sqlExpression: 'time_column_in_x_axis',
label: 'time_column_in_x_axis',
expressionType: 'SQL',

View File

@@ -58,11 +58,13 @@ export default styled(CountryMap)`
}
.superset-legacy-chart-country-map text.result-text {
fill: ${theme.colorText};
font-weight: ${theme.fontWeightLight};
font-size: ${theme.fontSizeXL}px;
}
.superset-legacy-chart-country-map text.big-text {
fill: ${theme.colorText};
font-weight: ${theme.fontWeightStrong};
font-size: ${theme.fontSizeLG}px;
}

View File

@@ -31,6 +31,7 @@ const propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
country: PropTypes.string,
code: PropTypes.string,
latitude: PropTypes.number,
longitude: PropTypes.number,
name: PropTypes.string,
@@ -116,7 +117,7 @@ function WorldMap(element, props) {
const selected = Object.values(filterState.selectedValues || {});
const key = source.id || source.country;
const country =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
if (!country) {
return undefined;
@@ -170,7 +171,7 @@ function WorldMap(element, props) {
pointerEvent.preventDefault();
const key = source.id || source.country;
const val =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
let drillToDetailFilters;
let drillByFilters;
if (val) {

View File

@@ -156,25 +156,39 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 100 };
const colorArray = [color.r, color.g, color.b, color.a * 255];
return data.map(d => ({
...d,
color: [color.r, color.g, color.b, color.a * 255],
}));
return data.map(d => ({ ...d, color: colorArray }));
}
case COLOR_SCHEME_TYPES.categorical_palette: {
if (!fd.dimension) {
const fallbackColor = fd.color_picker || {
r: 0,
g: 0,
b: 0,
a: 100,
};
const colorArray = [
fallbackColor.r,
fallbackColor.g,
fallbackColor.b,
fallbackColor.a * 255,
];
return data.map(d => ({ ...d, color: colorArray }));
}
return data.map(d => ({
...d,
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
}));
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const defaultBreakpointColor = fd.deafult_breakpoint_color
const defaultBreakpointColor = fd.default_breakpoint_color
? [
fd.deafult_breakpoint_color.r,
fd.deafult_breakpoint_color.g,
fd.deafult_breakpoint_color.b,
fd.deafult_breakpoint_color.a * 255,
fd.default_breakpoint_color.r,
fd.default_breakpoint_color.g,
fd.default_breakpoint_color.b,
fd.default_breakpoint_color.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
@@ -190,17 +204,17 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
d.metric <= breakpoint.maxValue,
);
return {
...d,
color: breakpointForPoint
? [
breakpointForPoint?.color.r,
breakpointForPoint?.color.g,
breakpointForPoint?.color.b,
breakpointForPoint?.color.a * 255,
]
: defaultBreakpointColor,
};
if (breakpointForPoint) {
const pointColor = [
breakpointForPoint.color.r,
breakpointForPoint.color.g,
breakpointForPoint.color.b,
breakpointForPoint.color.a * 255,
];
return { ...d, color: pointColor };
}
return { ...d, color: defaultBreakpointColor };
});
}
default: {

View File

@@ -0,0 +1,355 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { render, screen } from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import DeckGLPolygon, { getPoints } from './Polygon';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import * as utils from '../../utils';
// Mock the utils functions
const mockGetBuckets = jest.spyOn(utils, 'getBuckets');
const mockGetColorBreakpointsBuckets = jest.spyOn(
utils,
'getColorBreakpointsBuckets',
);
// Mock DeckGL container and Legend
jest.mock('../../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: ({ children }: any) => (
<div data-testid="deckgl-container">{children}</div>
),
}));
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
<div
data-testid="legend"
data-categories={JSON.stringify(categories)}
data-position={position}
>
Legend Mock
</div>
));
const mockProps = {
formData: {
// Required QueryFormData properties
datasource: 'test_datasource',
viz_type: 'deck_polygon',
// Polygon-specific properties
metric: { label: 'population' },
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
legend_position: 'tr',
legend_format: '.2f',
autozoom: false,
mapbox_style: 'mapbox://styles/mapbox/light-v9',
opacity: 80,
filled: true,
stroked: true,
extruded: false,
line_width: 1,
line_width_unit: 'pixels',
multiplier: 1,
break_points: [],
num_buckets: '5',
linear_color_scheme: 'blue_white_yellow',
},
payload: {
data: {
features: [
{
population: 100000,
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
},
{
population: 200000,
polygon: [
[2, 2],
[3, 2],
[3, 3],
[2, 3],
],
},
],
mapboxApiKey: 'test-key',
},
form_data: {},
},
setControlValue: jest.fn(),
viewport: { longitude: 0, latitude: 0, zoom: 1 },
onAddFilter: jest.fn(),
width: 800,
height: 600,
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
filterState: undefined,
emitCrossFilters: false,
};
describe('DeckGLPolygon bucket generation logic', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({
'100000 - 150000': { color: [0, 100, 200], enabled: true },
'150000 - 200000': { color: [50, 150, 250], enabled: true },
});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('should use getBuckets for linear_palette color scheme', () => {
const propsWithLinearPalette = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithLinearPalette} />);
// Should call getBuckets, not getColorBreakpointsBuckets
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for fixed_color color scheme', () => {
const propsWithFixedColor = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.fixed_color,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithFixedColor} />);
// Should call getBuckets, not getColorBreakpointsBuckets
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getColorBreakpointsBuckets for color_breakpoints scheme', () => {
const propsWithBreakpoints = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: [
{
minValue: 0,
maxValue: 100000,
color: { r: 255, g: 0, b: 0, a: 100 },
},
{
minValue: 100001,
maxValue: 200000,
color: { r: 0, g: 255, b: 0, a: 100 },
},
],
},
};
mockGetColorBreakpointsBuckets.mockReturnValue({
'0 - 100000': { color: [255, 0, 0], enabled: true },
'100001 - 200000': { color: [0, 255, 0], enabled: true },
});
renderWithTheme(<DeckGLPolygon {...propsWithBreakpoints} />);
// Should call getColorBreakpointsBuckets, not getBuckets
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalled();
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets when color_scheme_type is undefined (backward compatibility)', () => {
const propsWithUndefinedScheme = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: undefined,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithUndefinedScheme} />);
// Should call getBuckets for backward compatibility
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for unsupported color schemes (categorical_palette)', () => {
const propsWithUnsupportedScheme = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithUnsupportedScheme} />);
// Should fall back to getBuckets for unsupported color schemes
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
});
describe('DeckGLPolygon Error Handling and Edge Cases', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('handles empty features data gracefully', () => {
const propsWithEmptyData = {
...mockProps,
payload: {
...mockProps.payload,
data: {
...mockProps.payload.data,
features: [],
},
},
};
renderWithTheme(<DeckGLPolygon {...propsWithEmptyData} />);
// Should still call getBuckets with empty data
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('handles missing color_breakpoints for color_breakpoints scheme', () => {
const propsWithMissingBreakpoints = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: undefined,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithMissingBreakpoints} />);
// Should call getColorBreakpointsBuckets even with undefined breakpoints
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalledWith(undefined);
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('handles null legend_position correctly', () => {
const propsWithNullLegendPosition = {
...mockProps,
formData: {
...mockProps.formData,
legend_position: null,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithNullLegendPosition} />);
// Legend should not be rendered when position is null
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
});
});
describe('DeckGLPolygon Legend Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({
'100000 - 150000': { color: [0, 100, 200], enabled: true },
'150000 - 200000': { color: [50, 150, 250], enabled: true },
});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
const { container } = renderWithTheme(<DeckGLPolygon {...mockProps} />);
// Verify the component renders and calls the correct bucket function
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
// Verify the legend mock was rendered with non-empty categories
const legendElement = container.querySelector('[data-testid="legend"]');
expect(legendElement).toBeTruthy();
const categoriesAttr = legendElement?.getAttribute('data-categories');
const categoriesData = JSON.parse(categoriesAttr || '{}');
expect(Object.keys(categoriesData)).toHaveLength(2);
});
test('does not render legend when metric is null', () => {
const propsWithoutMetric = {
...mockProps,
formData: {
...mockProps.formData,
metric: null,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithoutMetric} />);
// Legend should not be rendered when no metric is defined
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
});
});
describe('getPoints utility', () => {
test('extracts points from polygon data', () => {
const data = [
{
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
},
{
polygon: [
[2, 2],
[3, 2],
[3, 3],
[2, 3],
],
},
];
const points = getPoints(data);
expect(points).toHaveLength(8); // 4 points per polygon * 2 polygons
expect(points[0]).toEqual([0, 0]);
expect(points[4]).toEqual([2, 2]);
});
});

View File

@@ -336,9 +336,10 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
const accessor = (d: JsonObject) => d[metricLabel];
const colorSchemeType = formData.color_scheme_type;
const buckets = colorSchemeType
? getColorBreakpointsBuckets(formData.color_breakpoints)
: getBuckets(formData, payload.data.features, accessor);
const buckets =
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
? getColorBreakpointsBuckets(formData.color_breakpoints)
: getBuckets(formData, payload.data.features, accessor);
return (
<div style={{ position: 'relative' }}>

View File

@@ -35,7 +35,6 @@ import {
mapboxStyle,
generateDeckGLColorSchemeControls,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
const config: ControlPanelConfig = {
onInit: controlState => ({
@@ -130,9 +129,7 @@ const config: ControlPanelConfig = {
controlSetRows: [
[legendPosition],
[legendFormat],
...generateDeckGLColorSchemeControls({
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
}),
...generateDeckGLColorSchemeControls({}),
],
},
{

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getColorBreakpointsBuckets } from './utils';
import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
import { ColorBreakpointType } from './types';
describe('getColorBreakpointsBuckets', () => {
@@ -44,3 +44,447 @@ describe('getColorBreakpointsBuckets', () => {
expect(result).toEqual({});
});
});
describe('getBreakPoints', () => {
const accessor = (d: any) => d.value;
describe('automatic breakpoint generation', () => {
it('generates correct number of breakpoints for given buckets', () => {
const features = [{ value: 0 }, { value: 50 }, { value: 100 }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
expect(breakPoints).toHaveLength(6); // n buckets = n+1 breakpoints
expect(breakPoints.every(bp => typeof bp === 'string')).toBe(true);
});
it('ensures data range is fully covered', () => {
// Test various data ranges to ensure min/max are always included
const testCases = [
{ data: [0, 100], buckets: 5 },
{ data: [0.1, 99.9], buckets: 4 },
{ data: [-50, 50], buckets: 10 },
{ data: [3.2, 38.7], buckets: 5 }, // Original max bug case
{ data: [3.14, 100], buckets: 5 }, // Min rounding bug case (3.14 -> 3)
{ data: [2.345, 10], buckets: 4 }, // Min rounding bug case (2.345 -> 2.35)
{ data: [0.0001, 0.0009], buckets: 3 }, // Very small numbers
{ data: [1000000, 9000000], buckets: 8 }, // Large numbers
];
testCases.forEach(({ data, buckets }) => {
const [min, max] = data;
const features = [{ value: min }, { value: max }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: String(buckets) },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// Critical: min and max must be within the breakpoint range
expect(firstBp).toBeLessThanOrEqual(min);
expect(lastBp).toBeGreaterThanOrEqual(max);
expect(breakPoints).toHaveLength(buckets + 1);
});
});
it('handles uniform distribution correctly', () => {
const features = [
{ value: 0 },
{ value: 25 },
{ value: 50 },
{ value: 75 },
{ value: 100 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '4' },
features,
accessor,
);
// Check that breakpoints are evenly spaced
const numericBreakPoints = breakPoints.map(parseFloat);
const deltas = [];
for (let i = 1; i < numericBreakPoints.length; i += 1) {
deltas.push(numericBreakPoints[i] - numericBreakPoints[i - 1]);
}
// All deltas should be approximately equal
const avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length;
deltas.forEach(delta => {
expect(delta).toBeCloseTo(avgDelta, 1);
});
});
it('handles single value datasets', () => {
const features = [{ value: 42 }, { value: 42 }, { value: 42 }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
expect(firstBp).toBeLessThanOrEqual(42);
expect(lastBp).toBeGreaterThanOrEqual(42);
});
it('preserves appropriate precision for different scales', () => {
const testCases = [
{ data: [0, 1], expectedMaxPrecision: 1 }, // 0.0, 0.2, 0.4...
{ data: [0, 0.1], expectedMaxPrecision: 2 }, // 0.00, 0.02...
{ data: [0, 0.01], expectedMaxPrecision: 3 }, // 0.000, 0.002...
{ data: [0, 1000], expectedMaxPrecision: 0 }, // 0, 200, 400...
];
testCases.forEach(({ data, expectedMaxPrecision }) => {
const [min, max] = data;
const features = [{ value: min }, { value: max }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
breakPoints.forEach(bp => {
const decimalPlaces = (bp.split('.')[1] || '').length;
expect(decimalPlaces).toBeLessThanOrEqual(expectedMaxPrecision);
});
});
});
it('handles negative values correctly', () => {
const features = [
{ value: -100 },
{ value: -50 },
{ value: 0 },
{ value: 50 },
{ value: 100 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const numericBreakPoints = breakPoints.map(parseFloat);
expect(numericBreakPoints[0]).toBeLessThanOrEqual(-100);
expect(
numericBreakPoints[numericBreakPoints.length - 1],
).toBeGreaterThanOrEqual(100);
// Verify ascending order
for (let i = 1; i < numericBreakPoints.length; i += 1) {
expect(numericBreakPoints[i]).toBeGreaterThan(
numericBreakPoints[i - 1],
);
}
});
it('handles mixed integer and decimal values', () => {
const features = [
{ value: 1 },
{ value: 2.5 },
{ value: 3.7 },
{ value: 5 },
{ value: 8.2 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '4' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
expect(firstBp).toBeLessThanOrEqual(1);
expect(lastBp).toBeGreaterThanOrEqual(8.2);
});
it('uses floor/ceil for boundary breakpoints to ensure inclusion', () => {
// Test that Math.floor and Math.ceil are used for boundaries
// This ensures all data points fall within the breakpoint range
const testCases = [
{ minValue: 3.14, maxValue: 100, buckets: 5 },
{ minValue: 2.345, maxValue: 10.678, buckets: 4 },
{ minValue: 1.67, maxValue: 5.33, buckets: 3 },
{ minValue: 0.123, maxValue: 0.987, buckets: 5 },
];
testCases.forEach(({ minValue, maxValue, buckets }) => {
const features = [{ value: minValue }, { value: maxValue }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: String(buckets) },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// First breakpoint should be floored (always <= minValue)
expect(firstBp).toBeLessThanOrEqual(minValue);
// Last breakpoint should be ceiled (always >= maxValue)
expect(lastBp).toBeGreaterThanOrEqual(maxValue);
// All values should be within range
expect(minValue).toBeGreaterThanOrEqual(firstBp);
expect(maxValue).toBeLessThanOrEqual(lastBp);
});
});
it('prevents minimum value exclusion edge case', () => {
// Specific edge case test for minimum value exclusion
// Tests the exact scenario where rounding would exclude the min value
const features = [
{ value: 3.14 }, // This would round to 3 at precision 0
{ value: 50 },
{ value: 100 },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
// The first breakpoint must be <= 3.14 (floor behavior)
expect(firstBp).toBeLessThanOrEqual(3.14);
// Verify that 3.14 is not excluded
expect(3.14).toBeGreaterThanOrEqual(firstBp);
// The first breakpoint should be a clean floor value
expect(breakPoints[0]).toMatch(/^3(\.0*)?$/);
});
it('prevents maximum value exclusion edge case', () => {
// Specific edge case test for maximum value exclusion
// Tests the exact scenario where rounding would exclude the max value
const features = [
{ value: 0 },
{ value: 20 },
{ value: 38.7 }, // Original bug case
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// The last breakpoint must be >= 38.7 (ceil behavior)
expect(lastBp).toBeGreaterThanOrEqual(38.7);
// Verify that 38.7 is not excluded
expect(38.7).toBeLessThanOrEqual(lastBp);
// The last breakpoint should be a clean ceil value
expect(breakPoints[breakPoints.length - 1]).toMatch(/^39(\.0*)?$/);
});
});
describe('custom breakpoints', () => {
it('uses custom breakpoints when provided', () => {
const features = [{ value: 5 }, { value: 15 }, { value: 25 }];
const customBreakPoints = ['0', '10', '20', '30', '40'];
const breakPoints = getBreakPoints(
{ break_points: customBreakPoints, num_buckets: '' },
features,
accessor,
);
expect(breakPoints).toEqual(['0', '10', '20', '30', '40']);
});
it('sorts custom breakpoints in ascending order', () => {
const features = [{ value: 5 }];
const customBreakPoints = ['30', '10', '0', '20'];
const breakPoints = getBreakPoints(
{ break_points: customBreakPoints, num_buckets: '' },
features,
accessor,
);
expect(breakPoints).toEqual(['0', '10', '20', '30']);
});
it('ignores num_buckets when custom breakpoints are provided', () => {
const features = [{ value: 5 }];
const customBreakPoints = ['0', '50', '100'];
const breakPoints = getBreakPoints(
{ break_points: customBreakPoints, num_buckets: '10' }, // num_buckets should be ignored
features,
accessor,
);
expect(breakPoints).toEqual(['0', '50', '100']);
expect(breakPoints).toHaveLength(3); // not 11
});
});
describe('edge cases and error handling', () => {
it('returns empty array when features are undefined', () => {
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
undefined as any,
accessor,
);
expect(breakPoints).toEqual([]);
});
it('returns empty array when features is null', () => {
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
null as any,
accessor,
);
expect(breakPoints).toEqual([]);
});
it('returns empty array when all values are undefined', () => {
const features = [
{ value: undefined },
{ value: undefined },
{ value: undefined },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
expect(breakPoints).toEqual([]);
});
it('handles empty features array', () => {
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
[],
accessor,
);
expect(breakPoints).toEqual([]);
});
it('handles string values that can be parsed as numbers', () => {
const features = [
{ value: '10.5' },
{ value: '20.3' },
{ value: '30.7' },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '3' },
features,
(d: any) =>
typeof d.value === 'string' ? parseFloat(d.value) : d.value,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
expect(firstBp).toBeLessThanOrEqual(10.5);
expect(lastBp).toBeGreaterThanOrEqual(30.7);
});
it('uses default number of buckets when not specified', () => {
const features = [{ value: 0 }, { value: 100 }];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '' },
features,
accessor,
);
// Should use DEFAULT_NUM_BUCKETS (10)
expect(breakPoints).toHaveLength(11); // 10 buckets = 11 breakpoints
});
it('handles Infinity and -Infinity values', () => {
const features = [
{ value: -Infinity },
{ value: 0 },
{ value: Infinity },
];
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
// Should return empty array when Infinity values are present
expect(breakPoints).toEqual([]);
});
});
describe('breakpoint boundaries validation', () => {
it('ensures no data points fall outside breakpoint range', () => {
// Generate random test data
const generateRandomData = (count: number, min: number, max: number) => {
const data = [];
for (let i = 0; i < count; i += 1) {
data.push({ value: Math.random() * (max - min) + min });
}
return data;
};
// Test with various random datasets
for (let i = 0; i < 10; i += 1) {
const features = generateRandomData(20, -1000, 1000);
const minValue = Math.min(...features.map(f => f.value));
const maxValue = Math.max(...features.map(f => f.value));
const breakPoints = getBreakPoints(
{ break_points: [], num_buckets: '5' },
features,
accessor,
);
const firstBp = parseFloat(breakPoints[0]);
const lastBp = parseFloat(breakPoints[breakPoints.length - 1]);
// Every data point should fall within the breakpoint range
features.forEach(feature => {
expect(feature.value).toBeGreaterThanOrEqual(firstBp);
expect(feature.value).toBeLessThanOrEqual(lastBp);
});
// The range should be as tight as possible while including all data
expect(firstBp).toBeLessThanOrEqual(minValue);
expect(lastBp).toBeGreaterThanOrEqual(maxValue);
}
});
});
});

View File

@@ -75,19 +75,35 @@ export function getBreakPoints(
if (minValue === undefined || maxValue === undefined) {
return [];
}
// Handle Infinity values
if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) {
return [];
}
const delta = (maxValue - minValue) / numBuckets;
const precision =
delta === 0 ? 0 : Math.max(0, Math.ceil(Math.log10(1 / delta)));
const extraBucket =
maxValue > parseFloat(maxValue.toFixed(precision)) ? 1 : 0;
const startValue =
minValue < parseFloat(minValue.toFixed(precision))
? minValue - 1
: minValue;
return new Array(numBuckets + 1 + extraBucket)
.fill(0)
.map((_, i) => (startValue + i * delta).toFixed(precision));
// Generate breakpoints
const breakPoints = new Array(numBuckets + 1).fill(0).map((_, i) => {
const value = minValue + i * delta;
// For the first breakpoint, floor to ensure minimum is included
if (i === 0) {
const scale = Math.pow(10, precision);
return (Math.floor(minValue * scale) / scale).toFixed(precision);
}
// For the last breakpoint, ceil to ensure maximum is included
if (i === numBuckets) {
const scale = Math.pow(10, precision);
return (Math.ceil(maxValue * scale) / scale).toFixed(precision);
}
// For middle breakpoints, use standard rounding
return value.toFixed(precision);
});
return breakPoints;
}
return formDataBreakPoints.sort(
@@ -146,7 +162,10 @@ export function getBreakPointColorScaler(
scaler = scaleThreshold<number, string>()
.domain(points)
.range(bucketedColors);
maskPoint = value => !!value && (value > points[n] || value < points[0]);
// Only mask values that are strictly outside the min/max bounds
// Include values equal to the max breakpoint
maskPoint = value =>
!!value && (value > points[points.length - 1] || value < points[0]);
} else {
// interpolate colors linearly
const linearScaleDomain = extent(features, accessor);

View File

@@ -72,6 +72,12 @@ export default styled(NVD3)`
text.nv-axislabel {
font-size: ${({ theme }) => theme.fontSize} !important;
}
g.nv-axis text {
fill: ${({ theme }) => theme.colorText};
}
g.nv-series text {
fill: ${({ theme }) => theme.colorText};
}
g.solid path,
line.solid {
stroke-dasharray: unset;

View File

@@ -131,10 +131,7 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
const defaultColDef = useMemo<ColDef>(
() => ({
flex: 1,
filter: true,
enableRowGroup: true,
enableValue: true,
sortable: true,
resizable: true,
minWidth: 100,
@@ -251,6 +248,12 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
}
}, [hasServerPageLengthChanged]);
useEffect(() => {
if (gridRef.current?.api) {
gridRef.current.api.sizeColumnsToFit();
}
}, [width]);
const onGridReady = (params: GridReadyEvent) => {
// This will make columns fill the grid width
params.api.sizeColumnsToFit();
@@ -312,7 +315,6 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
onCellClicked={handleCrossFilter}
initialState={gridInitialState}
suppressAggFuncInHeader
rowGroupPanelShow="always"
enableCellTextSelection
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}

View File

@@ -47,7 +47,7 @@ import {
isAdhocColumn,
isFeatureEnabled,
isPhysicalColumn,
legacyValidateInteger,
validateInteger,
QueryFormColumn,
QueryMode,
SMART_DATE_ID,
@@ -387,6 +387,7 @@ const config: ControlPanelConfig = {
description: t('Rows per page, 0 means no pagination'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.server_pagination?.value),
validators: [validateInteger],
},
},
],
@@ -405,7 +406,7 @@ const config: ControlPanelConfig = {
state?.common?.conf?.SQL_MAX_ROW,
}),
validators: [
legacyValidateInteger,
validateInteger,
(v, state) =>
validateMaxValue(
v,

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -75,6 +75,23 @@ function isPositiveNumber(value: string | number | null | undefined) {
);
}
const calculateDifferences = (
originalValue: number,
comparisonValue: number,
) => {
const valueDifference = originalValue - comparisonValue;
let percentDifferenceNum;
if (!originalValue && !comparisonValue) {
percentDifferenceNum = 0;
} else if (!originalValue || !comparisonValue) {
percentDifferenceNum = originalValue ? 1 : -1;
} else {
percentDifferenceNum =
(originalValue - comparisonValue) / Math.abs(comparisonValue);
}
return { valueDifference, percentDifferenceNum };
};
const processComparisonTotals = (
comparisonSuffix: string,
totals?: DataRecord[],
@@ -150,23 +167,6 @@ const getComparisonColFormatter = (
return formatter;
};
const calculateDifferences = (
originalValue: number,
comparisonValue: number,
) => {
const valueDifference = originalValue - comparisonValue;
let percentDifferenceNum;
if (!originalValue && !comparisonValue) {
percentDifferenceNum = 0;
} else if (!originalValue || !comparisonValue) {
percentDifferenceNum = originalValue ? 1 : -1;
} else {
percentDifferenceNum =
(originalValue - comparisonValue) / Math.abs(comparisonValue);
}
return { valueDifference, percentDifferenceNum };
};
const processComparisonDataRecords = memoizeOne(
function processComparisonDataRecords(
originalData: DataRecord[] | undefined,
@@ -472,6 +472,7 @@ const transformProps = (
filterState,
hooks: { setDataMask = () => {} },
emitCrossFilters,
theme,
} = chartProps;
const {
@@ -495,6 +496,36 @@ const transformProps = (
const allowRearrangeColumns = true;
// Calculate time comparison settings early since they're used in multiple places
const isUsingTimeComparison =
!isEmpty(time_compare) &&
queryMode === QueryMode.Aggregate &&
comparison_type === ComparisonType.Values &&
isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled);
const nonCustomNorInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift !== 'custom' && shift !== 'inherit',
);
const customOrInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift === 'custom' || shift === 'inherit',
);
let timeOffsets: string[] = [];
if (isUsingTimeComparison && !isEmpty(nonCustomNorInheritShifts)) {
timeOffsets = nonCustomNorInheritShifts;
}
// Shifts for custom or inherit time comparison
if (isUsingTimeComparison && !isEmpty(customOrInheritShifts)) {
if (customOrInheritShifts.includes('custom')) {
timeOffsets = timeOffsets.concat([formData.start_date_offset]);
}
if (customOrInheritShifts.includes('inherit')) {
timeOffsets = timeOffsets.concat(['inherit']);
}
}
const calculateBasicStyle = (
percentDifferenceNum: number,
colorOption: ColorSchemeEnum,
@@ -607,12 +638,6 @@ const transformProps = (
: undefined;
};
const isUsingTimeComparison =
!isEmpty(time_compare) &&
queryMode === QueryMode.Aggregate &&
comparison_type === ComparisonType.Values &&
isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled);
let hasServerPageLengthChanged = false;
const pageLengthFromMap = serverPageLengthMap.get(slice_id);
@@ -625,28 +650,6 @@ const transformProps = (
const timeGrain = extractTimegrain(formData);
const nonCustomNorInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift !== 'custom' && shift !== 'inherit',
);
const customOrInheritShifts = ensureIsArray(formData.time_compare).filter(
(shift: string) => shift === 'custom' || shift === 'inherit',
);
let timeOffsets: string[] = [];
if (isUsingTimeComparison && !isEmpty(nonCustomNorInheritShifts)) {
timeOffsets = nonCustomNorInheritShifts;
}
// Shifts for custom or inherit time comparison
if (isUsingTimeComparison && !isEmpty(customOrInheritShifts)) {
if (customOrInheritShifts.includes('custom')) {
timeOffsets = timeOffsets.concat([formData.start_date_offset]);
}
if (customOrInheritShifts.includes('inherit')) {
timeOffsets = timeOffsets.concat(['inherit']);
}
}
const comparisonSuffix = isUsingTimeComparison
? ensureIsArray(timeOffsets)[0]
: '';
@@ -686,7 +689,7 @@ const transformProps = (
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData) ?? [];
getColorFormatters(conditionalFormatting, passedData, theme) ?? [];
const basicColorColumnFormatters = getBasicColorFormatterForColumn(
baseQuery?.data,

View File

@@ -43,6 +43,7 @@ export default function transformProps(
rawFormData,
hooks,
datasource: { currencyFormats = {}, columnFormats = {} },
theme,
} = chartProps;
const {
metricNameFontSize,
@@ -105,7 +106,7 @@ export default function transformProps(
const defaultColorFormatters = [] as ColorFormatters;
const colorThresholdFormatters =
getColorFormatters(conditionalFormatting, data, false) ??
getColorFormatters(conditionalFormatting, data, theme, false) ??
defaultColorFormatters;
return {
width,

View File

@@ -25,7 +25,6 @@ import {
getXAxisLabel,
Metric,
getValueFormatter,
supersetTheme,
t,
tooltipHtml,
} from '@superset-ui/core';
@@ -81,6 +80,7 @@ export default function transformProps(
rawFormData,
hooks,
inContextMenu,
theme,
datasource: { currencyFormats = {}, columnFormats = {} },
} = chartProps;
const {
@@ -281,7 +281,7 @@ export default function transformProps(
},
{
offset: 1,
color: supersetTheme.colorBgContainer,
color: theme.colorBgContainer,
},
]),
},

View File

@@ -142,7 +142,7 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('X AXIS TITLE MARGIN'),
label: t('X axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
@@ -214,7 +214,7 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('Y AXIS TITLE MARGIN'),
label: t('Y axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),

View File

@@ -19,7 +19,6 @@
import { t } from '@superset-ui/core';
import {
ControlPanelConfig,
ControlStateMapping,
ControlSubSectionHeader,
D3_FORMAT_DOCS,
D3_FORMAT_OPTIONS,
@@ -197,15 +196,6 @@ const config: ControlPanelConfig = {
],
},
],
onInit(state: ControlStateMapping) {
return {
...state,
row_limit: {
...state.row_limit,
value: state.row_limit.default,
},
};
},
formDataOverrides: formData => ({
...formData,
metric: getStandardizedControls().shiftMetric(),

View File

@@ -324,6 +324,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
show: true,
position: 'start',
formatter: '{b}',
color: theme.colorText,
},
data: categoryLines,
},

View File

@@ -27,7 +27,6 @@ import {
getValueFormatter,
rgbToHex,
addAlpha,
supersetTheme,
tooltipHtml,
} from '@superset-ui/core';
import memoizeOne from 'memoize-one';
@@ -78,7 +77,8 @@ export default function transformProps(
chartProps: HeatmapChartProps,
): HeatmapTransformedProps {
const refs: Refs = {};
const { width, height, formData, queriesData, datasource } = chartProps;
const { width, height, formData, queriesData, datasource, theme } =
chartProps;
const {
bottomMargin,
xAxis,
@@ -176,9 +176,9 @@ export default function transformProps(
},
emphasis: {
itemStyle: {
borderColor: supersetTheme.colorBgContainer,
borderColor: 'transparent',
shadowBlur: 10,
shadowColor: supersetTheme.colorTextBase,
shadowColor: addAlpha(theme.colorText, 0.3),
},
},
},

View File

@@ -129,6 +129,7 @@ export default function transformProps(
theme,
inContextMenu,
emitCrossFilters,
legendState,
} = chartProps;
let focusedSeries: string | null = null;
@@ -157,6 +158,7 @@ export default function transformProps(
timeShiftColor,
contributionMode,
legendOrientation,
legendMargin,
legendType,
logAxis,
logAxisSecondary,
@@ -243,6 +245,10 @@ export default function transformProps(
const MetricDisplayNameA = getMetricDisplayName(metrics[0], verboseMap);
const MetricDisplayNameB = getMetricDisplayName(metricsB[0], verboseMap);
const dataTypes = getColtypesMapping(queriesData[0]);
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
const xAxisType = getAxisType(stack, xAxisForceCategorical, xAxisDataType);
const [rawSeriesA, sortedTotalValuesA] = extractSeries(rebasedDataA, {
fillNeighborValue: stack ? 0 : undefined,
xAxis: xAxisLabel,
@@ -250,6 +256,7 @@ export default function transformProps(
sortSeriesAscending,
stack,
totalStackedValues,
xAxisType,
});
const rebasedDataB = rebaseForecastDatum(data2, verboseMap);
const {
@@ -267,11 +274,8 @@ export default function transformProps(
sortSeriesAscending: sortSeriesAscendingB,
stack: Boolean(stackB),
totalStackedValues: totalStackedValuesB,
xAxisType,
});
const dataTypes = getColtypesMapping(queriesData[0]);
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
const xAxisType = getAxisType(stack, xAxisForceCategorical, xAxisDataType);
const series: SeriesOption[] = [];
const formatter = contributionMode
? getNumberFormatter(',.0%')
@@ -316,12 +320,6 @@ export default function transformProps(
primarySeries.add(seriesOption.id as string);
}
};
rawSeriesA.forEach(seriesOption =>
mapSeriesIdToAxis(seriesOption, yAxisIndex),
);
rawSeriesB.forEach(seriesOption =>
mapSeriesIdToAxis(seriesOption, yAxisIndexB),
);
const showValueIndexesA = extractShowValueIndexes(rawSeriesA, {
stack,
onlyTotal,
@@ -421,6 +419,7 @@ export default function transformProps(
{
...entry,
id: `${displayName || ''}`,
name: `${displayName || ''}`,
},
colorScale,
colorScaleKey,
@@ -450,9 +449,14 @@ export default function transformProps(
showValueIndexes: showValueIndexesA,
thresholdValues,
timeShiftColor,
theme,
},
);
if (transformedSeries) series.push(transformedSeries);
if (transformedSeries) {
series.push(transformedSeries);
mapSeriesIdToAxis(transformedSeries, yAxisIndex);
}
});
rawSeriesB.forEach(entry => {
@@ -486,6 +490,7 @@ export default function transformProps(
{
...entry,
id: `${displayName || ''}`,
name: `${displayName || ''}`,
},
colorScale,
@@ -516,9 +521,14 @@ export default function transformProps(
showValueIndexes: showValueIndexesB,
thresholdValues: thresholdValuesB,
timeShiftColor,
theme,
},
);
if (transformedSeries) series.push(transformedSeries);
if (transformedSeries) {
series.push(transformedSeries);
mapSeriesIdToAxis(transformedSeries, yAxisIndexB);
}
});
// default to 0-100% range when doing row-level contribution chart
@@ -546,7 +556,7 @@ export default function transformProps(
legendOrientation,
addYAxisTitleOffset,
zoomable,
null,
legendMargin,
addXAxisTitleOffset,
yAxisTitlePosition,
convertInteger(yAxisTitleMargin),
@@ -709,6 +719,8 @@ export default function transformProps(
showLegend,
theme,
zoomable,
legendState,
chartPadding,
),
// @ts-ignore
data: series

View File

@@ -0,0 +1,310 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { EChartsCoreOption } from 'echarts/core';
import type { ReactNode } from 'react';
import { AxisType } from '@superset-ui/core';
import {
render,
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
import {
LegendOrientation,
LegendType,
type EchartsHandler,
type EchartsProps,
} from '../types';
import EchartsTimeseries from './EchartsTimeseries';
import {
EchartsTimeseriesSeriesType,
OrientationType,
type EchartsTimeseriesFormData,
type TimeseriesChartTransformedProps,
} from './types';
const mockEchart = jest.fn();
jest.mock('../components/Echart', () => {
const { forwardRef } = jest.requireActual<typeof import('react')>('react');
const MockEchart = forwardRef<EchartsHandler | null, EchartsProps>(
(props, _ref) => {
mockEchart(props);
return null;
},
);
MockEchart.displayName = 'MockEchart';
return {
__esModule: true,
default: MockEchart,
};
});
jest.mock('../components/ExtraControls', () => ({
ExtraControls: ({ children }: { children?: ReactNode }) => (
<div data-testid="extra-controls">{children}</div>
),
}));
const originalResizeObserver = globalThis.ResizeObserver;
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
);
let mockOffsetHeight = 0;
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get() {
return mockOffsetHeight;
},
});
});
afterAll(() => {
if (offsetHeightDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
'offsetHeight',
offsetHeightDescriptor,
);
} else {
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
}
});
afterEach(() => {
cleanup();
mockEchart.mockReset();
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
originalResizeObserver;
});
const defaultFormData: EchartsTimeseriesFormData & {
vizType: string;
dateFormat: string;
numberFormat: string;
granularitySqla?: string;
} = {
annotationLayers: [],
area: false,
colorScheme: undefined,
timeShiftColor: false,
contributionMode: undefined,
forecastEnabled: false,
forecastPeriods: 0,
forecastInterval: 0,
forecastSeasonalityDaily: null,
forecastSeasonalityWeekly: null,
forecastSeasonalityYearly: null,
logAxis: false,
markerEnabled: false,
markerSize: 1,
metrics: [],
minorSplitLine: false,
minorTicks: false,
opacity: 1,
orderDesc: false,
rowLimit: 0,
seriesType: EchartsTimeseriesSeriesType.Line,
stack: null,
stackDimension: '',
timeCompare: [],
tooltipTimeFormat: undefined,
showTooltipTotal: false,
showTooltipPercentage: false,
truncateXAxis: false,
truncateYAxis: false,
yAxisFormat: undefined,
xAxisForceCategorical: false,
xAxisTimeFormat: undefined,
timeGrainSqla: undefined,
forceMaxInterval: false,
xAxisBounds: [null, null],
yAxisBounds: [null, null],
zoomable: false,
richTooltip: false,
xAxisLabelRotation: 0,
xAxisLabelInterval: 0,
showValue: false,
onlyTotal: false,
showExtraControls: true,
percentageThreshold: 0,
orientation: OrientationType.Vertical,
datasource: '1__table',
viz_type: 'echarts_timeseries',
legendMargin: 0,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: false,
legendSort: null,
xAxisTitle: '',
xAxisTitleMargin: 0,
yAxisTitle: '',
yAxisTitleMargin: 0,
yAxisTitlePosition: '',
time_range: 'No filter',
granularity: undefined,
granularity_sqla: undefined,
sql: '',
url_params: {},
custom_params: {},
extra_form_data: {},
adhoc_filters: [],
order_desc: false,
row_limit: 0,
row_offset: 0,
time_grain_sqla: undefined,
vizType: 'echarts_timeseries',
dateFormat: 'smart_date',
numberFormat: 'SMART_NUMBER',
};
const defaultProps: TimeseriesChartTransformedProps = {
echartOptions: {} as EChartsCoreOption,
formData: defaultFormData,
height: 400,
width: 800,
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
onLegendStateChanged: jest.fn(),
refs: {},
emitCrossFilters: false,
coltypeMapping: {},
onLegendScroll: jest.fn(),
groupby: [],
labelMap: {},
setControlValue: jest.fn(),
selectedValues: {},
legendData: [],
xValueFormatter: String,
xAxis: {
label: 'x',
type: AxisType.Time,
},
onFocusedSeries: jest.fn(),
};
function getLatestHeight() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
return props.height;
}
test('observes extra control height changes when ResizeObserver is available', async () => {
const disconnectSpy = jest.fn();
const observeSpy = jest.fn();
class MockResizeObserver implements ResizeObserver {
private static latestInstance: MockResizeObserver | null = null;
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.latestInstance = this;
}
observe = (target: Element) => {
observeSpy(target);
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
unobserve(_target: Element): void {}
disconnect = () => {
disconnectSpy();
};
trigger(entries: ResizeObserverEntry[] = []) {
this.callback(entries, this);
}
static getLatestInstance() {
return this.latestInstance;
}
}
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
mockOffsetHeight = 42;
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(observeSpy).toHaveBeenCalledWith(expect.any(HTMLElement));
mockOffsetHeight = 24;
MockResizeObserver.getLatestInstance()?.trigger();
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(disconnectSpy).not.toHaveBeenCalled();
expect(MockResizeObserver.getLatestInstance()).not.toBeNull();
unmount();
expect(disconnectSpy).toHaveBeenCalled();
});
test('falls back to window resize listener when ResizeObserver is unavailable', async () => {
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
undefined;
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
mockOffsetHeight = 30;
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(addEventListenerSpy).toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
mockOffsetHeight = 10;
window.dispatchEvent(new Event('resize'));
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});

View File

@@ -67,8 +67,32 @@ export default function EchartsTimeseries({
const extraControlRef = useRef<HTMLDivElement>(null);
const [extraControlHeight, setExtraControlHeight] = useState(0);
useEffect(() => {
const updatedHeight = extraControlRef.current?.offsetHeight || 0;
setExtraControlHeight(updatedHeight);
const element = extraControlRef.current;
if (!element) {
setExtraControlHeight(0);
return undefined;
}
const updateHeight = () => {
setExtraControlHeight(element.offsetHeight || 0);
};
updateHeight();
if (typeof ResizeObserver === 'function') {
const resizeObserver = new ResizeObserver(() => {
updateHeight();
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}
window.addEventListener('resize', updateHeight);
return () => {
window.removeEventListener('resize', updateHeight);
};
}, [formData.showExtraControls]);
const hasDimensions = ensureIsArray(groupby).length > 0;

View File

@@ -80,7 +80,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('AXIS TITLE MARGIN'),
label: t('Axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
@@ -113,7 +113,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('AXIS TITLE MARGIN'),
label: t('Axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
@@ -131,7 +131,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
type: 'SelectControl',
freeForm: true,
clearable: false,
label: t('AXIS TITLE POSITION'),
label: t('Axis title position'),
renderTrigger: true,
default: sections.TITLE_POSITION_OPTIONS[0][0],
choices: sections.TITLE_POSITION_OPTIONS,

View File

@@ -27,7 +27,6 @@ import {
sharedControls,
} from '@superset-ui/chart-controls';
import { EchartsTimeseriesSeriesType } from '../../types';
import {
DEFAULT_FORM_DATA,
TIME_SERIES_DESCRIPTION_TEXT,
@@ -52,7 +51,6 @@ const {
minorSplitLine,
opacity,
rowLimit,
seriesType,
truncateYAxis,
yAxisBounds,
} = DEFAULT_FORM_DATA;
@@ -70,27 +68,6 @@ const config: ControlPanelConfig = {
...seriesOrderSection,
['color_scheme'],
['time_shift_color'],
[
{
name: 'seriesType',
config: {
type: 'SelectControl',
label: t('Series Style'),
renderTrigger: true,
default: seriesType,
choices: [
[EchartsTimeseriesSeriesType.Line, t('Line')],
[EchartsTimeseriesSeriesType.Scatter, t('Scatter')],
[EchartsTimeseriesSeriesType.Smooth, t('Smooth Line')],
[EchartsTimeseriesSeriesType.Bar, t('Bar')],
[EchartsTimeseriesSeriesType.Start, t('Step - start')],
[EchartsTimeseriesSeriesType.Middle, t('Step - middle')],
[EchartsTimeseriesSeriesType.End, t('Step - end')],
],
description: t('Series chart type (line, bar etc)'),
},
},
],
...showValueSection,
[
{

View File

@@ -47,7 +47,10 @@ import {
isDerivedSeries,
} from '@superset-ui/chart-controls';
import type { EChartsCoreOption } from 'echarts/core';
import type { LineStyleOption } from 'echarts/types/src/util/types';
import type {
LineStyleOption,
CallbackDataParams,
} from 'echarts/types/src/util/types';
import type { SeriesOption } from 'echarts';
import {
EchartsTimeseriesChartProps,
@@ -195,6 +198,7 @@ export default function transformProps(
zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const refs: Refs = {};
const groupBy = ensureIsArray(groupby);
const labelMap: { [key: string]: string[] } = Object.entries(
@@ -233,6 +237,8 @@ export default function transformProps(
);
const isMultiSeries = groupBy.length || metrics?.length > 1;
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
const xAxisType = getAxisType(stack, xAxisForceCategorical, xAxisDataType);
const [rawSeries, sortedTotalValues, minPositiveValue] = extractSeries(
rebasedData,
@@ -247,6 +253,7 @@ export default function transformProps(
sortSeriesAscending,
xAxisSortSeries: isMultiSeries ? xAxisSort : undefined,
xAxisSortSeriesAscending: isMultiSeries ? xAxisSortAsc : undefined,
xAxisType,
},
);
const showValueIndexes = extractShowValueIndexes(rawSeries, {
@@ -259,9 +266,6 @@ export default function transformProps(
rawSeries.map(series => series.name as string),
);
const isAreaExpand = stack === StackControlsValue.Expand;
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
const xAxisType = getAxisType(stack, xAxisForceCategorical, xAxisDataType);
const series: SeriesOption[] = [];
const forcePercentFormatter = Boolean(contributionMode || isAreaExpand);
@@ -296,7 +300,24 @@ export default function transformProps(
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
let colorScaleKey = getOriginalSeries(seriesName, array);
// If this series name exactly matches a time compare value, it's a time-shifted series
// and we need to find the corresponding original series for color matching
if (array && array.includes(seriesName)) {
// Find the original series (first non-time-compare series)
const originalSeries = rawSeries.find(s => {
const sName = inverted[String(s.name || '')] || String(s.name || '');
return !array.includes(sName);
});
if (originalSeries) {
const originalSeriesName =
inverted[String(originalSeries.name || '')] ||
String(originalSeries.name || '');
colorScaleKey = getOriginalSeries(originalSeriesName, array);
}
}
const transformedSeries = transformSeries(
entry,
@@ -331,6 +352,7 @@ export default function transformProps(
lineStyle,
timeCompare: array,
timeShiftColor,
theme,
},
);
if (transformedSeries) {
@@ -566,16 +588,31 @@ export default function transformProps(
const xValue: number = richTooltip
? params[0].value[xIndex]
: params.value[xIndex];
const forecastValue: any[] = richTooltip ? params : [params];
const forecastValue: CallbackDataParams[] = richTooltip
? params
: [params];
const sortedKeys = extractTooltipKeys(
forecastValue,
yIndex,
richTooltip,
tooltipSortByMetric,
);
const filteredForecastValue = forecastValue.filter(
(item: CallbackDataParams) =>
!annotationLayers.some(
(annotation: AnnotationLayer) =>
item.seriesName === annotation.name,
),
);
const forecastValues: Record<string, ForecastValue> =
extractForecastValuesFromTooltipParams(forecastValue, isHorizontal);
const filteredForecastValues: Record<string, ForecastValue> =
extractForecastValuesFromTooltipParams(
filteredForecastValue,
isHorizontal,
);
const isForecast = Object.values(forecastValues).some(
value =>
value.forecastTrend || value.forecastLower || value.forecastUpper,
@@ -586,7 +623,7 @@ export default function transformProps(
: (getCustomFormatter(customFormatters, metrics) ?? defaultFormatter);
const rows: string[][] = [];
const total = Object.values(forecastValues).reduce(
const total = Object.values(filteredForecastValues).reduce(
(acc, value) =>
value.observation !== undefined ? acc + value.observation : acc,
0,
@@ -608,7 +645,16 @@ export default function transformProps(
seriesName: key,
formatter,
});
if (showPercentage && value.observation !== undefined) {
const annotationRow = annotationLayers.some(
item => item.name === key,
);
if (
showPercentage &&
value.observation !== undefined &&
!annotationRow
) {
row.push(
percentFormatter.format(value.observation / (total || 1)),
);

Some files were not shown because too many files have changed in this diff Show More