Compare commits

...

113 Commits

Author SHA1 Message Date
Beto Dealmeida
8013b32f0e chore: remove is_select_query (#33457) 2025-05-22 20:53:22 -04:00
Beto Dealmeida
adeed60fe0 feat: implement limit extraction in sqlglot (#33456) 2025-05-22 20:09:36 -04:00
Vitor Avila
546945e7a6 fix(AllEntities): Display action buttons according to the user permissions (#33553) 2025-05-22 16:01:26 -03:00
Giampaolo Capelli
5b2f1bbf9e feat(stack by dimension): add a stack by dimension dropdown list (#32707)
Co-authored-by: CAPELLI Giampaolo <giampaolo.capelli@docaposte.fr>
2025-05-22 11:10:18 -03:00
Beto Dealmeida
875f538d54 fix: text => JSON migration util (#33516) 2025-05-22 08:41:38 -04:00
Mike Klumpenaar
b7d3ff1e85 fix(user settings): Update forked cosmo theme to resolve down chevron in caret style (#30514) (#30577)
Co-authored-by: garriscp <garriscp@gmail.com>
2025-05-21 12:32:51 -06:00
Beto Dealmeida
c03964dc5f chore: remove useless-suppression (#33549) 2025-05-21 14:11:58 -04:00
amaannawab923
950a3313d8 fix(table): table sort by fix (#33540)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
Co-authored-by: Geido <60598000+geido@users.noreply.github.com>
2025-05-21 15:00:25 +02:00
Geido
e2a22d481c fix(Select): Add buttonStyle prop for backward compatibility (#33543) 2025-05-20 18:40:23 +02:00
Rafael Benitez
b4e2406385 fix(Sqllab): Autocomplete got stuck in UI when open it too fast (#33522) 2025-05-20 16:38:55 +02:00
Geido
ca9e74edd8 chore(Icons): Additional Ant Design Icons (#33539) 2025-05-20 14:05:18 +02:00
Evan Rusackas
39b3de6b5d fix(CI): adding explicit allowable licenses for python dependencies (#33521) 2025-05-19 15:54:01 -06:00
Maxime Beauchemin
26563bb330 fix: optimize Explore popovers rendering (#33501) 2025-05-19 13:58:42 -07:00
Damian Pendrak
0653e123cc feat(chart): add dynamicQueryObjectCount property to Chart Metadata (#33451) 2025-05-19 14:54:57 +02:00
Alexandru Soare
76358ed64e chore(fab): bumped fab from 4.6.3 to 4.6.4 (#33469) 2025-05-19 12:16:18 +02:00
amaannawab923
217f11a8f7 fix(table): table ui fixes (#33494)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
2025-05-17 13:48:49 -07:00
dependabot[bot]
af21ef2497 chore(deps): bump ace-builds from 1.37.5 to 1.41.0 in /superset-frontend (#33498)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 13:54:11 +07:00
dependabot[bot]
51c25831e8 chore(deps): bump debug from 4.4.0 to 4.4.1 in /superset-websocket/utils/client-ws-app (#33476)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:33:46 +07:00
dependabot[bot]
be41e0526a chore(deps-dev): bump eslint-config-prettier from 10.1.2 to 10.1.5 in /docs (#33491)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:28:10 +07:00
dependabot[bot]
0f240ea1b2 chore(deps-dev): bump webpack from 5.99.7 to 5.99.8 in /docs (#33492)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:27:20 +07:00
dependabot[bot]
e520538af6 chore(deps): bump antd from 5.24.9 to 5.25.1 in /docs (#33490)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:26:50 +07:00
dependabot[bot]
e03d840d06 chore(deps-dev): bump @babel/preset-env from 7.26.7 to 7.27.2 in /superset-frontend (#33499)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 11:26:00 +07:00
Evan Rusackas
1921ba993e fix(dependabot): adds required schedule to uv updates (#33475) 2025-05-16 12:22:11 -07:00
Elizabeth Thompson
b050897ebd fix: allow metadata to parse json (#33444) 2025-05-16 11:27:16 -07:00
Lukas Biermann
0bdd8a223d docs: added europace to INTHEWILD.md (#33458) 2025-05-16 10:11:37 -04:00
Sam Firke
d12f86363f docs(installation): show example of extending Docker image (#33472) 2025-05-16 09:34:25 -04:00
Geido
9f680a63f8 fix(NativeFilters): Apply existing values (#33467) 2025-05-16 13:55:31 +02:00
dependabot[bot]
928a052440 chore(deps): bump express from 4.21.2 to 5.1.0 in /superset-websocket/utils/client-ws-app (#32948)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 17:24:17 -06:00
github-actions[bot]
fbc84a1f9a chore(🦾): bump python shillelagh subpackage(s) (#33278)
Co-authored-by: GitHub Action <action@github.com>
2025-05-14 17:18:44 -06:00
Vladislav Korenkov
fa1693dc5f feat(Pie Chart): threshold for Other (#33348) 2025-05-14 12:20:30 -06:00
sha174n
8a8fb49617 docs: CVEs fixed on 4.1.2 (#33435) 2025-05-14 11:36:58 -06:00
JUST.in DO IT
dc4474889d fix(table-chart): time shift is not working (#33425) 2025-05-14 14:19:21 -03:00
Syed Bariman Jan
29ac507d56 fix(deckgl): fix deckgl multiple layers chart filter and viewport (#33364) 2025-05-13 23:03:14 -07:00
Maxime Beauchemin
7f14e434c8 fix: loading examples in CI returns http error "too many requests" (#33412) 2025-05-13 08:36:12 -07:00
Mehmet Salih Yavuz
21ca26acd7 fix(Row): don't unload charts while embedded to reduce rerenders (#33422) 2025-05-13 15:32:39 +02:00
Damian Pendrak
33e48146b0 chore: Add missing ECharts tags (#33397) 2025-05-12 18:10:04 +02:00
irodriguez-nebustream
73701b7295 fix(embedded): handle SUPERSET_APP_ROOT in embedded dashboard URLs (#33356)
Co-authored-by: Irving Rodriguez <irodriguez@Mac.lan>
2025-05-09 15:25:40 -07:00
amaannawab923
22475e787e feat(Table Chart): Row limit Increase , Backend Sorting , Backend Search , Excel/CSV Improvements (#33357)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
2025-05-09 11:27:31 -06:00
VED PRAKASH KASHYAP
9e38a0cc29 docs: fix for role sync issues in case of custom OAuth2 configuration (#30878) 2025-05-09 11:12:23 -06:00
Rafael Benitez
a391ebecca feat: Run SQL on DataSourceEditor implementation (#33340) 2025-05-09 17:35:59 +02:00
Vitor Avila
72cd9dffa3 fix: Persist catalog change during dataset update + validation fixes (#33384) 2025-05-08 15:22:25 -03:00
Đỗ Trọng Hải
4ed05f4ff1 fix(be/utils): sync cache timeout for memoized function (#31917)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2025-05-07 15:45:15 -06:00
Shao Yu-Lung (Allen)
871cfe0c78 fix(i18n): zh_TW pybabel compile error: placeholders are incompatible (#33345) 2025-05-07 15:18:05 -06:00
Fardin Mustaque
a928f8cd9e feat: add metric name for big number chart types #33013 (#33099)
Co-authored-by: Fardin Mustaque <fardinmustaque@Fardins-Mac-mini.local>
2025-05-07 16:56:02 +02:00
dependabot[bot]
afaaf64f52 chore(deps): bump antd from 5.24.5 to 5.24.9 in /docs (#33319)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-07 21:14:46 +07:00
Beto Dealmeida
dc0d542054 chore: regenerate openapi.json (#33378) 2025-05-06 15:56:00 -07:00
github-actions[bot]
0cd3a12daa chore(🦾): bump python markdown 3.7 -> 3.8 (#33279)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Maxime Beauchemin <maximebeauchemin@gmail.com>
2025-05-06 09:06:59 -07:00
amaannawab923
35b30480f0 fix: Exclude Filter Values (#33271)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
2025-05-06 13:40:27 +02:00
github-actions[bot]
6d1f17bd46 chore(🦾): bump python sshtunnel subpackage(s) (#33370)
Co-authored-by: GitHub Action <action@github.com>
2025-05-05 18:30:08 -07:00
github-actions[bot]
ab899e71e7 chore(🦾): bump python cryptography 44.0.2 -> 44.0.3 (#33371)
Co-authored-by: GitHub Action <action@github.com>
2025-05-05 18:29:40 -07:00
github-actions[bot]
6b9d8708d3 chore(🦾): bump python humanize 4.12.2 -> 4.12.3 (#33369)
Co-authored-by: GitHub Action <action@github.com>
2025-05-05 18:29:10 -07:00
github-actions[bot]
bc1e8e07cf chore(🦾): bump python sqlglot 26.16.2 -> 26.16.4 (#33368)
Co-authored-by: GitHub Action <action@github.com>
2025-05-05 18:28:56 -07:00
github-actions[bot]
82526865d2 chore(🦾): bump python h11 0.14.0 -> 0.16.0 (#33339)
Co-authored-by: GitHub Action <action@github.com>
2025-05-06 07:41:10 +08:00
Daniel Vaz Gaspar
02c8c9c752 fix: bump FAB to 4.6.3 (#33363) 2025-05-06 00:02:25 +01:00
dependabot[bot]
6475188e6a chore(deps): bump swagger-ui-react from 5.20.2 to 5.21.0 in /docs (#33318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 16:34:20 -06:00
dependabot[bot]
6e485c9f70 chore(deps-dev): update ts-loader requirement from ^9.5.1 to ^9.5.2 in /superset-frontend/packages/superset-ui-demo (#33323)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 16:33:58 -06:00
dependabot[bot]
b49e5857c9 chore(deps): bump uuid from 11.0.2 to 11.1.0 in /superset-websocket (#33311)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 16:33:18 -06:00
dependabot[bot]
13ced58261 chore(deps-dev): bump @eslint/js from 9.17.0 to 9.25.1 in /superset-websocket (#33312)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 16:32:59 -06:00
dependabot[bot]
ed36674a99 chore(deps): bump less from 4.2.2 to 4.3.0 in /docs (#33317)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 16:32:28 -06:00
Jonas DOREL
99aa3a6507 docs(docker-builds.mdx): clarify dockerize images (#33350) 2025-05-05 16:31:33 -06:00
Maxime Beauchemin
f045a73e2d fix: loading examples from raw.githubusercontent.com fails with 429 errors (#33354) 2025-05-05 13:07:23 +02:00
dependabot[bot]
7791674f24 chore(deps-dev): bump eslint-config-prettier from 10.1.1 to 10.1.2 in /docs (#33315)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 15:37:33 -06:00
Vitor Avila
9f0ae77341 fix: Edge case with metric not getting quoted in sort by when normalize_columns is enabled (#33337) 2025-05-02 18:20:57 -07:00
dependabot[bot]
5a9e366c0a chore(deps-dev): bump typescript from 5.8.2 to 5.8.3 in /docs (#33320)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 11:00:13 -06:00
dependabot[bot]
c22c532a5c chore(deps-dev): bump eslint-plugin-react from 7.37.4 to 7.37.5 in /docs (#33314)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 10:59:53 -06:00
Michael S. Molina
6db3a4d9d2 fix: Temporal filter conversion in viz migrations (#33224) 2025-05-02 08:27:49 -03:00
dependabot[bot]
17d7b72f3b chore(deps-dev): bump webpack from 5.98.0 to 5.99.7 in /docs (#33316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 14:31:29 +07:00
dependabot[bot]
fee33dd0cf chore(deps): bump @rjsf/validator-ajv8 from 5.24.1 to 5.24.9 in /superset-frontend (#33321)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 14:29:44 +07:00
dependabot[bot]
65605b4a54 chore(deps-dev): bump @babel/plugin-transform-runtime from 7.25.9 to 7.27.1 in /superset-frontend (#33332)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 14:28:41 +07:00
dependabot[bot]
e304f2d5ad chore(deps): bump react-intersection-observer from 9.15.1 to 9.16.0 in /superset-frontend (#33333)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-01 16:22:16 -07:00
Beto Dealmeida
4e0c261c9d fix: show only filterable columns on filter dropdown (#33338) 2025-05-01 18:36:32 -04:00
Beto Dealmeida
22de26cd77 fix: metric.currency should be JSON, not string (#33303) 2025-05-01 18:16:51 -04:00
Beto Dealmeida
339ba96600 fix: improve function detection (#33306) 2025-05-01 13:45:03 -04:00
Phillip LeBlanc
3c6091144b chore(deps): Upgrade pyarrow to 18.1.0 (#31476)
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
2025-04-30 22:31:47 -06:00
JUST.in DO IT
ef14b529b8 fix(echarts): rename time series shifted colnames (#33269) 2025-04-30 14:18:18 -03:00
github-actions[bot]
2a97a6ec1f chore(🦾): bump python importlib-metadata 8.6.1 -> 8.7.0 (#33277)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 10:03:18 -07:00
github-actions[bot]
fa6548939e chore(🦾): bump python mako 1.3.9 -> 1.3.10 (#33280)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 10:02:45 -07:00
github-actions[bot]
418c673699 chore(🦾): bump python pyparsing 3.2.2 -> 3.2.3 (#33281)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 10:01:42 -07:00
github-actions[bot]
13f77a7416 chore(🦾): bump python celery 5.4.0 -> 5.5.2 (#33257)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:28:39 -07:00
github-actions[bot]
303a80a316 chore(🦾): bump python packaging 24.2 -> 25.0 (#33259)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:27:48 -07:00
github-actions[bot]
2392ac6827 chore(🦾): bump python deprecation subpackage(s) (#33260)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:27:25 -07:00
github-actions[bot]
01ce4b987e chore(🦾): bump python python-dotenv 1.0.1 -> 1.1.0 (#33262)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:26:59 -07:00
github-actions[bot]
2f308a85d8 chore(🦾): bump python pandas subpackage(s) (#33263)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:26:40 -07:00
github-actions[bot]
e8d60509a0 chore(🦾): bump python sqlglot 26.11.1 -> 26.16.2 (#33266)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:26:01 -07:00
github-actions[bot]
d6f80eaae7 chore(🦾): bump python gunicorn subpackage(s) (#33265)
Co-authored-by: GitHub Action <action@github.com>
2025-04-29 08:25:23 -07:00
Emad Rad
a5f986fec5 feat: Persian translations (#29580) 2025-04-29 09:01:34 -06:00
Beto Dealmeida
141d0252f2 fix: mask password on DB import (#33267) 2025-04-29 10:27:03 -04:00
Daniel Vaz Gaspar
c029b532d4 fix: LocalProxy is not mapped warning (#33025) 2025-04-28 23:01:26 -06:00
github-actions[bot]
13816443ba chore(🦾): bump python croniter subpackage(s) (#33258)
Co-authored-by: GitHub Action <action@github.com>
2025-04-28 16:52:09 -07:00
Elizabeth Thompson
2c4e22e598 chore: add some utils tests (#33236) 2025-04-28 15:00:32 -07:00
Hamir Mahal
aea776a131 fix: Unexpected input(s) 'depth' CI warnings (#33254) 2025-04-28 11:07:13 -06:00
Evan Rusackas
d2360b533b fix(histogram): remove extra single quotes (#33248) 2025-04-25 16:45:05 -06:00
Vitor Avila
de84a534ac fix(DB update): Gracefully handle querry error during DB update (#33250) 2025-04-25 15:38:59 -03:00
Sam Firke
ac636c73ae fix(heatmap): correctly render int and boolean falsy values on axes (#33238) 2025-04-25 11:25:50 -04:00
Levis Mbote
6a586fe4fd fix(chart): Restore subheader used in bignumber with trendline (#33196) 2025-04-25 09:39:07 -03:00
Vitor Avila
fbd8ae2888 fix(sqllab permalink): Commit SQL Lab permalinks (#33237) 2025-04-24 22:41:15 -03:00
Vitor Avila
7e4fde7a14 fix(standalone): Ensure correct URL param value for standalone mode (#33234) 2025-04-24 16:41:42 -03:00
Evan Rusackas
150b9a0168 feat(maps): Adding Republic of Serbia to country maps (#33208)
Co-authored-by: dykoffi <dykoffi@users.noreply.github.com>
2025-04-23 11:29:35 -06:00
Vitor Avila
f7b7aace38 fix(export): Full CSV/Excel exports respecting SQL_MAX_ROW config (#33214) 2025-04-23 13:13:07 -03:00
Sam Firke
f78c94c988 docs(installation): compare installation methods (#33137) 2025-04-23 11:57:33 -04:00
sha174n
74ff8dc724 docs: Add note on SQL execution security considerations (#33210) 2025-04-23 13:58:33 +01:00
Shao Yu-Lung (Allen)
8aa127eac2 feat(i18n): Frontend add zh_TW Option (#33192)
Co-authored-by: Shao Yu-Lung (Allen) <mis@cendai.com.tw>
2025-04-22 15:36:09 -06:00
Kalai
3729016a0d docs: improve documentation(docs): clarify URL encoding requirement for connection strings (#30047)
Co-authored-by: Evan Rusackas <evan@preset.io>
2025-04-22 15:30:19 -06:00
Elizabeth Thompson
b6628cdfd2 chore: migrate to more db migration utils (#33155) 2025-04-22 11:26:54 -07:00
Evan Rusackas
ae48dba3e1 feat(maps): Adding Ivory Coast / Côte d'Ivoire (#33198)
Co-authored-by: dykoffi <dykoffi@users.noreply.github.com>
2025-04-22 10:04:19 -06:00
dependabot[bot]
09364d182c chore(deps-dev): bump http-proxy-middleware from 2.0.7 to 2.0.9 in /superset-frontend (#33197)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 09:31:10 -06:00
Geido
99ed968289 fix(Native Filters): Keep default filter values when configuring creatable behavior (#33205) 2025-04-22 16:32:30 +02:00
Geido
8fa3b8d7e3 fix(Native Filters): Keep default filter values when configuring creatable behavior (#33205) 2025-04-22 16:30:36 +02:00
Maxime Alay-Eddine
7530487760 feat(country-map): fix France Regions IDF region code - Fixes #32627 (#32695)
Co-authored-by: Maxime ALAY-EDDINE <maxime@galeax.com>
2025-04-21 20:15:27 -06:00
Maxime Beauchemin
79afc2b545 docs: add a high-level architecture diagram to the docs (#33173) 2025-04-21 11:15:29 -07:00
JUST.in DO IT
8c94f9c435 fix(sqllab): Invalid SQL Error breaks SQL Lab (#33164) 2025-04-18 13:31:54 -07:00
Evan Rusackas
b589d44dfb fix(deckgl): Update Arc to properly adjust line width (#33154) 2025-04-18 10:07:40 -06:00
Elizabeth Thompson
4140261797 fix: subheader should show as subtitle (#33172) 2025-04-18 13:03:20 +08:00
246 changed files with 55563 additions and 28142 deletions

View File

@@ -27,6 +27,8 @@ updates:
- package-ecosystem: "uv"
directory: "requirements/"
open-pull-requests-limit: 10
schedule:
interval: "weekly"
labels:
- uv
- dependabot

5
.github/labeler.yml vendored
View File

@@ -127,6 +127,11 @@
- any-glob-to-any-file:
- 'superset/translations/es/**'
"i18n:persian":
- changed-files:
- any-glob-to-any-file:
- 'superset/translations/fa/**'
############################################
# Sub-projects and monorepo packages
############################################

View File

@@ -17,13 +17,12 @@ jobs:
check-python-deps:
runs-on: ubuntu-22.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
depth: 1
fetch-depth: 1
- name: Setup Python
if: steps.check.outputs.python

View File

@@ -48,6 +48,8 @@ jobs:
allow-dependencies-licenses: pkg:npm/store2@2.14.2, pkg:npm/applitools/core, pkg:npm/applitools/core-base, pkg:npm/applitools/css-tree, pkg:npm/applitools/ec-client, pkg:npm/applitools/eg-socks5-proxy-server, pkg:npm/applitools/eyes, pkg:npm/applitools/eyes-cypress, pkg:npm/applitools/nml-client, pkg:npm/applitools/tunnel-client, pkg:npm/applitools/utils, pkg:npm/node-forge@1.3.1, pkg:npm/rgbcolor, pkg:npm/jszip@3.10.1
python-dependency-liccheck:
# NOTE: Configuration for liccheck lives in our pyproject.yml.
# You cannot use a liccheck.ini file in this workflow.
runs-on: ubuntu-22.04
steps:
- name: "Checkout Repository"

View File

@@ -43,6 +43,7 @@ Join our growing community!
- [Cape Crypto](https://capecrypto.com)
- [Capital Service S.A.](https://capitalservice.pl) [@pkonarzewski]
- [Clark.de](https://clark.de/)
- [Europace](https://europace.de)
- [KarrotPay](https://www.daangnpay.com/)
- [Remita](https://remita.net) [@mujibishola]
- [Taveo](https://www.taveo.com) [@codek]

View File

@@ -86,6 +86,7 @@
"Israel",
"Italy",
"Italy (regions)",
"Ivory Coast",
"Japan",
"Jordan",
"Kazakhstan",
@@ -143,6 +144,7 @@
"Poland",
"Portugal",
"Qatar",
"Republic Of Serbia",
"Romania",
"Russia",
"Rwanda",

View File

@@ -302,6 +302,15 @@ AUTH_USER_REGISTRATION = True
AUTH_USER_REGISTRATION_ROLE = "Public"
```
In case you want to assign the `Admin` role on new user registration, it can be assigned as follows:
```python
AUTH_USER_REGISTRATION_ROLE = "Admin"
```
If you encounter the [issue](https://github.com/apache/superset/issues/13243) of not being able to list users from the Superset main page settings, although a newly registered user has an `Admin` role, please re-run `superset init` to sync the required permissions. Below is the command to re-run `superset init` using docker compose.
```
docker-compose exec superset superset init
```
Then, create a `CustomSsoSecurityManager` that extends `SupersetSecurityManager` and overrides
`oauth_user_info`:

View File

@@ -1293,6 +1293,13 @@ The connection string for SQL Server looks like this:
mssql+pyodbc:///?odbc_connect=Driver%3D%7BODBC+Driver+17+for+SQL+Server%7D%3BServer%3Dtcp%3A%3Cmy_server%3E%2C1433%3BDatabase%3Dmy_database%3BUid%3Dmy_user_name%3BPwd%3Dmy_password%3BEncrypt%3Dyes%3BConnection+Timeout%3D30
```
:::note
You might have noticed that some special charecters are used in the above connection string. For example see the `odbc_connect` parameter. The value is `Driver%3D%7BODBC+Driver+17+for+SQL+Server%7D%3B` which is a URL-encoded form of `Driver={ODBC+Driver+17+for+SQL+Server};`. It's important to give the connection string is URL encoded.
For more information about this check the [sqlalchemy documentation](https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords). Which says `When constructing a fully formed URL string to pass to create_engine(), special characters such as those that may be used in the user and password need to be URL encoded to be parsed correctly. This includes the @ sign.`
:::
#### StarRocks
The [sqlalchemy-starrocks](https://pypi.org/project/starrocks/) library is the recommended

View File

@@ -4,9 +4,95 @@ version: 1
---
import InteractiveSVG from '../../src/components/InteractiveERDSVG';
import Mermaid from '@theme/Mermaid';
# Resources
## High Level Architecture
<div style={{ maxWidth: "600px", margin: "0 auto", marginLeft: 0, marginRight: "auto" }}>
```mermaid
flowchart TD
%% Top Level
LB["<b>Load Balancer(s)</b><br/>(optional)"]
LB -.-> WebServers
%% Web Servers
subgraph WebServers ["<b>Web Server(s)</b>"]
WS1["<b>Frontend</b><br/>(React, AntD, ECharts, AGGrid)"]
WS2["<b>Backend</b><br/>(Python, Flask, SQLAlchemy, Pandas, ...)"]
end
%% Infra
subgraph InfraServices ["<b>Infra</b>"]
DB[("<b>Metadata Database</b><br/>(Postgres / MySQL)")]
subgraph Caching ["<b>Caching Subservices<br/></b>(Redis, memcache, S3, ...)"]
direction LR
DummySpace[" "]:::invisible
QueryCache["<b>Query Results Cache</b><br/>(Accelerated Dashboards)"]
CsvCache["<b>CSV Exports Cache</b>"]
ThumbnailCache["<b>Thumbnails Cache</b>"]
AlertImageCache["<b>Alert/Report Images Cache</b>"]
QueryCache -- " " --> CsvCache
linkStyle 1 stroke:transparent;
ThumbnailCache -- " " --> AlertImageCache
linkStyle 2 stroke:transparent;
end
Broker(("<b>Message Queue</b><br/>(Redis / RabbitMQ / SQS)"))
end
AsyncBackend["<b>Async Workers (Celery)</b><br>required for Alerts & Reports, thumbnails, CSV exports, long-running workloads, ..."]
%% External DBs
subgraph ExternalDatabases ["<b>Analytics Databases</b>"]
direction LR
BigQuery[(BigQuery)]
Snowflake[(Snowflake)]
Redshift[(Redshift)]
Postgres[(Postgres)]
Postgres[(... any ...)]
end
%% Connections
LB -.-> WebServers
WebServers --> DB
WebServers -.-> Caching
WebServers -.-> Broker
WebServers -.-> ExternalDatabases
Broker -.-> AsyncBackend
AsyncBackend -.-> ExternalDatabases
AsyncBackend -.-> Caching
%% Legend styling
classDef requiredNode stroke-width:2px,stroke:black;
class Required requiredNode;
class Optional optionalNode;
%% Hide real arrow
linkStyle 0 stroke:transparent;
%% Styling
classDef optionalNode stroke-dasharray: 5 5, opacity:0.9;
class LB optionalNode;
class Caching optionalNode;
class AsyncBackend optionalNode;
class Broker optionalNode;
class QueryCache optionalNode;
class CsvCache optionalNode;
class ThumbnailCache optionalNode;
class AlertImageCache optionalNode;
class Celery optionalNode;
classDef invisible fill:transparent,stroke:transparent;
```
</div>
## Entity-Relationship Diagram
Here is our interactive ERD:

View File

@@ -1,7 +1,7 @@
---
title: Docker Builds
hide_title: true
sidebar_position: 6
sidebar_position: 7
version: 1
---
@@ -44,7 +44,7 @@ Here are the build presets that are exposed through the `supersetbot docker` uti
- `py311`, e.g., Py311: Similar to lean but with a different Python version (in this example, 3.11).
- `ci`: For certain CI workloads.
- `websocket`: For Superset clusters supporting advanced features.
- `dockerize`: Used by Helm.
- `dockerize`: Used by Helm in initContainers to wait for database dependencies to be available.
## Key tags examples
@@ -64,6 +64,51 @@ check the [supersetbot docker](https://github.com/apache-superset/supersetbot)
subcommand and the [docker.yml](https://github.com/apache/superset/blob/master/.github/workflows/docker.yml)
GitHub action.
## Building your own production Docker image
Every Superset deployment will require its own set of drivers depending on the data warehouse(s),
etc. so we recommend that users build their own Docker image by extending the `lean` image.
Here's an example Dockerfile that does this. Follow the in-line comments to customize it for
your desired Superset version and database drivers. The comments also note that a certain feature flag will
have to be enabled in your config file.
You would build the image with `docker build -t mysuperset:latest .` or `docker build -t ourcompanysuperset:4.1.2 .`
```Dockerfile
# change this to apache/superset:4.1.2 or whatever version you want to build from;
# otherwise the default is the latest commit on GitHub master branch
FROM apache/superset:master
USER root
# Set environment variable for Playwright
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers
# Install packages using uv into the virtual environment
# Superset started using uv after the 4.1 branch; if you are building from apache/superset:4.1.x,
# replace the first two lines with RUN pip install \
RUN . /app/.venv/bin/activate && \
uv pip install \
# install psycopg2 for using PostgreSQL metadata store - could be a MySQL package if using that backend:
psycopg2-binary \
# add the driver(s) for your data warehouse(s), in this example we're showing for Microsoft SQL Server:
pymssql \
# package needed for using single-sign on authentication:
Authlib \
# install Playwright for taking screenshots for Alerts & Reports. This assumes the feature flag PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled
# That feature flag will default to True starting in 6.0.0
# Playwright works only with Chrome.
# If you are still using Selenium instead of Playwright, you would instead install here the selenium package and a headless browser & webdriver
playwright \
&& PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers playwright install chromium
# Switch back to the superset user
USER superset
CMD ["/app/docker/entrypoints/run-server.sh"]
```
## Key ARGs in Dockerfile
- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the

View File

@@ -1,7 +1,7 @@
---
title: Docker Compose
hide_title: true
sidebar_position: 4
sidebar_position: 5
version: 1
---

View File

@@ -0,0 +1,56 @@
---
title: Installation Methods
hide_title: true
sidebar_position: 2
version: 1
---
import useBaseUrl from "@docusaurus/useBaseUrl";
# Installation Methods
How should you install Superset? Here's a comparison of the different options. It will help if you've first read the [Architecture](/docs/installation/architecture.mdx) page to understand Superset's different components.
The fundamental trade-off is between you needing to do more of the detail work yourself vs. using a more complex deployment route that handles those details.
## [Docker Compose](/docs/installation/docker-compose.mdx)
**Summary:** This takes advantage of containerization while remaining simpler than Kubernetes. This is the best way to try out Superset; it's also useful for developing & contributing back to Superset.
If you're not just demoing the software, you'll need a moderate understanding of Docker to customize your deployment and avoid a few risks. Even when fully-optimized this is not as robust a method as Kubernetes when it comes to large-scale production deployments.
You manage a superset-config.py file and a docker-compose.yml file. Docker Compose brings up all the needed services - the Superset application, a Postgres metadata DB, Redis cache, Celery worker and beat. They are automatically connected to each other.
**Responsibilities**
You will need to back up your metadata DB. That could mean backing up the service running as a Docker container and its volume; ideally you are running Postgres as a service outside of that container and backing up that service.
You will also need to extend the Superset docker image. The default `lean` images do not contain drivers needed to access your metadata database (Postgres or MySQL), nor to access your data warehouse, nor the headless browser needed for Alerts & Reports. You could run a `-dev` image while demoing Superset, which has some of this, but you'll still need to install the driver for your data warehouse. The `-dev` images run as root, which is not recommended for production.
Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs. See [Building your own production Docker image](/docs/installation/docker-builds/#building-your-own-production-docker-image).
## [Kubernetes (K8s)](/docs/installation/kubernetes.mdx)
**Summary:** This is the best-practice way to deploy a production instance of Superset, but has the steepest skill requirement - someone who knows Kubernetes.
You will deploy Superset into a K8s cluster. The most common method is using the community-maintained Helm chart, though work is now underway to implement [SIP-149 - a Kubernetes Operator for Superset](https://github.com/apache/superset/issues/31408).
A K8s deployment can scale up and down based on usage and deploy rolling updates with zero downtime - features that big deployments appreciate.
**Responsibilities**
You will need to build your own Docker image, and back up your metadata DB, both as described in Docker Compose above. You'll also need to customize your Helm chart values and deploy and maintain your Kubernetes cluster.
## [PyPI (Python)](/docs/installation/pypi.mdx)
**Summary:** This is the only method that requires no knowledge of containers. It requires the most hands-on work to deploy, connect, and maintain each component.
You install Superset as a Python package and run it that way, providing your own metadata database. Superset has documentation on how to install this way, but it is updated infrequently.
If you want caching, you'll set up Redis or RabbitMQ. If you want Alerts & Reports, you'll set up Celery.
**Responsibilities**
You will need to get the component services running and communicating with each other. You'll need to arrange backups of your metadata database.
When upgrading, you'll need to manage the system environment and packages and ensure all components have functional dependencies.

View File

@@ -1,7 +1,7 @@
---
title: Kubernetes
hide_title: true
sidebar_position: 2
sidebar_position: 3
version: 1
---

View File

@@ -1,7 +1,7 @@
---
title: PyPI
hide_title: true
sidebar_position: 3
sidebar_position: 4
version: 1
---

View File

@@ -1,7 +1,7 @@
---
title: Upgrading Superset
hide_title: true
sidebar_position: 5
sidebar_position: 6
version: 1
---

View File

@@ -2,6 +2,12 @@
title: CVEs fixed by release
sidebar_position: 2
---
#### Version 4.1.2
| CVE | Title | Affected |
|:---------------|:-----------------------------------------------------------------------------------|---------:|
| CVE-2025-27696 | Improper authorization leading to resource ownership takeover | < 4.1.2 |
#### Version 4.1.0
| CVE | Title | Affected |

View File

@@ -64,6 +64,26 @@ tables in the **Permissions** dropdown. To select the data sources you want to a
You can then confirm with users assigned to the **Gamma** role that they see the
objects (dashboards and slices) associated with the tables you just extended them.
### SQL Execution Security Considerations
Apache Superset includes features designed to provide safeguards when interacting with connected databases, such as the `DISALLOWED_SQL_FUNCTIONS` configuration setting. This aims to prevent the execution of potentially harmful database functions or system variables directly from Superset interfaces like SQL Lab.
However, it is crucial to understand the following:
**Superset is Not a Database Firewall**: Superset's built-in checks, like `DISALLOWED_SQL_FUNCTIONS`, provide a layer of protection but cannot guarantee complete security against all database-level threats or advanced bypass techniques (like specific comment injection methods). They should be viewed as a supplement to, not a replacement for, robust database security.
**Configuration is Key**: The effectiveness of Superset's safeguards heavily depends on proper configuration by the Superset administrator. This includes maintaining the `DISALLOWED_SQL_FUNCTIONS` list, carefully managing feature flags (like `ENABLE_TEMPLATE_PROCESSING`), and configuring other security settings appropriately.
**Database Security is Paramount**: The ultimate responsibility for securing database access, controlling permissions, and preventing unauthorized function execution lies with the database administrators (DBAs) and security teams managing the underlying database instance.
**Recommended Database Practices**: We strongly recommend implementing security best practices at the database level, including:
* **Least Privilege**: Connecting Superset using dedicated database user accounts with the minimum permissions required for Superset's operation (typically read-only access to necessary schemas/tables).
* **Database Roles & Permissions**: Utilizing database-native roles and permissions to restrict access to sensitive functions, system variables (like `@@hostname`), schemas, or tables.
* **Network Security**: Employing network-level controls like database firewalls or proxies to restrict connections.
* **Auditing**: Enabling database-level auditing to monitor executed queries and access patterns.
By combining Superset's configurable safeguards with strong database-level security practices, you can achieve a more robust and layered security posture.
### REST API for user & role management
Flask-AppBuilder supports a REST API for user CRUD,

View File

@@ -31,10 +31,13 @@ const config: Config = {
baseUrl: '/',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'throw',
markdown: {
mermaid: true,
},
favicon: '/img/favicon.ico',
organizationName: 'apache',
projectName: 'superset',
themes: ['@saucelabs/theme-github-codeblock'],
themes: ['@saucelabs/theme-github-codeblock', '@docusaurus/theme-mermaid'],
plugins: [
[
'docusaurus-plugin-less',

View File

@@ -19,22 +19,23 @@
},
"dependencies": {
"@ant-design/icons": "^5.5.2",
"@docusaurus/core": "^3.5.2",
"@docusaurus/plugin-client-redirects": "^3.5.2",
"@docusaurus/preset-classic": "^3.5.2",
"@docusaurus/core": "3.7.0",
"@docusaurus/plugin-client-redirects": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@docusaurus/theme-mermaid": "3.7.0",
"@emotion/styled": "^10.0.27",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@superset-ui/style": "^0.14.23",
"antd": "^5.24.5",
"antd": "^5.25.1",
"docusaurus-plugin-less": "^2.0.2",
"less": "^4.2.2",
"less": "^4.3.0",
"less-loader": "^11.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-github-btn": "^1.4.0",
"react-svg-pan-zoom": "^3.13.1",
"swagger-ui-react": "^5.20.2"
"swagger-ui-react": "^5.21.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.7.0",
@@ -43,12 +44,12 @@
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^10.1.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.0.0",
"eslint-plugin-react": "^7.37.5",
"prettier": "^2.0.0",
"typescript": "~5.8.2",
"webpack": "^5.98.0"
"typescript": "~5.8.3",
"webpack": "^5.99.8"
},
"browserslist": {
"production": [

View File

@@ -111,7 +111,7 @@ const StyledTitleContainer = styled('div')`
}
`;
const StyledButton = styled(Link)`
const StyledButton = styled(Link as React.ComponentType<any>)`
border-radius: 10px;
font-size: 20px;
font-weight: bold;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,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.6.1, <5.0.0",
"flask-appbuilder>=4.6.4, <5.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -81,7 +81,7 @@ 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>=14.0.1, <15",
"pyarrow>=18.1.0, <19",
"pyyaml>=6.0.0, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=4.6.0, <5.0",
@@ -240,6 +240,12 @@ disallow_untyped_calls = false
disallow_untyped_defs = false
disable_error_code = "annotation-unchecked"
# TODO: remove this once cryptography is fixed, introduced in cryptography 44.0.3
[[tool.mypy.overrides]]
module = "cryptography.*"
ignore_errors = true
follow_imports = "skip"
[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
@@ -272,7 +278,6 @@ exclude = [
"venv",
]
# Same as Black.
line-length = 88
indent-width = 4
@@ -367,17 +372,21 @@ docstring-code-line-length = "dynamic"
requirement_txt_file = "requirements/base.txt"
authorized_licenses = [
"academic free license (afl)",
"any-osi",
"apache license 2.0",
"apache software",
"apache software, bsd",
"bsd",
"bsd-3-clause",
"isc license (iscl)",
"isc license",
"mit",
"mozilla public license 2.0 (mpl 2.0)",
"osi approved",
"osi approved",
"psf-2.0",
"python software foundation",
"simplified bsd",
"the unlicense (unlicense)",
"the unlicense",
]

View File

@@ -1,6 +1,6 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml requirements/base.in -o requirements/base.txt
alembic==1.15.1
alembic==1.15.2
# via flask-migrate
amqp==5.3.1
# via kombu
@@ -8,7 +8,7 @@ apispec==6.6.1
# via
# -r requirements/base.in
# flask-appbuilder
apsw==3.49.1.0
apsw==3.49.2.0
# via shillelagh
async-timeout==4.0.3
# via
@@ -32,7 +32,7 @@ billiard==4.2.1
# via celery
blinker==1.9.0
# via flask
bottleneck==1.4.2
bottleneck==1.5.0
# via apache-superset (pyproject.toml)
brotli==1.1.0
# via flask-compress
@@ -42,11 +42,11 @@ cachelib==0.13.0
# flask-session
cachetools==5.5.2
# via google-auth
cattrs==24.1.2
cattrs==24.1.3
# via requests-cache
celery==5.4.0
celery==5.5.2
# via apache-superset (pyproject.toml)
certifi==2025.1.31
certifi==2025.4.26
# via
# requests
# selenium
@@ -54,9 +54,9 @@ cffi==1.17.1
# via
# cryptography
# pynacl
charset-normalizer==3.4.1
charset-normalizer==3.4.2
# via requests
click==8.1.8
click==8.2.0
# via
# apache-superset (pyproject.toml)
# celery
@@ -82,7 +82,7 @@ cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
# via apache-superset (pyproject.toml)
cryptography==44.0.2
cryptography==44.0.3
# via
# apache-superset (pyproject.toml)
# paramiko
@@ -99,7 +99,7 @@ email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
exceptiongroup==1.2.2
exceptiongroup==1.3.0
# via
# cattrs
# trio
@@ -118,7 +118,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==4.6.1
flask-appbuilder==4.6.4
# via apache-superset (pyproject.toml)
flask-babel==2.0.0
# via flask-appbuilder
@@ -152,7 +152,7 @@ geographiclib==2.0
# via geopy
geopy==2.4.1
# via apache-superset (pyproject.toml)
google-auth==2.38.0
google-auth==2.40.1
# via shillelagh
greenlet==3.1.1
# via
@@ -160,20 +160,21 @@ greenlet==3.1.1
# shillelagh
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.14.0
h11==0.16.0
# via wsproto
hashids==1.3.1
# via apache-superset (pyproject.toml)
holidays==0.25
# via apache-superset (pyproject.toml)
humanize==4.12.2
humanize==4.12.3
# via apache-superset (pyproject.toml)
idna==3.10
# via
# email-validator
# requests
# trio
importlib-metadata==8.6.1
# url-normalize
importlib-metadata==8.7.0
# via apache-superset (pyproject.toml)
isodate==0.7.2
# via apache-superset (pyproject.toml)
@@ -189,19 +190,19 @@ jsonpath-ng==1.7.0
# via apache-superset (pyproject.toml)
jsonschema==4.23.0
# via flask-appbuilder
jsonschema-specifications==2024.10.1
jsonschema-specifications==2025.4.1
# via jsonschema
kombu==5.5.0
kombu==5.5.3
# via celery
korean-lunar-calendar==0.3.1
# via holidays
limits==4.4.1
limits==5.1.0
# via flask-limiter
mako==1.3.9
mako==1.3.10
# via
# apache-superset (pyproject.toml)
# alembic
markdown==3.7
markdown==3.8
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -235,7 +236,6 @@ numpy==1.26.4
# bottleneck
# numexpr
# pandas
# pyarrow
odfpy==1.4.1
# via pandas
openpyxl==3.1.5
@@ -243,8 +243,10 @@ openpyxl==3.1.5
ordered-set==4.1.0
# via flask-limiter
outcome==1.3.0.post0
# via trio
packaging==24.2
# via
# trio
# trio-websocket
packaging==25.0
# via
# apache-superset (pyproject.toml)
# apispec
@@ -263,7 +265,7 @@ parsedatetime==2.6
# via apache-superset (pyproject.toml)
pgsanity==0.2.9
# via apache-superset (pyproject.toml)
platformdirs==4.3.7
platformdirs==4.3.8
# via requests-cache
ply==3.11
# via jsonpath-ng
@@ -271,15 +273,15 @@ polyline==2.0.2
# via apache-superset (pyproject.toml)
prison==0.2.1
# via flask-appbuilder
prompt-toolkit==3.0.50
prompt-toolkit==3.0.51
# via click-repl
pyarrow==14.0.2
pyarrow==18.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.1
# via
# pyasn1-modules
# rsa
pyasn1-modules==0.4.1
pyasn1-modules==0.4.2
# via google-auth
pycparser==2.22
# via cffi
@@ -294,7 +296,7 @@ pynacl==1.5.0
# via paramiko
pyopenssl==25.0.0
# via shillelagh
pyparsing==3.2.2
pyparsing==3.2.3
# via apache-superset (pyproject.toml)
pysocks==1.7.1
# via urllib3
@@ -307,11 +309,11 @@ python-dateutil==2.9.0.post0
# holidays
# pandas
# shillelagh
python-dotenv==1.0.1
python-dotenv==1.1.0
# via apache-superset (pyproject.toml)
python-geohash==0.8.5
# via apache-superset (pyproject.toml)
pytz==2025.1
pytz==2025.2
# via
# croniter
# flask-babel
@@ -336,13 +338,13 @@ requests-cache==1.2.1
# via shillelagh
rich==13.9.4
# via flask-limiter
rpds-py==0.23.1
rpds-py==0.25.0
# via
# jsonschema
# referencing
rsa==4.9
rsa==4.9.1
# via google-auth
selenium==4.27.1
selenium==4.32.0
# via apache-superset (pyproject.toml)
shillelagh==1.3.5
# via apache-superset (pyproject.toml)
@@ -352,7 +354,6 @@ six==1.17.0
# via
# prison
# python-dateutil
# url-normalize
# wtforms-json
slack-sdk==3.35.0
# via apache-superset (pyproject.toml)
@@ -373,7 +374,7 @@ sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
sqlglot==26.11.1
sqlglot==26.17.1
# via apache-superset (pyproject.toml)
sqlparse==0.5.3
# via apache-superset (pyproject.toml)
@@ -381,29 +382,29 @@ sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.8.10
# via apache-superset (pyproject.toml)
trio==0.28.0
trio==0.30.0
# via
# selenium
# trio-websocket
trio-websocket==0.11.1
trio-websocket==0.12.2
# via selenium
typing-extensions==4.12.2
typing-extensions==4.13.2
# via
# apache-superset (pyproject.toml)
# alembic
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.1
tzdata==2025.2
# via
# celery
# kombu
# pandas
url-normalize==1.4.3
url-normalize==2.2.1
# via requests-cache
urllib3==1.26.20
# via

View File

@@ -2,7 +2,7 @@
# uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt
-e .
# via -r requirements/development.in
alembic==1.15.1
alembic==1.15.2
# via
# -c requirements/base.txt
# flask-migrate
@@ -14,7 +14,7 @@ apispec==6.6.1
# via
# -c requirements/base.txt
# flask-appbuilder
apsw==3.49.1.0
apsw==3.49.2.0
# via
# -c requirements/base.txt
# shillelagh
@@ -51,7 +51,7 @@ blinker==1.9.0
# via
# -c requirements/base.txt
# flask
bottleneck==1.4.2
bottleneck==1.5.0
# via
# -c requirements/base.txt
# apache-superset
@@ -68,15 +68,15 @@ cachetools==5.5.2
# via
# -c requirements/base.txt
# google-auth
cattrs==24.1.2
cattrs==24.1.3
# via
# -c requirements/base.txt
# requests-cache
celery==5.4.0
celery==5.5.2
# via
# -c requirements/base.txt
# apache-superset
certifi==2025.1.31
certifi==2025.4.26
# via
# -c requirements/base.txt
# requests
@@ -88,11 +88,11 @@ cffi==1.17.1
# pynacl
cfgv==3.4.0
# via pre-commit
charset-normalizer==3.4.1
charset-normalizer==3.4.2
# via
# -c requirements/base.txt
# requests
click==8.1.8
click==8.2.0
# via
# -c requirements/base.txt
# apache-superset
@@ -138,7 +138,7 @@ croniter==6.0.0
# via
# -c requirements/base.txt
# apache-superset
cryptography==44.0.2
cryptography==44.0.3
# via
# -c requirements/base.txt
# apache-superset
@@ -176,7 +176,7 @@ et-xmlfile==2.0.0
# via
# -c requirements/base.txt
# openpyxl
exceptiongroup==1.2.2
exceptiongroup==1.3.0
# via
# -c requirements/base.txt
# cattrs
@@ -202,7 +202,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==4.6.1
flask-appbuilder==4.6.4
# via
# -c requirements/base.txt
# apache-superset
@@ -280,7 +280,7 @@ google-api-core==2.23.0
# google-cloud-core
# pandas-gbq
# sqlalchemy-bigquery
google-auth==2.38.0
google-auth==2.40.1
# via
# -c requirements/base.txt
# google-api-core
@@ -329,7 +329,7 @@ gunicorn==23.0.0
# via
# -c requirements/base.txt
# apache-superset
h11==0.14.0
h11==0.16.0
# via
# -c requirements/base.txt
# wsproto
@@ -342,7 +342,7 @@ holidays==0.25
# -c requirements/base.txt
# apache-superset
# prophet
humanize==4.12.2
humanize==4.12.3
# via
# -c requirements/base.txt
# apache-superset
@@ -354,7 +354,8 @@ idna==3.10
# email-validator
# requests
# trio
importlib-metadata==8.6.1
# url-normalize
importlib-metadata==8.7.0
# via
# -c requirements/base.txt
# apache-superset
@@ -388,14 +389,14 @@ jsonschema==4.23.0
# openapi-spec-validator
jsonschema-path==0.3.4
# via openapi-spec-validator
jsonschema-specifications==2024.10.1
jsonschema-specifications==2025.4.1
# via
# -c requirements/base.txt
# jsonschema
# openapi-schema-validator
kiwisolver==1.4.7
# via matplotlib
kombu==5.5.0
kombu==5.5.3
# via
# -c requirements/base.txt
# celery
@@ -405,16 +406,16 @@ korean-lunar-calendar==0.3.1
# holidays
lazy-object-proxy==1.10.0
# via openapi-spec-validator
limits==4.4.1
limits==5.1.0
# via
# -c requirements/base.txt
# flask-limiter
mako==1.3.9
mako==1.3.10
# via
# -c requirements/base.txt
# alembic
# apache-superset
markdown==3.7
markdown==3.8
# via
# -c requirements/base.txt
# apache-superset
@@ -472,7 +473,6 @@ numpy==1.26.4
# pandas
# pandas-gbq
# prophet
# pyarrow
oauthlib==3.2.2
# via requests-oauthlib
odfpy==1.4.1
@@ -495,7 +495,8 @@ outcome==1.3.0.post0
# via
# -c requirements/base.txt
# trio
packaging==24.2
# trio-websocket
packaging==25.0
# via
# -c requirements/base.txt
# apache-superset
@@ -542,7 +543,7 @@ pillow==10.3.0
# via
# apache-superset
# matplotlib
platformdirs==4.3.7
platformdirs==4.3.8
# via
# -c requirements/base.txt
# requests-cache
@@ -565,7 +566,7 @@ prison==0.2.1
# flask-appbuilder
progress==1.6
# via apache-superset
prompt-toolkit==3.0.50
prompt-toolkit==3.0.51
# via
# -c requirements/base.txt
# click-repl
@@ -586,7 +587,7 @@ psutil==6.1.0
# via apache-superset
psycopg2-binary==2.9.6
# via apache-superset
pyarrow==14.0.2
pyarrow==18.1.0
# via
# -c requirements/base.txt
# apache-superset
@@ -598,7 +599,7 @@ pyasn1==0.6.1
# pyasn1-modules
# python-ldap
# rsa
pyasn1-modules==0.4.1
pyasn1-modules==0.4.2
# via
# -c requirements/base.txt
# google-auth
@@ -635,7 +636,7 @@ pyopenssl==25.0.0
# via
# -c requirements/base.txt
# shillelagh
pyparsing==3.2.2
pyparsing==3.2.3
# via
# -c requirements/base.txt
# apache-superset
@@ -668,7 +669,7 @@ python-dateutil==2.9.0.post0
# pyhive
# shillelagh
# trino
python-dotenv==1.0.1
python-dotenv==1.1.0
# via
# -c requirements/base.txt
# apache-superset
@@ -678,7 +679,7 @@ python-geohash==0.8.5
# apache-superset
python-ldap==3.4.4
# via apache-superset
pytz==2025.1
pytz==2025.2
# via
# -c requirements/base.txt
# croniter
@@ -731,22 +732,22 @@ rich==13.9.4
# via
# -c requirements/base.txt
# flask-limiter
rpds-py==0.23.1
rpds-py==0.25.0
# via
# -c requirements/base.txt
# jsonschema
# referencing
rsa==4.9
rsa==4.9.1
# via
# -c requirements/base.txt
# google-auth
ruff==0.8.0
# via apache-superset
selenium==4.27.1
selenium==4.32.0
# via
# -c requirements/base.txt
# apache-superset
setuptools==75.6.0
setuptools==80.7.1
# via
# nodeenv
# pandas-gbq
@@ -767,7 +768,6 @@ six==1.17.0
# prison
# python-dateutil
# rfc3339-validator
# url-normalize
# wtforms-json
slack-sdk==3.35.0
# via
@@ -799,7 +799,7 @@ sqlalchemy-utils==0.38.3
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
sqlglot==26.11.1
sqlglot==26.17.1
# via
# -c requirements/base.txt
# apache-superset
@@ -829,36 +829,36 @@ tqdm==4.67.1
# prophet
trino==0.330.0
# via apache-superset
trio==0.28.0
trio==0.30.0
# via
# -c requirements/base.txt
# selenium
# trio-websocket
trio-websocket==0.11.1
trio-websocket==0.12.2
# via
# -c requirements/base.txt
# selenium
typing-extensions==4.12.2
typing-extensions==4.13.2
# via
# -c requirements/base.txt
# alembic
# apache-superset
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.1
tzdata==2025.2
# via
# -c requirements/base.txt
# celery
# kombu
# pandas
tzlocal==5.2
# via trino
url-normalize==1.4.3
url-normalize==2.2.1
# via
# -c requirements/base.txt
# requests-cache

View File

@@ -56,7 +56,6 @@ describe('Visualization > Big Number with Trendline', () => {
it('should work', () => {
verify(BIG_NUMBER_FORM_DATA);
cy.get('.chart-container .header-line');
cy.get('.chart-container .subheader-line');
cy.get('.chart-container canvas');
});
@@ -66,7 +65,7 @@ describe('Visualization > Big Number with Trendline', () => {
compare_lag: null,
});
cy.get('.chart-container .header-line');
cy.get('.chart-container .subheader-line').should('not.exist');
cy.get('.chart-container .subtitle-line').should('not.exist');
cy.get('.chart-container canvas');
});
@@ -76,7 +75,6 @@ describe('Visualization > Big Number with Trendline', () => {
show_trend_line: false,
});
cy.get('[data-test="chart-container"] .header-line');
cy.get('[data-test="chart-container"] .subheader-line');
cy.get('[data-test="chart-container"] canvas').should('not.exist');
});
});

View File

@@ -252,4 +252,215 @@ describe('Visualization > Table', () => {
});
cy.get('td').contains(/\d*%/);
});
it('Test row limit with server pagination toggle', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
row_limit: 100,
});
// Enable server pagination
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
// Click row limit control and select high value (200k)
cy.get('div[aria-label="Row limit"]').click();
// Type 200000 and press enter to select the option
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('200000{enter}');
// Verify that there is no error tooltip when server pagination is enabled
cy.get('[data-test="error-tooltip"]').should('not.exist');
// Disable server pagination
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
// Verify error tooltip appears
cy.get('[data-test="error-tooltip"]').should('be.visible');
// Trigger mouseover and verify tooltip text
cy.get('[data-test="error-tooltip"]').trigger('mouseover');
// Verify tooltip content
cy.get('.antd5-tooltip-inner').should('be.visible');
cy.get('.antd5-tooltip-inner').should(
'contain',
'Server pagination needs to be enabled for values over',
);
// Hide the tooltip by adding display:none style
cy.get('.antd5-tooltip').invoke('attr', 'style', 'display: none');
// Enable server pagination again
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
cy.get('[data-test="error-tooltip"]').should('not.exist');
cy.get('div[aria-label="Row limit"]').click();
// Type 1000000
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('1000000');
// Wait for 1 second
cy.wait(1000);
// Press enter
cy.get('div[aria-label="Row limit"]')
.find('.ant-select-selection-search-input:visible')
.type('{enter}');
// Wait for error tooltip to appear and verify its content
cy.get('[data-test="error-tooltip"]')
.should('be.visible')
.trigger('mouseover');
// Wait for tooltip content and verify
cy.get('.antd5-tooltip-inner').should('exist');
cy.get('.antd5-tooltip-inner').should('be.visible');
// Verify tooltip content separately
cy.get('.antd5-tooltip-inner').should('contain', 'Value cannot exceed');
});
it('Test sorting with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name'],
row_limit: 100000,
server_pagination: true, // Enable server pagination
});
// Wait for the initial data load
cy.wait('@chartData');
// Get the first column header (name)
cy.get('.chart-container th').contains('name').as('nameHeader');
// Click to sort ascending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'A'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Aa]/);
// Click again to sort descending
cy.get('@nameHeader').click();
cy.wait('@chartData');
// Verify first row starts with 'Z'
cy.get('.chart-container td:first').invoke('text').should('match', /^[Zz]/);
// Test numeric sorting
cy.get('.chart-container th').contains('COUNT').as('countHeader');
// Click to sort ascending by count
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify ascending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.most(second);
});
// Click again to sort descending
cy.get('@countHeader').click();
cy.wait('@chartData');
// Get first two count values and verify descending order
cy.get('.chart-container td:nth-child(2)').then($cells => {
const first = parseFloat($cells[0].textContent || '0');
const second = parseFloat($cells[1].textContent || '0');
expect(first).to.be.at.least(second);
});
});
it('Test search with server pagination enabled', () => {
cy.visitChartByParams({
...VIZ_DEFAULTS,
metrics: ['count'],
groupby: ['name', 'state'],
row_limit: 100000,
server_pagination: true,
include_search: true,
});
cy.wait('@chartData');
// Basic search test
cy.get('span.dt-global-filter input.form-control.input-sm').should(
'be.visible',
);
cy.get('span.dt-global-filter input.form-control.input-sm').type('John');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/John/i);
});
// Clear and test case-insensitive search
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.wait('@chartData');
cy.get('span.dt-global-filter input.form-control.input-sm').type('mary');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Mary/i);
});
// Test special characters
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.get('span.dt-global-filter input.form-control.input-sm').type('Nicole');
cy.wait('@chartData');
cy.get('.chart-container tbody tr').each($row => {
cy.wrap($row).contains(/Nicole/i);
});
// Test no results
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.get('span.dt-global-filter input.form-control.input-sm').type('XYZ123');
cy.wait('@chartData');
cy.get('.chart-container').contains('No records found');
// Test column-specific search
cy.get('.search-select').should('be.visible');
cy.get('.search-select').click();
cy.get('.ant-select-dropdown').should('be.visible');
cy.get('.ant-select-item-option').contains('state').should('be.visible');
cy.get('.ant-select-item-option').contains('state').click();
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
cy.get('span.dt-global-filter input.form-control.input-sm').type('CA');
cy.wait('@chartData');
cy.wait(1000);
cy.get('td[aria-labelledby="header-state"]').should('be.visible');
cy.get('td[aria-labelledby="header-state"]')
.first()
.should('contain', 'CA');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^5.21.1",
"@rjsf/utils": "^5.24.3",
"@rjsf/validator-ajv8": "^5.22.3",
"@rjsf/validator-ajv8": "^5.24.9",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -122,7 +122,7 @@
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"abortcontroller-polyfill": "^1.7.8",
"ace-builds": "^1.36.3",
"ace-builds": "^1.41.0",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"antd": "4.10.3",
@@ -137,6 +137,7 @@
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.2.0",
"dom-to-pdf": "^0.3.2",
"dompurify": "^3.2.4",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -182,7 +183,7 @@
"react-dom": "^17.0.2",
"react-draggable": "^4.4.6",
"react-hot-loader": "^4.13.1",
"react-intersection-observer": "^9.10.2",
"react-intersection-observer": "^9.16.0",
"react-js-cron": "^2.1.2",
"react-json-tree": "^0.17.0",
"react-lines-ellipsis": "^0.15.4",
@@ -229,8 +230,8 @@
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
"@babel/plugin-transform-runtime": "^7.25.9",
"@babel/preset-env": "^7.26.7",
"@babel/plugin-transform-runtime": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@babel/register": "^7.23.7",

View File

@@ -26,6 +26,7 @@ import {
import { ColumnMeta, SortSeriesData, SortSeriesType } from './types';
export const DEFAULT_MAX_ROW = 100000;
export const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
// eslint-disable-next-line import/prefer-default-export
export const TIME_FILTER_LABELS = {

View File

@@ -21,7 +21,6 @@ import { Dataset } from './types';
export const TestDataset: Dataset = {
column_formats: {},
currency_formats: {},
columns: [
{
advanced_data_type: undefined,

View File

@@ -26,6 +26,7 @@ import {
} from '@superset-ui/core';
import { PostProcessingFactory } from './types';
import { getMetricOffsetsMap, isTimeComparison } from './utils';
import { TIME_COMPARISON_SEPARATOR } from './utils/constants';
export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
formData,
@@ -37,50 +38,60 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
);
const { truncate_metric } = formData;
const xAxisLabel = getXAxisLabel(formData);
const isTimeComparisonValue = isTimeComparison(formData, queryObject);
// remove or rename top level of column name(metric name) in the MultiIndex when
// 1) only 1 metric
// 1) at least 1 metric
// 2) dimension exist
// 3) xAxis exist
// 4) time comparison exist, and comparison type is "actual values"
// 5) truncate_metric in form_data and truncate_metric is true
// 4) truncate_metric in form_data and truncate_metric is true
if (
metrics.length === 1 &&
metrics.length > 0 &&
columns.length > 0 &&
xAxisLabel &&
!(
// todo: we should provide an approach to handle derived metrics
(
isTimeComparison(formData, queryObject) &&
[
ComparisonType.Difference,
ComparisonType.Ratio,
ComparisonType.Percentage,
].includes(formData.comparison_type)
)
) &&
truncate_metric !== undefined &&
!!truncate_metric
) {
const renamePairs: [string, string | null][] = [];
if (
// "actual values" will add derived metric.
// we will rename the "metric" from the metricWithOffset label
// for example: "count__1 year ago" => "1 year ago"
isTimeComparison(formData, queryObject) &&
formData.comparison_type === ComparisonType.Values
isTimeComparisonValue
) {
const metricOffsetMap = getMetricOffsetsMap(formData, queryObject);
const timeOffsets = ensureIsArray(formData.time_compare);
[...metricOffsetMap.keys()].forEach(metricWithOffset => {
const offsetLabel = timeOffsets.find(offset =>
metricWithOffset.includes(offset),
);
renamePairs.push([metricWithOffset, offsetLabel]);
});
[...metricOffsetMap.entries()].forEach(
([metricWithOffset, metricOnly]) => {
const offsetLabel = timeOffsets.find(offset =>
metricWithOffset.includes(offset),
);
renamePairs.push([
formData.comparison_type === ComparisonType.Values
? metricWithOffset
: [formData.comparison_type, metricOnly, metricWithOffset].join(
TIME_COMPARISON_SEPARATOR,
),
metrics.length > 1 ? `${metricOnly}, ${offsetLabel}` : offsetLabel,
]);
},
);
}
renamePairs.push([getMetricLabel(metrics[0]), null]);
if (
![
ComparisonType.Difference,
ComparisonType.Percentage,
ComparisonType.Ratio,
].includes(formData.comparison_type) &&
metrics.length === 1
) {
renamePairs.push([getMetricLabel(metrics[0]), null]);
}
if (renamePairs.length === 0) {
return undefined;
}
return {
operation: 'rename',

View File

@@ -69,7 +69,6 @@ export interface Dataset {
columns: ColumnMeta[];
metrics: Metric[];
column_formats: Record<string, string>;
currency_formats: Record<string, Currency>;
verbose_map: Record<string, string>;
main_dttm_col: string;
// eg. ['["ds", true]', 'ds [asc]']

View File

@@ -43,12 +43,12 @@ const queryObject: QueryObject = {
post_processing: [],
};
test('should skip renameOperator if exists multiple metrics', () => {
test('should skip renameOperator for empty metrics', () => {
expect(
renameOperator(formData, {
...queryObject,
...{
metrics: ['count(*)', 'sum(sales)'],
metrics: [],
},
}),
).toEqual(undefined);
@@ -77,7 +77,23 @@ test('should skip renameOperator if does not exist x_axis and is_timeseries', ()
).toEqual(undefined);
});
test('should skip renameOperator if exists derived metrics', () => {
test('should skip renameOperator if not is_timeseries and multi metrics', () => {
expect(
renameOperator(formData, {
...queryObject,
...{ is_timeseries: false, metrics: ['count(*)', 'sum(val)'] },
}),
).toEqual(undefined);
});
test('should add renameOperator', () => {
expect(renameOperator(formData, queryObject)).toEqual({
operation: 'rename',
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
});
});
test('should add renameOperator if exists derived metrics', () => {
[
ComparisonType.Difference,
ComparisonType.Ratio,
@@ -99,14 +115,14 @@ test('should skip renameOperator if exists derived metrics', () => {
},
},
),
).toEqual(undefined);
});
});
test('should add renameOperator', () => {
expect(renameOperator(formData, queryObject)).toEqual({
operation: 'rename',
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
).toEqual({
operation: 'rename',
options: {
columns: { [`${type}__count(*)__count(*)__1 year ago`]: '1 year ago' },
inplace: true,
level: 0,
},
});
});
});
@@ -170,6 +186,61 @@ test('should add renameOperator if exist "actual value" time comparison', () =>
});
});
test('should add renameOperator if derived time comparison exists', () => {
expect(
renameOperator(
{
...formData,
...{
comparison_type: ComparisonType.Ratio,
time_compare: ['1 year ago', '1 year later'],
},
},
queryObject,
),
).toEqual({
operation: 'rename',
options: {
columns: {
'ratio__count(*)__count(*)__1 year ago': '1 year ago',
'ratio__count(*)__count(*)__1 year later': '1 year later',
},
inplace: true,
level: 0,
},
});
});
test('should add renameOperator if multiple metrics exist', () => {
expect(
renameOperator(
{
...formData,
...{
comparison_type: ComparisonType.Values,
time_compare: ['1 year ago'],
},
},
{
...queryObject,
...{
metrics: ['count(*)', 'sum(sales)'],
},
},
),
).toEqual({
operation: 'rename',
options: {
columns: {
'count(*)__1 year ago': 'count(*), 1 year ago',
'sum(sales)__1 year ago': 'sum(sales), 1 year ago',
},
inplace: true,
level: 0,
},
});
});
test('should remove renameOperator', () => {
expect(
renameOperator(

View File

@@ -53,7 +53,6 @@ describe('columnChoices()', () => {
],
verbose_map: {},
column_formats: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' },
currency_formats: {},
datasource_name: 'my_datasource',
description: 'this is my datasource',
}),
@@ -105,7 +104,6 @@ describe('columnChoices()', () => {
],
verbose_map: {},
column_formats: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' },
currency_formats: {},
datasource_name: 'my_datasource',
description: 'this is my datasource',
}),

View File

@@ -41,7 +41,6 @@ describe('defineSavedMetrics', () => {
columns: [],
verbose_map: {},
column_formats: {},
currency_formats: {},
datasource_name: 'my_datasource',
description: 'this is my datasource',
};

View File

@@ -49,6 +49,7 @@ export interface ChartMetadataConfig {
label?: ChartLabel | null;
labelExplanation?: string | null;
queryObjectCount?: number;
dynamicQueryObjectCount?: boolean;
parseMethod?: ParseMethod;
// suppressContextMenu: true hides the default context menu for the chart.
// This is useful for viz plugins that define their own context menu.
@@ -92,6 +93,8 @@ export default class ChartMetadata {
queryObjectCount: number;
dynamicQueryObjectCount: boolean;
parseMethod: ParseMethod;
suppressContextMenu?: boolean;
@@ -115,6 +118,7 @@ export default class ChartMetadata {
label = null,
labelExplanation = null,
queryObjectCount = 1,
dynamicQueryObjectCount = false,
parseMethod = 'json-bigint',
suppressContextMenu = false,
} = config;
@@ -145,6 +149,7 @@ export default class ChartMetadata {
this.label = label;
this.labelExplanation = labelExplanation;
this.queryObjectCount = queryObjectCount;
this.dynamicQueryObjectCount = dynamicQueryObjectCount;
this.parseMethod = parseMethod;
this.suppressContextMenu = suppressContextMenu;
}

View File

@@ -58,17 +58,18 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
const result: JsonResponse = {
response,
json: cloneDeepWith(json, (value: any) => {
// `json-bigint` could not handle floats well, see sidorares/json-bigint#62
// TODO: clean up after json-bigint>1.0.1 is released
if (value?.isInteger?.() === false) {
return Number(value);
}
if (
value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER)
value?.isInteger?.() === true &&
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
) {
return BigInt(value);
}
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
// // TODO: clean up after json-bigint>1.0.1 is released
if (value?.isNaN?.() === false) {
return value?.toNumber?.();
}
return undefined;
}),
};

View File

@@ -35,7 +35,8 @@ export type Locale =
| 'pt'
| 'pt_BR'
| 'ru'
| 'zh'; // supported locales in Superset
| 'zh'
| 'zh_TW'; // supported locales in Superset
/**
* Language pack provided to `jed`.

View File

@@ -25,3 +25,4 @@ export { default as validateNonEmpty } from './validateNonEmpty';
export { default as validateMaxValue } from './validateMaxValue';
export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
export { default as validateTimeComparisonRangeValues } from './validateTimeComparisonRangeValues';
export { default as validateServerPagination } from './validateServerPagination';

View File

@@ -0,0 +1,38 @@
/**
* 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 { t } from '../translation';
export default function validateServerPagination(
v: unknown,
serverPagination: boolean,
maxValueWithoutServerPagination: number,
maxServer: number,
) {
if (
Number(v) > +maxValueWithoutServerPagination &&
Number(v) <= maxServer &&
!serverPagination
) {
return t(
'Server pagination needs to be enabled for values over %s',
maxValueWithoutServerPagination,
);
}
return false;
}

View File

@@ -143,7 +143,7 @@ describe('parseResponse()', () => {
const mockBigIntUrl = '/mock/get/bigInt';
const mockGetBigIntPayload = `{
"value": 9223372036854775807, "minus": { "value": -483729382918228373892, "str": "something" },
"number": 1234, "floatValue": { "plus": 0.3452211361231223, "minus": -0.3452211361231223 },
"number": 1234, "floatValue": { "plus": 0.3452211361231223, "minus": -0.3452211361231223, "even": 1234567890123456.0000000 },
"string.constructor": "data.constructor",
"constructor": "constructor"
}`;
@@ -161,6 +161,7 @@ describe('parseResponse()', () => {
expect(responseBigNumber.json.floatValue.minus).toEqual(
-0.3452211361231223,
);
expect(responseBigNumber.json.floatValue.even).toEqual(1234567890123456);
expect(
responseBigNumber.json.floatValue.plus +
responseBigNumber.json.floatValue.minus,

View File

@@ -0,0 +1,153 @@
/**
* 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 { validateServerPagination } from '@superset-ui/core';
import './setup';
const DEFAULT_MAX_ROW = 100000;
const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
test('validateServerPagination returns warning message only when value is between max thresholds and server pagination is disabled', () => {
// Should show warning - value between thresholds and server pagination disabled
expect(
validateServerPagination(
200000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeTruthy();
expect(
validateServerPagination(
300000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeTruthy();
// Should not show warning - value above max server threshold
expect(
validateServerPagination(
600000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
// Should not show warning - value below max without server threshold
expect(
validateServerPagination(
50000,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});
test('validateServerPagination returns false when server pagination is enabled regardless of value', () => {
expect(
validateServerPagination(
200000,
true,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
300000,
true,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
600000,
true,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});
test('validateServerPagination handles string inputs correctly', () => {
expect(
validateServerPagination(
'200000',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeTruthy();
expect(
validateServerPagination(
'600000',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
'50000',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});
test('validateServerPagination handles edge cases', () => {
expect(
validateServerPagination(
undefined,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
null,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
NaN,
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
expect(
validateServerPagination(
'invalid',
false,
DEFAULT_MAX_ROW,
DEFAULT_MAX_ROW_TABLE_SERVER,
),
).toBeFalsy();
});

View File

@@ -54,13 +54,13 @@
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.7",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.23.3",
"@storybook/react-webpack5": "8.2.9",
"babel-loader": "^9.1.3",
"fork-ts-checker-webpack-plugin": "^9.0.2",
"ts-loader": "^9.5.1",
"ts-loader": "^9.5.2",
"typescript": "^5.7.2"
},
"peerDependencies": {

View File

@@ -103,6 +103,7 @@ import iran from './countries/iran.geojson';
import israel from './countries/israel.geojson';
import italy from './countries/italy.geojson';
import italy_regions from './countries/italy_regions.geojson';
import ivory_coast from './countries/ivory_coast.geojson';
import japan from './countries/japan.geojson';
import jordan from './countries/jordan.geojson';
import kazakhstan from './countries/kazakhstan.geojson';
@@ -160,6 +161,7 @@ import philippines_regions from './countries/philippines_regions.geojson';
import poland from './countries/poland.geojson';
import portugal from './countries/portugal.geojson';
import qatar from './countries/qatar.geojson';
import republic_of_serbia from './countries/republic_of_serbia.geojson';
import romania from './countries/romania.geojson';
import russia from './countries/russia.geojson';
import rwanda from './countries/rwanda.geojson';
@@ -304,6 +306,7 @@ export const countries = {
israel,
italy,
italy_regions,
ivory_coast,
japan,
jordan,
kazakhstan,
@@ -361,6 +364,7 @@ export const countries = {
poland,
portugal,
qatar,
republic_of_serbia,
romania,
russia,
rwanda,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -38,9 +38,20 @@ import {
} from '../DeckGLContainer';
import { getExploreLongUrl } from '../utils/explore';
import layerGenerators from '../layers';
import { Viewport } from '../utils/fitViewport';
import fitViewport, { Viewport } from '../utils/fitViewport';
import { TooltipProps } from '../components/Tooltip';
import { getPoints as getPointsArc } from '../layers/Arc/Arc';
import { getPoints as getPointsPath } from '../layers/Path/Path';
import { getPoints as getPointsPolygon } from '../layers/Polygon/Polygon';
import { getPoints as getPointsGrid } from '../layers/Grid/Grid';
import { getPoints as getPointsScatter } from '../layers/Scatter/Scatter';
import { getPoints as getPointsContour } from '../layers/Contour/Contour';
import { getPoints as getPointsHeatmap } from '../layers/Heatmap/Heatmap';
import { getPoints as getPointsHex } from '../layers/Hex/Hex';
import { getPoints as getPointsGeojson } from '../layers/Geojson/Geojson';
import { getPoints as getPointsScreengrid } from '../layers/Screengrid/Screengrid';
export type DeckMultiProps = {
formData: QueryFormData;
payload: JsonObject;
@@ -56,7 +67,35 @@ export type DeckMultiProps = {
const DeckMulti = (props: DeckMultiProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
const [viewport, setViewport] = useState<Viewport>();
const getAdjustedViewport = useCallback(() => {
let viewport = { ...props.viewport };
const points = [
...getPointsPolygon(props.payload.data.features.deck_polygon || []),
...getPointsPath(props.payload.data.features.deck_path || []),
...getPointsGrid(props.payload.data.features.deck_grid || []),
...getPointsScatter(props.payload.data.features.deck_scatter || []),
...getPointsContour(props.payload.data.features.deck_contour || []),
...getPointsHeatmap(props.payload.data.features.deck_heatmap || []),
...getPointsHex(props.payload.data.features.deck_hex || []),
...getPointsArc(props.payload.data.features.deck_arc || []),
...getPointsGeojson(props.payload.data.features.deck_geojson || []),
...getPointsScreengrid(props.payload.data.features.deck_screengrid || []),
];
if (props.formData) {
viewport = fitViewport(viewport, {
width: props.width,
height: props.height,
points,
});
}
if (viewport.zoom < 0) {
viewport.zoom = 0;
}
return viewport;
}, [props]);
const [viewport, setViewport] = useState<Viewport>(getAdjustedViewport());
const [subSlicesLayers, setSubSlicesLayers] = useState<Record<number, Layer>>(
{},
);
@@ -70,23 +109,31 @@ const DeckMulti = (props: DeckMultiProps) => {
const loadLayers = useCallback(
(formData: QueryFormData, payload: JsonObject, viewport?: Viewport) => {
setViewport(viewport);
setViewport(getAdjustedViewport());
setSubSlicesLayers({});
payload.data.slices.forEach(
(subslice: { slice_id: number } & JsonObject) => {
// Filters applied to multi_deck are passed down to underlying charts
// note that dashboard contextual information (filter_immune_slices and such) aren't
// taken into consideration here
const filters = [
...(subslice.form_data.filters || []),
...(formData.filters || []),
const extra_filters = [
...(subslice.form_data.extra_filters || []),
...(formData.extra_filters || []),
...(formData.extra_form_data?.filters || []),
];
const adhoc_filters = [
...(formData.adhoc_filters || []),
...(subslice.formData?.adhoc_filters || []),
...(formData.extra_form_data?.adhoc_filters || []),
];
const subsliceCopy = {
...subslice,
form_data: {
...subslice.form_data,
filters,
extra_filters,
adhoc_filters,
},
};
@@ -117,7 +164,13 @@ const DeckMulti = (props: DeckMultiProps) => {
},
);
},
[props.datasource, props.onAddFilter, props.onSelect, setTooltip],
[
props.datasource,
props.onAddFilter,
props.onSelect,
setTooltip,
getAdjustedViewport,
],
);
const prevDeckSlices = usePrevious(props.formData.deck_slices);
@@ -136,7 +189,7 @@ const DeckMulti = (props: DeckMultiProps) => {
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport || props.viewport}
viewport={viewport}
layers={layers}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}

View File

@@ -29,7 +29,7 @@ import TooltipRow from '../../TooltipRow';
import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
function getPoints(data: JsonObject[]) {
export function getPoints(data: JsonObject[]) {
const points: Point[] = [];
data.forEach(d => {
points.push(d.sourcePosition);
@@ -77,7 +77,7 @@ export function getLayer(
getTargetColor: (d: any) =>
d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a],
id: `path-layer-${fd.slice_id}` as const,
strokeWidth: fd.stroke_width ? fd.stroke_width : 3,
getWidth: fd.stroke_width ? fd.stroke_width : 3,
...commonLayerProps(fd, setTooltip, setTooltipContent(fd)),
});
}

View File

@@ -97,7 +97,7 @@ export const getLayer: getLayerType<unknown> = function (
});
};
function getPoints(data: any[]) {
export function getPoints(data: any[]) {
return data.map(d => d.position);
}

View File

@@ -39,6 +39,7 @@ import { commonLayerProps } from '../common';
import TooltipRow from '../../TooltipRow';
import fitViewport, { Viewport } from '../../utils/fitViewport';
import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject;
@@ -172,6 +173,17 @@ export type DeckGLGeoJsonProps = {
width: number;
};
export function getPoints(data: Point[]) {
return data.reduce((acc: Array<any>, feature: any) => {
const bounds = geojsonExtent(feature);
if (bounds) {
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
}
return acc;
}, []);
}
const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => {
@@ -186,24 +198,13 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
const viewport: Viewport = useMemo(() => {
if (formData.autozoom) {
const points =
payload?.data?.features?.reduce?.(
(acc: [number, number, number, number][], feature: any) => {
const bounds = geojsonExtent(feature);
if (bounds) {
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
}
return acc;
},
[],
) || [];
const points = getPoints(payload.data.features) || [];
if (points.length) {
return fitViewport(props.viewport, {
width,
height,
points,
points: getPoints(payload.data.features) || [],
});
}
}

View File

@@ -86,7 +86,7 @@ export function getLayer(
});
}
function getPoints(data: JsonObject[]) {
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}

View File

@@ -79,7 +79,7 @@ export const getLayer: getLayerType<unknown> = (
});
};
function getPoints(data: any[]) {
export function getPoints(data: any[]) {
return data.map(d => d.position);
}

View File

@@ -84,7 +84,7 @@ export function getLayer(
});
}
function getPoints(data: JsonObject[]) {
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}

View File

@@ -76,7 +76,7 @@ export function getLayer(
});
}
function getPoints(data: JsonObject[]) {
export function getPoints(data: JsonObject[]) {
let points: Point[] = [];
data.forEach(d => {
points = points.concat(d.path);

View File

@@ -173,6 +173,10 @@ export type DeckGLPolygonProps = {
height: number;
};
export function getPoints(data: JsonObject[]) {
return data.flatMap(getPointsFromPolygon);
}
const DeckGLPolygon = (props: DeckGLPolygonProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
@@ -183,7 +187,7 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
viewport = fitViewport(viewport, {
width: props.width,
height: props.height,
points: features.flatMap(getPointsFromPolygon),
points: getPoints(features),
});
}
if (viewport.zoom < 0) {

View File

@@ -30,7 +30,7 @@ import TooltipRow from '../../TooltipRow';
import { unitToRadius } from '../../utils/geo';
import { TooltipProps } from '../../components/Tooltip';
function getPoints(data: JsonObject[]) {
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}

View File

@@ -34,7 +34,7 @@ import {
} from '../../DeckGLContainer';
import { TooltipProps } from '../../components/Tooltip';
function getPoints(data: JsonObject[]) {
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}

View File

@@ -31,11 +31,11 @@
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.2.4",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.21",
"dayjs": "^1.11.13",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.2.4",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},

View File

@@ -36,13 +36,25 @@ import {
} from './types';
import { useOverflowDetection } from './useOverflowDetection';
const MetricNameText = styled.div<{ metricNameFontSize?: number }>`
${({ theme, metricNameFontSize }) => `
font-family: ${theme.typography.families.sansSerif};
font-weight: ${theme.typography.weights.normal};
font-size: ${metricNameFontSize || theme.typography.sizes.s * 2}px;
text-align: center;
margin-bottom: ${theme.gridUnit * 3}px;
`}
`;
const NumbersContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
padding: 12px;
`;
const ComparisonValue = styled.div<PopKPIComparisonValueStyleProps>`
@@ -73,6 +85,8 @@ export default function PopKPI(props: PopKPIProps) {
prevNumber,
valueDifference,
percentDifferenceFormattedString,
metricName,
metricNameFontSize,
headerFontSize,
subheaderFontSize,
comparisonColorEnabled,
@@ -84,8 +98,8 @@ export default function PopKPI(props: PopKPIProps) {
subtitle,
subtitleFontSize,
dashboardTimeRange,
showMetricName,
} = props;
const [comparisonRange, setComparisonRange] = useState<string>('');
useEffect(() => {
@@ -260,9 +274,16 @@ export default function PopKPI(props: PopKPIProps) {
width: fit-content;
margin: auto;
align-items: flex-start;
overflow: auto;
`
}
>
{showMetricName && metricName && (
<MetricNameText metricNameFontSize={metricNameFontSize}>
{metricName}
</MetricNameText>
)}
<div css={bigValueContainerStyles}>
{bigNumber}
{percentDifferenceNumber !== 0 && (

View File

@@ -28,6 +28,8 @@ import {
subheaderFontSize,
subtitleControl,
subtitleFontSize,
showMetricNameControl,
metricNameFontSizeWithVisibility,
} from '../sharedControls';
import { ColorSchemeEnum } from './types';
@@ -70,6 +72,8 @@ const config: ControlPanelConfig = {
],
[subtitleControl],
[subtitleFontSize],
[showMetricNameControl],
[metricNameFontSizeWithVisibility],
[
{
...subheaderFontSize,

View File

@@ -32,6 +32,7 @@ export default class PopKPIPlugin extends ChartPlugin {
tags: [
t('Comparison'),
t('Business'),
t('ECharts'),
t('Percentages'),
t('Report'),
t('Advanced-Analytics'),

View File

@@ -26,7 +26,13 @@ import {
SimpleAdhocFilter,
ensureIsArray,
} from '@superset-ui/core';
import { getComparisonFontSize, getHeaderFontSize } from './utils';
import {
getComparisonFontSize,
getHeaderFontSize,
getMetricNameFontSize,
} from './utils';
import { getOriginalLabel } from '../utils';
dayjs.extend(utc);
@@ -83,6 +89,7 @@ export default function transformProps(chartProps: ChartProps) {
headerFontSize,
headerText,
metric,
metricNameFontSize,
yAxisFormat,
currencyFormat,
subheaderFontSize,
@@ -91,11 +98,14 @@ export default function transformProps(chartProps: ChartProps) {
percentDifferenceFormat,
subtitle = '',
subtitleFontSize,
columnConfig,
columnConfig = {},
} = formData;
const { data: dataA = [] } = queriesData[0];
const data = dataA;
const metricName = metric ? getMetricLabel(metric) : '';
const metrics = chartProps.datasource?.metrics || [];
const originalLabel = getOriginalLabel(metric, metrics);
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
const timeComparison = ensureIsArray(chartProps.rawFormData?.time_compare)[0];
const startDateOffset = chartProps.rawFormData?.start_date_offset;
const currentTimeRangeFilter = chartProps.rawFormData?.adhoc_filters?.filter(
@@ -179,7 +189,7 @@ export default function transformProps(chartProps: ChartProps) {
width,
height,
data,
metricName,
metricName: originalLabel,
bigNumber,
prevNumber,
valueDifference,
@@ -187,6 +197,8 @@ export default function transformProps(chartProps: ChartProps) {
boldText,
subtitle,
subtitleFontSize,
showMetricName,
metricNameFontSize: getMetricNameFontSize(metricNameFontSize),
headerFontSize: getHeaderFontSize(headerFontSize),
subheaderFontSize: getComparisonFontSize(subheaderFontSize),
headerText,

View File

@@ -61,6 +61,8 @@ export type PopKPIProps = PopKPIStylesProps &
data: TimeseriesDataRecord[];
metrics: Metric[];
metricName: string;
metricNameFontSize?: number;
showMetricName: boolean;
bigNumber: string;
prevNumber: string;
subtitle?: string;

View File

@@ -16,10 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import { headerFontSize, subheaderFontSize } from '../sharedControls';
import {
headerFontSize,
subheaderFontSize,
metricNameFontSize,
} from '../sharedControls';
const headerFontSizes = [16, 20, 30, 48, 60];
const comparisonFontSizes = [16, 20, 26, 32, 40];
const sharedFontSizes = [16, 20, 26, 32, 40];
const metricNameProportionValues =
metricNameFontSize.config.options.map(
(option: { label: string; value: number }) => option.value,
) ?? [];
const headerProportionValues =
headerFontSize.config.options.map(
@@ -40,6 +49,10 @@ const getFontSizeMapping = (
return acc;
}, {});
const metricNameFontSizesMapping = getFontSizeMapping(
metricNameProportionValues,
sharedFontSizes,
);
const headerFontSizesMapping = getFontSizeMapping(
headerProportionValues,
headerFontSizes,
@@ -47,13 +60,17 @@ const headerFontSizesMapping = getFontSizeMapping(
const comparisonFontSizesMapping = getFontSizeMapping(
subheaderProportionValues,
comparisonFontSizes,
sharedFontSizes,
);
export const getMetricNameFontSize = (proportionValue: number) =>
metricNameFontSizesMapping[proportionValue] ??
sharedFontSizes[sharedFontSizes.length - 1];
export const getHeaderFontSize = (proportionValue: number) =>
headerFontSizesMapping[proportionValue] ??
headerFontSizes[headerFontSizes.length - 1];
export const getComparisonFontSize = (proportionValue: number) =>
comparisonFontSizesMapping[proportionValue] ??
comparisonFontSizes[comparisonFontSizes.length - 1];
sharedFontSizes[sharedFontSizes.length - 1];

View File

@@ -0,0 +1,97 @@
/**
* 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 { SqlaFormData } from '@superset-ui/core';
import * as ChartControls from '@superset-ui/chart-controls';
import controlPanel from './controlPanel';
const { __mockShiftMetric } = ChartControls as any;
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Numeric: 'numeric' },
SMART_DATE_ID: 'SMART_DATE_ID',
t: (str: string) => str,
}));
jest.mock('@superset-ui/chart-controls', () => {
// Define the mock function inside the factory
const mockShiftMetric = jest.fn(() => 'shiftedMetric');
return {
ControlPanelConfig: {},
D3_FORMAT_DOCS: 'Format docs',
D3_TIME_FORMAT_OPTIONS: [['', 'default']],
getStandardizedControls: () => ({
shiftMetric: mockShiftMetric,
}),
// Optional export to let tests access the mock
__mockShiftMetric: mockShiftMetric,
};
});
describe('BigNumber Total Control Panel Config', () => {
it('should have the required control panel sections', () => {
expect(controlPanel).toHaveProperty('controlPanelSections');
const sections = controlPanel.controlPanelSections;
expect(Array.isArray(sections)).toBe(true);
expect(sections.length).toBe(2);
// First section should have label 'Query' and contain rows with metric and adhoc_filters
expect(sections[0]!.label).toBe('Query');
expect(Array.isArray(sections[0]!.controlSetRows)).toBe(true);
expect(sections[0]!.controlSetRows[0]).toEqual(['metric']);
expect(sections[0]!.controlSetRows[1]).toEqual(['adhoc_filters']);
// Second section should contain a control named subtitle
const secondSectionRow = sections[1]!.controlSetRows[1];
expect(secondSectionRow[0]).toHaveProperty('name', 'subtitle');
// Second section should include controls for time_format and conditional_formatting
const thirdSection = sections[1]!.controlSetRows;
// Check time_format control exists in one of the rows
const timeFormatRow = thirdSection.find(row =>
row.some((control: any) => control.name === 'time_format'),
);
expect(timeFormatRow).toBeTruthy();
// Check conditional_formatting control exists in one of the rows
const conditionalFormattingRow = thirdSection.find(row =>
row.some((control: any) => control.name === 'conditional_formatting'),
);
expect(conditionalFormattingRow).toBeTruthy();
});
it('should have y_axis_format override with correct label', () => {
expect(controlPanel).toHaveProperty('controlOverrides');
expect(controlPanel.controlOverrides).toHaveProperty('y_axis_format');
expect(controlPanel.controlOverrides!.y_axis_format!.label).toBe(
'Number format',
);
});
it('should override formData metric using getStandardizedControls', () => {
const dummyFormData = { someProp: 'test' } as unknown as SqlaFormData;
const newFormData = controlPanel.formDataOverrides!(dummyFormData);
// The original properties are spread correctly.
expect(newFormData.someProp).toBe('test');
// The metric property should be replaced by the output of shiftMetric.
expect(newFormData.metric).toBe('shiftedMetric');
// Ensure that the mockShiftMetric function was called.
expect(__mockShiftMetric).toHaveBeenCalled();
});
});

View File

@@ -28,6 +28,8 @@ import {
headerFontSize,
subtitleFontSize,
subtitleControl,
showMetricNameControl,
metricNameFontSizeWithVisibility,
} from '../sharedControls';
export default {
@@ -44,6 +46,8 @@ export default {
[headerFontSize],
[subtitleControl],
[subtitleFontSize],
[showMetricNameControl],
[metricNameFontSizeWithVisibility],
['y_axis_format'],
['currency_format'],
[

View File

@@ -39,6 +39,7 @@ const metadata = {
tags: [
t('Additive'),
t('Business'),
t('ECharts'),
t('Legacy'),
t('Percentages'),
t('Featured'),

View File

@@ -0,0 +1,253 @@
/**
* 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 { GenericDataType } from '@superset-ui/core';
import { getColorFormatters } from '@superset-ui/chart-controls';
import { BigNumberTotalChartProps } from '../types';
import transformProps from './transformProps';
jest.mock('@superset-ui/chart-controls', () => ({
getColorFormatters: jest.fn(),
}));
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
getMetricLabel: jest.fn(metric => metric),
extractTimegrain: jest.fn(() => 'P1D'),
getValueFormatter: jest.fn(() => (v: any) => `$${v}`),
}));
jest.mock('../utils', () => ({
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
parseMetricValue: jest.fn(val => Number(val)),
getOriginalLabel: jest.fn((metric, metrics) => metric),
}));
describe('BigNumberTotal transformProps', () => {
const onContextMenu = jest.fn();
const baseFormData = {
headerFontSize: 20,
metric: 'value',
subheader: 'sub header text',
subheaderFontSize: 14,
forceTimestampFormatting: false,
timeFormat: 'YYYY-MM-DD',
yAxisFormat: 'SMART_NUMBER',
conditionalFormatting: [{ color: 'red', op: '>', value: 0 }],
currencyFormat: { symbol: '$', symbolPosition: 'prefix' },
};
const baseDatasource = {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
};
const baseHooks = { onContextMenu };
const baseRawFormData = { dummy: 'raw' };
it('should return null bigNumber when no data is provided', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [{ data: [], coltypes: [] }],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBeNull();
expect(result.width).toBe(400);
expect(result.height).toBe(300);
expect(result.subtitle).toBe(baseFormData.subheader);
expect(result.onContextMenu).toBe(onContextMenu);
expect(result.refs).toEqual({});
// headerFormatter should be set even if there's no data
expect(typeof result.headerFormatter).toBe('function');
// colorThresholdFormatters fallback to empty array when getColorFormatters returns falsy
expect(result.colorThresholdFormatters).toEqual([]);
});
it('should convert subheader to subtitle', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [{ data: [], coltypes: [] }],
formData: { ...baseFormData, subheader: 'test' },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.subtitle).toBe('test');
});
const baseChartProps = {
width: 400,
height: 300,
queriesData: [{ data: [], coltypes: [] }],
rawFormData: { dummy: 'raw' },
hooks: { onContextMenu: jest.fn() },
datasource: {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
},
};
it('uses subtitle font size when subtitle is provided', () => {
const result = transformProps({
...baseChartProps,
formData: {
subtitle: 'Subtitle wins',
subheader: 'Fallback subheader',
subtitleFontSize: 0.4,
subheaderFontSize: 0.99,
metric: 'value',
headerFontSize: 0.3,
yAxisFormat: 'SMART_NUMBER',
timeFormat: 'smart_date',
},
} as unknown as BigNumberTotalChartProps);
expect(result.subtitle).toBe('Subtitle wins');
expect(result.subtitleFontSize).toBe(0.4);
});
it('should compute bigNumber using parseMetricValue when data exists', () => {
const chartProps = {
width: 500,
height: 400,
queriesData: [
{ data: [{ value: '456' }], coltypes: [GenericDataType.String] },
],
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
sortBy: 'value',
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
// parseMetricValue converts '456' to number 456 by our mock
expect(result.bigNumber).toEqual(456);
});
it('should use formatTime as headerFormatter for Temporal or String types or forced formatting', () => {
// Case 1: Temporal type
const chartPropsTemporal = {
width: 600,
height: 450,
queriesData: [
{ data: [{ value: '789' }], coltypes: [GenericDataType.Temporal] },
],
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const resultTemporal = transformProps(
chartPropsTemporal as unknown as BigNumberTotalChartProps,
);
expect(resultTemporal.headerFormatter(5)).toBe('5pm');
// Case 2: String type regardless of forcing formatting
const chartPropsString = {
width: 600,
height: 450,
queriesData: [
{ data: [{ value: '789' }], coltypes: [GenericDataType.String] },
],
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const resultString = transformProps(
chartPropsString as unknown as BigNumberTotalChartProps,
);
expect(resultString.headerFormatter(5)).toBe('5pm');
// Case 3: Forced timestamp formatting
const chartPropsForced = {
width: 600,
height: 450,
queriesData: [{ data: [{ value: '789' }], coltypes: [0] }], // non-temporal/non-string
formData: { ...baseFormData, forceTimestampFormatting: true },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const resultForced = transformProps(
chartPropsForced as unknown as BigNumberTotalChartProps,
);
expect(resultForced.headerFormatter(5)).toBe('5pm');
});
it('should use numberFormatter as headerFormatter when not Temporal/String and no forced formatting', () => {
const chartProps = {
width: 700,
height: 500,
queriesData: [{ data: [{ value: '321' }], coltypes: [0] }], // non-temporal/non-string
formData: { ...baseFormData, forceTimestampFormatting: false },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.headerFormatter(500)).toBe('$500');
});
it('should propagate colorThresholdFormatters from getColorFormatters', () => {
// Override the getColorFormatters mock to return specific value
const mockFormatters = [{ formatter: 'red' }];
(getColorFormatters as jest.Mock).mockReturnValueOnce(mockFormatters);
const chartProps = {
width: 800,
height: 600,
queriesData: [
{ data: [{ value: '100' }], coltypes: [GenericDataType.Temporal] },
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.colorThresholdFormatters).toEqual(mockFormatters);
});
});

View File

@@ -29,7 +29,7 @@ import {
getValueFormatter,
} from '@superset-ui/core';
import { BigNumberTotalChartProps, BigNumberVizProps } from '../types';
import { getDateFormatter, parseMetricValue } from '../utils';
import { getDateFormatter, getOriginalLabel, parseMetricValue } from '../utils';
import { Refs } from '../../types';
export default function transformProps(
@@ -45,21 +45,30 @@ export default function transformProps(
datasource: { currencyFormats = {}, columnFormats = {} },
} = chartProps;
const {
metricNameFontSize,
headerFontSize,
metric = 'value',
subtitle = '',
subtitle,
subtitleFontSize,
forceTimestampFormatting,
timeFormat,
yAxisFormat,
conditionalFormatting,
currencyFormat,
subheader,
subheaderFontSize,
} = formData;
const refs: Refs = {};
const { data = [], coltypes = [] } = queriesData[0];
const { data = [], coltypes = [] } = queriesData[0] || {};
const granularity = extractTimegrain(rawFormData as QueryFormData);
const metrics = chartProps.datasource?.metrics || [];
const originalLabel = getOriginalLabel(metric, metrics);
const metricName = getMetricLabel(metric);
const formattedSubtitle = subtitle;
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || '';
const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? 1)
: (subheaderFontSize ?? 1);
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
@@ -98,19 +107,20 @@ export default function transformProps(
const colorThresholdFormatters =
getColorFormatters(conditionalFormatting, data, false) ??
defaultColorFormatters;
return {
width,
height,
bigNumber,
headerFormatter,
headerFontSize,
subtitleFontSize,
subheaderFontSize,
subtitleFontSize: formattedSubtitleFontSize,
subtitle: formattedSubtitle,
subheader: '',
subheaderFontSize: subtitleFontSize,
onContextMenu,
refs,
colorThresholdFormatters,
metricName: originalLabel,
showMetricName,
metricNameFontSize,
};
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, MouseEvent } from 'react';
import { PureComponent, MouseEvent, createRef } from 'react';
import {
t,
getNumberFormatter,
@@ -35,6 +35,7 @@ const defaultNumberFormatter = getNumberFormatter();
const PROPORTION = {
// text size: proportion of the chart container sans trendline
METRIC_NAME: 0.125,
KICKER: 0.1,
HEADER: 0.3,
SUBHEADER: 0.125,
@@ -42,13 +43,20 @@ const PROPORTION = {
TRENDLINE: 0.3,
};
class BigNumberVis extends PureComponent<BigNumberVizProps> {
type BigNumberVisState = {
elementsRendered: boolean;
recalculateTrigger: boolean;
};
class BigNumberVis extends PureComponent<BigNumberVizProps, BigNumberVisState> {
static defaultProps = {
className: '',
headerFormatter: defaultNumberFormatter,
formatTime: getTimeFormatter(SMART_DATE_VERBOSE_ID),
headerFontSize: PROPORTION.HEADER,
kickerFontSize: PROPORTION.KICKER,
metricNameFontSize: PROPORTION.METRIC_NAME,
showMetricName: true,
mainColor: BRAND_COLOR,
showTimestamp: false,
showTrendLine: false,
@@ -58,6 +66,40 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
timeRangeFixed: false,
};
// Create refs for each component to measure heights
metricNameRef = createRef<HTMLDivElement>();
kickerRef = createRef<HTMLDivElement>();
headerRef = createRef<HTMLDivElement>();
subheaderRef = createRef<HTMLDivElement>();
subtitleRef = createRef<HTMLDivElement>();
state = {
elementsRendered: false,
recalculateTrigger: false,
};
componentDidMount() {
// Wait for elements to render and then calculate heights
setTimeout(() => {
this.setState({ elementsRendered: true });
}, 0);
}
componentDidUpdate(prevProps: BigNumberVizProps) {
if (
prevProps.height !== this.props.height ||
prevProps.showTrendLine !== this.props.showTrendLine
) {
this.setState(prevState => ({
recalculateTrigger: !prevState.recalculateTrigger,
}));
}
}
getClassName() {
const { className, showTrendLine, bigNumberFallback } = this.props;
const names = `superset-legacy-chart-big-number ${className} ${
@@ -92,6 +134,37 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
);
}
renderMetricName(maxHeight: number) {
const { metricName, width, showMetricName } = this.props;
if (!showMetricName || !metricName) return null;
const text = metricName;
const container = this.createTemporaryContainer();
document.body.append(container);
const fontSize = computeMaxFontSize({
text,
maxWidth: width,
maxHeight,
className: 'metric-name',
container,
});
container.remove();
return (
<div
ref={this.metricNameRef}
className="metric-name"
style={{
fontSize,
height: 'auto',
}}
>
{text}
</div>
);
}
renderKicker(maxHeight: number) {
const { timestamp, showTimestamp, formatTime, width } = this.props;
if (
@@ -118,6 +191,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return (
<div
ref={this.kickerRef}
className="kicker"
style={{
fontSize,
@@ -173,6 +247,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return (
<div
ref={this.headerRef}
className="header-line"
style={{
display: 'flex',
@@ -188,34 +263,30 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
);
}
renderSubheader(maxHeight: number) {
const { bigNumber, subheader, width, bigNumberFallback } = this.props;
rendermetricComparisonSummary(maxHeight: number) {
const { subheader, width } = this.props;
let fontSize = 0;
const NO_DATA_OR_HASNT_LANDED = t(
'No data after filtering or data is NULL for the latest time record',
);
const NO_DATA = t(
'Try applying different filters or ensuring your datasource has data',
);
let text = subheader;
if (bigNumber === null) {
text = bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED;
}
const text = subheader;
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
fontSize = computeMaxFontSize({
text,
maxWidth: width * 0.9, // max width reduced
maxHeight,
className: 'subheader-line',
container,
});
container.remove();
try {
fontSize = computeMaxFontSize({
text,
maxWidth: width * 0.9,
maxHeight,
className: 'subheader-line',
container,
});
} finally {
container.remove();
}
return (
<div
ref={this.subheaderRef}
className="subheader-line"
style={{
fontSize,
@@ -230,34 +301,47 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
}
renderSubtitle(maxHeight: number) {
const { subtitle, width } = this.props;
const { subtitle, width, bigNumber, bigNumberFallback } = this.props;
let fontSize = 0;
if (subtitle) {
const NO_DATA_OR_HASNT_LANDED = t(
'No data after filtering or data is NULL for the latest time record',
);
const NO_DATA = t(
'Try applying different filters or ensuring your datasource has data',
);
let text = subtitle;
if (bigNumber === null) {
text =
subtitle || (bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED);
}
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
try {
fontSize = computeMaxFontSize({
text: subtitle,
maxWidth: width * 0.9,
maxHeight,
className: 'subtitle-line',
container,
});
} finally {
container.remove();
}
fontSize = computeMaxFontSize({
text,
maxWidth: width * 0.9,
maxHeight,
className: 'subtitle-line',
container,
});
container.remove();
return (
<div
className="subtitle-line"
style={{
fontSize,
height: maxHeight,
}}
>
{subtitle}
</div>
<>
<div
ref={this.subtitleRef}
className="subtitle-line subheader-line"
style={{
fontSize: `${fontSize}px`,
height: maxHeight,
}}
>
{text}
</div>
</>
);
}
return null;
@@ -309,25 +393,75 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
);
}
getTotalElementsHeight() {
const marginPerElement = 8; // theme.gridUnit = 4, so margin-bottom = 8px
const refs = [
this.metricNameRef,
this.kickerRef,
this.headerRef,
this.subheaderRef,
this.subtitleRef,
];
// Filter refs to only those with a current element
const visibleRefs = refs.filter(ref => ref.current);
const totalHeight = visibleRefs.reduce((sum, ref, index) => {
const height = ref.current?.offsetHeight || 0;
const margin = index < visibleRefs.length - 1 ? marginPerElement : 0;
return sum + height + margin;
}, 0);
return totalHeight;
}
shouldApplyOverflow(availableHeight: number) {
if (!this.state.elementsRendered) return false;
const totalHeight = this.getTotalElementsHeight();
return totalHeight > availableHeight;
}
render() {
const {
showTrendLine,
height,
kickerFontSize,
headerFontSize,
subheaderFontSize,
subtitleFontSize,
metricNameFontSize,
subheaderFontSize,
} = this.props;
const className = this.getClassName();
if (showTrendLine) {
const chartHeight = Math.floor(PROPORTION.TRENDLINE * height);
const allTextHeight = height - chartHeight;
const shouldApplyOverflow = this.shouldApplyOverflow(allTextHeight);
return (
<div className={className}>
<div className="text-container" style={{ height: allTextHeight }}>
<div
className="text-container"
style={{
height: allTextHeight,
...(shouldApplyOverflow
? {
display: 'block',
boxSizing: 'border-box',
overflowX: 'hidden',
overflowY: 'auto',
width: '100%',
}
: {}),
}}
>
{this.renderFallbackWarning()}
{this.renderMetricName(
Math.ceil(
(metricNameFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
),
)}
{this.renderKicker(
Math.ceil(
(kickerFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
@@ -336,7 +470,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
{this.renderHeader(
Math.ceil(headerFontSize * (1 - PROPORTION.TRENDLINE) * height),
)}
{this.renderSubheader(
{this.rendermetricComparisonSummary(
Math.ceil(
subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height,
),
@@ -349,14 +483,33 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
</div>
);
}
const shouldApplyOverflow = this.shouldApplyOverflow(height);
return (
<div className={className} style={{ height }}>
{this.renderFallbackWarning()}
{this.renderKicker((kickerFontSize || 0) * height)}
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
<div
className={className}
style={{
height,
...(shouldApplyOverflow
? {
display: 'block',
boxSizing: 'border-box',
overflowX: 'hidden',
overflowY: 'auto',
width: '100%',
}
: {}),
}}
>
<div className="text-container">
{this.renderFallbackWarning()}
{this.renderMetricName((metricNameFontSize || 0) * height)}
{this.renderKicker((kickerFontSize || 0) * height)}
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.rendermetricComparisonSummary(
Math.ceil(subheaderFontSize * height),
)}
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
</div>
</div>
);
}
@@ -391,7 +544,12 @@ export default styled(BigNumberVis)`
.kicker {
line-height: 1em;
padding-bottom: 2em;
margin-bottom: ${theme.gridUnit * 2}px;
}
.metric-name {
line-height: 1em;
margin-bottom: ${theme.gridUnit * 2}px;
}
.header-line {
@@ -407,12 +565,12 @@ export default styled(BigNumberVis)`
.subheader-line {
line-height: 1em;
padding-bottom: 0.3em;
margin-bottom: ${theme.gridUnit * 2}px;
}
.subtitle-line {
line-height: 1em;
padding-top: 0.3em;
margin-bottom: ${theme.gridUnit * 2}px;
}
&.is-fallback-value {

View File

@@ -31,6 +31,8 @@ import {
subheaderFontSize,
subtitleFontSize,
subtitleControl,
showMetricNameControl,
metricNameFontSizeWithVisibility,
} from '../sharedControls';
const config: ControlPanelConfig = {
@@ -141,6 +143,8 @@ const config: ControlPanelConfig = {
[subheaderFontSize],
[subtitleControl],
[subtitleFontSize],
[showMetricNameControl],
[metricNameFontSizeWithVisibility],
['y_axis_format'],
['currency_format'],
[

View File

@@ -37,6 +37,7 @@ const metadata = {
name: t('Big Number with Trendline'),
tags: [
t('Advanced-Analytics'),
t('ECharts'),
t('Line'),
t('Percentages'),
t('Featured'),

View File

@@ -0,0 +1,197 @@
/**
* 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 { GenericDataType } from '@superset-ui/core';
import transformProps from './transformProps';
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
extractTimegrain: jest.fn(() => 'P1D'),
getMetricLabel: jest.fn(metric => metric),
getXAxisLabel: jest.fn(() => '__timestamp'),
getValueFormatter: jest.fn(() => ({
format: (v: number) => `$${v}`,
})),
getNumberFormatter: jest.fn(() => (v: number) => `${(v * 100).toFixed(1)}%`),
t: jest.fn(v => v),
tooltipHtml: jest.fn(() => '<div>tooltip</div>'),
NumberFormats: {
PERCENT_SIGNED_1_POINT: '.1%',
},
}));
jest.mock('../utils', () => ({
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
parseMetricValue: jest.fn(val => Number(val)),
getOriginalLabel: jest.fn((metric, metrics) => metric),
}));
jest.mock('../../utils/tooltip', () => ({
getDefaultTooltip: jest.fn(() => ({})),
}));
describe('BigNumberWithTrendline transformProps', () => {
const onContextMenu = jest.fn();
const baseFormData = {
headerFontSize: 20,
metric: 'value',
subtitle: 'subtitle message',
subtitleFontSize: 14,
forceTimestampFormatting: false,
timeFormat: 'YYYY-MM-DD',
yAxisFormat: 'SMART_NUMBER',
compareLag: 1,
compareSuffix: 'WoW',
colorPicker: { r: 0, g: 0, b: 0 },
currencyFormat: { symbol: '$', symbolPosition: 'prefix' },
};
const baseDatasource = {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
};
const baseHooks = { onContextMenu };
const baseRawFormData = { dummy: 'raw' };
it('should return null bigNumber when no data is provided', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [{ data: [] as unknown as BigNumberDatum[], coltypes: [] }],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.bigNumber).toBeNull();
expect(result.subtitle).toBe('subtitle message');
});
it('should calculate subheader as percent change with suffix', () => {
const chartProps = {
width: 500,
height: 400,
queriesData: [
{
data: [
{ __timestamp: 2, value: 110 },
{ __timestamp: 1, value: 100 },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: ['TEMPORAL', 'NUMERIC'],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.subheader).toBe('10.0% WoW');
});
it('should compute bigNumber from parseMetricValue', () => {
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [
{ __timestamp: 2, value: '456' },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [GenericDataType.Temporal, GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.bigNumber).toEqual(456);
});
it('should use formatTime as headerFormatter for Temporal/String or forced', () => {
const formData = { ...baseFormData, forceTimestampFormatting: true };
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [
{ __timestamp: 2, value: '123' },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [0, GenericDataType.String],
},
],
formData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.headerFormatter(5)).toBe('5pm');
});
it('should use numberFormatter when not Temporal/String and not forced', () => {
const formData = { ...baseFormData, forceTimestampFormatting: false };
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [{ __timestamp: 2, value: 500 }] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [0, 0],
},
],
formData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.headerFormatter.format(500)).toBe('$500');
});
});

View File

@@ -35,7 +35,7 @@ import {
BigNumberWithTrendlineChartProps,
TimeSeriesDatum,
} from '../types';
import { getDateFormatter, parseMetricValue } from '../utils';
import { getDateFormatter, parseMetricValue, getOriginalLabel } from '../utils';
import { getDefaultTooltip } from '../../utils/tooltip';
import { Refs } from '../../types';
@@ -62,6 +62,7 @@ export default function transformProps(
compareLag: compareLag_,
compareSuffix = '',
timeFormat,
metricNameFontSize,
headerFontSize,
metric = 'value',
showTimestamp,
@@ -96,6 +97,9 @@ export default function transformProps(
const aggregatedData = hasAggregatedData ? aggregatedQueryData.data[0] : null;
const refs: Refs = {};
const metricName = getMetricLabel(metric);
const metrics = chartProps.datasource?.metrics || [];
const originalLabel = getOriginalLabel(metric, metrics);
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
const compareLag = Number(compareLag_) || 0;
let formattedSubheader = subheader;
@@ -303,6 +307,9 @@ export default function transformProps(
headerFormatter,
formatTime,
formData,
metricName: originalLabel,
showMetricName,
metricNameFontSize,
headerFontSize,
subtitleFontSize,
subtitle,

View File

@@ -21,106 +21,68 @@
import { t } from '@superset-ui/core';
import { CustomControlItem } from '@superset-ui/chart-controls';
export const headerFontSize: CustomControlItem = {
name: 'header_font_size',
config: {
type: 'SelectControl',
label: t('Big Number Font Size'),
renderTrigger: true,
clearable: false,
default: 0.4,
// Values represent the percentage of space a header should take
options: [
{
label: t('Tiny'),
value: 0.2,
},
{
label: t('Small'),
value: 0.3,
},
{
label: t('Normal'),
value: 0.4,
},
{
label: t('Large'),
value: 0.5,
},
{
label: t('Huge'),
value: 0.6,
},
],
},
};
const FONT_SIZE_OPTIONS_SMALL = [
{ label: t('Tiny'), value: 0.125 },
{ label: t('Small'), value: 0.15 },
{ label: t('Normal'), value: 0.2 },
{ label: t('Large'), value: 0.3 },
{ label: t('Huge'), value: 0.4 },
];
export const subtitleFontSize: CustomControlItem = {
name: 'subtitle_font_size',
config: {
type: 'SelectControl',
label: t('Subtitle Font Size'),
renderTrigger: true,
clearable: false,
default: 0.15,
// Values represent the percentage of space a subtitle should take
options: [
{
label: t('Tiny'),
value: 0.125,
},
{
label: t('Small'),
value: 0.15,
},
{
label: t('Normal'),
value: 0.2,
},
{
label: t('Large'),
value: 0.3,
},
{
label: t('Huge'),
value: 0.4,
},
],
},
};
export const subheaderFontSize: CustomControlItem = {
name: 'subheader_font_size',
config: {
type: 'SelectControl',
label: t('Subheader Font Size'),
renderTrigger: true,
clearable: false,
default: 0.15,
// Values represent the percentage of space a subheader should take
options: [
{
label: t('Tiny'),
value: 0.125,
},
{
label: t('Small'),
value: 0.15,
},
{
label: t('Normal'),
value: 0.2,
},
{
label: t('Large'),
value: 0.3,
},
{
label: t('Huge'),
value: 0.4,
},
],
},
};
const FONT_SIZE_OPTIONS_LARGE = [
{ label: t('Tiny'), value: 0.2 },
{ label: t('Small'), value: 0.3 },
{ label: t('Normal'), value: 0.4 },
{ label: t('Large'), value: 0.5 },
{ label: t('Huge'), value: 0.6 },
];
function makeFontSizeControl(
name: string,
label: string,
defaultValue: number,
options: { label: string; value: number }[],
): CustomControlItem {
return {
name,
config: {
type: 'SelectControl',
label: t(label),
renderTrigger: true,
clearable: false,
default: defaultValue,
options,
},
};
}
export const headerFontSize = makeFontSizeControl(
'header_font_size',
'Big Number Font Size',
0.4,
FONT_SIZE_OPTIONS_LARGE,
);
export const subtitleFontSize = makeFontSizeControl(
'subtitle_font_size',
'Subtitle Font Size',
0.15,
FONT_SIZE_OPTIONS_SMALL,
);
export const subheaderFontSize = makeFontSizeControl(
'subheader_font_size',
'Subheader Font Size',
0.15,
FONT_SIZE_OPTIONS_SMALL,
);
export const metricNameFontSize = makeFontSizeControl(
'metric_name_font_size',
'Metric Name Font Size',
0.15,
FONT_SIZE_OPTIONS_SMALL,
);
export const subtitleControl: CustomControlItem = {
name: 'subtitle',
@@ -131,3 +93,23 @@ export const subtitleControl: CustomControlItem = {
description: t('Description text that shows up below your Big Number'),
},
};
export const showMetricNameControl: CustomControlItem = {
name: 'show_metric_name',
config: {
type: 'CheckboxControl',
label: t('Show Metric Name'),
renderTrigger: true,
default: false,
description: t('Whether to display the metric name'),
},
};
export const metricNameFontSizeWithVisibility: CustomControlItem = {
...metricNameFontSize,
config: {
...metricNameFontSize.config,
visibility: ({ controls }) => controls?.show_metric_name?.value === true,
resetOnHide: false,
},
};

View File

@@ -75,9 +75,13 @@ export type BigNumberVizProps = {
bigNumberFallback?: TimeSeriesDatum;
headerFormatter: ValueFormatter | TimeFormatter;
formatTime?: TimeFormatter;
metricName?: string;
friendlyMetricName?: string;
metricNameFontSize?: number;
showMetricName?: boolean;
headerFontSize: number;
kickerFontSize?: number;
subheader: string;
subheader?: string;
subtitle: string;
subheaderFontSize: number;
subtitleFontSize: number;

View File

@@ -22,6 +22,10 @@ import utc from 'dayjs/plugin/utc';
import {
getTimeFormatter,
getTimeFormatterForGranularity,
isAdhocMetricSimple,
isSavedMetric,
Metric,
QueryFormMetric,
SMART_DATE_ID,
TimeGranularity,
} from '@superset-ui/core';
@@ -47,3 +51,43 @@ export const getDateFormatter = (
timeFormat === SMART_DATE_ID
? getTimeFormatterForGranularity(granularity)
: getTimeFormatter(timeFormat ?? fallbackFormat);
export function getOriginalLabel(
metric: QueryFormMetric,
metrics: Metric[] = [],
): string {
const metricLabel = typeof metric === 'string' ? metric : metric.label || '';
if (isSavedMetric(metric)) {
const metricEntry = metrics.find(m => m.metric_name === metric);
return (
metricEntry?.verbose_name ||
metricEntry?.metric_name ||
metric ||
'Unknown Metric'
);
}
if (isAdhocMetricSimple(metric)) {
const column = metric.column || {};
const columnName = column.column_name || 'unknown_column';
const verboseName = column.verbose_name || columnName;
const aggregate = metric.aggregate || 'UNKNOWN';
return metric.hasCustomLabel && metric.label
? metric.label
: `${aggregate}(${verboseName})`;
}
if (
typeof metric === 'object' &&
'expressionType' in metric &&
metric.expressionType === 'SQL' &&
'sqlExpression' in metric
) {
return metric.hasCustomLabel && metric.label
? metric.label
: metricLabel || 'Custom Metric';
}
return metricLabel || 'Unknown Metric';
}

View File

@@ -150,7 +150,7 @@ export default function transformProps(
data: data.map(row =>
colnames.map(col => {
const value = row[col];
if (!value) {
if (value === null || value === undefined) {
return NULL_STRING;
}
if (typeof value === 'boolean' || typeof value === 'bigint') {

View File

@@ -84,7 +84,7 @@ export default function transformProps(
.filter(key => !groupbySet.has(key))
.map(key => {
const array = key.split(' - ').map(value => parseFloat(value));
return `${xAxisFormatter(array[0])} '-' ${xAxisFormatter(array[1])}`;
return `${xAxisFormatter(array[0])} - ${xAxisFormatter(array[1])}`;
});
const barSeries: BarSeriesOption[] = data.map(datum => {
const seriesName =

View File

@@ -16,14 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
import { buildQueryContext, QueryFormData } from '@superset-ui/core';
import {
buildQueryContext,
getMetricLabel,
QueryFormData,
} from '@superset-ui/core';
import { getContributionLabel } from './utils';
export default function buildQuery(formData: QueryFormData) {
const { metric, sort_by_metric } = formData;
const metricLabel = getMetricLabel(metric);
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
...(sort_by_metric && { orderby: [[metric, false]] }),
post_processing: [
{
operation: 'contribution',
options: {
columns: [metricLabel],
rename_columns: [getContributionLabel(metricLabel)],
},
},
],
},
]);
}

View File

@@ -0,0 +1,19 @@
/**
* 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.
*/
export const CONTRIBUTION_SUFFIX = '__contribution' as const;

View File

@@ -84,6 +84,23 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'threshold_for_other',
config: {
type: 'NumberControl',
label: t('Threshold for Other'),
min: 0,
step: 0.5,
max: 100,
default: 0,
renderTrigger: true,
description: t(
'Values less than this percentage will be grouped into the Other category.',
),
},
},
],
[
{
name: 'roseType',

View File

@@ -27,6 +27,7 @@ import {
ValueFormatter,
getValueFormatter,
tooltipHtml,
DataRecord,
} from '@superset-ui/core';
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import type { EChartsCoreOption } from 'echarts/core';
@@ -36,6 +37,7 @@ import {
EchartsPieChartProps,
EchartsPieFormData,
EchartsPieLabelType,
PieChartDataItem,
PieChartTransformedProps,
} from './types';
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
@@ -50,6 +52,7 @@ import { defaultGrid } from '../defaults';
import { convertInteger } from '../utils/convertInteger';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { getContributionLabel } from './utils';
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
@@ -133,7 +136,7 @@ export default function transformProps(
datasource,
} = chartProps;
const { columnFormats = {}, currencyFormats = {} } = datasource;
const { data = [] } = queriesData[0];
const { data: rawData = [] } = queriesData[0];
const coltypeMapping = getColtypesMapping(queriesData[0]);
const {
@@ -159,6 +162,7 @@ export default function transformProps(
sliceId,
showTotal,
roseType,
thresholdForOther,
}: EchartsPieFormData = {
...DEFAULT_LEGEND_FORM_DATA,
...DEFAULT_PIE_FORM_DATA,
@@ -166,17 +170,68 @@ export default function transformProps(
};
const refs: Refs = {};
const metricLabel = getMetricLabel(metric);
const contributionLabel = getContributionLabel(metricLabel);
const groupbyLabels = groupby.map(getColumnLabel);
const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6;
const keys = data.map(datum =>
extractGroupbyLabel({
datum,
groupby: groupbyLabels,
coltypeMapping,
timeFormatter: getTimeFormatter(dateFormat),
}),
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
currencyFormat,
);
let data = rawData;
const otherRows: DataRecord[] = [];
const otherTooltipData: string[][] = [];
let otherDatum: PieChartDataItem | null = null;
let otherSum = 0;
if (thresholdForOther) {
let contributionSum = 0;
data = data.filter(datum => {
const contribution = datum[contributionLabel] as number;
if (!contribution || contribution * 100 >= thresholdForOther) {
return true;
}
otherSum += datum[metricLabel] as number;
contributionSum += contribution;
otherRows.push(datum);
otherTooltipData.push([
extractGroupbyLabel({
datum,
groupby: groupbyLabels,
coltypeMapping,
timeFormatter: getTimeFormatter(dateFormat),
}),
numberFormatter(datum[metricLabel] as number),
percentFormatter(contribution),
]);
return false;
});
const otherName = t('Other');
otherTooltipData.push([
t('Total'),
numberFormatter(otherSum),
percentFormatter(contributionSum),
]);
if (otherSum) {
otherDatum = {
name: otherName,
value: otherSum,
itemStyle: {
color: theme.colors.grayscale.dark1,
opacity:
filterState.selectedValues &&
!filterState.selectedValues.includes(otherName)
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent,
},
isOther: true,
};
}
}
const labelMap = data.reduce((acc: Record<string, string[]>, datum) => {
const label = extractGroupbyLabel({
datum,
@@ -192,13 +247,6 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
currencyFormat,
);
let totalValue = 0;
@@ -229,6 +277,10 @@ export default function transformProps(
},
};
});
if (otherDatum) {
transformedData.push(otherDatum);
totalValue += otherSum;
}
const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<string, number>, selectedValue: string) => {
@@ -372,6 +424,9 @@ export default function transformProps(
numberFormatter,
sanitizeName: true,
});
if (params?.data?.isOther) {
return tooltipHtml(otherTooltipData, name);
}
return tooltipHtml(
[[metricLabel, formattedValue, formattedPercent]],
name,
@@ -380,7 +435,7 @@ export default function transformProps(
},
legend: {
...getLegendProps(legendType, legendOrientation, showLegend, theme),
data: keys,
data: transformedData.map(datum => datum.name),
},
graphic: showTotal
? {

View File

@@ -47,6 +47,7 @@ export type EchartsPieFormData = QueryFormData &
dateFormat: string;
showLabelsThreshold: number;
roseType: 'radius' | 'area' | null;
thresholdForOther: number;
};
export enum EchartsPieLabelType {
@@ -82,9 +83,20 @@ export const DEFAULT_FORM_DATA: EchartsPieFormData = {
showLabelsThreshold: 5,
dateFormat: 'smart_date',
roseType: null,
thresholdForOther: 0,
};
export type PieChartTransformedProps =
BaseTransformedProps<EchartsPieFormData> &
ContextMenuTransformedProps &
CrossFilterTransformedProps;
export interface PieChartDataItem {
name: string;
value: number;
itemStyle: {
color: string;
opacity: number;
};
isOther?: boolean;
}

View File

@@ -0,0 +1,22 @@
/**
* 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 { CONTRIBUTION_SUFFIX } from './constants';
export const getContributionLabel = (metricLabel: string) =>
`${metricLabel}${CONTRIBUTION_SUFFIX}`;

View File

@@ -57,7 +57,7 @@ export default class EchartsSankeyChartPlugin extends ChartPlugin<
),
exampleGallery: [{ url: example1 }, { url: example2 }],
name: t('Sankey Chart'),
tags: [t('Directional'), t('Distribution'), t('Flow')],
tags: [t('Directional'), t('ECharts'), t('Distribution'), t('Flow')],
thumbnail,
}),
transformProps,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { JsonArray, t } from '@superset-ui/core';
import {
ControlPanelConfig,
ControlPanelsContainerProps,
@@ -45,6 +45,7 @@ import {
DEFAULT_FORM_DATA,
TIME_SERIES_DESCRIPTION_TEXT,
} from '../../constants';
import { StackControlsValue } from '../../../constants';
const {
logAxis,
@@ -321,6 +322,38 @@ const config: ControlPanelConfig = {
['color_scheme'],
['time_shift_color'],
...showValueSection,
[
{
name: 'stackDimension',
config: {
type: 'SelectControl',
label: t('Split stack by'),
visibility: ({ controls }) =>
controls?.stack?.value === StackControlsValue.Stack,
renderTrigger: true,
description: t(
'Stack in groups, where each group corresponds to a dimension',
),
shouldMapStateToProps: (
prevState,
state,
controlState,
chartState,
) => true,
mapStateToProps: (state, controlState, chartState) => {
const value: JsonArray = state.controls.groupby
.value as JsonArray;
const valueAsStringArr: string[][] = value.map(v => {
if (v) return [v.toString(), v.toString()];
return ['', ''];
});
return {
choices: valueAsStringArr,
};
},
},
},
],
[minorTicks],
[
{

View File

@@ -58,6 +58,7 @@ export default class EchartsTimeseriesChartPlugin extends EchartsChartPlugin<
name: t('Generic Chart'),
tags: [
t('Advanced-Analytics'),
t('ECharts'),
t('Line'),
t('Predictive'),
t('Time'),

View File

@@ -191,6 +191,7 @@ export default function transformProps(
yAxisTitleMargin,
yAxisTitlePosition,
zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const refs: Refs = {};
const groupBy = ensureIsArray(groupby);
@@ -418,6 +419,23 @@ export default function transformProps(
}
});
if (
stack === StackControlsValue.Stack &&
stackDimension &&
chartProps.rawFormData.groupby
) {
const idxSelectedDimension =
formData.metrics.length > 1
? 1
: 0 + chartProps.rawFormData.groupby.indexOf(stackDimension);
for (const s of series) {
if (s.id) {
const columnsArr = labelMap[s.id];
(s as any).stack = columnsArr[idxSelectedDimension];
}
}
}
// axis bounds need to be parsed to replace incompatible values with undefined
const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);

View File

@@ -74,6 +74,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
rowLimit: number;
seriesType: EchartsTimeseriesSeriesType;
stack: StackType;
stackDimension: string;
timeCompare?: string[];
tooltipTimeFormat?: string;
showTooltipTotal?: boolean;

View File

@@ -28,7 +28,7 @@ import type {
CallbackDataParams,
} from 'echarts/types/src/util/types';
import transformProps, { parseParams } from '../../src/Pie/transformProps';
import { EchartsPieChartProps } from '../../src/Pie/types';
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
describe('Pie transformProps', () => {
const formData: SqlaFormData = {
@@ -46,8 +46,13 @@ describe('Pie transformProps', () => {
queriesData: [
{
data: [
{ foo: 'Sylvester', bar: 1, sum__num: 10 },
{ foo: 'Arnold', bar: 2, sum__num: 2.5 },
{
foo: 'Sylvester',
bar: 1,
sum__num: 10,
sum__num__contribution: 0.8,
},
{ foo: 'Arnold', bar: 2, sum__num: 2.5, sum__num__contribution: 0.2 },
],
},
],
@@ -215,3 +220,77 @@ describe('Pie label string template', () => {
).toEqual('Tablet:123456\n55.5');
});
});
describe('Other category', () => {
const defaultFormData: SqlaFormData = {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'metric',
groupby: ['foo', 'bar'],
viz_type: 'my_viz',
};
const getChartProps = (formData: Partial<SqlaFormData>) =>
new ChartProps({
formData: {
...defaultFormData,
...formData,
},
width: 800,
height: 600,
queriesData: [
{
data: [
{
foo: 'foo 1',
bar: 'bar 1',
metric: 1,
metric__contribution: 1 / 15, // 6.7%
},
{
foo: 'foo 2',
bar: 'bar 2',
metric: 2,
metric__contribution: 2 / 15, // 13.3%
},
{
foo: 'foo 3',
bar: 'bar 3',
metric: 3,
metric__contribution: 3 / 15, // 20%
},
{
foo: 'foo 4',
bar: 'bar 4',
metric: 4,
metric__contribution: 4 / 15, // 26.7%
},
{
foo: 'foo 5',
bar: 'bar 5',
metric: 5,
metric__contribution: 5 / 15, // 33.3%
},
],
},
],
theme: supersetTheme,
});
it('generates Other category', () => {
const chartProps = getChartProps({
threshold_for_other: 20,
});
const transformed = transformProps(chartProps as EchartsPieChartProps);
const series = transformed.echartOptions.series as PieSeriesOption[];
const data = series[0].data as PieChartDataItem[];
expect(data).toHaveLength(4);
expect(data[0].value).toBe(3);
expect(data[1].value).toBe(4);
expect(data[2].value).toBe(5);
expect(data[3].value).toBe(1 + 2);
expect(data[3].name).toBe('Other');
expect(data[3].isOther).toBe(true);
});
});

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable import/no-extraneous-dependencies */
import {
useCallback,
useRef,
@@ -24,6 +25,7 @@ import {
MutableRefObject,
CSSProperties,
DragEvent,
useEffect,
} from 'react';
import {
@@ -39,8 +41,9 @@ import {
Row,
} from 'react-table';
import { matchSorter, rankings } from 'match-sorter';
import { typedMemo, usePrevious } from '@superset-ui/core';
import { styled, typedMemo, usePrevious } from '@superset-ui/core';
import { isEqual } from 'lodash';
import { Space } from 'antd';
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
import SelectPageSize, {
SelectPageSizeProps,
@@ -50,6 +53,8 @@ import SimplePagination from './components/Pagination';
import useSticky from './hooks/useSticky';
import { PAGE_SIZE_OPTIONS } from '../consts';
import { sortAlphanumericCaseInsensitive } from './utils/sortAlphanumericCaseInsensitive';
import { SearchOption, SortByItem } from '../types';
import SearchSelectDropdown from './components/SearchSelectDropdown';
export interface DataTableProps<D extends object> extends TableOptions<D> {
tableClassName?: string;
@@ -62,7 +67,12 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
height?: string | number;
serverPagination?: boolean;
onServerPaginationChange: (pageNumber: number, pageSize: number) => void;
serverPaginationData: { pageSize?: number; currentPage?: number };
serverPaginationData: {
pageSize?: number;
currentPage?: number;
sortBy?: SortByItem[];
searchColumn?: string;
};
pageSize?: number;
noResults?: string | ((filterString: string) => ReactNode);
sticky?: boolean;
@@ -71,6 +81,14 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
onColumnOrderChange: () => void;
renderGroupingHeaders?: () => JSX.Element;
renderTimeComparisonDropdown?: () => JSX.Element;
handleSortByChange: (sortBy: SortByItem[]) => void;
sortByFromParent: SortByItem[];
manualSearch?: boolean;
onSearchChange?: (searchText: string) => void;
initialSearchText?: string;
searchInputId?: string;
onSearchColChange: (searchCol: string) => void;
searchOptions: SearchOption[];
}
export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
@@ -81,6 +99,24 @@ const sortTypes = {
alphanumeric: sortAlphanumericCaseInsensitive,
};
const StyledSpace = styled(Space)`
display: flex;
justify-content: flex-end;
.search-select-container {
display: flex;
}
.search-by-label {
align-self: center;
margin-right: 4px;
}
`;
const StyledRow = styled.div`
display: flex;
`;
// Be sure to pass our updateMyData and the skipReset option
export default typedMemo(function DataTable<D extends object>({
tableClassName,
@@ -105,6 +141,14 @@ export default typedMemo(function DataTable<D extends object>({
onColumnOrderChange,
renderGroupingHeaders,
renderTimeComparisonDropdown,
handleSortByChange,
sortByFromParent = [],
manualSearch = false,
onSearchChange,
initialSearchText,
searchInputId,
onSearchColChange,
searchOptions,
...moreUseTableOptions
}: DataTableProps<D>): JSX.Element {
const tableHooks: PluginHook<D>[] = [
@@ -115,6 +159,7 @@ export default typedMemo(function DataTable<D extends object>({
doSticky ? useSticky : [],
hooks || [],
].flat();
const columnNames = Object.keys(data?.[0] || {});
const previousColumnNames = usePrevious(columnNames);
const resultsSize = serverPagination ? rowCount : data.length;
@@ -127,7 +172,8 @@ export default typedMemo(function DataTable<D extends object>({
...initialState_,
// zero length means all pages, the `usePagination` plugin does not
// understand pageSize = 0
sortBy: sortByRef.current,
// sortBy: sortByRef.current,
sortBy: serverPagination ? sortByFromParent : sortByRef.current,
pageSize: initialPageSize > 0 ? initialPageSize : resultsSize || 10,
};
const defaultWrapperRef = useRef<HTMLDivElement>(null);
@@ -188,7 +234,13 @@ export default typedMemo(function DataTable<D extends object>({
wrapStickyTable,
setColumnOrder,
allColumns,
state: { pageIndex, pageSize, globalFilter: filterValue, sticky = {} },
state: {
pageIndex,
pageSize,
globalFilter: filterValue,
sticky = {},
sortBy,
},
} = useTable<D>(
{
columns,
@@ -198,10 +250,46 @@ export default typedMemo(function DataTable<D extends object>({
globalFilter: defaultGlobalFilter,
sortTypes,
autoResetSortBy: !isEqual(columnNames, previousColumnNames),
manualSortBy: !!serverPagination,
...moreUseTableOptions,
},
...tableHooks,
);
const handleSearchChange = useCallback(
(query: string) => {
if (manualSearch && onSearchChange) {
onSearchChange(query);
} else {
setGlobalFilter(query);
}
},
[manualSearch, onSearchChange, setGlobalFilter],
);
// updating the sort by to the own State of table viz
useEffect(() => {
const serverSortBy = serverPaginationData?.sortBy || [];
if (serverPagination && !isEqual(sortBy, serverSortBy)) {
if (Array.isArray(sortBy) && sortBy.length > 0) {
const [sortByItem] = sortBy;
const matchingColumn = columns.find(col => col?.id === sortByItem?.id);
if (matchingColumn && 'columnKey' in matchingColumn) {
const sortByWithColumnKey: SortByItem = {
...sortByItem,
key: (matchingColumn as { columnKey: string }).columnKey,
};
handleSortByChange([sortByWithColumnKey]);
}
} else {
handleSortByChange([]);
}
}
}, [sortBy]);
// make setPageSize accept 0
const setPageSize = (size: number) => {
if (serverPagination) {
@@ -355,6 +443,7 @@ export default typedMemo(function DataTable<D extends object>({
resultOnPageChange = (pageNumber: number) =>
onServerPaginationChange(pageNumber, serverPageSize);
}
return (
<div
ref={wrapperRef}
@@ -362,9 +451,9 @@ export default typedMemo(function DataTable<D extends object>({
>
{hasGlobalControl ? (
<div ref={globalControlRef} className="form-inline dt-controls">
<div className="row">
<StyledRow className="row">
<div
className={renderTimeComparisonDropdown ? 'col-sm-5' : 'col-sm-6'}
className={renderTimeComparisonDropdown ? 'col-sm-4' : 'col-sm-5'}
>
{hasPagination ? (
<SelectPageSize
@@ -381,16 +470,35 @@ export default typedMemo(function DataTable<D extends object>({
) : null}
</div>
{searchInput ? (
<div className="col-sm-6">
<StyledSpace
className={
renderTimeComparisonDropdown ? 'col-sm-7' : 'col-sm-8'
}
>
{serverPagination && (
<div className="search-select-container">
<span className="search-by-label">Search by: </span>
<SearchSelectDropdown
searchOptions={searchOptions}
value={serverPaginationData?.searchColumn || ''}
onChange={onSearchColChange}
/>
</div>
)}
<GlobalFilter<D>
searchInput={
typeof searchInput === 'boolean' ? undefined : searchInput
}
preGlobalFilteredRows={preGlobalFilteredRows}
setGlobalFilter={setGlobalFilter}
filterValue={filterValue}
setGlobalFilter={
manualSearch ? handleSearchChange : setGlobalFilter
}
filterValue={manualSearch ? initialSearchText : filterValue}
id={searchInputId}
serverPagination={!!serverPagination}
rowCount={rowCount}
/>
</div>
</StyledSpace>
) : null}
{renderTimeComparisonDropdown ? (
<div
@@ -400,7 +508,7 @@ export default typedMemo(function DataTable<D extends object>({
{renderTimeComparisonDropdown()}
</div>
) : null}
</div>
</StyledRow>
</div>
) : null}
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { memo, ComponentType, ChangeEventHandler } from 'react';
import {
memo,
ComponentType,
ChangeEventHandler,
useRef,
useEffect,
} from 'react';
import { Row, FilterValue } from 'react-table';
import useAsyncState from '../utils/useAsyncState';
@@ -24,8 +30,12 @@ export interface SearchInputProps {
count: number;
value: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onBlur?: () => void;
inputRef?: React.RefObject<HTMLInputElement>;
}
const isSearchFocused = new Map();
export interface GlobalFilterProps<D extends object> {
preGlobalFilteredRows: Row<D>[];
// filter value cannot be `undefined` otherwise React will report component
@@ -33,17 +43,28 @@ export interface GlobalFilterProps<D extends object> {
filterValue: string;
setGlobalFilter: (filterValue: FilterValue) => void;
searchInput?: ComponentType<SearchInputProps>;
id?: string;
serverPagination: boolean;
rowCount: number;
}
function DefaultSearchInput({ count, value, onChange }: SearchInputProps) {
function DefaultSearchInput({
count,
value,
onChange,
onBlur,
inputRef,
}: SearchInputProps) {
return (
<span className="dt-global-filter">
Search{' '}
<input
ref={inputRef}
className="form-control input-sm"
placeholder={`${count} records...`}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
</span>
);
@@ -56,8 +77,13 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
filterValue = '',
searchInput,
setGlobalFilter,
id = '',
serverPagination,
rowCount,
}: GlobalFilterProps<D>) {
const count = preGlobalFilteredRows.length;
const count = serverPagination ? rowCount : preGlobalFilteredRows.length;
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useAsyncState(
filterValue,
(newValue: string) => {
@@ -66,17 +92,37 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
200,
);
// Preserve focus during server-side filtering to maintain a better user experience
useEffect(() => {
if (
serverPagination &&
isSearchFocused.get(id) &&
document.activeElement !== inputRef.current
) {
inputRef.current?.focus();
}
}, [value, serverPagination]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
e.preventDefault();
isSearchFocused.set(id, true);
setValue(target.value);
};
const handleBlur = () => {
isSearchFocused.set(id, false);
};
const SearchInput = searchInput || DefaultSearchInput;
return (
<SearchInput
count={count}
value={value}
onChange={e => {
const target = e.target as HTMLInputElement;
e.preventDefault();
setValue(target.value);
}}
inputRef={inputRef}
onChange={handleChange}
onBlur={handleBlur}
/>
);
});

View File

@@ -0,0 +1,53 @@
/**
* 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 import/no-extraneous-dependencies */
import { styled } from '@superset-ui/core';
import { Select } from 'antd';
import { SearchOption } from '../../types';
const StyledSelect = styled(Select)`
width: 120px;
margin-right: 8px;
`;
interface SearchSelectDropdownProps {
/** The currently selected search column value */
value?: string;
/** Callback triggered when a new search column is selected */
onChange: (searchCol: string) => void;
/** Available search column options to populate the dropdown */
searchOptions: SearchOption[];
}
function SearchSelectDropdown({
value,
onChange,
searchOptions,
}: SearchSelectDropdownProps) {
return (
<StyledSelect
className="search-select"
value={value || (searchOptions?.[0]?.value ?? '')}
options={searchOptions}
onChange={onChange}
/>
);
}
export default SearchSelectDropdown;

View File

@@ -115,3 +115,11 @@ declare module 'react-table' {
extends UseTableHooks<D>,
UseSortByHooks<D> {}
}
interface TableOwnState {
currentPage?: number;
pageSize?: number;
sortColumn?: string;
sortOrder?: 'asc' | 'desc';
searchText?: string;
}

View File

@@ -18,6 +18,7 @@
*/
import { SetDataMaskHook } from '@superset-ui/core';
import { TableOwnState } from '../types/react-table';
export const updateExternalFormData = (
setDataMask: SetDataMaskHook = () => {},
@@ -30,3 +31,11 @@ export const updateExternalFormData = (
pageSize,
},
});
export const updateTableOwnState = (
setDataMask: SetDataMaskHook = () => {},
modifiedOwnState: TableOwnState,
) =>
setDataMask({
ownState: modifiedOwnState,
});

View File

@@ -24,6 +24,7 @@ import {
useState,
MouseEvent,
KeyboardEvent as ReactKeyboardEvent,
useEffect,
} from 'react';
import {
@@ -61,10 +62,12 @@ import {
PlusCircleOutlined,
TableOutlined,
} from '@ant-design/icons';
import { isEmpty } from 'lodash';
import { debounce, isEmpty, isEqual } from 'lodash';
import {
ColorSchemeEnum,
DataColumnMeta,
SearchOption,
SortByItem,
TableChartTransformedProps,
} from './types';
import DataTable, {
@@ -76,8 +79,8 @@ import DataTable, {
import Styles from './Styles';
import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS } from './consts';
import { updateExternalFormData } from './DataTable/utils/externalAPIs';
import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
import { updateTableOwnState } from './DataTable/utils/externalAPIs';
import getScrollBarSize from './DataTable/utils/getScrollBarSize';
type ValueRange = [number, number];
@@ -176,20 +179,26 @@ function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
return sortIcon;
}
function SearchInput({ count, value, onChange }: SearchInputProps) {
return (
<span className="dt-global-filter">
{t('Search')}{' '}
<input
aria-label={t('Search %s records', count)}
className="form-control input-sm"
placeholder={tn('search.num_records', count)}
value={value}
onChange={onChange}
/>
</span>
);
}
const SearchInput = ({
count,
value,
onChange,
onBlur,
inputRef,
}: SearchInputProps) => (
<span className="dt-global-filter">
{t('Search')}{' '}
<input
ref={inputRef}
aria-label={t('Search %s records', count)}
className="form-control input-sm"
placeholder={tn('search.num_records', count)}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
</span>
);
function SelectPageSize({
options,
@@ -267,6 +276,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
isUsingTimeComparison,
basicColorFormatters,
basicColorColumnFormatters,
hasServerPageLengthChanged,
serverPageLength,
slice_id,
} = props;
const comparisonColumns = [
{ key: 'all', label: t('Display all') },
@@ -294,7 +306,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) => n <= rowCount;
return PAGE_SIZE_OPTIONS.filter(([n]) =>
return (
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
).filter(([n]) =>
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
) as SizeOption[];
}, [data.length, rowCount, serverPagination]);
@@ -679,7 +693,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
);
const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
(
column: DataColumnMeta,
i: number,
): ColumnWithLooseAccessor<D> & {
columnKey: string;
} => {
const {
key,
label: originalLabel,
@@ -766,6 +785,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// must use custom accessor to allow `.` in column names
// typing is incorrect in current version of `@types/react-table`
// so we ask TS not to check.
columnKey: key,
accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => {
const [isHtml, text] = formatColumnValue(column, value);
@@ -1058,13 +1078,50 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[visibleColumnsMeta, getColumnConfigs],
);
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
useEffect(() => {
const options = (
columns as unknown as ColumnWithLooseAccessor &
{
columnKey: string;
sortType?: string;
}[]
)
.filter(col => col?.sortType === 'alphanumeric')
.map(column => ({
value: column.columnKey,
label: column.columnKey,
}));
if (!isEqual(options, searchOptions)) {
setSearchOptions(options || []);
}
}, [columns]);
const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => {
updateExternalFormData(setDataMask, pageNumber, pageSize);
const modifiedOwnState = {
...serverPaginationData,
currentPage: pageNumber,
pageSize,
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask],
);
useEffect(() => {
if (hasServerPageLengthChanged) {
const modifiedOwnState = {
...serverPaginationData,
currentPage: 0,
pageSize: serverPageLength,
};
updateTableOwnState(setDataMask, modifiedOwnState);
}
}, []);
const handleSizeChange = useCallback(
({ width, height }: { width: number; height: number }) => {
setTableSize({ width, height });
@@ -1100,6 +1157,42 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const { width: widthFromState, height: heightFromState } = tableSize;
const handleSortByChange = useCallback(
(sortBy: SortByItem[]) => {
if (!serverPagination) return;
const modifiedOwnState = {
...serverPaginationData,
sortBy,
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask, serverPagination],
);
const handleSearch = (searchText: string) => {
const modifiedOwnState = {
...(serverPaginationData || {}),
searchColumn:
serverPaginationData?.searchColumn || searchOptions[0]?.value,
searchText,
currentPage: 0, // Reset to first page when searching
};
updateTableOwnState(setDataMask, modifiedOwnState);
};
const debouncedSearch = debounce(handleSearch, 800);
const handleChangeSearchCol = (searchCol: string) => {
if (!isEqual(searchCol, serverPaginationData?.searchColumn)) {
const modifiedOwnState = {
...(serverPaginationData || {}),
searchColumn: searchCol,
searchText: '',
};
updateTableOwnState(setDataMask, modifiedOwnState);
}
};
return (
<Styles>
<DataTable<D>
@@ -1115,6 +1208,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange}
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}
initialSearchText={serverPaginationData?.searchText || ''}
sortByFromParent={serverPaginationData?.sortBy || []}
searchInputId={`${slice_id}-search`}
// 9 page items in > 340px works well even for 100+ pages
maxPageItemCount={width > 340 ? 9 : 7}
noResults={getNoResultsMessage}
@@ -1128,6 +1224,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
renderTimeComparisonDropdown={
isUsingTimeComparison ? renderTimeComparisonDropdown : undefined
}
handleSortByChange={handleSortByChange}
onSearchColChange={handleChangeSearchCol}
manualSearch={serverPagination}
onSearchChange={debouncedSearch}
searchOptions={searchOptions}
/>
</Styles>
);

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