mirror of
https://github.com/apache/superset.git
synced 2026-04-30 13:34:20 +00:00
Compare commits
363 Commits
feat/toolt
...
6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071ab203b5 | ||
|
|
79e3f1472c | ||
|
|
a66cf66158 | ||
|
|
2632df41c1 | ||
|
|
98a04b1bc2 | ||
|
|
3bb37d3f78 | ||
|
|
88e82bebe3 | ||
|
|
922f4a402c | ||
|
|
76d6a1dd6d | ||
|
|
48a6921ade | ||
|
|
3baecdc622 | ||
|
|
50f0306753 | ||
|
|
23431467e9 | ||
|
|
c85f58f9cc | ||
|
|
85c8ec073a | ||
|
|
667e964577 | ||
|
|
1c1af8d0c8 | ||
|
|
841adfc15a | ||
|
|
e3ba8b1b2a | ||
|
|
36864034e3 | ||
|
|
51f71deed8 | ||
|
|
564ed6cd13 | ||
|
|
0976718c5c | ||
|
|
22b981b48a | ||
|
|
a14eb47b5a | ||
|
|
62c36cb5ad | ||
|
|
075df66e2f | ||
|
|
dd36d6f967 | ||
|
|
0d9d427305 | ||
|
|
ae5f060df3 | ||
|
|
30e854f551 | ||
|
|
e36a1c216d | ||
|
|
fc1690f50b | ||
|
|
86d4605061 | ||
|
|
565503e835 | ||
|
|
ce8a709839 | ||
|
|
b00603d9fb | ||
|
|
27a0f08280 | ||
|
|
0640beee19 | ||
|
|
d1e7eeacf7 | ||
|
|
c8c934afe2 | ||
|
|
71863b73a3 | ||
|
|
95663c4619 | ||
|
|
9adcd7a451 | ||
|
|
2c0f00c8e4 | ||
|
|
7ddfda9576 | ||
|
|
72d28d7a0f | ||
|
|
a7aad1beff | ||
|
|
c4bbf1beae | ||
|
|
4f8d977940 | ||
|
|
cedce39c09 | ||
|
|
84972a7d6b | ||
|
|
3d71e69a68 | ||
|
|
3895db430a | ||
|
|
9239a8ee83 | ||
|
|
26c4b09b45 | ||
|
|
c912b93770 | ||
|
|
5796871919 | ||
|
|
054040be85 | ||
|
|
c3a7048c10 | ||
|
|
6b8409baf0 | ||
|
|
0773e25383 | ||
|
|
da63984a21 | ||
|
|
444f49c6cf | ||
|
|
9f359f9124 | ||
|
|
cfa0f03e88 | ||
|
|
682599d7de | ||
|
|
b4b900adce | ||
|
|
1a8bea4072 | ||
|
|
82e172dd3e | ||
|
|
261944713c | ||
|
|
0d7947c7fc | ||
|
|
bb7e56d8cc | ||
|
|
e643a47deb | ||
|
|
2083622f18 | ||
|
|
98c8ffa0df | ||
|
|
b183f04fa5 | ||
|
|
3fcebdb1cb | ||
|
|
81149348f1 | ||
|
|
7566a551ed | ||
|
|
e5192481c6 | ||
|
|
aa3b497262 | ||
|
|
e67e602892 | ||
|
|
fe9202ee0b | ||
|
|
c4fd5759af | ||
|
|
977ff44851 | ||
|
|
d3ba4c7b21 | ||
|
|
16f1bb5b88 | ||
|
|
68c03f501c | ||
|
|
cdb0317be8 | ||
|
|
d3eec7772a | ||
|
|
0022644b84 | ||
|
|
7702515c53 | ||
|
|
6042099296 | ||
|
|
0503983d59 | ||
|
|
668026eeac | ||
|
|
3d644e6918 | ||
|
|
747b4c0125 | ||
|
|
fee1f16bfe | ||
|
|
3b15ff3d83 | ||
|
|
9e9e8ce056 | ||
|
|
cc428c7a5c | ||
|
|
8a92092d7e | ||
|
|
cffaffd769 | ||
|
|
c5c72bbad7 | ||
|
|
4588d84929 | ||
|
|
844c3e1279 | ||
|
|
8e70875d6b | ||
|
|
03e5001e0e | ||
|
|
cbd27802a8 | ||
|
|
df28ce4deb | ||
|
|
1236a8582e | ||
|
|
b1c57c1fc8 | ||
|
|
66a7a0a690 | ||
|
|
3ce1687725 | ||
|
|
3e42dd8511 | ||
|
|
6a1c30e5e7 | ||
|
|
e763c96381 | ||
|
|
0753fd4c45 | ||
|
|
3a537498f6 | ||
|
|
8f061d0c0a | ||
|
|
e0e52086cd | ||
|
|
2a21ef6dc9 | ||
|
|
72fbaa6677 | ||
|
|
6d49d1e182 | ||
|
|
5b51c7e89e | ||
|
|
74f455f076 | ||
|
|
a911a50680 | ||
|
|
5b820699e8 | ||
|
|
c3308f4447 | ||
|
|
9cfd35d43a | ||
|
|
785a6d0237 | ||
|
|
71be38f003 | ||
|
|
f3db80bfee | ||
|
|
6cc46750b9 | ||
|
|
c9a7f2d02e | ||
|
|
3c5868c412 | ||
|
|
4112fecc93 | ||
|
|
ea1ab05e8d | ||
|
|
0e3092dd56 | ||
|
|
91814f3dfe | ||
|
|
c9501fddb8 | ||
|
|
fe58baa23c | ||
|
|
19ebeea030 | ||
|
|
18ed2290bc | ||
|
|
79ec265a30 | ||
|
|
aab835a096 | ||
|
|
b19372fe16 | ||
|
|
a083e189e2 | ||
|
|
0f5918dbd9 | ||
|
|
3a54cade44 | ||
|
|
cbdbc2c295 | ||
|
|
548480bd8e | ||
|
|
6179eb9ef4 | ||
|
|
15c4ee63b2 | ||
|
|
89a36ea131 | ||
|
|
2d0017033c | ||
|
|
1a4ae81058 | ||
|
|
c9d8baf9a1 | ||
|
|
a2fcb64ef3 | ||
|
|
8a84b17270 | ||
|
|
992cb83088 | ||
|
|
8c43578b70 | ||
|
|
933ec0a918 | ||
|
|
70117eb55f | ||
|
|
8c22e61ef1 | ||
|
|
bed186b32f | ||
|
|
a52cfef8b1 | ||
|
|
3b7a52d1eb | ||
|
|
d8c8430ed7 | ||
|
|
bc3f146eaf | ||
|
|
1245a1e26a | ||
|
|
446136b46c | ||
|
|
33e53143e6 | ||
|
|
706a04be7f | ||
|
|
2c86d1ae9c | ||
|
|
8bc1f98033 | ||
|
|
f7ebdd71e7 | ||
|
|
c48b5ad65e | ||
|
|
8e4efe2cc2 | ||
|
|
e818d0b11f | ||
|
|
e0a4b98351 | ||
|
|
131359c78f | ||
|
|
6267856b0d | ||
|
|
ce21b04621 | ||
|
|
1fdb3210f9 | ||
|
|
e21cb9a6d9 | ||
|
|
3f6f53569f | ||
|
|
87f36d2b2f | ||
|
|
36c14f81f3 | ||
|
|
e5ee7a0a15 | ||
|
|
78a53484fc | ||
|
|
5ecd067ed3 | ||
|
|
78983a6f25 | ||
|
|
4d996cec5e | ||
|
|
41ac8d8d9c | ||
|
|
d1feafb400 | ||
|
|
877997ceda | ||
|
|
26177314db | ||
|
|
a3aef76427 | ||
|
|
fc222eefb7 | ||
|
|
36bacf2ae4 | ||
|
|
bda2d8c145 | ||
|
|
a30b212a37 | ||
|
|
b07011872b | ||
|
|
23b02963b6 | ||
|
|
80fce9a662 | ||
|
|
3bfd4efa21 | ||
|
|
151633a1fd | ||
|
|
fe7823cc10 | ||
|
|
f7a64f05c5 | ||
|
|
35861f7ec0 | ||
|
|
7abe5c379e | ||
|
|
36f23a003f | ||
|
|
412d9c8cbc | ||
|
|
c799f5cedd | ||
|
|
1d6d12cfb4 | ||
|
|
b41ad89474 | ||
|
|
52993e68e1 | ||
|
|
2a474e017a | ||
|
|
8d4a1cfc05 | ||
|
|
6c82d480a7 | ||
|
|
f6b050d270 | ||
|
|
22c8551d64 | ||
|
|
37e280f5bd | ||
|
|
bc1fda5a4a | ||
|
|
0a6b3de884 | ||
|
|
012765cd0b | ||
|
|
b633bc5577 | ||
|
|
d5fa2d86f0 | ||
|
|
8dbbbbf136 | ||
|
|
e61a0dd619 | ||
|
|
24a84a23f2 | ||
|
|
e4cbc5db4c | ||
|
|
e0e8d1d177 | ||
|
|
b47dc64cd5 | ||
|
|
61bf39f0d5 | ||
|
|
efbfcd737d | ||
|
|
4c60bd1392 | ||
|
|
6420d06fc0 | ||
|
|
51396f0b94 | ||
|
|
6bb13ef3b4 | ||
|
|
b842eeb893 | ||
|
|
50e2a06306 | ||
|
|
e80f44716c | ||
|
|
94d10af733 | ||
|
|
8d49999af6 | ||
|
|
927c94306e | ||
|
|
5074ee0af8 | ||
|
|
7ba76a85f4 | ||
|
|
d9646dedd9 | ||
|
|
ac582322b8 | ||
|
|
d2d99d4698 | ||
|
|
76d13176a1 | ||
|
|
f6209e1ca9 | ||
|
|
97649d7290 | ||
|
|
df0f6f6ec3 | ||
|
|
0a89b306e5 | ||
|
|
7263133c52 | ||
|
|
a4e3d21176 | ||
|
|
485ff97b0f | ||
|
|
1621c70b68 | ||
|
|
13b7bbe9a4 | ||
|
|
886f525545 | ||
|
|
b76a0e1d2d | ||
|
|
542efcdb11 | ||
|
|
0ac7464649 | ||
|
|
e5bb8bf0ff | ||
|
|
8306b66515 | ||
|
|
9a0dc23755 | ||
|
|
5561f529f2 | ||
|
|
b0ceb2a162 | ||
|
|
7fde43b476 | ||
|
|
99519cd4ce | ||
|
|
9525742b56 | ||
|
|
ad7acecbf2 | ||
|
|
a423f8ecda | ||
|
|
1265b3d3e5 | ||
|
|
b2fd9e2fb1 | ||
|
|
f3163e1c27 | ||
|
|
8c1fdcb179 | ||
|
|
eb2b4bfc30 | ||
|
|
5baee67df7 | ||
|
|
e8562bc641 | ||
|
|
dedc10065e | ||
|
|
4dedfac238 | ||
|
|
2c870e8528 | ||
|
|
baee1ab82b | ||
|
|
a8cf0981fa | ||
|
|
8768a3f55a | ||
|
|
cf1902a4cc | ||
|
|
d94c92db01 | ||
|
|
acec8743c0 | ||
|
|
9918670315 | ||
|
|
997e000f6b | ||
|
|
428c97a1d6 | ||
|
|
7996359719 | ||
|
|
444b98b95e | ||
|
|
b51eda51ce | ||
|
|
8a3dcadf87 | ||
|
|
07fd60fe20 | ||
|
|
06ada5472e | ||
|
|
22826ba876 | ||
|
|
dbe0845dc0 | ||
|
|
0c045127e4 | ||
|
|
f69bdf5475 | ||
|
|
d5a523db25 | ||
|
|
38b456bc8a | ||
|
|
e1efc87fdc | ||
|
|
e84bdfaa6d | ||
|
|
930038d763 | ||
|
|
d30cd5dc2a | ||
|
|
714a03e007 | ||
|
|
026e016720 | ||
|
|
5442521e18 | ||
|
|
b8426b92c7 | ||
|
|
2f086475f8 | ||
|
|
1c95ea5ab8 | ||
|
|
a3a2c494cc | ||
|
|
d6cc324798 | ||
|
|
5756d25a7c | ||
|
|
b16d6ed224 | ||
|
|
18e8b064de | ||
|
|
0c0dfc0601 | ||
|
|
c80b8fea85 | ||
|
|
160a8fe16c | ||
|
|
fc80861f47 | ||
|
|
7169d9f2bd | ||
|
|
37347525e7 | ||
|
|
b6aa68dbfc | ||
|
|
e4f371b126 | ||
|
|
d0d816047c | ||
|
|
2e28e22596 | ||
|
|
a475d68693 | ||
|
|
dfd36f5a54 | ||
|
|
ea5ebd2ec9 | ||
|
|
d454a22f1c | ||
|
|
d01934d9d8 | ||
|
|
25775504b9 | ||
|
|
4cc6984ebf | ||
|
|
548d9e6f7b | ||
|
|
1adaf20ccb | ||
|
|
7e9658fad6 | ||
|
|
5ff6679804 | ||
|
|
0899496ca5 | ||
|
|
e2a469c32d | ||
|
|
2ffc1b95ba | ||
|
|
3b3aa1e302 | ||
|
|
958b29acbc | ||
|
|
f0cfd17dc5 | ||
|
|
ebfddb2b39 | ||
|
|
e10b6e8ae9 | ||
|
|
18d4acdee9 | ||
|
|
c199213e8e | ||
|
|
91834bbede | ||
|
|
7253c79959 | ||
|
|
04738c716c | ||
|
|
928dbe43e0 | ||
|
|
3f597a6551 | ||
|
|
3661482eb3 | ||
|
|
9443fe8b78 | ||
|
|
453241eb33 | ||
|
|
a5f7d236ac |
3
.github/dependabot.yml
vendored
3
.github/dependabot.yml
vendored
@@ -12,6 +12,9 @@ updates:
|
||||
# not until React >= 18.0.0
|
||||
- dependency-name: "storybook"
|
||||
- dependency-name: "@storybook*"
|
||||
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
|
||||
- dependency-name: "remark-gfm"
|
||||
- dependency-name: "react-markdown"
|
||||
# JSDOM v30 doesn't play well with Jest v30
|
||||
# Source: https://jestjs.io/blog#known-issues
|
||||
# GH thread: https://github.com/jsdom/jsdom/issues/3492
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -134,3 +134,4 @@ CLAUDE.local.md
|
||||
.claude_rc*
|
||||
.env.local
|
||||
PROJECT.md
|
||||
*.code-workspace
|
||||
|
||||
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=all
|
||||
enable=disallowed-json-import,disallowed-sql-import,consider-using-transaction
|
||||
enable=disallowed-sql-import,consider-using-transaction
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
@@ -44,4 +44,8 @@ under the License.
|
||||
- [4.0.1](./CHANGELOG/4.0.1.md)
|
||||
- [4.0.2](./CHANGELOG/4.0.2.md)
|
||||
- [4.1.0](./CHANGELOG/4.1.0.md)
|
||||
- [4.1.1](./CHANGELOG/4.1.1.md)
|
||||
- [4.1.2](./CHANGELOG/4.1.2.md)
|
||||
- [4.1.3](./CHANGELOG/4.1.3.md)
|
||||
- [5.0.0](./CHANGELOG/5.0.0.md)
|
||||
- [6.0.0](./CHANGELOG/6.0.0.md)
|
||||
|
||||
1062
CHANGELOG/6.0.0.md
Normal file
1062
CHANGELOG/6.0.0.md
Normal file
File diff suppressed because it is too large
Load Diff
113
CHANGELOG/6.0.1.md
Normal file
113
CHANGELOG/6.0.1.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
## Change Log
|
||||
|
||||
### 6.0.1 (Thu Feb 5 15:25:51 2026 +0530)
|
||||
|
||||
**Fixes**
|
||||
|
||||
- [#37403](https://github.com/apache/superset/pull/37403) fix: Rename Truncate Axis to Truncate Y Axis in bar chart controls (@bikashbarua)
|
||||
- [#37553](https://github.com/apache/superset/pull/37553) fix(security): update jspdf to 4.0.0 to address CVE-2025-68428 (@Nancy-Chauhan)
|
||||
- [#36985](https://github.com/apache/superset/pull/36985) fix(native-filters): update TEMPORAL_RANGE filter subject when Time Column filter is applied (@JCelento)
|
||||
- [#37284](https://github.com/apache/superset/pull/37284) fix(timeseries): restore ECharts tooltip after closing drill menu (@VanessaGiannoni)
|
||||
- [#37112](https://github.com/apache/superset/pull/37112) fix: charts row limit warning is missing for server (@ramiroaquinoromero)
|
||||
- [#37208](https://github.com/apache/superset/pull/37208) fix: Heatmap does not render correctly on normalization (@qf-jonathan)
|
||||
- [#37452](https://github.com/apache/superset/pull/37452) fix(dashboard): Avoid calling loadData for invisible charts on virtual rendering (@justinpark)
|
||||
- [#37407](https://github.com/apache/superset/pull/37407) fix(chart): enable cross-filter on bar charts without dimensions (@rusackas)
|
||||
- [#37218](https://github.com/apache/superset/pull/37218) fix(charts): Table chart shows an error on row limit (@FelipeGLopez)
|
||||
- [#37409](https://github.com/apache/superset/pull/37409) fix(themes): correct action icons size and restore missing tooltips (@massucattoj)
|
||||
- [#37248](https://github.com/apache/superset/pull/37248) fix(deckgl): change deck gl Path default line width unit to meters (@massucattoj)
|
||||
- [#37495](https://github.com/apache/superset/pull/37495) fix(Multilayer): preserve dashboard context for embedded (@msyavuz)
|
||||
- [#36962](https://github.com/apache/superset/pull/36962) fix(charts): numerical column for the Point Radius field in mapbox (@FelipeGLopez)
|
||||
- [#37456](https://github.com/apache/superset/pull/37456) fix(explore): remove extra spacing when Advanced Analytics section is hidden (@VanessaGiannoni)
|
||||
- [#37503](https://github.com/apache/superset/pull/37503) fix(dashboard): catch DatasourceNotFound in get_datasets to prevent 404 (@gabotorresruiz)
|
||||
- [#36963](https://github.com/apache/superset/pull/36963) fix(dashboard): resolve dropdown popup positioning (@reynoldmorel)
|
||||
- [#37253](https://github.com/apache/superset/pull/37253) fix(dashboard-filters): prevent clearing all filters when editing a native filter (@VanessaGiannoni)
|
||||
- [#37453](https://github.com/apache/superset/pull/37453) fix(select): prevent bulk action buttons from being cut off in filters (@massucattoj)
|
||||
- [#36990](https://github.com/apache/superset/pull/36990) fix(ag-grid-table): preserve time grain aggregation when temporal column casing changes (@VanessaGiannoni)
|
||||
- [#36958](https://github.com/apache/superset/pull/36958) fix(database): include `configuration_method` in the DB export/import flow (@isaac-jaynes-imperva)
|
||||
- [#37168](https://github.com/apache/superset/pull/37168) fix(charts): missing globalOpacity prop with mapbox (@FelipeGLopez)
|
||||
- [#37244](https://github.com/apache/superset/pull/37244) fix(deckgl-contour): prevent WebGL freeze by clamping and auto-scaling cellSize (@YousufFFFF)
|
||||
- [#37256](https://github.com/apache/superset/pull/37256) fix: display correct icon for Multi Chart in quick switcher (@ramiroaquinoromero)
|
||||
- [#36058](https://github.com/apache/superset/pull/36058) fix(superset-frontend): Fixes for broken functionality when an application root is defined (@martyngigg)
|
||||
- [#36986](https://github.com/apache/superset/pull/36986) fix(datasets): respect application root in database management link (@geier)
|
||||
- [#36996](https://github.com/apache/superset/pull/36996) fix(time-range-modal): time range modal for out of scope filter is not displayed correctly (@ramiroaquinoromero)
|
||||
- [#37165](https://github.com/apache/superset/pull/37165) fix(dataset-editor): add missing Data type label in calculated columns tab (@massucattoj)
|
||||
- [#36932](https://github.com/apache/superset/pull/36932) fix(sqllab): add colorEditorSelection token for visible text selection (@gabotorresruiz)
|
||||
- [#37071](https://github.com/apache/superset/pull/37071) fix(api): nan is not properly handled for athena connections (@ramiroaquinoromero)
|
||||
- [#36388](https://github.com/apache/superset/pull/36388) fix: pin remark-gfm to v3.0.1 for compatibility with react-markdown v8 (@rebenitez1802)
|
||||
- [#36989](https://github.com/apache/superset/pull/36989) fix(chart): Horizontal bar chart value labels cut off (@LuisSanchez)
|
||||
- [#37012](https://github.com/apache/superset/pull/37012) fix(delete-filter): deleted native filters are still shown until [sc-96553] (@ramiroaquinoromero)
|
||||
- [#37181](https://github.com/apache/superset/pull/37181) fix(timeseries): x-axis last month was hidden (@LuisSanchez)
|
||||
- [#37017](https://github.com/apache/superset/pull/37017) fix(native-filters): enable Apply button when selecting Boolean FALSE value (@JCelento)
|
||||
- [#36927](https://github.com/apache/superset/pull/36927) fix(Dashboard): Auto-apply filters with default values when extraForm… (@geido)
|
||||
- [#37064](https://github.com/apache/superset/pull/37064) fix(calendar-heatmap): correct month display across timezones (@massucattoj)
|
||||
- [#37177](https://github.com/apache/superset/pull/37177) fix(sunburst): make Show Total text theme-aware (@tiya-9975)
|
||||
- [#37217](https://github.com/apache/superset/pull/37217) fix(mixed-timeseries): prevent duplicate legend entries (@YousufFFFF)
|
||||
- [#36596](https://github.com/apache/superset/pull/36596) fix: Implement SIP-40 error styles for GAQ (@bsovran)
|
||||
- [#37210](https://github.com/apache/superset/pull/37210) fix: add droppable area to tab empty state (@phmoraesrodrigues)
|
||||
- [#37171](https://github.com/apache/superset/pull/37171) fix: HTML detection in tables (@kgabryje)
|
||||
- [#37173](https://github.com/apache/superset/pull/37173) fix: Move head_custom_extra above csrf token input (@kgabryje)
|
||||
- [#37115](https://github.com/apache/superset/pull/37115) fix(controls): Only initialize categorical control on numeric x axis (@msyavuz)
|
||||
- [#36991](https://github.com/apache/superset/pull/36991) fix(dashboard): revert cell hover and active colors to grayscale (@reynoldmorel)
|
||||
- [#37039](https://github.com/apache/superset/pull/37039) fix(table): keep d3-format semantics when applying currency formatting (@qf-jonathan)
|
||||
- [#37029](https://github.com/apache/superset/pull/37029) fix(deckgl): remove visibility condition in deckgl stroke color (@DamianPendrak)
|
||||
- [#36747](https://github.com/apache/superset/pull/36747) fix(sqlglot): use Athena dialect for awsathena parsing (@ankitajhanwar2001)
|
||||
- [#37020](https://github.com/apache/superset/pull/37020) fix(models): prevent SQLAlchemy and_() deprecation warning (@aminghadersohi)
|
||||
- [#36981](https://github.com/apache/superset/pull/36981) fix(reports): Use authenticated user as recipient for chart/dashboard reports (@msyavuz)
|
||||
- [#37018](https://github.com/apache/superset/pull/37018) fix(Tabs): prevent infinite rerenders with nested tabs (@msyavuz)
|
||||
- [#35009](https://github.com/apache/superset/pull/35009) fix: handle undefined template variables safely in query rendering. (@LevisNgigi)
|
||||
- [#35871](https://github.com/apache/superset/pull/35871) fix(alerts): wrong alert trigger with custom query (@gabotorresruiz)
|
||||
- [#36550](https://github.com/apache/superset/pull/36550) fix(security): enforce datasource access control in get_samples() (@rusackas)
|
||||
- [#36270](https://github.com/apache/superset/pull/36270) fix(ag-grid): Ag Grid Date Filter timezone correction (@amaannawab923)
|
||||
- [#36686](https://github.com/apache/superset/pull/36686) fix(dashboard): prevent table chart infinite reload loop (@ramiroaquinoromero)
|
||||
- [#36422](https://github.com/apache/superset/pull/36422) fix(SQLLab): remove error icon displayed when writing Jinja SQL even when the script is correct (@FelipeGLopez)
|
||||
- [#36872](https://github.com/apache/superset/pull/36872) fix(trino): update query progress using cursor stats (@justinpark)
|
||||
- [#36891](https://github.com/apache/superset/pull/36891) fix(plugin-chart-table): remove column misalignment when no scrollbars are present (@EnxDev)
|
||||
- [#36532](https://github.com/apache/superset/pull/36532) fix(RightMenu): fix inconsistent icon alignment in RightMenu items (@innovark37)
|
||||
- [#36671](https://github.com/apache/superset/pull/36671) fix(SavedQueries): unify query card actions styling across all home page cards (@innovark37)
|
||||
- [#36490](https://github.com/apache/superset/pull/36490) fix(logout): clicking logout displays an error notification "invalid username or password" (@LevisNgigi)
|
||||
- [#36819](https://github.com/apache/superset/pull/36819) fix(TableChart): render cell bars for columns with NULL values (@LuisSanchez)
|
||||
- [#36854](https://github.com/apache/superset/pull/36854) fix: Clear database form errors (@msyavuz)
|
||||
- [#36858](https://github.com/apache/superset/pull/36858) fix: SqlLab error when collapsing the left panel preview (@EnxDev)
|
||||
- [#36831](https://github.com/apache/superset/pull/36831) fix(chart-creation): use exact match when loading dataset from URL parameter (@EnxDev)
|
||||
- [#36639](https://github.com/apache/superset/pull/36639) fix: fix error with dashboard filters when global async queries is enabled and user navigates quickly (@LevisNgigi)
|
||||
- [#36809](https://github.com/apache/superset/pull/36809) fix(TableCollection): only apply highlight class when defined (@msyavuz)
|
||||
- [#36531](https://github.com/apache/superset/pull/36531) fix: UI cut off (@EnxDev)
|
||||
- [#36716](https://github.com/apache/superset/pull/36716) fix: Use is_active for guest users (@Vitor-Avila)
|
||||
- [#36306](https://github.com/apache/superset/pull/36306) fix(echarts): use scroll legend for horizontal layouts to prevent overlap (@YousufFFFF)
|
||||
- [#35945](https://github.com/apache/superset/pull/35945) fix: removed dashboard from main page in "All" tab, refreshes dashboard list (@SBIN2010)
|
||||
- [#36551](https://github.com/apache/superset/pull/36551) fix(dashboard): import with overwrite flag replaces charts instead of merging (@rusackas)
|
||||
- [#36545](https://github.com/apache/superset/pull/36545) fix(sql): handle backtick-quoted identifiers with base dialect (@rusackas)
|
||||
- [#36528](https://github.com/apache/superset/pull/36528) fix(tab): Fix tabs in column not clickable (@alexandrusoare)
|
||||
- [#36410](https://github.com/apache/superset/pull/36410) fix(api): Fix JWT authentication for /api/v1/me endpoints (@rusackas)
|
||||
- [#35098](https://github.com/apache/superset/pull/35098) fix: add subdirectory deployment support for app icon and reports urls (@eschutho)
|
||||
- [#36277](https://github.com/apache/superset/pull/36277) fix(SqlLab): enhance SQL formatting with Jinja template support. (@LuisSanchez)
|
||||
- [#36323](https://github.com/apache/superset/pull/36323) fix(chart): Display better hover text for country map charts (@Risheit)
|
||||
- [#36263](https://github.com/apache/superset/pull/36263) fix(roles): Add missing SQLLab permissions for estimate and format (@shunki-fujita)
|
||||
- [#36302](https://github.com/apache/superset/pull/36302) fix(heatmap): y-axis sorts in order (@sfirke)
|
||||
- [#36425](https://github.com/apache/superset/pull/36425) fix(Gauge): clearing previously set min and max values in a gauge chart sets the data labels to 0 (@EnxDev)
|
||||
- [#36227](https://github.com/apache/superset/pull/36227) fix(reports): simplify logging to focus on timing metrics (@eschutho)
|
||||
- [#36389](https://github.com/apache/superset/pull/36389) fix(echarts): pass vizType to enable theme overrides in all chart types (@gabotorresruiz)
|
||||
- [#36444](https://github.com/apache/superset/pull/36444) fix: button text capitalization (@yousoph)
|
||||
|
||||
**Others**
|
||||
|
||||
- [#37552](https://github.com/apache/superset/pull/37552) chore(deps): bump dependencies to address security vulnerabilities (@ASolarers-Rodriguez)
|
||||
4
LLMS.md
4
LLMS.md
@@ -9,7 +9,9 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
|
||||
### Frontend Modernization
|
||||
- **NO `any` types** - Use proper TypeScript types
|
||||
- **NO JavaScript files** - Convert to TypeScript (.ts/.tsx)
|
||||
- **Use @superset-ui/core** - Don't import Ant Design directly
|
||||
- **Use @superset-ui/core** - Don't import Ant Design directly, prefer Ant Design component wrappers from @superset-ui/core/components
|
||||
- **Use antd theming tokens** - Prefer antd tokens over legacy theming tokens
|
||||
- **Avoid custom css and styles** - Follow antd best practices and avoid styling and custom CSS whenever possible
|
||||
|
||||
### Testing Strategy Migration
|
||||
- **Prefer unit tests** over integration tests
|
||||
|
||||
@@ -22,7 +22,10 @@ under the License.
|
||||
This file documents any backwards-incompatible changes in Superset and
|
||||
assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
## 6.0.0
|
||||
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
|
||||
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
|
||||
- [34782](https://github.com/apache/superset/pull/34782): Dataset exports now include the dataset ID in their file name (similar to charts and dashboards). If managing assets as code, make sure to rename existing dataset YAMLs to include the ID (and avoid duplicated files).
|
||||
- [34536](https://github.com/apache/superset/pull/34536): The `ENVIRONMENT_TAG_CONFIG` color values have changed to support only Ant Design semantic colors. Update your `superset_config.py`:
|
||||
- Change `"error.base"` to just `"error"` after this PR
|
||||
- Change any hex color values to one of: `"success"`, `"processing"`, `"error"`, `"warning"`, `"default"`
|
||||
|
||||
@@ -26,7 +26,7 @@ gunicorn \
|
||||
--workers ${SERVER_WORKER_AMOUNT:-1} \
|
||||
--worker-class ${SERVER_WORKER_CLASS:-gthread} \
|
||||
--threads ${SERVER_THREADS_AMOUNT:-20} \
|
||||
--log-level "${GUNICORN_LOGLEVEL:info}" \
|
||||
--log-level "${GUNICORN_LOGLEVEL:-info}" \
|
||||
--timeout ${GUNICORN_TIMEOUT:-60} \
|
||||
--keep-alive ${GUNICORN_KEEPALIVE:-2} \
|
||||
--max-requests ${WORKER_MAX_REQUESTS:-0} \
|
||||
|
||||
@@ -67,6 +67,22 @@ To send alerts and reports to Slack channels, you need to create a new Slack App
|
||||
|
||||
Note: when you configure an alert or a report, the Slack channel list takes channel names without the leading '#' e.g. use `alerts` instead of `#alerts`.
|
||||
|
||||
#### Large Slack Workspaces (10k+ channels)
|
||||
|
||||
For workspaces with many channels, fetching the complete channel list can take several minutes and may encounter Slack API rate limits. Add the following to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
from datetime import timedelta
|
||||
|
||||
# Increase cache timeout to reduce API calls
|
||||
# Default: 1 day (86400 seconds)
|
||||
SLACK_CACHE_TIMEOUT = int(timedelta(days=2).total_seconds())
|
||||
|
||||
# Increase retry count for rate limit errors
|
||||
# Default: 2
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 5
|
||||
```
|
||||
|
||||
### Kubernetes-specific
|
||||
|
||||
- You must have a `celery beat` pod running. If you're using the chart included in the GitHub repository under [helm/superset](https://github.com/apache/superset/tree/master/helm/superset), you need to put `supersetCeleryBeat.enabled = true` in your values override.
|
||||
|
||||
@@ -363,110 +363,6 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
|
||||
]
|
||||
```
|
||||
|
||||
### Keycloak-Specific Configuration using Flask-OIDC
|
||||
|
||||
If you are using Keycloak as OpenID Connect 1.0 Provider, the above configuration based on [`Authlib`](https://authlib.org/) might not work. In this case using [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is a viable option.
|
||||
|
||||
Make sure the pip package [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is installed on the webserver. This was successfully tested using version 2.2.0. This package requires [`Flask-OpenID`](https://pypi.org/project/Flask-OpenID/) as a dependency.
|
||||
|
||||
The following code defines a new security manager. Add it to a new file named `keycloak_security_manager.py`, placed in the same directory as your `superset_config.py` file.
|
||||
|
||||
```python
|
||||
from flask_appbuilder.security.manager import AUTH_OID
|
||||
from superset.security import SupersetSecurityManager
|
||||
from flask_oidc import OpenIDConnect
|
||||
from flask_appbuilder.security.views import AuthOIDView
|
||||
from flask_login import login_user
|
||||
from urllib.parse import quote
|
||||
from flask_appbuilder.views import ModelView, SimpleFormView, expose
|
||||
from flask import (
|
||||
redirect,
|
||||
request
|
||||
)
|
||||
import logging
|
||||
|
||||
class OIDCSecurityManager(SupersetSecurityManager):
|
||||
|
||||
def __init__(self, appbuilder):
|
||||
super(OIDCSecurityManager, self).__init__(appbuilder)
|
||||
if self.auth_type == AUTH_OID:
|
||||
self.oid = OpenIDConnect(self.appbuilder.get_app)
|
||||
self.authoidview = AuthOIDCView
|
||||
|
||||
class AuthOIDCView(AuthOIDView):
|
||||
|
||||
@expose('/login/', methods=['GET', 'POST'])
|
||||
def login(self, flag=True):
|
||||
sm = self.appbuilder.sm
|
||||
oidc = sm.oid
|
||||
|
||||
@self.appbuilder.sm.oid.require_login
|
||||
def handle_login():
|
||||
user = sm.auth_user_oid(oidc.user_getfield('email'))
|
||||
|
||||
if user is None:
|
||||
info = oidc.user_getinfo(['preferred_username', 'given_name', 'family_name', 'email'])
|
||||
user = sm.add_user(info.get('preferred_username'), info.get('given_name'), info.get('family_name'),
|
||||
info.get('email'), sm.find_role('Gamma'))
|
||||
|
||||
login_user(user, remember=False)
|
||||
return redirect(self.appbuilder.get_url_for_index)
|
||||
|
||||
return handle_login()
|
||||
|
||||
@expose('/logout/', methods=['GET', 'POST'])
|
||||
def logout(self):
|
||||
oidc = self.appbuilder.sm.oid
|
||||
|
||||
oidc.logout()
|
||||
super(AuthOIDCView, self).logout()
|
||||
redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
|
||||
|
||||
return redirect(
|
||||
oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url))
|
||||
```
|
||||
|
||||
Then add to your `superset_config.py` file:
|
||||
|
||||
```python
|
||||
from keycloak_security_manager import OIDCSecurityManager
|
||||
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
|
||||
import os
|
||||
|
||||
AUTH_TYPE = AUTH_OID
|
||||
SECRET_KEY: 'SomethingNotEntirelySecret'
|
||||
OIDC_CLIENT_SECRETS = '/path/to/client_secret.json'
|
||||
OIDC_ID_TOKEN_COOKIE_SECURE = False
|
||||
OIDC_OPENID_REALM: '<myRealm>'
|
||||
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
|
||||
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
|
||||
|
||||
# Will allow user self registration, allowing to create Flask users from Authorized User
|
||||
AUTH_USER_REGISTRATION = True
|
||||
|
||||
# The default user self registration role
|
||||
AUTH_USER_REGISTRATION_ROLE = 'Public'
|
||||
```
|
||||
|
||||
Store your client-specific OpenID information in a file called `client_secret.json`. Create this file in the same directory as `superset_config.py`:
|
||||
|
||||
```json
|
||||
{
|
||||
"<myOpenIDProvider>": {
|
||||
"issuer": "https://<myKeycloakDomain>/realms/<myRealm>",
|
||||
"auth_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/auth",
|
||||
"client_id": "https://<myKeycloakDomain>",
|
||||
"client_secret": "<myClientSecret>",
|
||||
"redirect_uris": [
|
||||
"https://<SupersetWebserver>/oauth-authorized/<myOpenIDProvider>"
|
||||
],
|
||||
"userinfo_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/userinfo",
|
||||
"token_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token",
|
||||
"token_introspection_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token/introspect"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
FAB supports authenticating user credentials against an LDAP server.
|
||||
|
||||
@@ -747,6 +747,26 @@ To run a single test file:
|
||||
npm run test -- path/to/file.js
|
||||
```
|
||||
|
||||
#### Known Issues and Workarounds
|
||||
|
||||
**Jest Test Hanging (MessageChannel Issue)**
|
||||
|
||||
If Jest tests hang with "Jest did not exit one second after the test run has completed", this is likely due to the MessageChannel issue from rc-overflow (Ant Design v5 components).
|
||||
|
||||
**Root Cause**: `rc-overflow@1.4.1` creates MessageChannel handles for responsive overflow detection that remain open after test completion.
|
||||
|
||||
**Current Workaround**: MessageChannel is mocked as undefined in `spec/helpers/jsDomWithFetchAPI.ts`, forcing rc-overflow to use requestAnimationFrame fallback.
|
||||
|
||||
**To verify if still needed**: Remove the MessageChannel mocking lines and run `npm test -- --shard=4/8`. If tests hang, the workaround is still required.
|
||||
|
||||
**Future removal conditions**: This workaround can be removed when:
|
||||
- rc-overflow updates to properly clean up MessagePorts in test environments
|
||||
- Jest updates to handle MessageChannel/MessagePort cleanup better
|
||||
- Ant Design switches away from rc-overflow
|
||||
- We switch away from Ant Design v5
|
||||
|
||||
**See**: [PR #34871](https://github.com/apache/superset/pull/34871) for full technical details.
|
||||
|
||||
### Debugging Server App
|
||||
|
||||
#### Local
|
||||
|
||||
@@ -344,7 +344,7 @@ const config: Config = {
|
||||
'data-project-name': 'Apache Superset',
|
||||
'data-project-color': '#FFFFFF',
|
||||
'data-project-logo':
|
||||
'https://images.seeklogo.com/logo-png/50/2/superset-icon-logo-png_seeklogo-500354.png',
|
||||
'https://superset.apache.org/img/superset-logo-icon-only.png',
|
||||
'data-modal-override-open-id': 'ask-ai-input',
|
||||
'data-modal-override-open-class': 'search-input',
|
||||
'data-modal-disclaimer':
|
||||
|
||||
BIN
docs/static/img/superset-logo-icon-only.png
vendored
Normal file
BIN
docs/static/img/superset-logo-icon-only.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -46,7 +46,7 @@ dependencies = [
|
||||
"cryptography>=42.0.4, <45.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <3.0.0",
|
||||
"flask-appbuilder>=4.8.0, <5.0.0",
|
||||
"flask-appbuilder>=5.0.0,<6",
|
||||
"flask-caching>=2.1.0, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
@@ -58,8 +58,8 @@ dependencies = [
|
||||
"greenlet>=3.0.3, <=3.1.1",
|
||||
"gunicorn>=22.0.0; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# known issue with holidays 0.26.0 and above related to prophet lib #25017
|
||||
"holidays>=0.25, <0.26",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.45, <1",
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.6.1, <2",
|
||||
@@ -73,7 +73,7 @@ dependencies = [
|
||||
"packaging",
|
||||
# --------------------------
|
||||
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
|
||||
"pandas[excel]>=2.0.3, <2.1",
|
||||
"pandas[excel]>=2.1.4, <2.2",
|
||||
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
|
||||
# --------------------------
|
||||
"parsedatetime",
|
||||
@@ -85,18 +85,18 @@ dependencies = [
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
"python-geohash",
|
||||
"pyarrow>=16.1.0, <17", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyarrow>=16.1.0, <19", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyyaml>=6.0.0, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=4.6.0, <5.0",
|
||||
"selenium>=4.14.0, <5.0",
|
||||
"shillelagh[gsheetsapi]>=1.2.18, <2.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.3, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=3.15.0",
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-utils>=0.38.3, <0.39",
|
||||
"sqlglot>=27.3.0, <28",
|
||||
"sqlglot>=27.15.2, <28",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.9.0, <1.0",
|
||||
"typing-extensions>=4, <5",
|
||||
@@ -128,7 +128,7 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb-engine>=0.12.1, <0.13"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
|
||||
@@ -137,7 +137,7 @@ excel = ["xlrd>=1.2.0, <1.3"]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=23.9.1"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.2.18, <2"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.3, <2"]
|
||||
hana = ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"]
|
||||
hive = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
@@ -165,10 +165,10 @@ playwright = ["playwright>=1.37.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.6"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.328.0"]
|
||||
prophet = ["prophet>=1.1.5, <2"]
|
||||
prophet = ["prophet>=1.1.6, <2"]
|
||||
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
risingwave = ["sqlalchemy-risingwave"]
|
||||
shillelagh = ["shillelagh[all]>=1.2.18, <2"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.3, <2"]
|
||||
singlestore = ["sqlalchemy-singlestoredb>=1.1.1, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.2.4, <2"]
|
||||
spark = [
|
||||
@@ -397,6 +397,7 @@ authorized_licenses = [
|
||||
"apache software",
|
||||
"apache software, bsd",
|
||||
"bsd",
|
||||
"bsd-2-clause",
|
||||
"bsd-3-clause",
|
||||
"isc license (iscl)",
|
||||
"isc license",
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
urllib3==2.5.0
|
||||
werkzeug>=3.0.1
|
||||
# Security: CVE-2026-21441 - decompression bomb bypass on redirects
|
||||
urllib3>=2.6.3,<3.0.0
|
||||
# Security: GHSA-87hc-h4r5-73f7 - Windows path traversal fix
|
||||
werkzeug>=3.1.5,<4.0.0
|
||||
# Security: CVE-2025-68146 - TOCTOU symlink vulnerability
|
||||
filelock>=3.20.3,<4.0.0
|
||||
# Security: decompression bomb fix (required by aiohttp 3.13.3)
|
||||
brotli>=1.2.0,<2.0.0
|
||||
numexpr>=2.9.0
|
||||
|
||||
# 5.0.0 has a sensitive deprecation used in other libs
|
||||
@@ -36,3 +42,9 @@ marshmallow-sqlalchemy>=1.3.0,<1.4.1
|
||||
|
||||
# needed for python 3.12 support
|
||||
openapi-schema-validator>=0.6.3
|
||||
|
||||
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
|
||||
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
|
||||
# Known affected packages: Preset's 'clients' package
|
||||
# See docs/docs/contributing/pkg-resources-migration.md for details
|
||||
setuptools<81
|
||||
|
||||
@@ -32,8 +32,10 @@ blinker==1.9.0
|
||||
# via flask
|
||||
bottleneck==1.5.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
brotli==1.1.0
|
||||
# via flask-compress
|
||||
brotli==1.2.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask-compress
|
||||
cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
@@ -97,6 +99,8 @@ email-validator==2.2.0
|
||||
# via flask-appbuilder
|
||||
et-xmlfile==2.0.0
|
||||
# via openpyxl
|
||||
filelock==3.20.3
|
||||
# via -r requirements/base.in
|
||||
flask==2.3.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -112,9 +116,9 @@ flask==2.3.3
|
||||
# flask-session
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-appbuilder==4.8.0
|
||||
flask-appbuilder==5.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-babel==2.0.0
|
||||
flask-babel==3.1.0
|
||||
# via flask-appbuilder
|
||||
flask-caching==2.3.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -154,13 +158,14 @@ greenlet==3.1.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==23.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
hashids==1.3.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
holidays==0.25
|
||||
holidays==0.82
|
||||
# via apache-superset (pyproject.toml)
|
||||
humanize==4.12.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -192,8 +197,6 @@ jsonschema-specifications==2025.4.1
|
||||
# openapi-schema-validator
|
||||
kombu==5.5.3
|
||||
# via celery
|
||||
korean-lunar-calendar==0.3.1
|
||||
# via holidays
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.3.10
|
||||
@@ -235,6 +238,7 @@ numpy==1.26.4
|
||||
# bottleneck
|
||||
# numexpr
|
||||
# pandas
|
||||
# pyarrow
|
||||
odfpy==1.4.1
|
||||
# via pandas
|
||||
openapi-schema-validator==0.6.3
|
||||
@@ -256,7 +260,7 @@ packaging==25.0
|
||||
# limits
|
||||
# marshmallow
|
||||
# shillelagh
|
||||
pandas==2.0.3
|
||||
pandas==2.1.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
paramiko==3.5.1
|
||||
# via
|
||||
@@ -267,7 +271,7 @@ parsedatetime==2.6
|
||||
pgsanity==0.2.9
|
||||
# via apache-superset (pyproject.toml)
|
||||
pillow==11.3.0
|
||||
# via apache_superset (pyproject.toml)
|
||||
# via apache-superset (pyproject.toml)
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
ply==3.11
|
||||
@@ -280,7 +284,7 @@ prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==16.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
pyasn1==0.6.1
|
||||
pyasn1==0.6.2
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# rsa
|
||||
@@ -351,7 +355,9 @@ rsa==4.9.1
|
||||
# via google-auth
|
||||
selenium==4.32.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
shillelagh==1.3.5
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
simplejson==3.20.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -380,7 +386,7 @@ sqlalchemy-utils==0.38.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
sqlglot==27.3.0
|
||||
sqlglot==27.15.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -408,7 +414,7 @@ tzdata==2025.2
|
||||
# pandas
|
||||
url-normalize==2.2.1
|
||||
# via requests-cache
|
||||
urllib3==2.5.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
@@ -423,7 +429,7 @@ wcwidth==0.2.13
|
||||
# via prompt-toolkit
|
||||
websocket-client==1.8.0
|
||||
# via selenium
|
||||
werkzeug==3.1.3
|
||||
werkzeug==3.1.5
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask
|
||||
|
||||
@@ -53,7 +53,7 @@ bottleneck==1.5.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
brotli==1.1.0
|
||||
brotli==1.2.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-compress
|
||||
@@ -176,8 +176,10 @@ et-xmlfile==2.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# openpyxl
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
filelock==3.20.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# virtualenv
|
||||
flask==2.3.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -195,11 +197,11 @@ flask==2.3.3
|
||||
# flask-sqlalchemy
|
||||
# flask-testing
|
||||
# flask-wtf
|
||||
flask-appbuilder==4.8.0
|
||||
flask-appbuilder==5.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
flask-babel==2.0.0
|
||||
flask-babel==3.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-appbuilder
|
||||
@@ -313,6 +315,7 @@ greenlet==3.1.1
|
||||
# apache-superset
|
||||
# gevent
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
grpcio==1.71.0
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -332,7 +335,7 @@ hashids==1.3.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
holidays==0.25
|
||||
holidays==0.82
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -393,10 +396,6 @@ kombu==5.5.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# celery
|
||||
korean-lunar-calendar==0.3.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# holidays
|
||||
lazy-object-proxy==1.10.0
|
||||
# via openapi-spec-validator
|
||||
limits==5.1.0
|
||||
@@ -469,6 +468,7 @@ numpy==1.26.4
|
||||
# pandas
|
||||
# pandas-gbq
|
||||
# prophet
|
||||
# pyarrow
|
||||
oauthlib==3.2.2
|
||||
# via requests-oauthlib
|
||||
odfpy==1.4.1
|
||||
@@ -510,7 +510,7 @@ packaging==25.0
|
||||
# pytest
|
||||
# shillelagh
|
||||
# sqlalchemy-bigquery
|
||||
pandas==2.0.3
|
||||
pandas==2.1.4
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -539,6 +539,7 @@ pgsanity==0.2.9
|
||||
# apache-superset
|
||||
pillow==11.3.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# matplotlib
|
||||
pip==25.1.1
|
||||
@@ -571,7 +572,7 @@ prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# click-repl
|
||||
prophet==1.1.5
|
||||
prophet==1.2.0
|
||||
# via apache-superset
|
||||
proto-plus==1.25.0
|
||||
# via
|
||||
@@ -594,7 +595,7 @@ pyarrow==16.1.0
|
||||
# apache-superset
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
pyasn1==0.6.1
|
||||
pyasn1==0.6.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# pyasn1-modules
|
||||
@@ -752,14 +753,15 @@ selenium==4.32.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
setuptools==80.7.1
|
||||
setuptools==80.9.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# nodeenv
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
# zope-event
|
||||
# zope-interface
|
||||
shillelagh==1.3.5
|
||||
shillelagh==1.4.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -804,7 +806,7 @@ sqlalchemy-utils==0.38.3
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# flask-appbuilder
|
||||
sqlglot==27.3.0
|
||||
sqlglot==27.15.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -859,7 +861,7 @@ url-normalize==2.2.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# requests-cache
|
||||
urllib3==2.5.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# docker
|
||||
@@ -882,7 +884,7 @@ websocket-client==1.8.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# selenium
|
||||
werkzeug==3.1.3
|
||||
werkzeug==3.1.5
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask
|
||||
|
||||
@@ -1,766 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { Interception } from 'cypress/types/net-stubbing';
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import { SUPPORTED_CHARTS_DASHBOARD } from 'cypress/utils/urls';
|
||||
import {
|
||||
openTopLevelTab,
|
||||
SUPPORTED_TIER1_CHARTS,
|
||||
SUPPORTED_TIER2_CHARTS,
|
||||
} from './utils';
|
||||
import {
|
||||
interceptExploreJson,
|
||||
interceptV1ChartData,
|
||||
interceptFormDataKey,
|
||||
} from '../explore/utils';
|
||||
|
||||
const interceptDrillInfo = () => {
|
||||
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
result: {
|
||||
id: 1,
|
||||
changed_on_humanized: '2 days ago',
|
||||
created_on_humanized: 'a week ago',
|
||||
table_name: 'birth_names',
|
||||
changed_by: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
created_by: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
owners: [
|
||||
{
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
column_name: 'gender',
|
||||
verbose_name: null,
|
||||
},
|
||||
{
|
||||
column_name: 'state',
|
||||
verbose_name: null,
|
||||
},
|
||||
{
|
||||
column_name: 'name',
|
||||
verbose_name: null,
|
||||
},
|
||||
{
|
||||
column_name: 'ds',
|
||||
verbose_name: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}).as('drillInfo');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="close-drill-by-modal"]').length) {
|
||||
cy.getBySel('close-drill-by-modal').click({ force: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openTableContextMenu = (
|
||||
cellContent: string,
|
||||
tableSelector = "[data-test-viz-type='table']",
|
||||
) => {
|
||||
cy.get(tableSelector).scrollIntoView();
|
||||
cy.get(tableSelector).contains(cellContent).first().rightclick();
|
||||
};
|
||||
|
||||
const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
|
||||
if (isLegacy) {
|
||||
interceptExploreJson('legacyData');
|
||||
} else {
|
||||
interceptV1ChartData();
|
||||
}
|
||||
|
||||
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)', { timeout: 15000 })
|
||||
.should('be.visible')
|
||||
.find("[role='menu'] [role='menuitem']")
|
||||
.contains(/^Drill by$/)
|
||||
.trigger('mouseover', { force: true });
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.should('be.visible')
|
||||
.find('[role="menuitem"]')
|
||||
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
||||
.click();
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
).should('not.exist');
|
||||
|
||||
if (isLegacy) {
|
||||
return cy.wait('@legacyData');
|
||||
}
|
||||
return cy.wait('@v1Data');
|
||||
};
|
||||
|
||||
const verifyExpectedFormData = (
|
||||
interceptedRequest: Interception,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expectedFormData: Record<string, any>,
|
||||
) => {
|
||||
const actualFormData = interceptedRequest.request.body?.form_data;
|
||||
Object.entries(expectedFormData).forEach(([key, val]) => {
|
||||
expect(actualFormData?.[key]).to.eql(val);
|
||||
});
|
||||
};
|
||||
|
||||
const testEchart = (
|
||||
vizType: string,
|
||||
chartName: string,
|
||||
drillClickCoordinates: [[number, number], [number, number]],
|
||||
furtherDrillDimension = 'name',
|
||||
) => {
|
||||
cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => {
|
||||
// click 'boy'
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger(
|
||||
'mouseover',
|
||||
drillClickCoordinates[0][0],
|
||||
drillClickCoordinates[0][1],
|
||||
);
|
||||
cy.wrap($canvas).rightclick(
|
||||
drillClickCoordinates[0][0],
|
||||
drillClickCoordinates[0][1],
|
||||
);
|
||||
|
||||
drillBy('state').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupby: ['state'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.getBySel(`"Drill by: ${chartName}-modal"`).as('drillByModal');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.draggable-trigger')
|
||||
.should('contain', chartName);
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'state');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible');
|
||||
|
||||
// further drill
|
||||
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
|
||||
// click 'other'
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger(
|
||||
'mouseover',
|
||||
drillClickCoordinates[1][0],
|
||||
drillClickCoordinates[1][1],
|
||||
);
|
||||
cy.wrap($canvas).rightclick(
|
||||
drillClickCoordinates[1][0],
|
||||
drillClickCoordinates[1][1],
|
||||
);
|
||||
|
||||
drillBy(furtherDrillDimension).then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupby: [furtherDrillDimension],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'other',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'state',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible');
|
||||
|
||||
// undo - back to drill by state
|
||||
interceptV1ChartData('drillByUndo');
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'state (other)')
|
||||
.and('contain', furtherDrillDimension)
|
||||
.contains('state (other)')
|
||||
.click();
|
||||
cy.wait('@drillByUndo').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupby: ['state'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('not.contain', 'state (other)')
|
||||
.and('not.contain', furtherDrillDimension)
|
||||
.and('contain', 'state');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('Drill by modal', () => {
|
||||
beforeEach(() => {
|
||||
closeModal();
|
||||
});
|
||||
before(() => {
|
||||
interceptDrillInfo();
|
||||
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
|
||||
});
|
||||
|
||||
describe('Modal actions + Table', () => {
|
||||
before(() => {
|
||||
closeModal();
|
||||
interceptDrillInfo();
|
||||
openTopLevelTab('Tier 1');
|
||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it.only('opens the modal from the context menu', () => {
|
||||
openTableContextMenu('boy');
|
||||
drillBy('state').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupby: ['state'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.getBySel('"Drill by: Table-modal"').as('drillByModal');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.draggable-trigger')
|
||||
.should('contain', 'Drill by: Table');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="metadata-bar"]')
|
||||
.should('be.visible');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'state');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible')
|
||||
.and('contain', 'state')
|
||||
.and('contain', 'sum__num');
|
||||
|
||||
// further drilling
|
||||
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
|
||||
drillBy('name').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupby: ['name'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'CA',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'state',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible')
|
||||
.and('not.contain', 'state')
|
||||
.and('contain', 'name')
|
||||
.and('contain', 'sum__num');
|
||||
|
||||
// undo - back to drill by state
|
||||
interceptV1ChartData('drillByUndo');
|
||||
interceptFormDataKey();
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'state (CA)')
|
||||
.and('contain', 'name')
|
||||
.contains('state (CA)')
|
||||
.click();
|
||||
cy.wait('@drillByUndo').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupby: ['state'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible')
|
||||
.and('not.contain', 'name')
|
||||
.and('contain', 'state')
|
||||
.and('contain', 'sum__num');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('not.contain', 'state (CA)')
|
||||
.and('not.contain', 'name')
|
||||
.and('contain', 'state');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-display-toggle"]')
|
||||
.contains('Table')
|
||||
.click();
|
||||
|
||||
cy.getBySel('drill-by-chart').should('not.exist');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-results-table"]')
|
||||
.should('be.visible');
|
||||
|
||||
cy.wait('@formDataKey').then(intercept => {
|
||||
cy.get('@drillByModal')
|
||||
.contains('Edit chart')
|
||||
.should('have.attr', 'href')
|
||||
.and(
|
||||
'contain',
|
||||
`/explore/?form_data_key=${intercept.response?.body?.key}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tier 1 charts', () => {
|
||||
before(() => {
|
||||
closeModal();
|
||||
interceptDrillInfo();
|
||||
openTopLevelTab('Tier 1');
|
||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it('Pivot Table', () => {
|
||||
openTableContextMenu('boy', "[data-test-viz-type='pivot_table_v2']");
|
||||
drillBy('name').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupbyRows: ['state'],
|
||||
groupbyColumns: ['name'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.getBySel('"Drill by: Pivot Table-modal"').as('drillByModal');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.draggable-trigger')
|
||||
.should('contain', 'Drill by: Pivot Table');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'name');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible')
|
||||
.and('contain', 'state')
|
||||
.and('contain', 'name')
|
||||
.and('contain', 'sum__num')
|
||||
.and('not.contain', 'Gender');
|
||||
|
||||
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
|
||||
drillBy('ds').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupbyColumns: ['name'],
|
||||
groupbyRows: ['ds'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'CA',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'state',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible')
|
||||
.and('contain', 'name')
|
||||
.and('contain', 'ds')
|
||||
.and('contain', 'sum__num')
|
||||
.and('not.contain', 'state');
|
||||
|
||||
interceptV1ChartData('drillByUndo');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'name (CA)')
|
||||
.and('contain', 'ds')
|
||||
.contains('name (CA)')
|
||||
.click();
|
||||
cy.wait('@drillByUndo').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
groupbyRows: ['state'],
|
||||
groupbyColumns: ['name'],
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: 'boy',
|
||||
expressionType: 'SIMPLE',
|
||||
operator: '==',
|
||||
operatorId: 'EQUALS',
|
||||
subject: 'gender',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible')
|
||||
.and('not.contain', 'ds')
|
||||
.and('contain', 'state')
|
||||
.and('contain', 'name')
|
||||
.and('contain', 'sum__num');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('not.contain', 'name (CA)')
|
||||
.and('not.contain', 'ds')
|
||||
.and('contain', 'name');
|
||||
});
|
||||
|
||||
it('Line chart', () => {
|
||||
testEchart('echarts_timeseries_line', 'Line Chart', [
|
||||
[85, 93],
|
||||
[85, 93],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Area Chart', () => {
|
||||
testEchart('echarts_area', 'Area Chart', [
|
||||
[85, 93],
|
||||
[85, 93],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Scatter Chart', () => {
|
||||
testEchart('echarts_timeseries_scatter', 'Scatter Chart', [
|
||||
[85, 93],
|
||||
[85, 93],
|
||||
]);
|
||||
});
|
||||
|
||||
it.skip('Bar Chart', () => {
|
||||
testEchart('echarts_timeseries_bar', 'Bar Chart', [
|
||||
[85, 94],
|
||||
[490, 68],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Pie Chart', () => {
|
||||
testEchart('pie', 'Pie Chart', [
|
||||
[243, 167],
|
||||
[534, 248],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tier 2 charts', () => {
|
||||
before(() => {
|
||||
closeModal();
|
||||
interceptDrillInfo();
|
||||
openTopLevelTab('Tier 2');
|
||||
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it('Box Plot Chart', () => {
|
||||
testEchart(
|
||||
'box_plot',
|
||||
'Box Plot Chart',
|
||||
[
|
||||
[139, 277],
|
||||
[787, 441],
|
||||
],
|
||||
'ds',
|
||||
);
|
||||
});
|
||||
|
||||
it('Generic Chart', () => {
|
||||
testEchart('echarts_timeseries', 'Generic Chart', [
|
||||
[85, 93],
|
||||
[85, 93],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Smooth Line Chart', () => {
|
||||
testEchart('echarts_timeseries_smooth', 'Smooth Line Chart', [
|
||||
[85, 93],
|
||||
[85, 93],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Step Line Chart', () => {
|
||||
testEchart('echarts_timeseries_step', 'Step Line Chart', [
|
||||
[85, 93],
|
||||
[85, 93],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Funnel Chart', () => {
|
||||
testEchart('funnel', 'Funnel Chart', [
|
||||
[154, 80],
|
||||
[421, 39],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Gauge Chart', () => {
|
||||
testEchart('gauge_chart', 'Gauge Chart', [
|
||||
[151, 95],
|
||||
[300, 143],
|
||||
]);
|
||||
});
|
||||
|
||||
it.skip('Radar Chart', () => {
|
||||
testEchart('radar', 'Radar Chart', [
|
||||
[182, 49],
|
||||
[423, 91],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Treemap V2 Chart', () => {
|
||||
testEchart('treemap_v2', 'Treemap V2 Chart', [
|
||||
[145, 84],
|
||||
[220, 105],
|
||||
]);
|
||||
});
|
||||
|
||||
it.skip('Mixed Chart', () => {
|
||||
cy.get('[data-test-viz-type="mixed_timeseries"] canvas').then($canvas => {
|
||||
// click 'boy'
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mouseover', 85, 93);
|
||||
cy.wrap($canvas).rightclick(85, 93);
|
||||
|
||||
drillBy('name').then(intercepted => {
|
||||
const { queries } = intercepted.request.body;
|
||||
expect(queries[0].columns).to.eql(['name']);
|
||||
expect(queries[0].filters).to.eql([
|
||||
{ col: 'gender', op: '==', val: 'boy' },
|
||||
]);
|
||||
expect(queries[1].columns).to.eql(['state']);
|
||||
expect(queries[1].filters).to.eql([]);
|
||||
});
|
||||
|
||||
cy.getBySel('"Drill by: Mixed Chart-modal"').as('drillByModal');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.draggable-trigger')
|
||||
.should('contain', 'Mixed Chart');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'name');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible');
|
||||
|
||||
// further drill
|
||||
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
|
||||
// click second query
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mouseover', 261, 114);
|
||||
cy.wrap($canvas).rightclick(261, 114);
|
||||
|
||||
drillBy('ds').then(intercepted => {
|
||||
const { queries } = intercepted.request.body;
|
||||
expect(queries[0].columns).to.eql(['name']);
|
||||
expect(queries[0].filters).to.eql([
|
||||
{ col: 'gender', op: '==', val: 'boy' },
|
||||
]);
|
||||
expect(queries[1].columns).to.eql(['ds']);
|
||||
expect(queries[1].filters).to.eql([
|
||||
{ col: 'state', op: '==', val: 'other' },
|
||||
]);
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible');
|
||||
|
||||
// undo - back to drill by state
|
||||
interceptV1ChartData('drillByUndo');
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('contain', 'name (other)')
|
||||
.and('contain', 'ds')
|
||||
.contains('name (other)')
|
||||
.click();
|
||||
|
||||
cy.wait('@drillByUndo').then(intercepted => {
|
||||
const { queries } = intercepted.request.body;
|
||||
expect(queries[0].columns).to.eql(['name']);
|
||||
expect(queries[0].filters).to.eql([
|
||||
{ col: 'gender', op: '==', val: 'boy' },
|
||||
]);
|
||||
expect(queries[1].columns).to.eql(['state']);
|
||||
expect(queries[1].filters).to.eql([]);
|
||||
});
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('.ant-breadcrumb')
|
||||
.should('be.visible')
|
||||
.and('contain', 'gender (boy)')
|
||||
.and('contain', '/')
|
||||
.and('not.contain', 'name (other)')
|
||||
.and('not.contain', 'ds')
|
||||
.and('contain', 'name');
|
||||
|
||||
cy.get('@drillByModal')
|
||||
.find('[data-test="drill-by-chart"]')
|
||||
.should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -179,13 +179,13 @@ describe.skip('Drill to detail modal', () => {
|
||||
cy.on('uncaught:exception', () => false);
|
||||
cy.wait('@samples');
|
||||
cy.get('.virtual-table-cell').should($rows => {
|
||||
expect($rows).to.contain('Kelly');
|
||||
expect($rows).to.contain('Kimberly');
|
||||
});
|
||||
|
||||
// verify scroll top on pagination
|
||||
cy.getBySelLike('Number-modal').find('.virtual-grid').scrollTo(0, 200);
|
||||
|
||||
cy.get('.virtual-grid').contains('Juan').should('not.be.visible');
|
||||
cy.get('.virtual-grid').contains('Kim').should('not.be.visible');
|
||||
|
||||
cy.get('.ant-pagination-item').eq(0).click();
|
||||
|
||||
|
||||
@@ -160,6 +160,57 @@ describe('Native filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('user cannot create bi-directional dependencies between filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region', datasetId: 2 },
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
{ name: 'country_code', column: 'country_code', datasetId: 2 },
|
||||
{ name: 'year', column: 'year', datasetId: 2 },
|
||||
]);
|
||||
enterNativeFilterEditModal();
|
||||
|
||||
// First, make country_name dependent on region
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
|
||||
// Second, make country_code dependent on country_name
|
||||
selectFilter(2);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumn);
|
||||
|
||||
// Now select region filter and try to add dependency
|
||||
selectFilter(0);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
|
||||
// Verify that only 'year' is available as dependency for region
|
||||
// 'country_name' and 'country_code' should not be available (would create circular dependency)
|
||||
cy.get('input[aria-label^="Limit type"]').click({ force: true });
|
||||
cy.get('[role="listbox"]').should('be.visible');
|
||||
cy.get('[role="listbox"]').should('contain', 'year');
|
||||
cy.get('[role="listbox"]').should('not.contain', 'country_name');
|
||||
cy.get('[role="listbox"]').should('not.contain', 'country_code');
|
||||
cy.get('[role="listbox"]').contains('year').click();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('Dependent filter selects first item based on parent filter selection', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region', datasetId: 2 },
|
||||
|
||||
1905
superset-frontend/package-lock.json
generated
1905
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "superset",
|
||||
"version": "0.0.0-dev",
|
||||
"version": "6.0.1",
|
||||
"description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.",
|
||||
"keywords": [
|
||||
"big",
|
||||
@@ -130,7 +130,6 @@
|
||||
"dom-to-image-more": "^3.6.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"emotion-rgba": "0.0.12",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -255,7 +254,6 @@
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-ultimate-pagination": "^1.2.4",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/redux-localstorage": "^1.0.8",
|
||||
@@ -333,7 +331,7 @@
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.3.3",
|
||||
"webpack-visualizer-plugin2": "^1.2.0"
|
||||
@@ -352,8 +350,9 @@
|
||||
"core-js": "^3.38.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"underscore": "^1.13.7",
|
||||
"jspdf": "^3.0.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"nwsapi": "^2.2.13"
|
||||
},
|
||||
"readme": "ERROR: No README data found!",
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover, type PopoverProps } from '@superset-ui/core/components';
|
||||
import type ReactAce from 'react-ace';
|
||||
import {
|
||||
Popover,
|
||||
type PopoverProps,
|
||||
SQLEditor,
|
||||
} from '@superset-ui/core/components';
|
||||
import { CalculatorOutlined } from '@ant-design/icons';
|
||||
import { css, styled, useTheme, t } from '@superset-ui/core';
|
||||
|
||||
@@ -35,24 +37,10 @@ const StyledCalculatorIcon = styled(CalculatorOutlined)`
|
||||
|
||||
export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
|
||||
const theme = useTheme();
|
||||
const [AceEditor, setAceEditor] = useState<typeof ReactAce | null>(null);
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
import('react-ace'),
|
||||
import('ace-builds/src-min-noconflict/mode-sql'),
|
||||
]).then(([reactAceModule]) => {
|
||||
setAceEditor(() => reactAceModule.default);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!AceEditor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<AceEditor
|
||||
mode="sql"
|
||||
<SQLEditor
|
||||
value={props.sqlExpression}
|
||||
editorProps={{ $blockScrolling: true }}
|
||||
setOptions={{
|
||||
@@ -65,7 +53,6 @@ export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
|
||||
wrapEnabled
|
||||
style={{
|
||||
border: `1px solid ${theme.colorBorder}`,
|
||||
background: theme.colorPrimaryBg,
|
||||
maxWidth: theme.sizeUnit * 100,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -43,15 +43,17 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
|
||||
|
||||
// remove or rename top level of column name(metric name) in the MultiIndex when
|
||||
// 1) at least 1 metric
|
||||
// 2) dimension exist or multiple time shift metrics exist
|
||||
// 3) xAxis exist
|
||||
// 4) truncate_metric in form_data and truncate_metric is true
|
||||
// 2) xAxis exist
|
||||
// 3a) isTimeComparisonValue
|
||||
// 3b-1) dimension exist or multiple time shift metrics exist
|
||||
// 3b-2) truncate_metric in form_data and truncate_metric is true
|
||||
if (
|
||||
metrics.length > 0 &&
|
||||
(columns.length > 0 || timeOffsets.length > 1) &&
|
||||
xAxisLabel &&
|
||||
truncate_metric !== undefined &&
|
||||
!!truncate_metric
|
||||
(isTimeComparisonValue ||
|
||||
((columns.length > 0 || timeOffsets.length > 1) &&
|
||||
truncate_metric !== undefined &&
|
||||
!!truncate_metric))
|
||||
) {
|
||||
const renamePairs: [string, string | null][] = [];
|
||||
if (
|
||||
|
||||
@@ -218,8 +218,25 @@ export const xAxisForceCategoricalControl = {
|
||||
label: () => t('Force categorical'),
|
||||
default: false,
|
||||
description: t('Treat values as categorical.'),
|
||||
initialValue: (control: ControlState, state: ControlPanelState | null) =>
|
||||
state?.form_data?.x_axis_sort !== undefined || control.value,
|
||||
initialValue: (control: ControlState, state: ControlPanelState | null) => {
|
||||
// Check if x-axis is numeric - only numeric columns should have
|
||||
// their categorical behavior influenced by x_axis_sort setting
|
||||
const isNumericXAxis = checkColumnType(
|
||||
getColumnLabel(state?.controls?.x_axis?.value as QueryFormColumn),
|
||||
state?.controls?.datasource?.datasource,
|
||||
[GenericDataType.Numeric],
|
||||
);
|
||||
|
||||
// Non-numeric columns (temporal, text) should not be forced categorical
|
||||
// based on x_axis_sort - just use the control's existing value
|
||||
if (!isNumericXAxis) {
|
||||
return control.value;
|
||||
}
|
||||
|
||||
// For numeric columns, force categorical if x_axis_sort is defined
|
||||
// (user wants to sort) or use the control's existing value
|
||||
return state?.form_data?.x_axis_sort !== undefined || control.value;
|
||||
},
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }: { controls: ControlStateMapping }) =>
|
||||
checkColumnType(
|
||||
|
||||
@@ -32,21 +32,30 @@ const MIN_OPACITY_BOUNDED = 0.05;
|
||||
const MIN_OPACITY_UNBOUNDED = 0;
|
||||
const MAX_OPACITY = 1;
|
||||
export const getOpacity = (
|
||||
value: number,
|
||||
cutoffPoint: number,
|
||||
extremeValue: number,
|
||||
value: number | string,
|
||||
cutoffPoint: number | string,
|
||||
extremeValue: number | string,
|
||||
minOpacity = MIN_OPACITY_BOUNDED,
|
||||
maxOpacity = MAX_OPACITY,
|
||||
) => {
|
||||
if (extremeValue === cutoffPoint) {
|
||||
if (extremeValue === cutoffPoint || typeof value !== 'number') {
|
||||
return maxOpacity;
|
||||
}
|
||||
const numCutoffPoint =
|
||||
typeof cutoffPoint === 'string' ? parseFloat(cutoffPoint) : cutoffPoint;
|
||||
const numExtremeValue =
|
||||
typeof extremeValue === 'string' ? parseFloat(extremeValue) : extremeValue;
|
||||
|
||||
if (Number.isNaN(numCutoffPoint) || Number.isNaN(numExtremeValue)) {
|
||||
return maxOpacity;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
maxOpacity,
|
||||
round(
|
||||
Math.abs(
|
||||
((maxOpacity - minOpacity) / (extremeValue - cutoffPoint)) *
|
||||
(value - cutoffPoint),
|
||||
((maxOpacity - minOpacity) / (numExtremeValue - numCutoffPoint)) *
|
||||
(value - numCutoffPoint),
|
||||
) + minOpacity,
|
||||
2,
|
||||
),
|
||||
@@ -191,10 +200,21 @@ export const getColorFormatters = memoizeOne(
|
||||
(
|
||||
columnConfig: ConditionalFormattingConfig[] | undefined,
|
||||
data: DataRecord[],
|
||||
theme?: Record<string, any>,
|
||||
alpha?: boolean,
|
||||
) =>
|
||||
columnConfig?.reduce(
|
||||
(acc: ColorFormatters, config: ConditionalFormattingConfig) => {
|
||||
let resolvedColorScheme = config.colorScheme;
|
||||
if (
|
||||
theme &&
|
||||
typeof config.colorScheme === 'string' &&
|
||||
config.colorScheme.startsWith('color') &&
|
||||
theme[config.colorScheme]
|
||||
) {
|
||||
resolvedColorScheme = theme[config.colorScheme] as string;
|
||||
}
|
||||
|
||||
if (
|
||||
config?.column !== undefined &&
|
||||
(config?.operator === Comparator.None ||
|
||||
@@ -207,7 +227,7 @@ export const getColorFormatters = memoizeOne(
|
||||
acc.push({
|
||||
column: config?.column,
|
||||
getColorFromValue: getColorFunction(
|
||||
config,
|
||||
{ ...config, colorScheme: resolvedColorScheme },
|
||||
data.map(row => row[config.column!] as number),
|
||||
alpha,
|
||||
),
|
||||
|
||||
@@ -160,6 +160,44 @@ test('should add renameOperator if exists derived metrics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator if isTimeComparisonValue without columns', () => {
|
||||
[
|
||||
ComparisonType.Difference,
|
||||
ComparisonType.Ratio,
|
||||
ComparisonType.Percentage,
|
||||
].forEach(type => {
|
||||
expect(
|
||||
renameOperator(
|
||||
{
|
||||
...formData,
|
||||
...{
|
||||
comparison_type: type,
|
||||
time_compare: ['1 year ago'],
|
||||
},
|
||||
},
|
||||
{
|
||||
...queryObject,
|
||||
...{
|
||||
columns: [],
|
||||
metrics: ['sum(val)', 'avg(val2)'],
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'rename',
|
||||
options: {
|
||||
columns: {
|
||||
[`${type}__avg(val2)__avg(val2)__1 year ago`]:
|
||||
'avg(val2), 1 year ago',
|
||||
[`${type}__sum(val)__sum(val)__1 year ago`]: 'sum(val), 1 year ago',
|
||||
},
|
||||
inplace: true,
|
||||
level: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator if x_axis does not exist', () => {
|
||||
expect(
|
||||
renameOperator(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 type { ControlState } from '@superset-ui/chart-controls';
|
||||
import { xAxisForceCategoricalControl } from '../../src/shared-controls/customControls';
|
||||
import { checkColumnType } from '../../src/utils/checkColumnType';
|
||||
|
||||
jest.mock('../../src/utils/checkColumnType');
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
getColumnLabel: jest.fn((col: any) => col),
|
||||
}));
|
||||
|
||||
test('xAxisForceCategoricalControl should not treat temporal columns as categorical when x_axis_sort exists', () => {
|
||||
const mockCheckColumnType = jest.mocked(checkColumnType);
|
||||
|
||||
mockCheckColumnType.mockReturnValue(false); // temporal column (not numeric)
|
||||
|
||||
const control: ControlState = { value: false, type: 'CheckboxControl' };
|
||||
const state = {
|
||||
form_data: { x_axis_sort: 'asc' },
|
||||
controls: {
|
||||
x_axis: { value: 'date_column' },
|
||||
datasource: { datasource: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const result = xAxisForceCategoricalControl.config.initialValue!(
|
||||
control,
|
||||
state as any,
|
||||
);
|
||||
|
||||
// Verify: should return control value (false) for non-numeric columns
|
||||
expect(result).toBe(false);
|
||||
expect(mockCheckColumnType).toHaveBeenCalledWith('date_column', {}, [
|
||||
GenericDataType.Numeric,
|
||||
]);
|
||||
|
||||
mockCheckColumnType.mockClear();
|
||||
});
|
||||
@@ -32,360 +32,373 @@ const mockData = [
|
||||
];
|
||||
const countValues = mockData.map(row => row.count);
|
||||
|
||||
describe('round', () => {
|
||||
it('round', () => {
|
||||
expect(round(1)).toEqual(1);
|
||||
expect(round(1, 2)).toEqual(1);
|
||||
expect(round(0.6)).toEqual(1);
|
||||
expect(round(0.6, 1)).toEqual(0.6);
|
||||
expect(round(0.64999, 2)).toEqual(0.65);
|
||||
});
|
||||
test('round', () => {
|
||||
expect(round(1)).toEqual(1);
|
||||
expect(round(1, 2)).toEqual(1);
|
||||
expect(round(0.6)).toEqual(1);
|
||||
expect(round(0.6, 1)).toEqual(0.6);
|
||||
expect(round(0.64999, 2)).toEqual(0.65);
|
||||
});
|
||||
|
||||
describe('getOpacity', () => {
|
||||
it('getOpacity', () => {
|
||||
expect(getOpacity(100, 100, 100)).toEqual(1);
|
||||
expect(getOpacity(75, 50, 100)).toEqual(0.53);
|
||||
expect(getOpacity(75, 100, 50)).toEqual(0.53);
|
||||
expect(getOpacity(100, 100, 50)).toEqual(0.05);
|
||||
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
|
||||
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
|
||||
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
|
||||
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
|
||||
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
|
||||
});
|
||||
test('getOpacity', () => {
|
||||
expect(getOpacity(100, 100, 100)).toEqual(1);
|
||||
expect(getOpacity(75, 50, 100)).toEqual(0.53);
|
||||
expect(getOpacity(75, 100, 50)).toEqual(0.53);
|
||||
expect(getOpacity(100, 100, 50)).toEqual(0.05);
|
||||
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
|
||||
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
|
||||
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
|
||||
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
|
||||
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
|
||||
|
||||
expect(getOpacity('100', 100, 100)).toEqual(1);
|
||||
expect(getOpacity('75', 50, 100)).toEqual(1);
|
||||
expect(getOpacity('50', '100', '100')).toEqual(1);
|
||||
expect(getOpacity('50', '75', '100')).toEqual(1);
|
||||
expect(getOpacity('50', NaN, '100')).toEqual(1);
|
||||
expect(getOpacity('50', '75', NaN)).toEqual(1);
|
||||
expect(getOpacity('50', NaN, 100)).toEqual(1);
|
||||
expect(getOpacity('50', '75', NaN)).toEqual(1);
|
||||
expect(getOpacity('50', NaN, NaN)).toEqual(1);
|
||||
|
||||
expect(getOpacity(75, 50, 100)).toEqual(0.53);
|
||||
expect(getOpacity(100, 50, 100)).toEqual(1);
|
||||
expect(getOpacity(75, '50', 100)).toEqual(0.53);
|
||||
expect(getOpacity(75, 50, '100')).toEqual(0.53);
|
||||
expect(getOpacity(75, '50', '100')).toEqual(0.53);
|
||||
expect(getOpacity(50, NaN, NaN)).toEqual(1);
|
||||
expect(getOpacity(50, NaN, 100)).toEqual(1);
|
||||
expect(getOpacity(50, NaN, '100')).toEqual(1);
|
||||
expect(getOpacity(50, '75', NaN)).toEqual(1);
|
||||
expect(getOpacity(50, 75, NaN)).toEqual(1);
|
||||
});
|
||||
|
||||
describe('getColorFunction()', () => {
|
||||
it('getColorFunction GREATER_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction LESS_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction GREATER_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterOrEqual,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction LESS_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessOrEqual,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(100)).toEqual('#FF00000D');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Equal,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction NOT_EQUAL', () => {
|
||||
let colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 60,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(60)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(50)).toEqual('#FF00004A');
|
||||
|
||||
colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 90,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(90)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF00004A');
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
expect(colorFunction(25)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000');
|
||||
expect(colorFunction(75)).toEqual('#FF0000');
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
expect(colorFunction(125)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrLeftEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrRightEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction GREATER_THAN with target value undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN with target value left undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: undefined,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BETWEEN with target value right undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction unsupported operator', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
// @ts-ignore
|
||||
operator: 'unsupported operator',
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction with operator None', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(20)).toEqual(undefined);
|
||||
expect(colorFunction(50)).toEqual('#FF000000');
|
||||
expect(colorFunction(75)).toEqual('#FF000080');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(120)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('getColorFunction with operator undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: undefined,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction with colorScheme undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: undefined,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
test('getColorFunction GREATER_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
describe('getColorFormatters()', () => {
|
||||
it('correct column config', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 300,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'sum',
|
||||
},
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: undefined,
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfig, mockData);
|
||||
expect(colorFormatters.length).toEqual(3);
|
||||
|
||||
expect(colorFormatters[0].column).toEqual('count');
|
||||
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[1].column).toEqual('sum');
|
||||
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
|
||||
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
|
||||
|
||||
expect(colorFormatters[2].column).toEqual('count');
|
||||
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
it('undefined column config', () => {
|
||||
const colorFormatters = getColorFormatters(undefined, mockData);
|
||||
expect(colorFormatters.length).toEqual(0);
|
||||
});
|
||||
test('getColorFunction LESS_THAN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction GREATER_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterOrEqual,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction LESS_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessOrEqual,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(100)).toEqual('#FF00000D');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Equal,
|
||||
targetValue: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction NOT_EQUAL', () => {
|
||||
let colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 60,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(60)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(50)).toEqual('#FF00004A');
|
||||
|
||||
colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotEqual,
|
||||
targetValue: 90,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(90)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF00004A');
|
||||
expect(colorFunction(50)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(150)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
expect(colorFunction(25)).toBeUndefined();
|
||||
expect(colorFunction(50)).toEqual('#FF0000');
|
||||
expect(colorFunction(75)).toEqual('#FF0000');
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
expect(colorFunction(125)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrLeftEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toEqual('#FF00000D');
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BetweenOrRightEqual,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('getColorFunction GREATER_THAN with target value undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN with target value left undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: undefined,
|
||||
targetValueRight: 100,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction BETWEEN with target value right undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 50,
|
||||
targetValueRight: undefined,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction unsupported operator', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
// @ts-ignore
|
||||
operator: 'unsupported operator',
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction with operator None', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(20)).toEqual(undefined);
|
||||
expect(colorFunction(50)).toEqual('#FF000000');
|
||||
expect(colorFunction(75)).toEqual('#FF000080');
|
||||
expect(colorFunction(100)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(120)).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('getColorFunction with operator undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: undefined,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction with colorScheme undefined', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: undefined,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('correct column config', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 300,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'sum',
|
||||
},
|
||||
{
|
||||
operator: Comparator.Between,
|
||||
targetValueLeft: 75,
|
||||
targetValueRight: 125,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 150,
|
||||
colorScheme: '#FF0000',
|
||||
column: undefined,
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfig, mockData);
|
||||
expect(colorFormatters.length).toEqual(3);
|
||||
|
||||
expect(colorFormatters[0].column).toEqual('count');
|
||||
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[1].column).toEqual('sum');
|
||||
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
|
||||
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
|
||||
|
||||
expect(colorFormatters[2].column).toEqual('count');
|
||||
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
|
||||
});
|
||||
|
||||
test('undefined column config', () => {
|
||||
const colorFormatters = getColorFormatters(undefined, mockData);
|
||||
expect(colorFormatters.length).toEqual(0);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"reselect": "^5.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
ChartPlugin,
|
||||
ChartMetadata,
|
||||
DatasourceType,
|
||||
getChartComponentRegistry,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import SuperChartCore from './SuperChartCore';
|
||||
|
||||
const props = {
|
||||
chartType: 'line',
|
||||
};
|
||||
const FakeChart = () => <span>test</span>;
|
||||
|
||||
beforeEach(() => {
|
||||
const metadata = new ChartMetadata({
|
||||
name: 'test-chart',
|
||||
thumbnail: '',
|
||||
});
|
||||
const buildQuery = () => ({
|
||||
datasource: { id: 1, type: DatasourceType.Table },
|
||||
queries: [{ granularity: 'day' }],
|
||||
force: false,
|
||||
result_format: 'json',
|
||||
result_type: 'full',
|
||||
});
|
||||
const controlPanel = { abc: 1 };
|
||||
const plugin = new ChartPlugin({
|
||||
metadata,
|
||||
Chart: FakeChart,
|
||||
buildQuery,
|
||||
controlPanel,
|
||||
});
|
||||
plugin.configure({ key: props.chartType }).register();
|
||||
});
|
||||
|
||||
test('should return the result from cache unless transformProps has changed', async () => {
|
||||
const pre = jest.fn(x => x);
|
||||
const transform = jest.fn(x => x);
|
||||
const post = jest.fn(x => x);
|
||||
expect(getChartComponentRegistry().get(props.chartType)).toBe(FakeChart);
|
||||
|
||||
expect(pre).toHaveBeenCalledTimes(0);
|
||||
const { rerender } = render(
|
||||
<SuperChartCore
|
||||
{...props}
|
||||
preTransformProps={pre}
|
||||
overrideTransformProps={transform}
|
||||
postTransformProps={post}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(pre).toHaveBeenCalledTimes(1));
|
||||
expect(transform).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
|
||||
const updatedPost = jest.fn(x => x);
|
||||
|
||||
rerender(
|
||||
<SuperChartCore
|
||||
{...props}
|
||||
preTransformProps={pre}
|
||||
overrideTransformProps={transform}
|
||||
postTransformProps={updatedPost}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(updatedPost).toHaveBeenCalledTimes(1));
|
||||
expect(transform).toHaveBeenCalledTimes(1);
|
||||
expect(pre).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -85,31 +85,74 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
|
||||
container?: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute
|
||||
* and return previous value
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of
|
||||
* - preTransformProps
|
||||
* - transformProps
|
||||
* - postTransformProps
|
||||
* - chartProps
|
||||
* is changed.
|
||||
*/
|
||||
processChartProps = createSelector(
|
||||
preSelector = createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
transformProps?: TransformProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.preTransformProps,
|
||||
],
|
||||
(chartProps, pre = IDENTITY) => pre(chartProps),
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
transformSelector = createSelector(
|
||||
[
|
||||
(input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
|
||||
input.chartProps,
|
||||
input => input.transformProps,
|
||||
],
|
||||
(preprocessedChartProps, transform = IDENTITY) =>
|
||||
transform(preprocessedChartProps),
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
postSelector = createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.postTransformProps,
|
||||
],
|
||||
(chartProps, pre = IDENTITY, transform = IDENTITY, post = IDENTITY) =>
|
||||
post(transform(pre(chartProps))),
|
||||
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
|
||||
);
|
||||
|
||||
/**
|
||||
* Using each memoized function to retrieve the computed chartProps
|
||||
*/
|
||||
processChartProps = ({
|
||||
chartProps,
|
||||
preTransformProps,
|
||||
transformProps,
|
||||
postTransformProps,
|
||||
}: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
transformProps?: TransformProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) =>
|
||||
this.postSelector({
|
||||
chartProps: this.transformSelector({
|
||||
chartProps: this.preSelector({ chartProps, preTransformProps }),
|
||||
transformProps,
|
||||
}),
|
||||
postTransformProps,
|
||||
});
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute
|
||||
* and return previous value
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { ActionButton } from '.';
|
||||
|
||||
const defaultProps = {
|
||||
label: 'test-action',
|
||||
icon: <Icons.EditOutlined />,
|
||||
onClick: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders action button with icon', () => {
|
||||
render(<ActionButton {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('data-test', 'test-action');
|
||||
expect(button).toHaveClass('action-button');
|
||||
});
|
||||
|
||||
test('calls onClick when clicked', async () => {
|
||||
const onClick = jest.fn();
|
||||
render(<ActionButton {...defaultProps} onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.click(button);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders with tooltip when tooltip prop is provided', async () => {
|
||||
const tooltipText = 'This is a tooltip';
|
||||
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(tooltip).toHaveTextContent(tooltipText);
|
||||
});
|
||||
|
||||
test('renders without tooltip when tooltip prop is not provided', async () => {
|
||||
render(<ActionButton {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = screen.queryByRole('tooltip');
|
||||
expect(tooltip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('supports ReactElement tooltip', async () => {
|
||||
const tooltipElement = <div>Custom tooltip content</div>;
|
||||
render(<ActionButton {...defaultProps} tooltip={tooltipElement} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(tooltip).toHaveTextContent('Custom tooltip content');
|
||||
});
|
||||
|
||||
test('renders different icons correctly', () => {
|
||||
render(<ActionButton {...defaultProps} icon={<Icons.DeleteOutlined />} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with custom placement for tooltip', async () => {
|
||||
const tooltipText = 'Tooltip with custom placement';
|
||||
render(
|
||||
<ActionButton {...defaultProps} tooltip={tooltipText} placement="bottom" />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
userEvent.hover(button);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('has proper accessibility attributes', () => {
|
||||
render(<ActionButton {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('tabIndex', '0');
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
type TooltipPlacement,
|
||||
type IconType,
|
||||
} from '@superset-ui/core/components';
|
||||
import { css, useTheme } from '@superset-ui/core';
|
||||
|
||||
export interface ActionProps {
|
||||
label: string;
|
||||
tooltip?: string | ReactElement;
|
||||
placement?: TooltipPlacement;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const ActionButton = ({
|
||||
label,
|
||||
tooltip,
|
||||
placement,
|
||||
icon,
|
||||
onClick,
|
||||
}: ActionProps) => {
|
||||
const theme = useTheme();
|
||||
const actionButton = (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
color: ${theme.colorIcon};
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
&:hover {
|
||||
path {
|
||||
fill: ${theme.colorPrimary};
|
||||
}
|
||||
}
|
||||
`}
|
||||
className="action-button"
|
||||
data-test={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
|
||||
const tooltipId = `${label.replaceAll(' ', '-').toLowerCase()}-tooltip`;
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip id={tooltipId} title={tooltip} placement={placement}>
|
||||
{actionButton}
|
||||
</Tooltip>
|
||||
) : (
|
||||
actionButton
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import { render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import type AceEditor from 'react-ace';
|
||||
import {
|
||||
AsyncAceEditor,
|
||||
SQLEditor,
|
||||
@@ -99,3 +101,259 @@ test('renders a custom placeholder', () => {
|
||||
|
||||
expect(screen.getByRole('paragraph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('registers afterExec event listener for command handling', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Verify the commands object has the 'on' method (confirms event listener capability)
|
||||
expect(editorInstance.commands).toHaveProperty('on');
|
||||
expect(typeof editorInstance.commands.on).toBe('function');
|
||||
});
|
||||
|
||||
test('moves autocomplete popup to parent container when triggered', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup in the editor container
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(mockAutocompletePopup);
|
||||
|
||||
const parentContainer =
|
||||
editorInstance.container?.closest('#ace-editor') ??
|
||||
editorInstance.container?.parentElement;
|
||||
|
||||
// Manually trigger the afterExec event with insertstring command using _emit
|
||||
// Note: Using _emit is necessary here to test internal event handling behavior
|
||||
// since there's no public API to trigger the afterExec event directly
|
||||
type CommandManagerWithEmit = typeof editorInstance.commands & {
|
||||
_emit: (event: string, data: unknown) => void;
|
||||
};
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
|
||||
command: { name: 'insertstring' },
|
||||
args: ['SELECT'],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the popup has the data attribute set
|
||||
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
|
||||
// Check that the popup is in the parent container
|
||||
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('moves autocomplete popup on startAutocomplete command event', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(mockAutocompletePopup);
|
||||
|
||||
const parentContainer =
|
||||
editorInstance.container?.closest('#ace-editor') ??
|
||||
editorInstance.container?.parentElement;
|
||||
|
||||
// Manually trigger the afterExec event with startAutocomplete command
|
||||
// Note: Using _emit is necessary here to test internal event handling behavior
|
||||
// since there's no public API to trigger the afterExec event directly
|
||||
type CommandManagerWithEmit = typeof editorInstance.commands & {
|
||||
_emit: (event: string, data: unknown) => void;
|
||||
};
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
|
||||
command: { name: 'startAutocomplete' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the popup has the data attribute set
|
||||
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
|
||||
// Check that the popup is in the parent container
|
||||
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not move autocomplete popup on unrelated commands', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup in the body
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
document.body.appendChild(mockAutocompletePopup);
|
||||
|
||||
const originalParent = mockAutocompletePopup.parentElement;
|
||||
|
||||
// Simulate an unrelated command (e.g., 'selectall')
|
||||
editorInstance.commands.exec('selectall', editorInstance, {});
|
||||
|
||||
// Wait a bit to ensure no movement happens
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// The popup should remain in its original location
|
||||
expect(mockAutocompletePopup.parentElement).toBe(originalParent);
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(mockAutocompletePopup);
|
||||
});
|
||||
|
||||
test('revalidates cached autocomplete popup when detached from DOM', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create first autocomplete popup
|
||||
const firstPopup = document.createElement('div');
|
||||
firstPopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(firstPopup);
|
||||
|
||||
// Trigger command to cache the first popup
|
||||
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(firstPopup.dataset.aceAutocomplete).toBe('true');
|
||||
});
|
||||
|
||||
// Remove the first popup from DOM (simulating ACE editor replacing it)
|
||||
firstPopup.remove();
|
||||
|
||||
// Create a new autocomplete popup
|
||||
const secondPopup = document.createElement('div');
|
||||
secondPopup.className = 'ace_autocomplete';
|
||||
editorInstance.container?.appendChild(secondPopup);
|
||||
|
||||
// Trigger command again - should find and move the new popup
|
||||
editorInstance.commands.exec('insertstring', editorInstance, ' ');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(secondPopup.dataset.aceAutocomplete).toBe('true');
|
||||
const parentContainer =
|
||||
editorInstance.container?.closest('#ace-editor') ??
|
||||
editorInstance.container?.parentElement;
|
||||
expect(parentContainer?.contains(secondPopup)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('cleans up event listeners on unmount', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container, unmount } = render(
|
||||
<SQLEditor ref={ref as React.Ref<never>} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Spy on the commands.off method
|
||||
const offSpy = jest.spyOn(editorInstance.commands, 'off');
|
||||
|
||||
// Unmount the component
|
||||
unmount();
|
||||
|
||||
// Verify that the event listener was removed
|
||||
expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
|
||||
|
||||
offSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not move autocomplete popup if target container is document.body', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
expect(editorInstance).toBeDefined();
|
||||
|
||||
if (!editorInstance) return;
|
||||
|
||||
// Create a mock autocomplete popup
|
||||
const mockAutocompletePopup = document.createElement('div');
|
||||
mockAutocompletePopup.className = 'ace_autocomplete';
|
||||
document.body.appendChild(mockAutocompletePopup);
|
||||
|
||||
// Mock the closest method to return null (simulating no #ace-editor parent)
|
||||
const originalClosest = editorInstance.container?.closest;
|
||||
if (editorInstance.container) {
|
||||
editorInstance.container.closest = jest.fn(() => null);
|
||||
}
|
||||
|
||||
// Mock parentElement to be document.body
|
||||
Object.defineProperty(editorInstance.container, 'parentElement', {
|
||||
value: document.body,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const initialParent = mockAutocompletePopup.parentElement;
|
||||
|
||||
// Trigger command
|
||||
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
|
||||
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// The popup should NOT be moved because target container is document.body
|
||||
expect(mockAutocompletePopup.parentElement).toBe(initialParent);
|
||||
|
||||
// Cleanup
|
||||
if (editorInstance.container && originalClosest) {
|
||||
editorInstance.container.closest = originalClosest;
|
||||
}
|
||||
document.body.removeChild(mockAutocompletePopup);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from 'brace';
|
||||
import type AceEditor from 'react-ace';
|
||||
import type { IAceEditorProps } from 'react-ace';
|
||||
import type { Ace } from 'ace-builds';
|
||||
|
||||
import {
|
||||
AsyncEsmComponent,
|
||||
@@ -195,6 +196,70 @@ export function AsyncAceEditor(
|
||||
}
|
||||
}, [keywords, setCompleters]);
|
||||
|
||||
// Move autocomplete popup to the nearest parent container with data-ace-container
|
||||
useEffect(() => {
|
||||
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
|
||||
?.editor;
|
||||
if (!editorInstance) return undefined;
|
||||
|
||||
const editorContainer = editorInstance.container;
|
||||
if (!editorContainer) return undefined;
|
||||
|
||||
// Cache DOM elements to avoid repeated queries on every command execution
|
||||
let cachedAutocompletePopup: HTMLElement | null = null;
|
||||
let cachedTargetContainer: Element | null = null;
|
||||
|
||||
const moveAutocompleteToContainer = () => {
|
||||
// Revalidate cached popup if missing or detached from DOM
|
||||
if (
|
||||
!cachedAutocompletePopup ||
|
||||
!document.body.contains(cachedAutocompletePopup)
|
||||
) {
|
||||
cachedAutocompletePopup =
|
||||
editorContainer.querySelector<HTMLElement>(
|
||||
'.ace_autocomplete',
|
||||
) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
|
||||
}
|
||||
|
||||
// Revalidate cached container if missing or detached
|
||||
if (
|
||||
!cachedTargetContainer ||
|
||||
!document.body.contains(cachedTargetContainer)
|
||||
) {
|
||||
cachedTargetContainer =
|
||||
editorContainer.closest('#ace-editor') ??
|
||||
editorContainer.parentElement;
|
||||
}
|
||||
|
||||
if (
|
||||
cachedAutocompletePopup &&
|
||||
cachedTargetContainer &&
|
||||
cachedTargetContainer !== document.body
|
||||
) {
|
||||
cachedTargetContainer.appendChild(cachedAutocompletePopup);
|
||||
cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAfterExec = (e: Ace.Operation) => {
|
||||
const name: string | undefined = e?.command?.name;
|
||||
if (name === 'insertstring' || name === 'startAutocomplete') {
|
||||
moveAutocompleteToContainer();
|
||||
}
|
||||
};
|
||||
|
||||
const { commands } = editorInstance;
|
||||
commands.on('afterExec', handleAfterExec);
|
||||
|
||||
// Cleanup function to remove event listener and clear cached references
|
||||
return () => {
|
||||
commands.off('afterExec', handleAfterExec);
|
||||
// Clear cached references to avoid memory leaks
|
||||
cachedAutocompletePopup = null;
|
||||
cachedTargetContainer = null;
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Global
|
||||
@@ -222,7 +287,8 @@ export function AsyncAceEditor(
|
||||
}
|
||||
/* Adjust selection color */
|
||||
.ace_editor .ace_selection {
|
||||
background-color: ${token.colorPrimaryBgHover} !important;
|
||||
background-color: ${token.colorEditorSelection ??
|
||||
token.colorPrimaryBgHover} !important;
|
||||
}
|
||||
|
||||
/* Improve active line highlighting */
|
||||
@@ -276,14 +342,24 @@ export function AsyncAceEditor(
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
box-shadow: ${token.boxShadow};
|
||||
border-radius: ${token.borderRadius}px;
|
||||
padding: ${token.paddingXS}px ${token.paddingXS}px;
|
||||
}
|
||||
|
||||
& .tooltip-detail {
|
||||
.ace_tooltip.ace_doc-tooltip {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
&&& .tooltip-detail {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
gap: ${token.paddingXXS}px;
|
||||
align-items: center;
|
||||
background-color: ${token.colorBgContainer};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-width: ${token.sizeXXL * 5}px;
|
||||
max-width: ${token.sizeXXL * 10}px;
|
||||
font-size: ${token.fontSize}px;
|
||||
|
||||
& .tooltip-detail-head {
|
||||
background-color: ${token.colorBgElevated};
|
||||
@@ -306,7 +382,9 @@ export function AsyncAceEditor(
|
||||
|
||||
& .tooltip-detail-head,
|
||||
& .tooltip-detail-body {
|
||||
padding: ${token.padding}px ${token.paddingLG}px;
|
||||
background-color: ${token.colorBgLayout};
|
||||
padding: 0px ${token.paddingXXS}px;
|
||||
border: 1px ${token.colorSplit} solid;
|
||||
}
|
||||
|
||||
& .tooltip-detail-footer {
|
||||
@@ -393,10 +471,7 @@ export const FullSQLEditor = AsyncAceEditor(
|
||||
},
|
||||
);
|
||||
|
||||
export const MarkdownEditor = AsyncAceEditor([
|
||||
'mode/markdown',
|
||||
'theme/textmate',
|
||||
]);
|
||||
export const MarkdownEditor = AsyncAceEditor(['mode/markdown', 'theme/github']);
|
||||
|
||||
export const TextAreaEditor = AsyncAceEditor([
|
||||
'mode/markdown',
|
||||
|
||||
@@ -106,7 +106,7 @@ export const InteractiveButton = (args: ButtonProps & { label: string }) => {
|
||||
};
|
||||
|
||||
InteractiveButton.args = {
|
||||
buttonStyle: 'default',
|
||||
buttonStyle: 'primary',
|
||||
buttonSize: 'default',
|
||||
label: 'Button!',
|
||||
};
|
||||
|
||||
@@ -30,6 +30,22 @@ import type {
|
||||
OnClickHandler,
|
||||
} from './types';
|
||||
|
||||
const BUTTON_STYLE_MAP: Record<
|
||||
ButtonStyle,
|
||||
{
|
||||
type?: ButtonType;
|
||||
variant?: ButtonVariantType;
|
||||
color?: ButtonColorType;
|
||||
}
|
||||
> = {
|
||||
primary: { type: 'primary', variant: 'solid', color: 'primary' },
|
||||
secondary: { variant: 'filled', color: 'primary' },
|
||||
tertiary: { variant: 'outlined', color: 'default' },
|
||||
dashed: { type: 'dashed', variant: 'dashed', color: 'primary' },
|
||||
danger: { variant: 'solid', color: 'danger' },
|
||||
link: { type: 'link' },
|
||||
};
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const {
|
||||
tooltip,
|
||||
@@ -62,27 +78,11 @@ export function Button(props: ButtonProps) {
|
||||
padding = 4;
|
||||
}
|
||||
|
||||
let antdType: ButtonType = 'default';
|
||||
let variant: ButtonVariantType = 'solid';
|
||||
let color: ButtonColorType = 'primary';
|
||||
|
||||
if (!buttonStyle || buttonStyle === 'primary') {
|
||||
variant = 'solid';
|
||||
antdType = 'primary';
|
||||
} else if (buttonStyle === 'secondary') {
|
||||
variant = 'filled';
|
||||
color = 'primary';
|
||||
} else if (buttonStyle === 'tertiary') {
|
||||
variant = 'outlined';
|
||||
color = 'default';
|
||||
} else if (buttonStyle === 'dashed') {
|
||||
variant = 'dashed';
|
||||
antdType = 'dashed';
|
||||
} else if (buttonStyle === 'danger') {
|
||||
color = 'danger';
|
||||
} else if (buttonStyle === 'link') {
|
||||
variant = 'link';
|
||||
}
|
||||
const {
|
||||
type: antdType = 'default',
|
||||
variant,
|
||||
color,
|
||||
} = BUTTON_STYLE_MAP[buttonStyle ?? 'primary'] ?? BUTTON_STYLE_MAP.primary;
|
||||
|
||||
const element = children as ReactElement;
|
||||
|
||||
|
||||
@@ -48,10 +48,7 @@ export const CollapseLabelInModal: React.FC<CollapseLabelInModalProps> = ({
|
||||
{title}{' '}
|
||||
{validateCheckStatus !== undefined &&
|
||||
(validateCheckStatus ? (
|
||||
<Icons.CheckCircleOutlined
|
||||
iconColor={theme.colorSuccess}
|
||||
aria-label="check-circle"
|
||||
/>
|
||||
<Icons.CheckCircleOutlined iconColor={theme.colorSuccess} />
|
||||
) : (
|
||||
<span
|
||||
css={css`
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { fireEvent, render, waitFor } from '@superset-ui/core/spec';
|
||||
import { Button } from '../Button';
|
||||
import { ConfirmStatusChange } from '.';
|
||||
|
||||
const mockedProps = {
|
||||
title: 'please confirm',
|
||||
description: 'are you sure?',
|
||||
onConfirm: jest.fn(),
|
||||
};
|
||||
|
||||
test('opens a confirm modal', () => {
|
||||
const { getByTestId } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => (
|
||||
<>
|
||||
<Button data-test="btn1" onClick={confirm} />
|
||||
</>
|
||||
)}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('btn1'));
|
||||
|
||||
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls the function on confirm', async () => {
|
||||
const { getByTestId, getByRole } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => (
|
||||
<>
|
||||
<Button data-test="btn1" onClick={() => confirm('foo')} />
|
||||
</>
|
||||
)}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('btn1'));
|
||||
|
||||
const confirmInput = getByTestId('delete-modal-input');
|
||||
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
|
||||
|
||||
const confirmButton = getByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => expect(mockedProps.onConfirm).toHaveBeenCalledTimes(1));
|
||||
expect(mockedProps.onConfirm).toHaveBeenCalledWith('foo');
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { fireEvent, render, waitFor } from '@superset-ui/core/spec';
|
||||
import { Button } from '../Button';
|
||||
import { ConfirmStatusChange } from '.';
|
||||
import type { ConfirmStatusChangeProps } from './types';
|
||||
|
||||
const mockedProps: Omit<ConfirmStatusChangeProps, 'children'> = {
|
||||
title: 'please confirm',
|
||||
description: 'are you sure?',
|
||||
onConfirm: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders children with showConfirm function', () => {
|
||||
const childrenSpy = jest.fn().mockReturnValue(<div>test content</div>);
|
||||
|
||||
render(
|
||||
<ConfirmStatusChange {...mockedProps}>{childrenSpy}</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
expect(childrenSpy).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
test('opens modal when showConfirm is called', () => {
|
||||
const { getByTestId } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => <Button data-test="trigger" onClick={confirm} />}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('trigger'));
|
||||
|
||||
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('stores and passes arguments to onConfirm callback', async () => {
|
||||
const testArgs = ['arg1', { data: 'test' }, 42];
|
||||
const { getByTestId, getByRole } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => (
|
||||
<Button data-test="trigger" onClick={() => confirm(...testArgs)} />
|
||||
)}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('trigger'));
|
||||
|
||||
const confirmInput = getByTestId('delete-modal-input');
|
||||
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
|
||||
|
||||
const confirmButton = getByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => expect(mockedProps.onConfirm).toHaveBeenCalledTimes(1));
|
||||
expect(mockedProps.onConfirm).toHaveBeenCalledWith(...testArgs);
|
||||
});
|
||||
|
||||
test('calls preventDefault on event-like arguments', () => {
|
||||
const mockEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn(),
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => (
|
||||
<Button data-test="trigger" onClick={() => confirm(mockEvent)} />
|
||||
)}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('trigger'));
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips event handling on non-event arguments', () => {
|
||||
const regularArg = { someData: 'value' };
|
||||
const mockFunc = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => (
|
||||
<Button
|
||||
data-test="trigger"
|
||||
onClick={() => confirm(regularArg, mockFunc)}
|
||||
/>
|
||||
)}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
// Should not throw when processing non-event arguments
|
||||
expect(() => {
|
||||
fireEvent.click(getByTestId('trigger'));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('ignores null and undefined arguments', () => {
|
||||
const { getByTestId } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => (
|
||||
<Button
|
||||
data-test="trigger"
|
||||
onClick={() => confirm(null, undefined, 'valid')}
|
||||
/>
|
||||
)}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(getByTestId('trigger'));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles partial event objects gracefully', () => {
|
||||
const partialEvent1 = { preventDefault: jest.fn() }; // Only preventDefault
|
||||
const partialEvent2 = { stopPropagation: jest.fn() }; // Only stopPropagation
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => (
|
||||
<Button
|
||||
data-test="trigger"
|
||||
onClick={() => confirm(partialEvent1, partialEvent2)}
|
||||
/>
|
||||
)}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('trigger'));
|
||||
|
||||
expect(partialEvent1.preventDefault).toHaveBeenCalled();
|
||||
expect(partialEvent2.stopPropagation).toHaveBeenCalled();
|
||||
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('closes modal when onHide is called', () => {
|
||||
const { getByTestId, getByRole } = render(
|
||||
<ConfirmStatusChange {...mockedProps}>
|
||||
{confirm => <Button data-test="trigger" onClick={confirm} />}
|
||||
</ConfirmStatusChange>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
fireEvent.click(getByTestId('trigger'));
|
||||
const modal = getByTestId(`${mockedProps.title}-modal`);
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
// Close modal
|
||||
const cancelButton = getByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Modal should be hidden (not visible)
|
||||
expect(modal).not.toBeVisible();
|
||||
});
|
||||
@@ -31,14 +31,11 @@ export function ConfirmStatusChange({
|
||||
const [currentCallbackArgs, setCurrentCallbackArgs] = useState<any[]>([]);
|
||||
|
||||
const showConfirm = (...callbackArgs: any[]) => {
|
||||
// check if any args are DOM events, if so, call persist
|
||||
// check if any args are DOM events, if so, handle them
|
||||
callbackArgs.forEach(arg => {
|
||||
if (!arg) {
|
||||
return;
|
||||
}
|
||||
if (typeof arg.persist === 'function') {
|
||||
arg.persist();
|
||||
}
|
||||
if (typeof arg.preventDefault === 'function') {
|
||||
arg.preventDefault();
|
||||
}
|
||||
|
||||
@@ -34,8 +34,10 @@ const StyledEditableTitle = styled.span<{
|
||||
canEdit: boolean;
|
||||
}>`
|
||||
&.editable-title {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
display: inline;
|
||||
&.editable-title--editing {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
@@ -52,7 +54,6 @@ const StyledEditableTitle = styled.span<{
|
||||
|
||||
input[type='text'],
|
||||
textarea {
|
||||
border: 1px solid ${({ theme }) => theme.colorSplit};
|
||||
color: ${({ theme }) => theme.colorTextTertiary};
|
||||
border-radius: ${({ theme }) => theme.sizeUnit}px;
|
||||
font-size: ${({ theme }) => theme.fontSizeLG}px;
|
||||
|
||||
@@ -60,7 +60,7 @@ const EmptyStateContainer = styled.div`
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${theme.colorTextQuaternary};
|
||||
color: ${theme.colorTextTertiary};
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
@@ -84,7 +84,7 @@ const EmptyStateContainer = styled.div`
|
||||
const Title = styled.p<{ size: EmptyStateSize }>`
|
||||
${({ theme, size }) => css`
|
||||
font-size: ${size === 'large' ? theme.fontSizeLG : theme.fontSize}px;
|
||||
color: ${theme.colorTextQuaternary};
|
||||
color: ${theme.colorTextTertiary};
|
||||
margin-top: ${size === 'large' ? theme.sizeUnit * 4 : theme.sizeUnit * 2}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
`}
|
||||
@@ -93,7 +93,7 @@ const Title = styled.p<{ size: EmptyStateSize }>`
|
||||
const Description = styled.p<{ size: EmptyStateSize }>`
|
||||
${({ theme, size }) => css`
|
||||
font-size: ${size === 'large' ? theme.fontSize : theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextQuaternary};
|
||||
color: ${theme.colorTextTertiary};
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -50,17 +50,7 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
const iconContent = icon ? (
|
||||
<img
|
||||
src={icon as string}
|
||||
alt={altText || buttonText}
|
||||
css={css`
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
height: 100px;
|
||||
`}
|
||||
/>
|
||||
) : (
|
||||
const iconContent = (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
@@ -69,12 +59,19 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
height: 100px;
|
||||
`}
|
||||
>
|
||||
<Icons.DatabaseOutlined
|
||||
css={css`
|
||||
font-size: 48px;
|
||||
`}
|
||||
aria-label="default-icon"
|
||||
/>
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon as string}
|
||||
alt={altText || buttonText}
|
||||
css={css`
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
height: 48px;
|
||||
`}
|
||||
/>
|
||||
) : (
|
||||
<Icons.DatabaseOutlined iconSize="xxl" aria-label="default-icon" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export const IconTooltip = ({
|
||||
placement = 'top',
|
||||
style = {},
|
||||
tooltip = null,
|
||||
mouseEnterDelay = 0.3,
|
||||
mouseLeaveDelay = 0.15,
|
||||
}: IconTooltipProps) => {
|
||||
const iconTooltip = (
|
||||
<Button
|
||||
@@ -47,8 +49,8 @@ export const IconTooltip = ({
|
||||
id="tooltip"
|
||||
title={tooltip}
|
||||
placement={placement}
|
||||
mouseEnterDelay={0.3}
|
||||
mouseLeaveDelay={0.15}
|
||||
mouseEnterDelay={mouseEnterDelay}
|
||||
mouseLeaveDelay={mouseLeaveDelay}
|
||||
>
|
||||
{iconTooltip}
|
||||
</Tooltip>
|
||||
|
||||
@@ -37,4 +37,6 @@ export interface IconTooltipProps {
|
||||
| 'rightBottom';
|
||||
style?: object;
|
||||
tooltip?: string | null;
|
||||
mouseEnterDelay?: number;
|
||||
mouseLeaveDelay?: number;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ import {
|
||||
ExportOutlined,
|
||||
CompressOutlined,
|
||||
HistoryOutlined,
|
||||
SlackOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { FC } from 'react';
|
||||
import { IconType } from './types';
|
||||
@@ -281,6 +282,7 @@ const AntdIcons = {
|
||||
ExportOutlined,
|
||||
CompressOutlined,
|
||||
HistoryOutlined,
|
||||
SlackOutlined,
|
||||
} as const;
|
||||
|
||||
type AntdIconNames = keyof typeof AntdIcons;
|
||||
|
||||
@@ -25,7 +25,8 @@ import { BaseIconComponent } from './BaseIcon';
|
||||
const AsyncIcon = (props: IconType) => {
|
||||
const [, setLoaded] = useState(false);
|
||||
const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>>>();
|
||||
const { fileName, ...restProps } = props;
|
||||
const { fileName, customIcons, iconSize, iconColor, viewBox, ...restProps } =
|
||||
props;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -46,6 +47,11 @@ const AsyncIcon = (props: IconType) => {
|
||||
return (
|
||||
<BaseIconComponent
|
||||
component={ImportedSVG.current || TransparentIcon}
|
||||
fileName={fileName}
|
||||
customIcons={customIcons}
|
||||
iconSize={iconSize}
|
||||
iconColor={iconColor}
|
||||
viewBox={viewBox}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { AntdIconType, BaseIconProps, CustomIconType, IconType } from './types';
|
||||
|
||||
const genAriaLabel = (fileName: string) => {
|
||||
const name = fileName.replace(/_/g, '-'); // Replace underscores with dashes
|
||||
const words = name.split(/(?=[A-Z])/); // Split at uppercase letters
|
||||
const words = name.split(/(?<=[a-z])(?=[A-Z])/); // Split at lowercase-to-uppercase transitions
|
||||
|
||||
if (words.length === 2) {
|
||||
return words[0].toLowerCase();
|
||||
|
||||
@@ -42,6 +42,7 @@ const customIcons = [
|
||||
'Error',
|
||||
'Full',
|
||||
'Layers',
|
||||
'Multiple',
|
||||
'Queued',
|
||||
'Redo',
|
||||
'Running',
|
||||
|
||||
@@ -40,7 +40,14 @@ export const PublishedLabel: React.FC<PublishedLabelProps> = ({
|
||||
const labelType = isPublished ? 'success' : 'primary';
|
||||
|
||||
return (
|
||||
<Label type={labelType} icon={icon} onClick={onClick}>
|
||||
<Label
|
||||
type={labelType}
|
||||
icon={icon}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
color: isPublished ? theme.colorSuccessText : theme.colorPrimaryText,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
);
|
||||
|
||||
@@ -34,8 +34,9 @@ const LoaderImg = styled.img`
|
||||
}
|
||||
&.inline-centered {
|
||||
margin: 0 auto;
|
||||
width: 30px;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
&.floating {
|
||||
padding: 0;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { Ellipsis } from './Ellipsis';
|
||||
|
||||
test('Ellipsis - click when the button is enabled', async () => {
|
||||
const click = jest.fn();
|
||||
render(<Ellipsis onClick={click} />);
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Ellipsis - click when the button is disabled', async () => {
|
||||
const click = jest.fn();
|
||||
render(<Ellipsis onClick={click} disabled />);
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { PaginationButtonProps } from './types';
|
||||
|
||||
export function Ellipsis({ disabled, onClick }: PaginationButtonProps) {
|
||||
return (
|
||||
<li className={classNames({ disabled })}>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (!disabled) onClick(e);
|
||||
}}
|
||||
>
|
||||
…
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { Item } from './Item';
|
||||
|
||||
test('Item - click when the item is not active', async () => {
|
||||
const click = jest.fn();
|
||||
render(
|
||||
<Item onClick={click}>
|
||||
<div data-test="test" />
|
||||
</Item>,
|
||||
);
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Item - click when the item is active', async () => {
|
||||
const click = jest.fn();
|
||||
render(
|
||||
<Item onClick={click} active>
|
||||
<div data-test="test" />
|
||||
</Item>,
|
||||
);
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
expect(screen.getByTestId('test')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { Prev } from './Prev';
|
||||
|
||||
test('Prev - click when the button is enabled', async () => {
|
||||
const click = jest.fn();
|
||||
render(<Prev onClick={click} />);
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Prev - click when the button is disabled', async () => {
|
||||
const click = jest.fn();
|
||||
render(<Prev onClick={click} disabled />);
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(click).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, cleanup } from '@superset-ui/core/spec';
|
||||
import Wrapper from './Wrapper';
|
||||
|
||||
// Add cleanup after each test
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
// Wait for any pending effects to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
jest.mock('./Next', () => ({
|
||||
Next: () => <div data-test="next" />,
|
||||
}));
|
||||
jest.mock('./Prev', () => ({
|
||||
Prev: () => <div data-test="prev" />,
|
||||
}));
|
||||
jest.mock('./Item', () => ({
|
||||
Item: () => <div data-test="item" />,
|
||||
}));
|
||||
jest.mock('./Ellipsis', () => ({
|
||||
Ellipsis: () => <div data-test="ellipsis" />,
|
||||
}));
|
||||
|
||||
test('Pagination rendering correctly', async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<li data-test="test" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Next attribute', async () => {
|
||||
render(<Wrapper.Next onClick={jest.fn()} />);
|
||||
expect(screen.getByTestId('next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Prev attribute', async () => {
|
||||
render(<Wrapper.Next onClick={jest.fn()} />);
|
||||
expect(screen.getByTestId('next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Item attribute', async () => {
|
||||
render(
|
||||
<Wrapper.Item onClick={jest.fn()}>
|
||||
<></>
|
||||
</Wrapper.Item>,
|
||||
);
|
||||
expect(screen.getByTestId('item')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Ellipsis attribute', async () => {
|
||||
render(<Wrapper.Ellipsis onClick={jest.fn()} />);
|
||||
expect(screen.getByTestId('ellipsis')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { Next } from './Next';
|
||||
import { Prev } from './Prev';
|
||||
import { Item } from './Item';
|
||||
import { Ellipsis } from './Ellipsis';
|
||||
|
||||
interface PaginationProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
const PaginationList = styled.ul`
|
||||
${({ theme }) => `
|
||||
display: inline-block;
|
||||
padding: ${theme.sizeUnit * 3}px;
|
||||
|
||||
li {
|
||||
display: inline;
|
||||
margin: 0 4px;
|
||||
|
||||
> span {
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
background-color: ${theme.colorBgContainer};
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
color: ${theme.colorText};
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
color: ${theme.colorText};
|
||||
background-color: ${theme.colorBgLayout};
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
span {
|
||||
background-color: transparent;
|
||||
cursor: default;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
span {
|
||||
z-index: 3;
|
||||
color: ${theme.colorBgLayout};
|
||||
cursor: default;
|
||||
background-color: ${theme.colorPrimary};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
function Pagination({ children }: PaginationProps) {
|
||||
return <PaginationList role="navigation">{children}</PaginationList>;
|
||||
}
|
||||
|
||||
Pagination.Next = Next;
|
||||
Pagination.Prev = Prev;
|
||||
Pagination.Item = Item;
|
||||
Pagination.Ellipsis = Ellipsis;
|
||||
|
||||
export default Pagination;
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import Pagination from '@superset-ui/core/components/Pagination/Wrapper';
|
||||
import {
|
||||
createUltimatePagination,
|
||||
ITEM_TYPES,
|
||||
} from 'react-ultimate-pagination';
|
||||
|
||||
const ListViewPagination = createUltimatePagination({
|
||||
WrapperComponent: Pagination,
|
||||
itemTypeToComponent: {
|
||||
[ITEM_TYPES.PAGE]: ({ value, isActive, onClick }) => (
|
||||
<Pagination.Item active={isActive} onClick={onClick}>
|
||||
{value}
|
||||
</Pagination.Item>
|
||||
),
|
||||
[ITEM_TYPES.ELLIPSIS]: ({ isActive, onClick }) => (
|
||||
<Pagination.Ellipsis disabled={isActive} onClick={onClick} />
|
||||
),
|
||||
[ITEM_TYPES.PREVIOUS_PAGE_LINK]: ({ isActive, onClick }) => (
|
||||
<Pagination.Prev disabled={isActive} onClick={onClick} />
|
||||
),
|
||||
[ITEM_TYPES.NEXT_PAGE_LINK]: ({ isActive, onClick }) => (
|
||||
<Pagination.Next disabled={isActive} onClick={onClick} />
|
||||
),
|
||||
[ITEM_TYPES.FIRST_PAGE_LINK]: () => null,
|
||||
[ITEM_TYPES.LAST_PAGE_LINK]: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
export default ListViewPagination;
|
||||
@@ -18,6 +18,9 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
// TODO: Upgrade to remark-gfm v4+ after migrating to React 18.
|
||||
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
|
||||
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { mergeWith } from 'lodash';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
||||
|
||||
@@ -86,7 +86,7 @@ const getSelect = () =>
|
||||
screen.getByRole('combobox', { name: new RegExp(ARIA_LABEL, 'i') });
|
||||
|
||||
const selectAllButtonText = (length: number) => `Select all (${length})`;
|
||||
const deselectAllButtonText = (length: number) => `Deselect all (${length})`;
|
||||
const deselectAllButtonText = (length: number) => `Clear (${length})`;
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
@@ -779,6 +779,38 @@ test('Renders only an overflow tag if dropdown is open in oneLine mode', async (
|
||||
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
|
||||
});
|
||||
|
||||
// Test for checking the issue described in: https://github.com/apache/superset/issues/35132
|
||||
test('Maintains stable maxTagCount to prevent click target disappearing in oneLine mode', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
|
||||
mode="multiple"
|
||||
oneLine
|
||||
/>,
|
||||
);
|
||||
|
||||
const withinSelector = within(getElementByClassName('.ant-select-selector'));
|
||||
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
|
||||
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
|
||||
|
||||
await userEvent.click(getSelect());
|
||||
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
withinSelector.queryByText(OPTIONS[0].label),
|
||||
).not.toBeInTheDocument();
|
||||
expect(withinSelector.getByText('+ 3 ...')).toBeVisible();
|
||||
});
|
||||
|
||||
// Close dropdown
|
||||
await type('{esc}');
|
||||
|
||||
expect(await withinSelector.findByText(OPTIONS[0].label)).toBeVisible();
|
||||
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
|
||||
});
|
||||
|
||||
test('does not render "Select all" when there are 0 or 1 options', async () => {
|
||||
const { rerender } = render(
|
||||
<Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
ClipboardEvent,
|
||||
Ref,
|
||||
ReactElement,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
} from './styles';
|
||||
import {
|
||||
DEFAULT_SORT_COMPARATOR,
|
||||
DROPDOWN_ALIGN_BOTTOM,
|
||||
EMPTY_OPTIONS,
|
||||
MAX_TAG_COUNT,
|
||||
TOKEN_SEPARATORS,
|
||||
@@ -147,6 +149,32 @@ const Select = forwardRef(
|
||||
}
|
||||
}, [isDropdownVisible, oneLine]);
|
||||
|
||||
// Prevent maxTagCount change during click events to avoid click target disappearing
|
||||
const [stableMaxTagCount, setStableMaxTagCount] = useState(maxTagCount);
|
||||
const isOpeningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (oneLine) {
|
||||
if (isDropdownVisible && !isOpeningRef.current) {
|
||||
// Mark that we're in the opening process
|
||||
isOpeningRef.current = true;
|
||||
// Use requestAnimationFrame to ensure DOM has settled after the click
|
||||
requestAnimationFrame(() => {
|
||||
setStableMaxTagCount(0);
|
||||
isOpeningRef.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isDropdownVisible) {
|
||||
// When closing, immediately show the first tag
|
||||
setStableMaxTagCount(1);
|
||||
isOpeningRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStableMaxTagCount(maxTagCount);
|
||||
}, [maxTagCount, isDropdownVisible, oneLine]);
|
||||
|
||||
const mappedMode = isSingleMode ? undefined : 'multiple';
|
||||
|
||||
const sortSelectedFirst = useCallback(
|
||||
@@ -498,7 +526,7 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{`${t('Deselect all')} (${bulkSelectCounts.deselectable})`}
|
||||
{`${t('Clear')} (${bulkSelectCounts.deselectable})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
@@ -607,16 +635,16 @@ const Select = forwardRef(
|
||||
|
||||
const omittedCount = useMemo(() => {
|
||||
const num_selected = ensureIsArray(selectValue).length;
|
||||
const num_shown = maxTagCount as number;
|
||||
const num_shown = stableMaxTagCount as number;
|
||||
return num_selected - num_shown - (selectAllMode ? 1 : 0);
|
||||
}, [maxTagCount, selectAllMode, selectValue]);
|
||||
}, [stableMaxTagCount, selectAllMode, selectValue]);
|
||||
|
||||
const customMaxTagPlaceholder = () =>
|
||||
`+ ${omittedCount > 0 ? omittedCount : 1} ...`;
|
||||
|
||||
// We can't remove the + tag so when Select All
|
||||
// is the only item omitted, we subtract one from maxTagCount
|
||||
let actualMaxTagCount = maxTagCount;
|
||||
let actualMaxTagCount = stableMaxTagCount;
|
||||
if (
|
||||
actualMaxTagCount !== 'responsive' &&
|
||||
omittedCount === 0 &&
|
||||
@@ -756,7 +784,9 @@ const Select = forwardRef(
|
||||
options={visibleOptions}
|
||||
optionRender={option => <Space>{option.label || option.value}</Space>}
|
||||
oneLine={oneLine}
|
||||
popupMatchSelectWidth={selectAllEnabled ? 168 : true}
|
||||
css={props.css}
|
||||
dropdownAlign={DROPDOWN_ALIGN_BOTTOM}
|
||||
{...props}
|
||||
showSearch={shouldShowSearch}
|
||||
ref={ref}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { LabeledValue as AntdLabeledValue } from 'antd/es/select';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { rankedSearchCompare } from '../../utils/rankedSearchCompare';
|
||||
import { RawValue } from './types';
|
||||
import { RawValue, SelectProps } from './types';
|
||||
|
||||
export const MAX_TAG_COUNT = 4;
|
||||
|
||||
@@ -33,6 +33,12 @@ export const SELECT_ALL_VALUE: RawValue = t('Select All');
|
||||
|
||||
export const VIRTUAL_THRESHOLD = 20;
|
||||
|
||||
export const DROPDOWN_ALIGN_BOTTOM: SelectProps['dropdownAlign'] = {
|
||||
points: ['tl', 'bl'],
|
||||
offset: [0, 4],
|
||||
overflow: { adjustX: 0, adjustY: 1 },
|
||||
};
|
||||
|
||||
export const SELECT_ALL_OPTION = {
|
||||
value: SELECT_ALL_VALUE,
|
||||
label: String(SELECT_ALL_VALUE),
|
||||
|
||||
@@ -28,6 +28,7 @@ export const StyledHeader = styled.span<{ headerPosition: string }>`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: ${headerPosition === 'left' ? theme.sizeUnit * 2 : 0}px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -141,5 +142,9 @@ export const StyledBulkActionsContainer = styled(Flex)`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.sizeUnit}px;
|
||||
border-top: 1px solid ${theme.colorSplit};
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
& .superset-button {
|
||||
font-family: inherit;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -71,6 +71,7 @@ export type AntdExposedProps = Pick<
|
||||
| 'virtual'
|
||||
| 'getPopupContainer'
|
||||
| 'menuItemSelectedIcon'
|
||||
| 'dropdownAlign'
|
||||
>;
|
||||
|
||||
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
|
||||
|
||||
@@ -167,6 +167,33 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
|
||||
.ant-table-body {
|
||||
overflow: auto;
|
||||
height: ${height ? `${height}px` : undefined};
|
||||
|
||||
/* Chrome/Safari/Edge webkit scrollbar styling */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${theme.colorFillSecondary};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorFillTertiary};
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
/* Firefox scrollbar styling */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${theme.colorFillSecondary} ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
|
||||
@@ -180,6 +207,10 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
td.ant-table-cell.no-ellipsis {
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from '@superset-ui/core/spec';
|
||||
import { render, screen, fireEvent } from '@superset-ui/core/spec';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { TableInstance, useTable } from 'react-table';
|
||||
import TableCollection from '.';
|
||||
@@ -36,19 +36,28 @@ beforeEach(() => {
|
||||
accessor: 'col2',
|
||||
id: 'col2',
|
||||
},
|
||||
{
|
||||
Header: 'Nested Field',
|
||||
accessor: 'parent.child',
|
||||
id: 'parent.child',
|
||||
dataIndex: ['parent', 'child'],
|
||||
},
|
||||
];
|
||||
const data = [
|
||||
{
|
||||
col1: 'Line 01 - Col 01',
|
||||
col2: 'Line 01 - Col 02',
|
||||
parent: { child: 'Nested Value 1' },
|
||||
},
|
||||
{
|
||||
col1: 'Line 02 - Col 01',
|
||||
col2: 'Line 02 - Col 02',
|
||||
parent: { child: 'Nested Value 2' },
|
||||
},
|
||||
{
|
||||
col1: 'Line 03 - Col 01',
|
||||
col2: 'Line 03 - Col 02',
|
||||
parent: { child: 'Nested Value 3' },
|
||||
},
|
||||
];
|
||||
// @ts-ignore
|
||||
@@ -100,3 +109,217 @@ test('Should the loading-indicator be visible during loading', () => {
|
||||
|
||||
expect(screen.getByTestId('loading-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Pagination controls should be rendered when pageSize is provided', () => {
|
||||
const paginationProps = {
|
||||
...defaultProps,
|
||||
pageSize: 2,
|
||||
totalCount: 3,
|
||||
pageIndex: 0,
|
||||
onPageChange: jest.fn(),
|
||||
};
|
||||
render(<TableCollection {...paginationProps} />);
|
||||
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Pagination should call onPageChange when page is changed', async () => {
|
||||
const onPageChange = jest.fn();
|
||||
const paginationProps = {
|
||||
...defaultProps,
|
||||
pageSize: 2,
|
||||
totalCount: 3,
|
||||
pageIndex: 0,
|
||||
onPageChange,
|
||||
};
|
||||
const { rerender } = render(<TableCollection {...paginationProps} />);
|
||||
|
||||
// Simulate pagination change
|
||||
await screen.findByTitle('Next Page');
|
||||
|
||||
// Verify onPageChange would be called with correct arguments
|
||||
// The actual AntD pagination will handle the click internally
|
||||
expect(onPageChange).toBeDefined();
|
||||
|
||||
// Verify that re-rendering with new pageIndex works
|
||||
rerender(<TableCollection {...paginationProps} pageIndex={1} />);
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Pagination callback should be stable across re-renders', () => {
|
||||
const onPageChange = jest.fn();
|
||||
const paginationProps = {
|
||||
...defaultProps,
|
||||
pageSize: 2,
|
||||
totalCount: 3,
|
||||
pageIndex: 0,
|
||||
onPageChange,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TableCollection {...paginationProps} />);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(<TableCollection {...paginationProps} />);
|
||||
|
||||
// onPageChange should not have been called during re-render
|
||||
expect(onPageChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Should display correct page info when showRowCount is true', () => {
|
||||
const paginationProps = {
|
||||
...defaultProps,
|
||||
pageSize: 2,
|
||||
totalCount: 3,
|
||||
pageIndex: 0,
|
||||
onPageChange: jest.fn(),
|
||||
showRowCount: true,
|
||||
};
|
||||
render(<TableCollection {...paginationProps} />);
|
||||
|
||||
// AntD pagination shows page info
|
||||
expect(screen.getByText('1-2 of 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should not display page info when showRowCount is false', () => {
|
||||
const paginationProps = {
|
||||
...defaultProps,
|
||||
pageSize: 2,
|
||||
totalCount: 3,
|
||||
pageIndex: 0,
|
||||
onPageChange: jest.fn(),
|
||||
showRowCount: false,
|
||||
};
|
||||
render(<TableCollection {...paginationProps} />);
|
||||
|
||||
// Page info should not be shown
|
||||
expect(screen.queryByText('1-2 of 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Bulk selection should work with pagination', () => {
|
||||
const toggleRowSelected = jest.fn();
|
||||
const toggleAllRowsSelected = jest.fn();
|
||||
const selectionProps = {
|
||||
...defaultProps,
|
||||
bulkSelectEnabled: true,
|
||||
selectedFlatRows: [],
|
||||
toggleRowSelected,
|
||||
toggleAllRowsSelected,
|
||||
pageSize: 2,
|
||||
totalCount: 3,
|
||||
pageIndex: 0,
|
||||
onPageChange: jest.fn(),
|
||||
};
|
||||
render(<TableCollection {...selectionProps} />);
|
||||
|
||||
// Check that selection checkboxes are rendered
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should call setSortBy when clicking sortable column header', () => {
|
||||
const setSortBy = jest.fn();
|
||||
const sortingProps = {
|
||||
...defaultProps,
|
||||
setSortBy,
|
||||
};
|
||||
|
||||
render(<TableCollection {...sortingProps} />);
|
||||
|
||||
// Target the nested field column (the column that needs the array-to-dot conversion)
|
||||
const nestedFieldHeader = screen.getByText('Nested Field');
|
||||
expect(nestedFieldHeader).toBeInTheDocument();
|
||||
|
||||
// Click on the nested field column header to trigger sorting
|
||||
fireEvent.click(nestedFieldHeader);
|
||||
|
||||
// Verify setSortBy was called with the correct arguments and dot notation conversion
|
||||
expect(setSortBy).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'parent.child',
|
||||
desc: expect.any(Boolean),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should not apply highlight class when highlightRowId is undefined', () => {
|
||||
const propsWithoutHighlight = {
|
||||
...defaultProps,
|
||||
highlightRowId: undefined,
|
||||
};
|
||||
|
||||
const { container } = render(<TableCollection {...propsWithoutHighlight} />);
|
||||
|
||||
// Check that no rows have the highlight class
|
||||
const highlightedRows = container.querySelectorAll('.table-row-highlighted');
|
||||
expect(highlightedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should not apply highlight class when highlightRowId is null', () => {
|
||||
const propsWithNullHighlight = {
|
||||
...defaultProps,
|
||||
highlightRowId: null,
|
||||
};
|
||||
|
||||
const { container } = render(<TableCollection {...propsWithNullHighlight} />);
|
||||
|
||||
// Check that no rows have the highlight class
|
||||
const highlightedRows = container.querySelectorAll('.table-row-highlighted');
|
||||
expect(highlightedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should apply highlight class only to matching row when highlightRowId is provided', () => {
|
||||
// Create data where the first row has id: 1 to match highlightRowId: 1
|
||||
const dataWithIds = [
|
||||
{
|
||||
col1: 'Line 01 - Col 01',
|
||||
col2: 'Line 01 - Col 02',
|
||||
id: 1, // This should be highlighted
|
||||
parent: { child: 'Nested Value 1' },
|
||||
},
|
||||
{
|
||||
col1: 'Line 02 - Col 01',
|
||||
col2: 'Line 02 - Col 02',
|
||||
id: 2,
|
||||
parent: { child: 'Nested Value 2' },
|
||||
},
|
||||
{
|
||||
col1: 'Line 03 - Col 01',
|
||||
col2: 'Line 03 - Col 02',
|
||||
id: 3,
|
||||
parent: { child: 'Nested Value 3' },
|
||||
},
|
||||
];
|
||||
|
||||
// Create new table hook with data that has ids
|
||||
const { result } = renderHook(() =>
|
||||
useTable({ columns: tableHook.columns, data: dataWithIds }),
|
||||
);
|
||||
const newTableHook = result.current;
|
||||
|
||||
const propsWithHighlight = {
|
||||
...defaultProps,
|
||||
highlightRowId: 1,
|
||||
rows: newTableHook.rows,
|
||||
prepareRow: newTableHook.prepareRow,
|
||||
};
|
||||
|
||||
const { container } = render(<TableCollection {...propsWithHighlight} />);
|
||||
|
||||
// Check that only one row has the highlight class
|
||||
const highlightedRows = container.querySelectorAll('.table-row-highlighted');
|
||||
expect(highlightedRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should not apply highlight when records have no id field and highlightRowId is undefined', () => {
|
||||
// This is the key test for the bug fix - use original data without id field
|
||||
const propsWithNoIds = {
|
||||
...defaultProps,
|
||||
highlightRowId: undefined,
|
||||
};
|
||||
|
||||
const { container } = render(<TableCollection {...propsWithNoIds} />);
|
||||
|
||||
// Check that no rows have the highlight class (was the bug: all rows were highlighted)
|
||||
const highlightedRows = container.querySelectorAll('.table-row-highlighted');
|
||||
expect(highlightedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { HTMLAttributes, memo, useMemo } from 'react';
|
||||
import { HTMLAttributes, memo, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
ColumnInstance,
|
||||
HeaderGroup,
|
||||
@@ -47,15 +47,25 @@ interface TableCollectionProps<T extends object> {
|
||||
toggleAllRowsSelected?: (value?: boolean) => void;
|
||||
sticky?: boolean;
|
||||
size?: TableSize;
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
totalCount?: number;
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
isPaginationSticky?: boolean;
|
||||
showRowCount?: boolean;
|
||||
}
|
||||
|
||||
const StyledTable = styled(Table)`
|
||||
${({ theme }) => `
|
||||
const StyledTable = styled(Table)<{
|
||||
isPaginationSticky?: boolean;
|
||||
showRowCount?: boolean;
|
||||
}>`
|
||||
${({ theme, isPaginationSticky, showRowCount }) => `
|
||||
th.ant-column-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
opacity: 0;
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
@@ -72,16 +82,25 @@ const StyledTable = styled(Table)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.ant-table-row:hover {
|
||||
.actions {
|
||||
opacity: 1;
|
||||
transition: opacity ease-in ${theme.motionDurationMid};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-row.table-row-highlighted > td.ant-table-cell,
|
||||
.ant-table-row.table-row-highlighted > td.ant-table-cell.ant-table-cell-row-hover {
|
||||
background-color: ${theme.colorPrimaryBg};
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
max-width: 320px;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
@@ -90,9 +109,50 @@ const StyledTable = styled(Table)`
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
height: ${theme.sizeUnit * 12}px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td.ant-table-cell:has(.ant-avatar-group) {
|
||||
padding-top: ${theme.sizeUnit}px;
|
||||
padding-bottom: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
.ant-table-placeholder .ant-table-cell {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&.ant-table-wrapper .ant-table-pagination.ant-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: ${showRowCount ? theme.sizeUnit * 4 : 0}px 0 ${showRowCount ? theme.sizeUnit * 14 : 0}px 0;
|
||||
position: relative;
|
||||
|
||||
.ant-pagination-total-text {
|
||||
color: ${theme.colorTextBase};
|
||||
margin-inline-end: 0;
|
||||
position: absolute;
|
||||
top: ${theme.sizeUnit * 12}px;
|
||||
}
|
||||
|
||||
${
|
||||
isPaginationSticky &&
|
||||
`
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: ${theme.colorBgElevated};
|
||||
padding: ${theme.sizeUnit * 2}px 0;
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// Hotfix - antd doesn't apply background color to overflowing cells
|
||||
& table {
|
||||
background-color: ${theme.colorBgContainer};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -100,6 +160,7 @@ function TableCollection<T extends object>({
|
||||
columns,
|
||||
rows,
|
||||
loading,
|
||||
highlightRowId,
|
||||
setSortBy,
|
||||
headerGroups,
|
||||
columnsForWrapText,
|
||||
@@ -110,13 +171,22 @@ function TableCollection<T extends object>({
|
||||
prepareRow,
|
||||
sticky,
|
||||
size = TableSize.Middle,
|
||||
pageIndex = 0,
|
||||
pageSize = 25,
|
||||
totalCount = 0,
|
||||
onPageChange,
|
||||
isPaginationSticky = false,
|
||||
showRowCount = true,
|
||||
}: TableCollectionProps<T>) {
|
||||
const mappedColumns = mapColumns<T>(
|
||||
columns,
|
||||
headerGroups,
|
||||
columnsForWrapText,
|
||||
const mappedColumns = useMemo(
|
||||
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
|
||||
[columns, headerGroups, columnsForWrapText],
|
||||
);
|
||||
|
||||
const mappedRows = useMemo(
|
||||
() => mapRows(rows, prepareRow),
|
||||
[rows, prepareRow],
|
||||
);
|
||||
const mappedRows = mapRows(rows, prepareRow);
|
||||
|
||||
const selectedRowKeys = useMemo(
|
||||
() => selectedFlatRows?.map(row => row.id) || [],
|
||||
@@ -141,6 +211,81 @@ function TableCollection<T extends object>({
|
||||
toggleRowSelected,
|
||||
toggleAllRowsSelected,
|
||||
]);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(page: number, size: number) => {
|
||||
const validPage = Math.max(0, (page || 1) - 1);
|
||||
const validSize = size || pageSize;
|
||||
onPageChange?.(validPage, validSize);
|
||||
},
|
||||
[pageSize, onPageChange],
|
||||
);
|
||||
|
||||
const showTotalFunc = useCallback(
|
||||
(total: number, range: [number, number]) =>
|
||||
`${range[0]}-${range[1]} of ${total}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(_pagination: any, _filters: any, sorter: SorterResult) => {
|
||||
if (sorter && sorter.field) {
|
||||
// Convert array field back to dot notation for nested fields
|
||||
const fieldId = Array.isArray(sorter.field)
|
||||
? sorter.field.join('.')
|
||||
: sorter.field;
|
||||
|
||||
setSortBy?.([
|
||||
{
|
||||
id: fieldId,
|
||||
desc: sorter.order === 'descend',
|
||||
},
|
||||
] as SortingRule<T>[]);
|
||||
}
|
||||
},
|
||||
[setSortBy],
|
||||
);
|
||||
|
||||
const paginationConfig = useMemo(() => {
|
||||
if (totalCount === 0) return false;
|
||||
|
||||
const config: any = {
|
||||
pageSize,
|
||||
size: 'default' as const,
|
||||
showSizeChanger: false,
|
||||
showQuickJumper: false,
|
||||
align: 'center' as const,
|
||||
showTotal: showRowCount ? showTotalFunc : undefined,
|
||||
};
|
||||
|
||||
if (onPageChange) {
|
||||
config.current = pageIndex + 1;
|
||||
config.total = totalCount;
|
||||
config.onChange = handlePaginationChange;
|
||||
} else {
|
||||
if (pageIndex > 0) config.defaultCurrent = pageIndex + 1;
|
||||
config.total = totalCount;
|
||||
}
|
||||
|
||||
return config;
|
||||
}, [
|
||||
pageSize,
|
||||
totalCount,
|
||||
showRowCount,
|
||||
showTotalFunc,
|
||||
pageIndex,
|
||||
handlePaginationChange,
|
||||
onPageChange,
|
||||
]);
|
||||
|
||||
const getRowClassName = useCallback(
|
||||
(record: Record<string, unknown>) =>
|
||||
highlightRowId !== undefined && record?.id === highlightRowId
|
||||
? 'table-row-highlighted'
|
||||
: '',
|
||||
[highlightRowId],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTable
|
||||
loading={loading}
|
||||
@@ -149,12 +294,16 @@ function TableCollection<T extends object>({
|
||||
data={mappedRows}
|
||||
size={size}
|
||||
data-test="listview-table"
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
pagination={paginationConfig}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="auto"
|
||||
rowKey="rowId"
|
||||
rowSelection={rowSelection}
|
||||
locale={{ emptyText: null }}
|
||||
sortDirections={['ascend', 'descend', 'ascend']}
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={showRowCount}
|
||||
rowClassName={getRowClassName}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
@@ -170,14 +319,7 @@ function TableCollection<T extends object>({
|
||||
),
|
||||
},
|
||||
}}
|
||||
onChange={(_pagination, _filters, sorter: SorterResult) => {
|
||||
setSortBy?.([
|
||||
{
|
||||
id: sorter.field,
|
||||
desc: sorter.order === 'descend',
|
||||
},
|
||||
] as SortingRule<T>[]);
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type EnhancedColumnInstance<T extends object = any> = RTColumnInstance<T> &
|
||||
Partial<UseResizeColumnsColumnProps<T>> & {
|
||||
hidden?: boolean;
|
||||
size?: keyof typeof COLUMN_SIZE_MAP;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type EnhancedHeaderGroup<T extends object = any> = RTHeaderGroup<T> & {
|
||||
@@ -94,7 +95,7 @@ export function mapColumns<T extends object>(
|
||||
dataIndex: column.id?.includes('.') ? column.id.split('.') : column.id,
|
||||
hidden: column.hidden,
|
||||
key: column.id,
|
||||
width: column.size ? COLUMN_SIZE_MAP[column.size] : COLUMN_SIZE_MAP.md,
|
||||
width: column.size ? COLUMN_SIZE_MAP[column.size] : undefined,
|
||||
ellipsis: !columnsForWrapText?.includes(column.id),
|
||||
defaultSortOrder: (isSorted
|
||||
? isSortedDesc
|
||||
@@ -122,6 +123,7 @@ export function mapColumns<T extends object>(
|
||||
}
|
||||
return val;
|
||||
},
|
||||
className: column.className,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
|
||||
import { TableView, TableViewProps } from '.';
|
||||
|
||||
const mockedProps: TableViewProps = {
|
||||
@@ -30,6 +30,7 @@ const mockedProps: TableViewProps = {
|
||||
{
|
||||
accessor: 'age',
|
||||
Header: 'Age',
|
||||
sortable: true,
|
||||
id: 'age',
|
||||
},
|
||||
{
|
||||
@@ -78,10 +79,10 @@ test('should render the cells', () => {
|
||||
|
||||
test('should render the pagination', () => {
|
||||
render(<TableView {...mockedProps} />);
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4);
|
||||
expect(screen.getByText('«')).toBeInTheDocument();
|
||||
expect(screen.getByText('»')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||
expect(screen.getByTitle('Previous Page')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Next Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show the row count by default', () => {
|
||||
@@ -104,45 +105,63 @@ test('should NOT render the pagination when disabled', () => {
|
||||
withPagination: false,
|
||||
};
|
||||
render(<TableView {...withoutPaginationProps} />);
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should NOT render the pagination when fewer rows than page size', () => {
|
||||
test('should render the pagination even when fewer rows than page size', () => {
|
||||
const withoutPaginationProps = {
|
||||
...mockedProps,
|
||||
pageSize: 3,
|
||||
};
|
||||
render(<TableView {...withoutPaginationProps} />);
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should change page when « and » buttons are clicked', async () => {
|
||||
test('should change page when pagination is clicked', async () => {
|
||||
render(<TableView {...mockedProps} />);
|
||||
const nextBtn = screen.getByText('»');
|
||||
const prevBtn = screen.getByText('«');
|
||||
|
||||
await userEvent.click(nextBtn);
|
||||
expect(screen.getAllByRole('cell')).toHaveLength(3);
|
||||
expect(screen.getByText('321')).toBeInTheDocument();
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kate')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(prevBtn);
|
||||
expect(screen.getAllByRole('cell')).toHaveLength(3);
|
||||
expect(screen.getByText('123')).toBeInTheDocument();
|
||||
expect(screen.getByText('27')).toBeInTheDocument();
|
||||
expect(screen.getByText('Emily')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
|
||||
|
||||
const page2 = screen.getByRole('listitem', { name: '2' });
|
||||
await userEvent.click(page2);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('cell')).toHaveLength(3);
|
||||
expect(screen.getByText('321')).toBeInTheDocument();
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kate')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const page1 = screen.getByRole('listitem', { name: '1' });
|
||||
await userEvent.click(page1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('cell')).toHaveLength(3);
|
||||
expect(screen.getByText('123')).toBeInTheDocument();
|
||||
expect(screen.getByText('27')).toBeInTheDocument();
|
||||
expect(screen.getByText('Emily')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should sort by age', async () => {
|
||||
render(<TableView {...mockedProps} />);
|
||||
|
||||
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
|
||||
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
|
||||
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
|
||||
});
|
||||
});
|
||||
|
||||
test('should sort by initialSortBy DESC', () => {
|
||||
@@ -208,3 +227,146 @@ test('should render the right wrap content text by columnsForWrapText', () => {
|
||||
'ant-table-cell-ellipsis',
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle server-side pagination', async () => {
|
||||
const onServerPagination = jest.fn();
|
||||
const serverPaginationProps = {
|
||||
...mockedProps,
|
||||
serverPagination: true,
|
||||
onServerPagination,
|
||||
totalCount: 10,
|
||||
pageSize: 2,
|
||||
};
|
||||
render(<TableView {...serverPaginationProps} />);
|
||||
|
||||
// Click next page
|
||||
const page2 = screen.getByRole('listitem', { name: '2' });
|
||||
await userEvent.click(page2);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onServerPagination).toHaveBeenCalledWith({
|
||||
pageIndex: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle server-side sorting', async () => {
|
||||
const onServerPagination = jest.fn();
|
||||
const serverPaginationProps = {
|
||||
...mockedProps,
|
||||
serverPagination: true,
|
||||
onServerPagination,
|
||||
};
|
||||
render(<TableView {...serverPaginationProps} />);
|
||||
|
||||
// Click on sortable column
|
||||
await userEvent.click(screen.getAllByTestId('sort-header')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onServerPagination).toHaveBeenCalledWith({
|
||||
pageIndex: 0,
|
||||
sortBy: [{ id: 'id', desc: false }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('pagination callbacks should be stable across re-renders', () => {
|
||||
const onServerPagination = jest.fn();
|
||||
const serverPaginationProps = {
|
||||
...mockedProps,
|
||||
serverPagination: true,
|
||||
onServerPagination,
|
||||
totalCount: 10,
|
||||
pageSize: 2,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TableView {...serverPaginationProps} />);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(<TableView {...serverPaginationProps} />);
|
||||
|
||||
// onServerPagination should not have been called during re-render
|
||||
expect(onServerPagination).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should scroll to top when scrollTopOnPagination is true', async () => {
|
||||
const scrollToSpy = jest
|
||||
.spyOn(window, 'scrollTo')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const scrollProps = {
|
||||
...mockedProps,
|
||||
scrollTopOnPagination: true,
|
||||
pageSize: 1,
|
||||
};
|
||||
render(<TableView {...scrollProps} />);
|
||||
|
||||
// Click next page
|
||||
const page2 = screen.getByRole('listitem', { name: '2' });
|
||||
await userEvent.click(page2);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
scrollToSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should NOT scroll to top when scrollTopOnPagination is false', async () => {
|
||||
const scrollToSpy = jest
|
||||
.spyOn(window, 'scrollTo')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const scrollProps = {
|
||||
...mockedProps,
|
||||
scrollTopOnPagination: false,
|
||||
pageSize: 1,
|
||||
};
|
||||
render(<TableView {...scrollProps} />);
|
||||
|
||||
// Click next page
|
||||
const page2 = screen.getByRole('listitem', { name: '2' });
|
||||
await userEvent.click(page2);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('321')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
|
||||
scrollToSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should handle totalCount of 0 correctly', () => {
|
||||
const emptyProps = {
|
||||
...mockedProps,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
render(<TableView {...emptyProps} />);
|
||||
|
||||
// Pagination should not be shown when totalCount is 0
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle large datasets with pagination', () => {
|
||||
const largeDataset = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i,
|
||||
age: 20 + i,
|
||||
name: `Person ${i}`,
|
||||
}));
|
||||
|
||||
const largeDataProps = {
|
||||
...mockedProps,
|
||||
data: largeDataset,
|
||||
pageSize: 10,
|
||||
};
|
||||
render(<TableView {...largeDataProps} />);
|
||||
|
||||
// Should show only first page (10 items)
|
||||
expect(screen.getAllByTestId('table-row')).toHaveLength(10);
|
||||
|
||||
// Should show pagination with correct page count
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,16 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { isEqual } from 'lodash';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
|
||||
import { Empty } from '@superset-ui/core/components';
|
||||
import Pagination from '@superset-ui/core/components/Pagination';
|
||||
import TableCollection from '@superset-ui/core/components/TableCollection';
|
||||
import { TableSize } from '@superset-ui/core/components/Table';
|
||||
import { SortByType, ServerPagination } from './types';
|
||||
|
||||
const NOOP_SERVER_PAGINATION = () => {};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export enum EmptyWrapperType {
|
||||
@@ -96,29 +97,6 @@ const TableViewStyles = styled.div<{
|
||||
}
|
||||
`;
|
||||
|
||||
const PaginationStyles = styled.div<{
|
||||
isPaginationSticky?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.colorBgElevated};
|
||||
|
||||
${({ isPaginationSticky }) =>
|
||||
isPaginationSticky &&
|
||||
`
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
`};
|
||||
|
||||
.row-count-container {
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
}
|
||||
`;
|
||||
|
||||
const RawTableView = ({
|
||||
columns,
|
||||
data,
|
||||
@@ -133,16 +111,21 @@ const RawTableView = ({
|
||||
showRowCount = true,
|
||||
serverPagination = false,
|
||||
columnsForWrapText,
|
||||
onServerPagination = () => {},
|
||||
scrollTopOnPagination = false,
|
||||
onServerPagination = NOOP_SERVER_PAGINATION,
|
||||
scrollTopOnPagination = true,
|
||||
size = TableSize.Middle,
|
||||
...props
|
||||
}: TableViewProps) => {
|
||||
const initialState = {
|
||||
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
|
||||
pageIndex: initialPageIndex ?? 0,
|
||||
sortBy: initialSortBy,
|
||||
};
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
|
||||
const initialState = useMemo(
|
||||
() => ({
|
||||
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
|
||||
pageIndex: initialPageIndex ?? 0,
|
||||
sortBy: initialSortBy,
|
||||
}),
|
||||
[initialPageSize, initialPageIndex, initialSortBy],
|
||||
);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -151,10 +134,9 @@ const RawTableView = ({
|
||||
page,
|
||||
rows,
|
||||
prepareRow,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
setSortBy,
|
||||
state: { pageIndex, pageSize, sortBy },
|
||||
state: { pageIndex, sortBy },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
@@ -162,36 +144,94 @@ const RawTableView = ({
|
||||
initialState,
|
||||
manualPagination: serverPagination,
|
||||
manualSortBy: serverPagination,
|
||||
pageCount: Math.ceil(totalCount / initialState.pageSize),
|
||||
pageCount: serverPagination
|
||||
? Math.ceil(totalCount / initialState.pageSize)
|
||||
: undefined,
|
||||
autoResetSortBy: false,
|
||||
},
|
||||
useFilters,
|
||||
useSortBy,
|
||||
usePagination,
|
||||
...(withPagination ? [usePagination] : []),
|
||||
);
|
||||
|
||||
const content = withPagination ? page : rows;
|
||||
|
||||
let EmptyWrapperComponent;
|
||||
switch (emptyWrapperType) {
|
||||
case EmptyWrapperType.Small:
|
||||
EmptyWrapperComponent = ({ children }: any) => <>{children}</>;
|
||||
break;
|
||||
case EmptyWrapperType.Default:
|
||||
default:
|
||||
EmptyWrapperComponent = ({ children }: any) => (
|
||||
<EmptyWrapper>{children}</EmptyWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !loading && content.length === 0;
|
||||
const hasPagination = pageCount > 1 && withPagination;
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
const handleGotoPage = (p: number) => {
|
||||
if (scrollTopOnPagination) {
|
||||
tableRef?.current?.scroll(0, 0);
|
||||
const EmptyWrapperComponent = useMemo(() => {
|
||||
switch (emptyWrapperType) {
|
||||
case EmptyWrapperType.Small:
|
||||
return ({ children }: any) => <>{children}</>;
|
||||
case EmptyWrapperType.Default:
|
||||
default:
|
||||
return ({ children }: any) => <EmptyWrapper>{children}</EmptyWrapper>;
|
||||
}
|
||||
gotoPage(p);
|
||||
};
|
||||
}, [emptyWrapperType]);
|
||||
|
||||
const content = useMemo(
|
||||
() => (withPagination ? page : rows),
|
||||
[withPagination, page, rows],
|
||||
);
|
||||
|
||||
const isEmpty = useMemo(
|
||||
() => !loading && content.length === 0,
|
||||
[loading, content.length],
|
||||
);
|
||||
|
||||
const handleScrollToTop = useCallback(() => {
|
||||
if (scrollTopOnPagination) {
|
||||
if (tableRef?.current) {
|
||||
if (typeof tableRef.current.scrollTo === 'function') {
|
||||
tableRef.current.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else if (typeof tableRef.current.scroll === 'function') {
|
||||
tableRef.current.scroll(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.scrollTo)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, [scrollTopOnPagination]);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(p: number) => {
|
||||
if (scrollTopOnPagination) handleScrollToTop();
|
||||
|
||||
gotoPage(p);
|
||||
},
|
||||
[scrollTopOnPagination, handleScrollToTop, gotoPage],
|
||||
);
|
||||
|
||||
const paginationProps = useMemo(() => {
|
||||
if (!withPagination) {
|
||||
return {
|
||||
pageIndex: 0,
|
||||
pageSize: data.length,
|
||||
totalCount: 0,
|
||||
onPageChange: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (serverPagination) {
|
||||
return {
|
||||
pageIndex,
|
||||
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
|
||||
totalCount,
|
||||
onPageChange: handlePageChange,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pageIndex,
|
||||
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
|
||||
totalCount: data.length,
|
||||
onPageChange: handlePageChange,
|
||||
};
|
||||
}, [
|
||||
withPagination,
|
||||
serverPagination,
|
||||
pageIndex,
|
||||
initialPageSize,
|
||||
totalCount,
|
||||
data.length,
|
||||
handlePageChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverPagination && pageIndex !== initialState.pageIndex) {
|
||||
@@ -199,7 +239,7 @@ const RawTableView = ({
|
||||
pageIndex,
|
||||
});
|
||||
}
|
||||
}, [pageIndex]);
|
||||
}, [initialState.pageIndex, onServerPagination, pageIndex, serverPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverPagination && !isEqual(sortBy, initialState.sortBy)) {
|
||||
@@ -208,61 +248,38 @@ const RawTableView = ({
|
||||
sortBy,
|
||||
});
|
||||
}
|
||||
}, [sortBy]);
|
||||
}, [initialState.sortBy, onServerPagination, serverPagination, sortBy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableViewStyles {...props} ref={tableRef}>
|
||||
<TableCollection
|
||||
getTableProps={getTableProps}
|
||||
getTableBodyProps={getTableBodyProps}
|
||||
prepareRow={prepareRow}
|
||||
headerGroups={headerGroups}
|
||||
rows={content}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
setSortBy={setSortBy}
|
||||
size={size}
|
||||
columnsForWrapText={columnsForWrapText}
|
||||
/>
|
||||
{isEmpty && (
|
||||
<EmptyWrapperComponent>
|
||||
{noDataText ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={noDataText}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</EmptyWrapperComponent>
|
||||
)}
|
||||
</TableViewStyles>
|
||||
{hasPagination && (
|
||||
<PaginationStyles
|
||||
className="pagination-container"
|
||||
isPaginationSticky={props.isPaginationSticky}
|
||||
>
|
||||
<Pagination
|
||||
totalPages={pageCount || 0}
|
||||
currentPage={pageCount ? pageIndex + 1 : 0}
|
||||
onChange={(p: number) => handleGotoPage(p - 1)}
|
||||
hideFirstAndLastPageLinks
|
||||
/>
|
||||
{showRowCount && (
|
||||
<div className="row-count-container">
|
||||
{!loading &&
|
||||
t(
|
||||
'%s-%s of %s',
|
||||
pageSize * pageIndex + (page.length && 1),
|
||||
pageSize * pageIndex + page.length,
|
||||
totalCount,
|
||||
)}
|
||||
</div>
|
||||
<TableViewStyles {...props} ref={tableRef}>
|
||||
<TableCollection
|
||||
getTableProps={getTableProps}
|
||||
getTableBodyProps={getTableBodyProps}
|
||||
prepareRow={prepareRow}
|
||||
headerGroups={headerGroups}
|
||||
rows={content}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
setSortBy={setSortBy}
|
||||
size={size}
|
||||
columnsForWrapText={columnsForWrapText}
|
||||
isPaginationSticky={props.isPaginationSticky}
|
||||
showRowCount={showRowCount}
|
||||
{...paginationProps}
|
||||
/>
|
||||
{isEmpty && (
|
||||
<EmptyWrapperComponent>
|
||||
{noDataText ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={noDataText}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</PaginationStyles>
|
||||
</EmptyWrapperComponent>
|
||||
)}
|
||||
</>
|
||||
</TableViewStyles>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { fireEvent, render } from '@superset-ui/core/spec';
|
||||
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
|
||||
|
||||
const defaultItems = [
|
||||
{
|
||||
key: '1',
|
||||
label: 'Tab 1',
|
||||
children: <div data-testid="tab1-content">Tab 1 content</div>,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'Tab 2',
|
||||
children: <div data-testid="tab2-content">Tab 2 content</div>,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: 'Tab 3',
|
||||
children: <div data-testid="tab3-content">Tab 3 content</div>,
|
||||
},
|
||||
];
|
||||
|
||||
describe('Tabs', () => {
|
||||
describe('Basic Tabs', () => {
|
||||
it('should render tabs with default props', () => {
|
||||
const { getByText, container } = render(<Tabs items={defaultItems} />);
|
||||
|
||||
expect(getByText('Tab 1')).toBeInTheDocument();
|
||||
expect(getByText('Tab 2')).toBeInTheDocument();
|
||||
expect(getByText('Tab 3')).toBeInTheDocument();
|
||||
|
||||
const activeTabContent = container.querySelector(
|
||||
'.ant-tabs-tabpane-active',
|
||||
);
|
||||
|
||||
expect(activeTabContent).toBeDefined();
|
||||
expect(
|
||||
activeTabContent?.querySelector('[data-testid="tab1-content"]'),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render tabs component structure', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} />);
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
const tabsNav = container.querySelector('.ant-tabs-nav');
|
||||
const tabsContent = container.querySelector('.ant-tabs-content-holder');
|
||||
|
||||
expect(tabsElement).toBeDefined();
|
||||
expect(tabsNav).toBeDefined();
|
||||
expect(tabsContent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply default tabBarStyle with padding', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} />);
|
||||
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
|
||||
|
||||
// Check that tabBarStyle is applied (default padding is added)
|
||||
expect(tabsNav?.style?.paddingLeft).toBeDefined();
|
||||
});
|
||||
|
||||
it('should merge custom tabBarStyle with defaults', () => {
|
||||
const customStyle = { paddingRight: '20px', backgroundColor: 'red' };
|
||||
const { container } = render(
|
||||
<Tabs items={defaultItems} tabBarStyle={customStyle} />,
|
||||
);
|
||||
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
|
||||
|
||||
expect(tabsNav?.style?.paddingLeft).toBeDefined();
|
||||
expect(tabsNav?.style?.paddingRight).toBe('20px');
|
||||
expect(tabsNav?.style?.backgroundColor).toBe('red');
|
||||
});
|
||||
|
||||
it('should handle allowOverflow prop', () => {
|
||||
const { container: allowContainer } = render(
|
||||
<Tabs items={defaultItems} allowOverflow />,
|
||||
);
|
||||
const { container: disallowContainer } = render(
|
||||
<Tabs items={defaultItems} allowOverflow={false} />,
|
||||
);
|
||||
|
||||
expect(allowContainer.querySelector('.ant-tabs')).toBeDefined();
|
||||
expect(disallowContainer.querySelector('.ant-tabs')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should disable animation by default', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} />);
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
expect(tabsElement?.className).not.toContain('ant-tabs-animated');
|
||||
});
|
||||
|
||||
it('should handle tab change events', () => {
|
||||
const onChangeMock = jest.fn();
|
||||
const { getByText } = render(
|
||||
<Tabs items={defaultItems} onChange={onChangeMock} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText('Tab 2'));
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith('2');
|
||||
});
|
||||
|
||||
it('should pass through additional props to Antd Tabs', () => {
|
||||
const onTabClickMock = jest.fn();
|
||||
const { getByText } = render(
|
||||
<Tabs
|
||||
items={defaultItems}
|
||||
onTabClick={onTabClickMock}
|
||||
size="large"
|
||||
centered
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText('Tab 2'));
|
||||
|
||||
expect(onTabClickMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditableTabs', () => {
|
||||
it('should render with editable features', () => {
|
||||
const { container } = render(<EditableTabs items={defaultItems} />);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
expect(tabsElement?.className).toContain('ant-tabs-card');
|
||||
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
|
||||
});
|
||||
|
||||
it('should handle onEdit callback for add/remove actions', () => {
|
||||
const onEditMock = jest.fn();
|
||||
const itemsWithRemove = defaultItems.map(item => ({
|
||||
...item,
|
||||
closable: true,
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<EditableTabs items={itemsWithRemove} onEdit={onEditMock} />,
|
||||
);
|
||||
|
||||
const removeButton = container.querySelector('.ant-tabs-tab-remove');
|
||||
expect(removeButton).toBeDefined();
|
||||
|
||||
fireEvent.click(removeButton!);
|
||||
expect(onEditMock).toHaveBeenCalledWith(expect.any(String), 'remove');
|
||||
});
|
||||
|
||||
it('should have default props set correctly', () => {
|
||||
expect(EditableTabs.defaultProps?.type).toBe('editable-card');
|
||||
expect(EditableTabs.defaultProps?.animated).toEqual({
|
||||
inkBar: true,
|
||||
tabPane: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LineEditableTabs', () => {
|
||||
it('should render as line-style editable tabs', () => {
|
||||
const { container } = render(<LineEditableTabs items={defaultItems} />);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
expect(tabsElement?.className).toContain('ant-tabs-card');
|
||||
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
|
||||
});
|
||||
|
||||
it('should render with line-specific styling', () => {
|
||||
const { container } = render(<LineEditableTabs items={defaultItems} />);
|
||||
|
||||
const inkBar = container.querySelector('.ant-tabs-ink-bar');
|
||||
expect(inkBar).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabPane Legacy Support', () => {
|
||||
it('should support TabPane component access', () => {
|
||||
expect(Tabs.TabPane).toBeDefined();
|
||||
expect(EditableTabs.TabPane).toBeDefined();
|
||||
expect(LineEditableTabs.TabPane).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render using legacy TabPane syntax', () => {
|
||||
const { getByText, container } = render(
|
||||
<Tabs>
|
||||
<Tabs.TabPane tab="Legacy Tab 1" key="1">
|
||||
<div data-testid="legacy-content-1">Legacy content 1</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="Legacy Tab 2" key="2">
|
||||
<div data-testid="legacy-content-2">Legacy content 2</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
expect(getByText('Legacy Tab 1')).toBeInTheDocument();
|
||||
expect(getByText('Legacy Tab 2')).toBeInTheDocument();
|
||||
|
||||
const activeTabContent = container.querySelector(
|
||||
'.ant-tabs-tabpane-active [data-testid="legacy-content-1"]',
|
||||
);
|
||||
|
||||
expect(activeTabContent).toBeDefined();
|
||||
expect(activeTabContent?.textContent).toBe('Legacy content 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty items array', () => {
|
||||
const { container } = render(<Tabs items={[]} />);
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
expect(tabsElement).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle undefined items', () => {
|
||||
const { container } = render(<Tabs />);
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
expect(tabsElement).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle tabs with no content', () => {
|
||||
const itemsWithoutContent = [
|
||||
{ key: '1', label: 'Tab 1' },
|
||||
{ key: '2', label: 'Tab 2' },
|
||||
];
|
||||
|
||||
const { getByText } = render(<Tabs items={itemsWithoutContent} />);
|
||||
|
||||
expect(getByText('Tab 1')).toBeInTheDocument();
|
||||
expect(getByText('Tab 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle allowOverflow default value', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} />);
|
||||
expect(container.querySelector('.ant-tabs')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should render with proper ARIA roles', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} />);
|
||||
|
||||
const tablist = container.querySelector('[role="tablist"]');
|
||||
const tabs = container.querySelectorAll('[role="tab"]');
|
||||
|
||||
expect(tablist).toBeDefined();
|
||||
expect(tabs.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should support keyboard navigation', () => {
|
||||
const { container, getByText } = render(<Tabs items={defaultItems} />);
|
||||
|
||||
const firstTab = container.querySelector('[role="tab"]');
|
||||
const secondTab = getByText('Tab 2');
|
||||
|
||||
if (firstTab) {
|
||||
fireEvent.keyDown(firstTab, { key: 'ArrowRight', code: 'ArrowRight' });
|
||||
}
|
||||
|
||||
fireEvent.click(secondTab);
|
||||
|
||||
expect(secondTab).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling Integration', () => {
|
||||
it('should accept and apply custom CSS classes', () => {
|
||||
const { container } = render(
|
||||
// eslint-disable-next-line react/forbid-component-props
|
||||
<Tabs items={defaultItems} className="custom-tabs-class" />,
|
||||
);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
expect(tabsElement?.className).toContain('custom-tabs-class');
|
||||
});
|
||||
|
||||
it('should accept and apply custom styles', () => {
|
||||
const customStyle = { minHeight: '200px' };
|
||||
const { container } = render(
|
||||
// eslint-disable-next-line react/forbid-component-props
|
||||
<Tabs items={defaultItems} style={customStyle} />,
|
||||
);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
|
||||
|
||||
expect(tabsElement?.style?.minHeight).toBe('200px');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('fullHeight prop renders component hierarchy correctly', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} fullHeight />);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
const contentHolder = container.querySelector('.ant-tabs-content-holder');
|
||||
const content = container.querySelector('.ant-tabs-content');
|
||||
const tabPane = container.querySelector('.ant-tabs-tabpane');
|
||||
|
||||
expect(tabsElement).toBeInTheDocument();
|
||||
expect(contentHolder).toBeInTheDocument();
|
||||
expect(content).toBeInTheDocument();
|
||||
expect(tabPane).toBeInTheDocument();
|
||||
expect(tabsElement?.contains(contentHolder as Node)).toBe(true);
|
||||
expect(contentHolder?.contains(content as Node)).toBe(true);
|
||||
expect(content?.contains(tabPane as Node)).toBe(true);
|
||||
});
|
||||
|
||||
test('fullHeight prop maintains structure when content updates', () => {
|
||||
const { container, rerender } = render(
|
||||
<Tabs items={defaultItems} fullHeight />,
|
||||
);
|
||||
|
||||
const initialTabsElement = container.querySelector('.ant-tabs');
|
||||
|
||||
const newItems = [
|
||||
...defaultItems,
|
||||
{
|
||||
key: '4',
|
||||
label: 'Tab 4',
|
||||
children: <div data-testid="tab4-content">New tab content</div>,
|
||||
},
|
||||
];
|
||||
|
||||
rerender(<Tabs items={newItems} fullHeight />);
|
||||
|
||||
const updatedTabsElement = container.querySelector('.ant-tabs');
|
||||
const updatedContentHolder = container.querySelector(
|
||||
'.ant-tabs-content-holder',
|
||||
);
|
||||
|
||||
expect(updatedTabsElement).toBeInTheDocument();
|
||||
expect(updatedContentHolder).toBeInTheDocument();
|
||||
expect(initialTabsElement).toBe(updatedTabsElement);
|
||||
});
|
||||
|
||||
test('fullHeight prop works with allowOverflow to handle tall content', () => {
|
||||
const { container } = render(
|
||||
<Tabs items={defaultItems} fullHeight allowOverflow />,
|
||||
);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
|
||||
const contentHolder = container.querySelector(
|
||||
'.ant-tabs-content-holder',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(tabsElement).toBeInTheDocument();
|
||||
expect(contentHolder).toBeInTheDocument();
|
||||
|
||||
// Verify overflow handling is not restricted
|
||||
const holderStyles = window.getComputedStyle(contentHolder);
|
||||
expect(holderStyles.overflow).not.toBe('hidden');
|
||||
});
|
||||
|
||||
test('fullHeight prop handles empty items array', () => {
|
||||
const { container } = render(<Tabs items={[]} fullHeight />);
|
||||
|
||||
expect(container.querySelector('.ant-tabs')).toBeInTheDocument();
|
||||
});
|
||||
@@ -21,27 +21,45 @@ import { css, styled, useTheme } from '@superset-ui/core';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Tabs as AntdTabs, TabsProps as AntdTabsProps } from 'antd';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import type { SerializedStyles } from '@emotion/react';
|
||||
|
||||
export interface TabsProps extends AntdTabsProps {
|
||||
allowOverflow?: boolean;
|
||||
fullHeight?: boolean;
|
||||
contentStyle?: SerializedStyles;
|
||||
}
|
||||
|
||||
const StyledTabs = ({
|
||||
animated = false,
|
||||
allowOverflow = true,
|
||||
fullHeight = false,
|
||||
tabBarStyle,
|
||||
contentStyle,
|
||||
...props
|
||||
}: TabsProps) => {
|
||||
const theme = useTheme();
|
||||
const defaultTabBarStyle = { paddingLeft: theme.sizeUnit * 4 };
|
||||
const mergedStyle = { ...defaultTabBarStyle, ...tabBarStyle };
|
||||
|
||||
return (
|
||||
<AntdTabs
|
||||
animated={animated}
|
||||
{...props}
|
||||
tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }}
|
||||
tabBarStyle={mergedStyle}
|
||||
css={theme => css`
|
||||
overflow: ${allowOverflow ? 'visible' : 'hidden'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
overflow: ${allowOverflow ? 'visible' : 'auto'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
}
|
||||
.ant-tabs-content {
|
||||
${fullHeight && 'height: 100%;'}
|
||||
}
|
||||
.ant-tabs-tabpane {
|
||||
${fullHeight && 'height: 100%;'}
|
||||
${contentStyle}
|
||||
}
|
||||
.ant-tabs-tab {
|
||||
flex: 1 1 auto;
|
||||
@@ -81,9 +99,10 @@ const Tabs = Object.assign(StyledTabs, {
|
||||
});
|
||||
|
||||
const StyledEditableTabs = styled(StyledTabs)`
|
||||
${({ theme }) => `
|
||||
${({ theme, contentStyle }) => `
|
||||
.ant-tabs-content-holder {
|
||||
background: ${theme.colorBgContainer};
|
||||
${contentStyle}
|
||||
}
|
||||
|
||||
& > .ant-tabs-nav {
|
||||
|
||||
@@ -218,3 +218,29 @@ test('handles missing theme gracefully', () => {
|
||||
// Should still render without crashing
|
||||
expect(screen.getByTestId('ag-grid-react')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('merges theme overrides with default theme parameters', () => {
|
||||
const themeOverrides = {
|
||||
fontSize: 16,
|
||||
headerBackgroundColor: '#custom-color',
|
||||
};
|
||||
|
||||
render(
|
||||
<ThemedAgGridReact
|
||||
rowData={mockRowData}
|
||||
columnDefs={mockColumnDefs}
|
||||
themeOverrides={themeOverrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
const agGrid = screen.getByTestId('ag-grid-react');
|
||||
const theme = JSON.parse(agGrid.getAttribute('data-theme') || '{}');
|
||||
|
||||
// Custom overrides should be applied
|
||||
expect(theme.fontSize).toBe(16);
|
||||
expect(theme.headerBackgroundColor).toBe('#custom-color');
|
||||
|
||||
// Default theme parameters should still be present
|
||||
expect(theme.foregroundColor).toBeDefined();
|
||||
expect(theme.borderColor).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -106,6 +106,9 @@ export const ThemedAgGridReact = forwardRef<
|
||||
borderColor: theme.colorSplit,
|
||||
columnBorderColor: theme.colorSplit,
|
||||
|
||||
// Checkbox tick color
|
||||
checkboxCheckedShapeColor: theme.colorBgElevated,
|
||||
|
||||
// Interactive elements
|
||||
accentColor: theme.colorPrimary,
|
||||
rangeSelectionBorderColor: theme.colorPrimary,
|
||||
@@ -186,5 +189,5 @@ export {
|
||||
// Re-export AgGridReact for ref types
|
||||
export { AgGridReact } from 'ag-grid-react';
|
||||
|
||||
// Export the setup function for AG-Grid modules
|
||||
export { setupAGGridModules } from './setupAGGridModules';
|
||||
// Export the setup function and default modules for AG-Grid
|
||||
export { setupAGGridModules, defaultModules } from './setupAGGridModules';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ModuleRegistry, type Module } from 'ag-grid-community';
|
||||
import { setupAGGridModules, defaultModules } from './setupAGGridModules';
|
||||
|
||||
jest.mock('ag-grid-community', () => ({
|
||||
ModuleRegistry: {
|
||||
registerModules: jest.fn(),
|
||||
},
|
||||
ColumnAutoSizeModule: { moduleName: 'ColumnAutoSizeModule' },
|
||||
ColumnHoverModule: { moduleName: 'ColumnHoverModule' },
|
||||
RowAutoHeightModule: { moduleName: 'RowAutoHeightModule' },
|
||||
RowStyleModule: { moduleName: 'RowStyleModule' },
|
||||
PaginationModule: { moduleName: 'PaginationModule' },
|
||||
CellStyleModule: { moduleName: 'CellStyleModule' },
|
||||
TextFilterModule: { moduleName: 'TextFilterModule' },
|
||||
NumberFilterModule: { moduleName: 'NumberFilterModule' },
|
||||
DateFilterModule: { moduleName: 'DateFilterModule' },
|
||||
ExternalFilterModule: { moduleName: 'ExternalFilterModule' },
|
||||
CsvExportModule: { moduleName: 'CsvExportModule' },
|
||||
ColumnApiModule: { moduleName: 'ColumnApiModule' },
|
||||
RowApiModule: { moduleName: 'RowApiModule' },
|
||||
CellApiModule: { moduleName: 'CellApiModule' },
|
||||
RenderApiModule: { moduleName: 'RenderApiModule' },
|
||||
ClientSideRowModelModule: { moduleName: 'ClientSideRowModelModule' },
|
||||
CustomFilterModule: { moduleName: 'CustomFilterModule' },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('defaultModules exports an array of AG Grid modules', () => {
|
||||
expect(Array.isArray(defaultModules)).toBe(true);
|
||||
expect(defaultModules.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify it contains expected modules
|
||||
const moduleNames = defaultModules.map((m: any) => m.moduleName);
|
||||
expect(moduleNames).toContain('ColumnAutoSizeModule');
|
||||
expect(moduleNames).toContain('PaginationModule');
|
||||
expect(moduleNames).toContain('ClientSideRowModelModule');
|
||||
});
|
||||
|
||||
test('setupAGGridModules registers default modules when called without arguments', () => {
|
||||
setupAGGridModules();
|
||||
|
||||
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
|
||||
expect(ModuleRegistry.registerModules).toHaveBeenCalledWith(defaultModules);
|
||||
});
|
||||
|
||||
test('setupAGGridModules registers default + additional modules when provided', () => {
|
||||
const mockEnterpriseModule1 = {
|
||||
moduleName: 'MultiFilterModule',
|
||||
} as unknown as Module;
|
||||
const mockEnterpriseModule2 = {
|
||||
moduleName: 'PivotModule',
|
||||
} as unknown as Module;
|
||||
const additionalModules = [mockEnterpriseModule1, mockEnterpriseModule2];
|
||||
|
||||
setupAGGridModules(additionalModules);
|
||||
|
||||
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
|
||||
|
||||
const registeredModules = (ModuleRegistry.registerModules as jest.Mock).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Should contain all default modules
|
||||
defaultModules.forEach(module => {
|
||||
expect(registeredModules).toContain(module);
|
||||
});
|
||||
|
||||
// Should contain additional modules
|
||||
expect(registeredModules).toContain(mockEnterpriseModule1);
|
||||
expect(registeredModules).toContain(mockEnterpriseModule2);
|
||||
|
||||
// Total length should be default + additional
|
||||
expect(registeredModules.length).toBe(
|
||||
defaultModules.length + additionalModules.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('setupAGGridModules handles empty additional modules array', () => {
|
||||
setupAGGridModules([]);
|
||||
|
||||
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
|
||||
expect(ModuleRegistry.registerModules).toHaveBeenCalledWith(defaultModules);
|
||||
});
|
||||
|
||||
test('setupAGGridModules does not mutate defaultModules array', () => {
|
||||
const originalLength = defaultModules.length;
|
||||
const mockEnterpriseModule = {
|
||||
moduleName: 'EnterpriseModule',
|
||||
} as unknown as Module;
|
||||
|
||||
setupAGGridModules([mockEnterpriseModule]);
|
||||
|
||||
// defaultModules should remain unchanged
|
||||
expect(defaultModules.length).toBe(originalLength);
|
||||
expect(defaultModules).not.toContain(mockEnterpriseModule);
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import {
|
||||
ModuleRegistry,
|
||||
type Module,
|
||||
ColumnAutoSizeModule,
|
||||
ColumnHoverModule,
|
||||
RowAutoHeightModule,
|
||||
@@ -39,27 +40,29 @@ import {
|
||||
} from 'ag-grid-community';
|
||||
|
||||
/**
|
||||
* Registers the AG-Grid modules required for Superset's table functionality.
|
||||
* This should be called once during application initialization.
|
||||
* Default AG Grid modules that are registered by default.
|
||||
* These modules provide core AG Grid functionality.
|
||||
*/
|
||||
export const setupAGGridModules = () => {
|
||||
ModuleRegistry.registerModules([
|
||||
ColumnAutoSizeModule,
|
||||
ColumnHoverModule,
|
||||
RowAutoHeightModule,
|
||||
RowStyleModule,
|
||||
PaginationModule,
|
||||
CellStyleModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
DateFilterModule,
|
||||
ExternalFilterModule,
|
||||
CsvExportModule,
|
||||
ColumnApiModule,
|
||||
RowApiModule,
|
||||
CellApiModule,
|
||||
RenderApiModule,
|
||||
ClientSideRowModelModule,
|
||||
CustomFilterModule,
|
||||
]);
|
||||
export const defaultModules: Module[] = [
|
||||
ColumnAutoSizeModule,
|
||||
ColumnHoverModule,
|
||||
RowAutoHeightModule,
|
||||
RowStyleModule,
|
||||
PaginationModule,
|
||||
CellStyleModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
DateFilterModule,
|
||||
ExternalFilterModule,
|
||||
CsvExportModule,
|
||||
ColumnApiModule,
|
||||
RowApiModule,
|
||||
CellApiModule,
|
||||
RenderApiModule,
|
||||
ClientSideRowModelModule,
|
||||
CustomFilterModule,
|
||||
];
|
||||
|
||||
export const setupAGGridModules = (additionalModules: Module[] = []) => {
|
||||
ModuleRegistry.registerModules([...defaultModules, ...additionalModules]);
|
||||
};
|
||||
|
||||
@@ -179,4 +179,6 @@ export {
|
||||
ThemedAgGridReact,
|
||||
type ThemedAgGridReactProps,
|
||||
setupAGGridModules,
|
||||
defaultModules,
|
||||
} from './ThemedAgGridReact';
|
||||
export { ActionButton, type ActionProps } from './ActionButton';
|
||||
|
||||
@@ -60,7 +60,11 @@ class CurrencyFormatter extends ExtensibleFunction {
|
||||
}
|
||||
|
||||
getNormalizedD3Format() {
|
||||
return this.d3Format.replace(/\$|%/g, '');
|
||||
return this.d3Format.replace(/\$/g, '');
|
||||
}
|
||||
|
||||
normalizeForCurrency(value: string) {
|
||||
return value.replace(/%/g, '');
|
||||
}
|
||||
|
||||
format(value: number) {
|
||||
@@ -71,10 +75,11 @@ class CurrencyFormatter extends ExtensibleFunction {
|
||||
return formattedValue as string;
|
||||
}
|
||||
|
||||
const normalizedValue = this.normalizeForCurrency(formattedValue);
|
||||
if (this.currency.symbolPosition === 'prefix') {
|
||||
return `${getCurrencySymbol(this.currency)} ${formattedValue}`;
|
||||
return `${getCurrencySymbol(this.currency)} ${normalizedValue}`;
|
||||
}
|
||||
return `${formattedValue} ${getCurrencySymbol(this.currency)}`;
|
||||
return `${normalizedValue} ${getCurrencySymbol(this.currency)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export function normalizeTimeColumn(
|
||||
sqlExpression: formData.x_axis,
|
||||
label: formData.x_axis,
|
||||
expressionType: 'SQL',
|
||||
isColumnReference: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface AdhocColumn {
|
||||
optionName?: string;
|
||||
sqlExpression: string;
|
||||
expressionType: 'SQL';
|
||||
isColumnReference?: boolean;
|
||||
columnType?: 'BASE_AXIS' | 'SERIES';
|
||||
timeGrain?: string;
|
||||
datasourceWarning?: boolean;
|
||||
@@ -74,6 +75,10 @@ export function isAdhocColumn(column?: any): column is AdhocColumn {
|
||||
);
|
||||
}
|
||||
|
||||
export function isAdhocColumnReference(column?: any): column is AdhocColumn {
|
||||
return isAdhocColumn(column) && column?.isColumnReference === true;
|
||||
}
|
||||
|
||||
export function isQueryFormColumn(column: any): column is QueryFormColumn {
|
||||
return isPhysicalColumn(column) || isAdhocColumn(column);
|
||||
}
|
||||
|
||||
@@ -123,6 +123,13 @@ export interface SupersetSpecificTokens {
|
||||
brandLogoMargin: string;
|
||||
brandLogoHref: string;
|
||||
brandLogoHeight: string;
|
||||
|
||||
// Editor-related
|
||||
/**
|
||||
* Background color for code editor text selection.
|
||||
* Defaults to colorPrimaryBgHover if not specified.
|
||||
*/
|
||||
colorEditorSelection?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,6 +261,7 @@ export const allowedAntdTokens = [
|
||||
'controlTmpOutline',
|
||||
'fontFamily',
|
||||
'fontFamilyCode',
|
||||
'fontWeightStrong',
|
||||
'fontHeight',
|
||||
'fontHeightLG',
|
||||
'fontHeightSM',
|
||||
|
||||
@@ -32,8 +32,10 @@ export enum FeatureFlag {
|
||||
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
|
||||
CssTemplates = 'CSS_TEMPLATES',
|
||||
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
|
||||
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
|
||||
DashboardRbac = 'DASHBOARD_RBAC',
|
||||
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
|
||||
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
|
||||
/** @deprecated */
|
||||
DrillToDetail = 'DRILL_TO_DETAIL',
|
||||
DrillBy = 'DRILL_BY',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
removeHTMLTags,
|
||||
isJsonString,
|
||||
getParagraphContents,
|
||||
extractTextFromHTML,
|
||||
} from './html';
|
||||
|
||||
describe('sanitizeHtml', () => {
|
||||
@@ -64,6 +65,24 @@ describe('isProbablyHTML', () => {
|
||||
expect(isProbablyHTML('if x < 5 and y > 10')).toBe(false);
|
||||
expect(isProbablyHTML('price < $100')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for all known HTML tags', () => {
|
||||
expect(isProbablyHTML('<section>Content</section>')).toBe(true);
|
||||
expect(isProbablyHTML('<article>Content</article>')).toBe(true);
|
||||
expect(isProbablyHTML('<nav>Content</nav>')).toBe(true);
|
||||
expect(isProbablyHTML('<header>Content</header>')).toBe(true);
|
||||
expect(isProbablyHTML('<footer>Content</footer>')).toBe(true);
|
||||
expect(isProbablyHTML('<button>Click me</button>')).toBe(true);
|
||||
expect(isProbablyHTML('<form>Content</form>')).toBe(true);
|
||||
expect(isProbablyHTML('<input type="text">')).toBe(true);
|
||||
expect(isProbablyHTML('<textarea>Content</textarea>')).toBe(true);
|
||||
expect(isProbablyHTML('<select><option>1</option></select>')).toBe(true);
|
||||
expect(isProbablyHTML('<blockquote>Quote</blockquote>')).toBe(true);
|
||||
expect(isProbablyHTML('<video src="video.mp4"></video>')).toBe(true);
|
||||
expect(isProbablyHTML('<audio src="audio.mp3"></audio>')).toBe(true);
|
||||
expect(isProbablyHTML('<canvas></canvas>')).toBe(true);
|
||||
expect(isProbablyHTML('<iframe src="page.html"></iframe>')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHtmlIfNeeded', () => {
|
||||
@@ -204,3 +223,101 @@ describe('getParagraphContents', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTextFromHTML', () => {
|
||||
it('should extract text from HTML div tags', () => {
|
||||
const htmlString = '<div>Hello World</div>';
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should extract text from nested HTML tags', () => {
|
||||
const htmlString = '<div><p>Hello <strong>World</strong></p></div>';
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should extract text from multiple HTML elements', () => {
|
||||
const htmlString = '<h1>Title</h1><p>Content</p><span>Footer</span>';
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toBe('TitleContentFooter');
|
||||
});
|
||||
|
||||
it('should return original string when input is not HTML', () => {
|
||||
const plainText = 'Just plain text';
|
||||
const result = extractTextFromHTML(plainText);
|
||||
expect(result).toBe('Just plain text');
|
||||
});
|
||||
|
||||
it('should return original value when input is not a string', () => {
|
||||
const numberValue = 12345;
|
||||
const result = extractTextFromHTML(numberValue);
|
||||
expect(result).toBe(12345);
|
||||
|
||||
const nullValue = null;
|
||||
const nullResult = extractTextFromHTML(nullValue);
|
||||
expect(nullResult).toBe(null);
|
||||
|
||||
const booleanValue = true;
|
||||
const booleanResult = extractTextFromHTML(booleanValue);
|
||||
expect(booleanResult).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty HTML tags', () => {
|
||||
const htmlString = '<div></div>';
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle HTML with only whitespace', () => {
|
||||
const htmlString = '<div> </div>';
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toBe(' ');
|
||||
});
|
||||
|
||||
it('should extract text from HTML with attributes', () => {
|
||||
const htmlString = '<div class="container" id="main">Hello World</div>';
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should handle self-closing tags', () => {
|
||||
const htmlString = '<img src="image.jpg" alt="Image"><br><p>Text after</p>';
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toBe('Text after');
|
||||
});
|
||||
|
||||
it('should handle complex HTML structure', () => {
|
||||
const htmlString = `
|
||||
<html>
|
||||
<head><title>Page Title</title></head>
|
||||
<body>
|
||||
<header><h1>Main Title</h1></header>
|
||||
<main>
|
||||
<p>First paragraph with <em>emphasis</em>.</p>
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = extractTextFromHTML(htmlString);
|
||||
expect(result).toContain('Page Title');
|
||||
expect(result).toContain('Main Title');
|
||||
expect(result).toContain('First paragraph with emphasis.');
|
||||
expect(result).toContain('Item 1');
|
||||
expect(result).toContain('Item 2');
|
||||
});
|
||||
|
||||
it('should not extract text from strings that look like HTML but are not', () => {
|
||||
const fakeHtmlString = '<abcdef:12345>';
|
||||
const result = extractTextFromHTML(fakeHtmlString);
|
||||
expect(result).toBe('<abcdef:12345>');
|
||||
|
||||
const mathExpression = 'x < 5 and y > 10';
|
||||
const mathResult = extractTextFromHTML(mathExpression);
|
||||
expect(mathResult).toBe('x < 5 and y > 10');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { FilterXSS, getDefaultWhiteList } from 'xss';
|
||||
import { DataRecordValue } from '../types';
|
||||
|
||||
const xssFilter = new FilterXSS({
|
||||
whiteList: {
|
||||
@@ -51,11 +52,73 @@ export function sanitizeHtml(htmlString: string) {
|
||||
return xssFilter.process(htmlString);
|
||||
}
|
||||
|
||||
export function hasHtmlTagPattern(str: string): boolean {
|
||||
const htmlTagPattern =
|
||||
/<(html|head|body|div|span|a|p|h[1-6]|title|meta|link|script|style)/i;
|
||||
const KNOWN_HTML_TAGS = new Set([
|
||||
'div',
|
||||
'span',
|
||||
'p',
|
||||
'a',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'em',
|
||||
'strong',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'table',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'tbody',
|
||||
'thead',
|
||||
'tfoot',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
'br',
|
||||
'hr',
|
||||
'pre',
|
||||
'code',
|
||||
'blockquote',
|
||||
'section',
|
||||
'article',
|
||||
'nav',
|
||||
'header',
|
||||
'footer',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'select',
|
||||
'option',
|
||||
'textarea',
|
||||
'label',
|
||||
'fieldset',
|
||||
'legend',
|
||||
'video',
|
||||
'audio',
|
||||
'canvas',
|
||||
'iframe',
|
||||
'script',
|
||||
'style',
|
||||
'link',
|
||||
'meta',
|
||||
'title',
|
||||
'html',
|
||||
'head',
|
||||
'body',
|
||||
]);
|
||||
|
||||
return htmlTagPattern.test(str);
|
||||
const HTML_TAG_PATTERN = new RegExp(
|
||||
`<(${Array.from(KNOWN_HTML_TAGS).join('|')})\\b`,
|
||||
'i',
|
||||
);
|
||||
|
||||
export function hasHtmlTagPattern(str: string): boolean {
|
||||
return HTML_TAG_PATTERN.test(str);
|
||||
}
|
||||
|
||||
export function isProbablyHTML(text: string) {
|
||||
@@ -90,64 +153,7 @@ export function isProbablyHTML(text: string) {
|
||||
// This prevents strings like "<abcdef:12345>" from being treated as HTML
|
||||
return elements.some(element => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
// List of common HTML tags we want to recognize
|
||||
const knownHtmlTags = [
|
||||
'div',
|
||||
'span',
|
||||
'p',
|
||||
'a',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'em',
|
||||
'strong',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'table',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'tbody',
|
||||
'thead',
|
||||
'tfoot',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
'br',
|
||||
'hr',
|
||||
'pre',
|
||||
'code',
|
||||
'blockquote',
|
||||
'section',
|
||||
'article',
|
||||
'nav',
|
||||
'header',
|
||||
'footer',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'select',
|
||||
'option',
|
||||
'textarea',
|
||||
'label',
|
||||
'fieldset',
|
||||
'legend',
|
||||
'video',
|
||||
'audio',
|
||||
'canvas',
|
||||
'iframe',
|
||||
'script',
|
||||
'style',
|
||||
'link',
|
||||
'meta',
|
||||
'title',
|
||||
];
|
||||
return knownHtmlTags.includes(tagName);
|
||||
return KNOWN_HTML_TAGS.has(tagName);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,7 +175,10 @@ export function safeHtmlSpan(possiblyHtmlString: string) {
|
||||
}
|
||||
|
||||
export function removeHTMLTags(str: string): string {
|
||||
return str.replace(/<[^>]*>/g, '');
|
||||
const doc = new DOMParser().parseFromString(str, 'text/html');
|
||||
const bodyText = doc.body?.textContent || '';
|
||||
const headText = doc.head?.textContent || '';
|
||||
return headText + bodyText;
|
||||
}
|
||||
|
||||
export function isJsonString(str: string): boolean {
|
||||
@@ -204,3 +213,10 @@ export function getParagraphContents(
|
||||
|
||||
return paragraphContents;
|
||||
}
|
||||
|
||||
export function extractTextFromHTML(value: DataRecordValue): DataRecordValue {
|
||||
if (typeof value === 'string' && isProbablyHTML(value)) {
|
||||
return removeHTMLTags(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export { default as makeSingleton } from './makeSingleton';
|
||||
export { default as promiseTimeout } from './promiseTimeout';
|
||||
export { default as logging } from './logging';
|
||||
export { default as removeDuplicates } from './removeDuplicates';
|
||||
export { default as withLabel } from './withLabel';
|
||||
export { lruCache } from './lruCache';
|
||||
export { getSelectedText } from './getSelectedText';
|
||||
export * from './featureFlags';
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
const console = window.console || {};
|
||||
const console =
|
||||
typeof window !== 'undefined' ? window.console || {} : globalThis.console;
|
||||
const log = console.log || (() => {});
|
||||
|
||||
const logger = {
|
||||
|
||||
@@ -17,22 +17,27 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { PaginationButtonProps } from './types';
|
||||
import type { ValidatorFunction } from '../validator';
|
||||
|
||||
export function Prev({ disabled, onClick }: PaginationButtonProps) {
|
||||
return (
|
||||
<li className={classNames({ disabled })}>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (!disabled) onClick(e);
|
||||
}}
|
||||
>
|
||||
«
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
/**
|
||||
* Wraps a validator function to prepend a label to its error message.
|
||||
*
|
||||
* @param validator - The validator function to wrap
|
||||
* @param label - The label to prepend to error messages
|
||||
* @returns A new validator function that includes the label in error messages
|
||||
*
|
||||
* @example
|
||||
* validators: [
|
||||
* withLabel(validateInteger, t('Row limit')),
|
||||
* ]
|
||||
* // Returns: "Row limit is expected to be an integer"
|
||||
*/
|
||||
export default function withLabel<V = unknown, S = unknown>(
|
||||
validator: ValidatorFunction<V, S>,
|
||||
label: string,
|
||||
): ValidatorFunction<V, S> {
|
||||
return (value: V, state?: S): string | false => {
|
||||
const error = validator(value, state);
|
||||
return error ? `${label} ${error}` : false;
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export { default as legacyValidateInteger } from './legacyValidateInteger';
|
||||
export { default as legacyValidateNumber } from './legacyValidateNumber';
|
||||
export { default as validateInteger } from './validateInteger';
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '../translation';
|
||||
* formerly called integer()
|
||||
* @param v
|
||||
*/
|
||||
export default function legacyValidateInteger(v: unknown) {
|
||||
export default function legacyValidateInteger(v: unknown): string | false {
|
||||
if (
|
||||
v &&
|
||||
(Number.isNaN(Number(v)) || parseInt(v as string, 10) !== Number(v))
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '../translation';
|
||||
* formerly called numeric()
|
||||
* @param v
|
||||
*/
|
||||
export default function numeric(v: unknown) {
|
||||
export default function numeric(v: unknown): string | false {
|
||||
if (v && Number.isNaN(Number(v))) {
|
||||
return t('is expected to be a number');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* 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
|
||||
@@ -17,12 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getFormattedUTCTime } from '../src/utils';
|
||||
|
||||
describe('getFormattedUTCTime', () => {
|
||||
it('formatted date string should equal to UTC date', () => {
|
||||
const ts = 1420070400000; // 2015.01.01 00:00:00 UTC
|
||||
const formattedTime = getFormattedUTCTime(ts, '%Y-%m-%d %H:%M:%S');
|
||||
expect(formattedTime).toEqual('2015-01-01 00:00:00');
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Type definition for a validator function.
|
||||
* Returns an error message string if validation fails, or false if validation passes.
|
||||
*/
|
||||
export type ValidatorFunction<V = unknown, S = unknown> = (
|
||||
value: V,
|
||||
state?: S,
|
||||
) => string | false;
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '../translation';
|
||||
|
||||
export default function validateInteger(v: unknown) {
|
||||
export default function validateInteger(v: unknown): string | false {
|
||||
if (
|
||||
(typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
|
||||
@@ -25,7 +25,7 @@ const VALIDE_OSM_URLS = ['https://tile.osm', 'https://tile.openstreetmap'];
|
||||
* Validate a [Mapbox styles URL](https://docs.mapbox.com/help/glossary/style-url/)
|
||||
* @param v
|
||||
*/
|
||||
export default function validateMapboxStylesUrl(v: unknown) {
|
||||
export default function validateMapboxStylesUrl(v: unknown): string | false {
|
||||
if (typeof v === 'string') {
|
||||
const trimmed_v = v.trim();
|
||||
if (
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
*/
|
||||
import { t } from '../translation';
|
||||
|
||||
export default function validateMaxValue(v: unknown, max: Number) {
|
||||
export default function validateMaxValue(
|
||||
v: unknown,
|
||||
max: number,
|
||||
): string | false {
|
||||
if (Number(v) > +max) {
|
||||
return t('Value cannot exceed %s', max);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '../translation';
|
||||
|
||||
export default function validateNonEmpty(v: unknown) {
|
||||
export default function validateNonEmpty(v: unknown): string | false {
|
||||
if (
|
||||
v === null ||
|
||||
typeof v === 'undefined' ||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '../translation';
|
||||
|
||||
export default function validateInteger(v: any) {
|
||||
export default function validateNumber(v: unknown): string | false {
|
||||
if (
|
||||
(typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function validateServerPagination(
|
||||
serverPagination: boolean,
|
||||
maxValueWithoutServerPagination: number,
|
||||
maxServer: number,
|
||||
) {
|
||||
): string | false {
|
||||
if (
|
||||
Number(v) > +maxValueWithoutServerPagination &&
|
||||
Number(v) <= maxServer &&
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user