Compare commits

...

83 Commits

Author SHA1 Message Date
AAfghahi
c4315a28d3 fix: regex for multi-region IPs (#16410)
* regex for multi-region IPs

* Update index.tsx

(cherry picked from commit a6aad52e38)
2021-08-24 12:11:30 -07:00
Ville Brofeldt
a300d51d9b fix(explore): retain chart ownership on query context update (#16419)
(cherry picked from commit 35864748f2)
2021-08-24 10:41:53 -07:00
Evan Rusackas
33ad710bae Revert "fix(explore): let admin overwrite slice (#16290)" (#16408)
This reverts commit d13b081cfe.

(cherry picked from commit 81241b6024)
2021-08-24 10:40:33 -07:00
Beto Dealmeida
880a3e27a2 fix: update table ID in query context on chart import (#16374)
* fix: update table ID in query context on chart import

* Fix test

(cherry picked from commit adebc0997b)
2021-08-20 11:58:12 -07:00
Beto Dealmeida
cd794f20e0 fix: import dashboard w/o metadata 2021-08-20 11:57:06 -07:00
Phillip Kelley-Dotson
bbbc1be805 change filter (#16280)
(cherry picked from commit f581e0402b)
2021-08-19 20:02:23 -07:00
Kamil Gabryjelski
8abf9c24d0 feat(explore): make dnd controls clickable (#16119)
* Make ghost buttons clickable

* Popover for column control

* Make column dnd ghost button clickable

* Prefill operator only if column is defined

* Remove data-tests

* lint fix

* Hide new features behind a feature flag

* Change ghost button texts

* Remove caret for non clickable columns

(cherry picked from commit 203c311ca3)
2021-08-19 13:41:17 -07:00
Geido
c178bbddcb fix(Explore): Show the tooltip only when label does not fit the container in METRICS/FILTERS/GROUP BY/SORT BY of the DATA panel (#16060)
* Implement dynamic tooltip

* Normalize and consolidate

* Clean up

* Refactor and clean up

* Remove unnecessary var

* Fix type import

* Update superset-frontend/src/explore/components/controls/OptionControls/index.tsx

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

* Remove unnecessary styled span

* Show full tooltip title

* Force show tooltip

* Force show tooltip D&D off

Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
(cherry picked from commit a1e18ed110)
2021-08-19 13:05:04 -07:00
Beto Dealmeida
906124a69a fix: columns/index rebuild (#16355)
(cherry picked from commit 37f09bd296)
2021-08-19 10:46:18 -07:00
Beto Dealmeida
8d92d731fd fix: send CSV pivoted in reports (#16347)
(cherry picked from commit ec8d3b03e4)
2021-08-19 10:44:18 -07:00
Beto Dealmeida
0145f21def feat: improve embedded data table in text reports (#16335)
* feat: improve HTML table in text reports

* Remove unused import

* Update tests

* Fix test

(cherry picked from commit afb8bd5fe6)
2021-08-19 10:44:08 -07:00
Kamil Gabryjelski
86dd052e0e fix(explore): reordering columns with dnd sometimes glitching (#16322)
* fix(explore): reordering columns with dnd sometimes glitching

* Fix metrics and filters popover being stale after reordering

(cherry picked from commit a547dcb73e)
2021-08-19 10:42:33 -07:00
Kamil Gabryjelski
6550d56560 feat(explore): make dnd controls clickable (#16119)
* Make ghost buttons clickable

* Popover for column control

* Make column dnd ghost button clickable

* Prefill operator only if column is defined

* Remove data-tests

* lint fix

* Hide new features behind a feature flag

* Change ghost button texts

* Remove caret for non clickable columns

(cherry picked from commit 203c311ca3)
2021-08-19 10:38:18 -07:00
Evan Rusackas
89df102300 chore: bump superset-ui to v0.17.85 (#16350)
(cherry picked from commit 42cd21e383)
2021-08-19 10:28:59 -07:00
Elizabeth Thompson
68756c8ff5 adjust initial state (#16329)
(cherry picked from commit efe850b731)
2021-08-19 10:28:58 -07:00
Elizabeth Thompson
61ae7166f7 pass correct report_format (#16306)
(cherry picked from commit 4960b5ee2b)
2021-08-19 10:28:57 -07:00
Beto Dealmeida
9916c3c0a2 fix: allow reports to update query_context (#16303)
(cherry picked from commit 7a284bb9e8)
2021-08-19 10:28:57 -07:00
Beto Dealmeida
08960aa5c9 fix: improve pivot post-processing (#16289)
* fix: improve pivot post-processing

* Add tests

* Trim space from column name

(cherry picked from commit ac8e54d909)
2021-08-19 10:28:57 -07:00
AAfghahi
0dc73c56d0 timezone editor (#16281)
(cherry picked from commit f5fbfef618)
2021-08-19 10:28:57 -07:00
Ville Brofeldt
0ce0ad42e7 fix(explore): let admin overwrite slice (#16290)
(cherry picked from commit d13b081cfe)
2021-08-19 10:28:56 -07:00
Beto Dealmeida
a9a5d6e544 fix: pivot col names in post_process (#16262)
(cherry picked from commit 542b864e61)
2021-08-19 10:28:56 -07:00
Elizabeth Thompson
966b417035 check roles before fetching reports (#16260)
(cherry picked from commit 3709131089)
2021-08-14 15:47:17 -07:00
Beto Dealmeida
525b88f42e fix: pivot columns with ints for name (#16259)
(cherry picked from commit 9b2dffeb1d)
2021-08-14 15:47:03 -07:00
Phillip Kelley-Dotson
a1f9f02524 fix examples tab for dashboard (#16253)
(cherry picked from commit a5dbe6a14d)
2021-08-14 15:46:34 -07:00
Phillip Kelley-Dotson
f8d3037c25 chore: bump superset-ui packages to 0.17.84 (#16251)
* initial bump

* commit pack-lock file

(cherry picked from commit f94695480a)
2021-08-14 15:46:16 -07:00
Kamil Gabryjelski
bbc8dadc83 fix(explore): metric label disappearing in some scenarios (#16190)
(cherry picked from commit 98fc29cbbb)
2021-08-13 11:12:52 -07:00
Kamil Gabryjelski
a1b297c133 fix(dashboard): cross filter chart highlight when filters badge icon clicked (#16233)
* fix(dashboard): cross filter chart highlight when filters badge icon pressed

* Fix tests

* Fix tests

* break out label logic

Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
(cherry picked from commit 517a678cd7)
2021-08-13 11:12:34 -07:00
Ville Brofeldt
f0fb37fda9 feat(dao): admin can remove self from object owners (#15149)
(cherry picked from commit d6f9c48aa1)
2021-08-13 11:11:56 -07:00
Beto Dealmeida
af1a63b672 fix: skip perms on query context update (#16250)
(cherry picked from commit 2611681de9)
2021-08-13 11:08:43 -07:00
AAfghahi
c8b7a99ca7 feat: Added multi-regional IPs to Database Connections (#16170)
* added google alert

* multi-regional IPs

* beto revisions

* Update superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>

* Update superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>

* Update superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit 2dc0bdda5d)
2021-08-13 11:07:23 -07:00
Evan Rusackas
dc4ab8da94 fix: Remove Advanced Analytics tag for 2 charts (#16240)
* removing AA tag from TimeTableChartPlugin

* package bump for echarts (removes AA tag there)

* package-lock bump for new echarts plugin

(cherry picked from commit cdcc161846)
2021-08-13 11:07:00 -07:00
Beto Dealmeida
f2ab391f99 fix: validate_parameters and query (#16241)
* fix: validate_parameters and query

* add onQueryChange

(cherry picked from commit 5d3d6b6eae)
2021-08-13 11:06:21 -07:00
Arash
728c457d4b changed Slack Channels
(cherry picked from commit 7142b4c64a)
2021-08-12 13:32:01 -07:00
Michael S. Molina
624d521f8e fix: Multiple dashboard refresh triggers for the same session (#16094)
(cherry picked from commit 07f33998ac)
2021-08-12 13:30:42 -07:00
Phillip Kelley-Dotson
7e0dee618d fix: sorting on "Modified By" in chart table (#16208)
* initial fix

* Update ChartList.tsx

change sort to first name

(cherry picked from commit b4555dfa4f)
2021-08-12 13:30:13 -07:00
Phillip Kelley-Dotson
c06383088b initial fix (#16212)
(cherry picked from commit c79de7abd7)
2021-08-12 13:29:50 -07:00
Kamil Gabryjelski
4161c128c9 fix(explore): conditional formatting value validators (#16230)
* fix(explore): conditional formatting value validators

* Fix typing, make validator more generic

* Remove commented code

(cherry picked from commit a16e290765)
2021-08-12 13:29:20 -07:00
Elizabeth Thompson
9ab34c9fb0 fix: remove encryption from db params (#16214)
* remove encryption from db params

* Update superset/db_engine_specs/base.py

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit 67c4c0116e)
2021-08-12 13:28:54 -07:00
Steven Uray
32f1dae38d Getting files from preset-io:arash/fixMessages with git checkout on commit d256edfc3a 2021-08-11 15:47:32 -07:00
Ville Brofeldt
03a67f7ff1 chore: bump superset-ui to 0.17.82 (#16186)
(cherry picked from commit 4df3672baa)
2021-08-11 15:44:01 -07:00
Kamil Gabryjelski
de615eb79e fix(explore): adhoc metrics popover resets label after hovering outside (#16196)
* fix(explore): adhoc metrics popover resets label after hovering outside

* Remove irrelevant tests and skip rest

* Use ensureIsArray

(cherry picked from commit ccfc95fbe6)
2021-08-11 09:40:25 -07:00
Junlin Chen
9fb638da6f chore: switch back tag name to popular from highly-used (#16174)
* chore: switch back tag name to popular from highly-used

* new package lock

* new package lock with npm 7

* fix lint

* remove package changes

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit 9841c78967)
2021-08-11 09:39:15 -07:00
Phillip Kelley-Dotson
60ceb9213f fix: ensure created user entities do not show inside examples (#16176)
* initial commit

* fix lint

* Update superset-frontend/src/views/CRUD/utils.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/views/CRUD/utils.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/views/CRUD/utils.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

Co-authored-by: Evan Rusackas <evan@preset.io>
(cherry picked from commit a0c9b9d9c2)
2021-08-11 09:38:59 -07:00
Phillip Kelley-Dotson
7458292ca9 fix: change listivew card layouts to the new homepage card layout (#16171)
* initial commit

* removing CardStylesOverrides (unused)

Co-authored-by: Evan Rusackas <evan@preset.io>
(cherry picked from commit a30d884cfc)
2021-08-11 09:38:36 -07:00
AAfghahi
adb3ebbba3 feat: Changing Dataset names (#16199)
* added google alert

* changing Dataset Names

(cherry picked from commit 6c304b83a9)
2021-08-11 09:38:17 -07:00
Elizabeth Thompson
c5f07bc6c1 update covid dashboard (#16183)
(cherry picked from commit 3aefa6925b)
2021-08-11 09:38:00 -07:00
AAfghahi
81d2d32dbf feat: CLI cleanup (#16178)
* added google alert

* removing datasets from cli

(cherry picked from commit 6df16c4b1f)
2021-08-11 09:37:37 -07:00
Elizabeth Thompson
cce369ee00 feat: change query predicate to text (#16160)
* change query predicate to text

* make input multiline

* remove value that is too long for the downgrade

* keep logging lint rule

(cherry picked from commit 628169a171)
2021-08-11 09:37:14 -07:00
David Aaron Suddjian
d95721cb14 fix(dashboard): user id can be null when there is an anonymous user (#15592)
(cherry picked from commit 23072161e2)
2021-08-11 09:36:56 -07:00
Kamil Gabryjelski
0a91bc8c3f fix(explore): revert dnd column dependency array change to fix infinite rerenders (#16115)
* fix(explore): revert dnd column dependency array change to fix infinite rerenders

* Remove console.log

* Remove comment

(cherry picked from commit 772da8de63)
2021-08-11 09:34:09 -07:00
Beto Dealmeida
13f01ac2ab fix: isDynamic function (#16175)
* fix: isDynamic function

* trigger tests

(cherry picked from commit 9f52c103ac)
2021-08-11 09:33:49 -07:00
Beto Dealmeida
705bad9792 fix: revert data endpoint name (#16162)
(cherry picked from commit 7b3fce7e81)
2021-08-11 09:33:23 -07:00
Elizabeth Thompson
86d079b31c add config to hide some user menu items (#16156)
(cherry picked from commit 5488a8a948)
2021-08-11 09:33:05 -07:00
Elizabeth Thompson
b4d4d1cc88 feat: add chart image info to reports from charts (#16158)
* refetch reports on props update

* add chart types to reports

(cherry picked from commit a3102488a1)
2021-08-11 09:32:45 -07:00
Phillip Kelley-Dotson
7c5546586c fix: ensure that users viewing chart does not automatically save edit data (#16077)
* add last_change_at migration

* add last_saved_by db migration

* finish rest of api migration

* run precommit

* fix name

* run precommitt

* remove unused mods

* merge migrations

* Update superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>

* Update superset/migrations/versions/6d20ba9ecb33_add_last_saved_at_to_slice_model.py

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>

* Update superset/migrations/versions/f6196627326f_update_chart_permissions.py

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>

* fix test

* precommit

* remove print

* fix test

* change test

* test commit

* test 2

* test 3

* third time the charm

* fix put req

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit f0e3b68cc2)
2021-08-11 09:32:28 -07:00
Phillip Kelley-Dotson
5895af50f5 feat: add sticky state to tables and loadingcards state. (#16102)
* initial feat commit

* fix chart and dash rendering onload

* Update superset-frontend/src/views/CRUD/welcome/Welcome.tsx

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

* fix jumpyness and add const

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
(cherry picked from commit a70248736f)
2021-08-11 09:32:10 -07:00
Michael S. Molina
baeac9dc97 fix: Safari is not showing scroll bars in Explore (#16089)
(cherry picked from commit 273ab3d257)
2021-08-11 09:31:52 -07:00
Ville Brofeldt
9e21009db3 feat(cross-filters): add support for temporal filters (#16139)
* feat(cross-filters): add support for temporal filters

* fix test

* make filter optional

* remove mocks

* fix more tests

* remove unnecessary optionality

* fix even more tests

* bump superset-ui

* add isExtra to schema

* address comments

* fix presto test

(cherry picked from commit 63ace7b288)
2021-08-11 09:31:32 -07:00
Yongjie Zhao
79e4253230 fix: boolean type into SQL 'in' operator (#16107)
* fix: boolean type into SQL 'in' operator

* fix ut

* fix ut again

* update url

* remove blank line

(cherry picked from commit bb1d8fe4ef)
2021-08-11 09:31:14 -07:00
Hugh A. Miles II
520284a4a4 fix: turn on SSL in database edit form show 500 error (#16151)
* fix error for query.update

* converrt before making request

* fix query params

* remove unchanged files

* this

* update tsconfig

(cherry picked from commit 3f86a54ac1)
2021-08-11 09:30:55 -07:00
Lyndsi Kay Williams
bb78d492e9 additional params field fixed (#16161)
(cherry picked from commit 3712ee02fa)
2021-08-11 09:30:36 -07:00
Kamil Gabryjelski
1e9f1de563 chore(explore): bump deckgl to 0.4.9 (#16086)
(cherry picked from commit af204ff449)
2021-08-11 09:30:17 -07:00
Michael S. Molina
d00a2c2899 fix: Fix the Select unselect for object values (#16062)
(cherry picked from commit 1917464d2b)
2021-08-11 09:29:59 -07:00
Kamil Gabryjelski
0b07566346 fix(explore): dnd error when dragging metric if multi: false (#16088)
* fix(explore): dnd error when dragging metric if multi: false

* Fix error for non-dnd controls

(cherry picked from commit b7cc89c6d4)
2021-08-11 09:29:40 -07:00
AAfghahi
c0572c5302 feat: added google alert to DB Connection Form (#16095)
* added google alert

* using superset_text

* made google alert public and others private

* Hugh revisions

(cherry picked from commit a51851308b)
2021-08-11 09:29:14 -07:00
Yongjie Zhao
4b4f6b9c1d fix: virtual dataset wont work (#16132)
(cherry picked from commit 3bbcc30d69)
2021-08-11 09:28:57 -07:00
AAfghahi
ffa4226f10 fix: change Alert Permissions (#16118)
* added google alert

* reworked permissions

(cherry picked from commit 606a7bf429)
2021-08-11 09:28:37 -07:00
AAfghahi
64d54d6fdd feat: better errors for report in charts and dashboard (#16131)
* added google alert

* better errors and report actions

(cherry picked from commit 5ce38839e7)
2021-08-11 09:28:15 -07:00
Geido
fa7a5249ea Adjust width (#16092)
(cherry picked from commit b07c80a839)
2021-08-11 09:27:55 -07:00
Maxime Beauchemin
f8f3b7abbb chore: add stats logging to thumbnail api (#16133)
(cherry picked from commit df50a47777)
2021-08-11 09:27:19 -07:00
Kamil Gabryjelski
83661aee99 chore(explore): change dnd placeholders (#16116)
* chore(explore): change dnd placeholders

* Fix tests and lint

(cherry picked from commit 6ac4f4ef2f)
2021-08-11 09:26:48 -07:00
ʈᵃᵢ
9976250954 fix: move watermark to about section (#16097)
(cherry picked from commit b80f018691)
2021-08-11 09:26:26 -07:00
David Aaron Suddjian
08e64048fa fix(explore): drag & drop column select component triggering onChange unnecessarily (#16073)
* fix(explore): avoid sync until after first render

* fix example

(cherry picked from commit e6292a89bb)
2021-08-11 09:25:57 -07:00
Beto Dealmeida
18f551580e fix: migrate_roles (#16098)
(cherry picked from commit 28c383af68)
2021-08-06 10:08:54 -07:00
Beto Dealmeida
d85875d962 fix: load tabbed dash only for tests (#16091)
(cherry picked from commit b72fd7b9f4)
2021-08-06 10:08:54 -07:00
AAfghahi
31f994b090 change button color (#16093)
(cherry picked from commit e6274e0764)
2021-08-06 10:08:54 -07:00
Beto Dealmeida
a16605cf01 chore: simplify chart permissions (#16078)
(cherry picked from commit 1dbd1e9f02)
2021-08-06 10:08:54 -07:00
Kamil Gabryjelski
e70bcc7283 chore(explore): Create new entrypoints for Echarts Timeseries (#15942)
* feat(explore): Create new entrypoints for Echarts Timeseries

* Change order of some charts

* bump superset-ui

* also bump echarts package

* fix UT

Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
(cherry picked from commit a59d458e41)
2021-08-06 10:08:54 -07:00
Hugh A. Miles II
980c38d1b7 fix: Remove grey bar for TableElement component when metadata is empty (#16054)
* create serialize json function

* remove grey space with no metadata

* remove console log

(cherry picked from commit 11a2d4dfdd)
2021-08-06 10:08:54 -07:00
Ville Brofeldt
87fad13f89 feat(explore): add automatic conditional formatter to pivot table v2 (#16045)
(cherry picked from commit 7ef97a54e2)
2021-08-06 10:08:54 -07:00
AAfghahi
a1a71ee6e1 fix: Adding report bug (#16065)
* report add fix

* added theme

(cherry picked from commit 4359650b7d)
2021-08-06 10:08:54 -07:00
Ville Brofeldt
feab690b8d fix(native-filters): add support for boolean cols to select (#16061)
(cherry picked from commit 86cecaeec5)
2021-08-06 10:08:54 -07:00
Ville Brofeldt
b7d9be449d chore: bump superset-ui to 0.17.78 (#16058)
(cherry picked from commit 7332055ff6)
2021-08-06 10:08:53 -07:00
152 changed files with 6242 additions and 2951 deletions

View File

@@ -118,6 +118,9 @@ services:
depends_on: *superset-depends-on
user: *superset-user
volumes: *superset-volumes
# Bump memory limit if processing selenium / thumbails on superset-worker
# mem_limit: 2038m
# mem_reservation: 128M
superset-worker-beat:
image: *superset-image

View File

@@ -66,7 +66,7 @@ Navigate to **Data ‣ Datasets** and select the **+ Dataset** button in the top
A modal window should pop up in front of you. Select your **Database**,
**Schema**, and **Table** using the drop downs that appear. In the following example,
we register the **cleaned_sales_data** table from the **examples** database.
we register the **Vehicle Sales** table from the **examples** database.
<img src="/images/tutorial_09_add_new_table.png" />

View File

@@ -30,7 +30,7 @@ combine_as_imports = true
include_trailing_comma = true
line_length = 88
known_first_party = superset
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,cron_descriptor,croniter,cryptography,dateutil,deprecation,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_jwt_extended,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,graphlib,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,marshmallow_enum,msgpack,numpy,pandas,parameterized,parsedatetime,pgsanity,pkg_resources,polyline,prison,progress,pyarrow,pyhive,pyparsing,pytest,pytest_mock,pytz,redis,requests,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,tabulate,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,cron_descriptor,croniter,cryptography,dateutil,deprecation,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_jwt_extended,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,graphlib,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,marshmallow_enum,msgpack,numpy,pandas,parameterized,parsedatetime,pgsanity,pkg_resources,polyline,prison,progress,pyarrow,pyhive,pyparsing,pytest,pytest_mock,pytz,redis,requests,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,urllib3,werkzeug,wtforms,wtforms_json,yaml
multi_line_output = 3
order_by_type = false

File diff suppressed because it is too large Load Diff

View File

@@ -67,35 +67,35 @@
"@emotion/babel-preset-css-prop": "^11.2.0",
"@emotion/cache": "^11.1.3",
"@emotion/react": "^11.1.5",
"@superset-ui/chart-controls": "^0.17.77",
"@superset-ui/core": "^0.17.75",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.77",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.77",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.77",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.77",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.77",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.77",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.77",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.77",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.77",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.77",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.77",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.77",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.77",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.77",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.77",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.77",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.77",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.77",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.77",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.77",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.7",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.77",
"@superset-ui/plugin-chart-echarts": "^0.17.77",
"@superset-ui/plugin-chart-pivot-table": "^0.17.77",
"@superset-ui/plugin-chart-table": "^0.17.77",
"@superset-ui/plugin-chart-word-cloud": "^0.17.77",
"@superset-ui/preset-chart-xy": "^0.17.77",
"@superset-ui/chart-controls": "^0.17.85",
"@superset-ui/core": "^0.17.81",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.85",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.85",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.85",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.85",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.85",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.85",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.85",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.85",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.85",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.85",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.85",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.85",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.85",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.85",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.85",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.85",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.85",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.85",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.85",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.85",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.10",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.85",
"@superset-ui/plugin-chart-echarts": "^0.17.85",
"@superset-ui/plugin-chart-pivot-table": "^0.17.85",
"@superset-ui/plugin-chart-table": "^0.17.85",
"@superset-ui/plugin-chart-word-cloud": "^0.17.85",
"@superset-ui/preset-chart-xy": "^0.17.85",
"@vx/responsive": "^0.0.195",
"abortcontroller-polyfill": "^1.1.9",
"antd": "^4.9.4",

View File

@@ -67,60 +67,14 @@ const sumValueAdhocMetric = new AdhocMetric({
label: 'SUM(value)',
});
describe('MetricsControl', () => {
// TODO: rewrite the tests to RTL
describe.skip('MetricsControl', () => {
it('renders Select', () => {
const { component } = setup();
expect(component.find(LabelsContainer)).toExist();
});
describe('constructor', () => {
it('unifies options for the dropdown select with aggregates', () => {
const { component } = setup();
expect(component.state('options')).toEqual([
{
optionName: '_col_source',
type: 'VARCHAR(255)',
column_name: 'source',
},
{
optionName: '_col_target',
type: 'VARCHAR(255)',
column_name: 'target',
},
{ optionName: '_col_value', type: 'DOUBLE', column_name: 'value' },
...Object.keys(AGGREGATES).map(aggregate => ({
aggregate_name: aggregate,
optionName: `_aggregate_${aggregate}`,
})),
{
optionName: 'sum__value',
metric_name: 'sum__value',
expression: 'SUM(energy_usage.value)',
},
{
optionName: 'avg__value',
metric_name: 'avg__value',
expression: 'AVG(energy_usage.value)',
},
]);
});
it('does not show aggregates in options if no columns', () => {
const { component } = setup({ columns: [] });
expect(component.state('options')).toEqual([
{
optionName: 'sum__value',
metric_name: 'sum__value',
expression: 'SUM(energy_usage.value)',
},
{
optionName: 'avg__value',
metric_name: 'avg__value',
expression: 'AVG(energy_usage.value)',
},
]);
});
it('coerces Adhoc Metrics from form data into instances of the AdhocMetric class and leaves saved metrics', () => {
const { component } = setup({
value: [
@@ -178,194 +132,7 @@ describe('MetricsControl', () => {
});
});
describe('checkIfAggregateInInput', () => {
it('handles an aggregate in the input', () => {
const { component } = setup();
expect(component.state('aggregateInInput')).toBeNull();
component.instance().checkIfAggregateInInput('AVG(');
expect(component.state('aggregateInInput')).toBe(AGGREGATES.AVG);
});
it('handles no aggregate in the input', () => {
const { component } = setup();
expect(component.state('aggregateInInput')).toBeNull();
component.instance().checkIfAggregateInInput('colu');
expect(component.state('aggregateInInput')).toBeNull();
});
});
describe('option filter', () => {
it('includes user defined metrics', () => {
const { component } = setup({ datasourceType: 'druid' });
expect(
!!component.instance().selectFilterOption(
{
data: {
metric_name: 'a_metric',
optionName: 'a_metric',
expression: 'SUM(FANCY(metric))',
},
},
'a',
),
).toBe(true);
});
it('includes auto generated avg metrics for druid', () => {
const { component } = setup({ datasourceType: 'druid' });
expect(
!!component.instance().selectFilterOption(
{
data: {
metric_name: 'avg__metric',
optionName: 'avg__metric',
expression: 'AVG(metric)',
},
},
'a',
),
).toBe(true);
});
it('includes columns and aggregates', () => {
const { component } = setup();
expect(
!!component.instance().selectFilterOption(
{
data: {
type: 'VARCHAR(255)',
column_name: 'source',
optionName: '_col_source',
},
},
'sou',
),
).toBe(true);
expect(
!!component
.instance()
.selectFilterOption(
{ data: { aggregate_name: 'AVG', optionName: '_aggregate_AVG' } },
'av',
),
).toBe(true);
});
it('includes columns based on verbose_name', () => {
const { component } = setup();
expect(
!!component.instance().selectFilterOption(
{
data: {
metric_name: 'sum__num',
verbose_name: 'babies',
optionName: '_col_sum_num',
},
},
'bab',
),
).toBe(true);
});
it('excludes auto generated avg metrics for sqla', () => {
const { component } = setup();
expect(
!!component.instance().selectFilterOption(
{
data: {
metric_name: 'avg__metric',
optionName: 'avg__metric',
expression: 'AVG(metric)',
},
},
'a',
),
).toBe(false);
});
it('includes custom made simple saved metrics', () => {
const { component } = setup();
expect(
!!component.instance().selectFilterOption(
{
data: {
metric_name: 'my_fancy_sum_metric',
optionName: 'my_fancy_sum_metric',
expression: 'SUM(value)',
},
},
'sum',
),
).toBe(true);
});
it('excludes auto generated metrics', () => {
const { component } = setup();
expect(
!!component.instance().selectFilterOption(
{
data: {
metric_name: 'sum__value',
optionName: 'sum__value',
expression: 'SUM(value)',
},
},
'sum',
),
).toBe(false);
expect(
!!component.instance().selectFilterOption(
{
data: {
metric_name: 'sum__value',
optionName: 'sum__value',
expression: 'SUM("table"."value")',
},
},
'sum',
),
).toBe(false);
});
it('filters out metrics if the input begins with an aggregate', () => {
const { component } = setup();
component.setState({ aggregateInInput: true });
expect(
!!component.instance().selectFilterOption(
{
data: { metric_name: 'metric', expression: 'SUM(FANCY(metric))' },
},
'SUM(',
),
).toBe(false);
});
it('includes columns if the input begins with an aggregate', () => {
const { component } = setup();
component.setState({ aggregateInInput: true });
expect(
!!component
.instance()
.selectFilterOption(
{ data: { type: 'DOUBLE', column_name: 'value' } },
'SUM(',
),
).toBe(true);
});
it('Removes metrics if savedMetrics changes', () => {
const { props, component, onChange } = setup({
value: [

View File

@@ -50,7 +50,7 @@ describe('VizTypeControl', () => {
new ChartMetadata({
name: 'vis1',
thumbnail: '',
tags: ['Highly-used'],
tags: ['Popular'],
}),
)
.registerValue(

View File

@@ -133,7 +133,8 @@ const TableElement = ({ table, actions, ...props }: TableElementProps) => {
));
}
if (!partitions && !metadata) {
if (!partitions && (!metadata || !metadata.length)) {
// hide partition and metadata card view
return null;
}

View File

@@ -32,3 +32,5 @@ const StyledForm = styled(AntDForm)`
export default function Form(props: FormProps) {
return <StyledForm {...props} />;
}
export { FormProps };

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import Form from './Form';
import Form, { FormProps } from './Form';
import FormItem from './FormItem';
import FormLabel from './FormLabel';
import LabeledErrorBoundInput from './LabeledErrorBoundInput';
export { Form, FormItem, FormLabel, LabeledErrorBoundInput };
export { Form, FormItem, FormLabel, LabeledErrorBoundInput, FormProps };

View File

@@ -27,12 +27,21 @@ interface CardCollectionProps {
prepareRow: TableInstance['prepareRow'];
renderCard?: (row: any) => React.ReactNode;
rows: TableInstance['rows'];
showThumbnails?: boolean;
}
const CardContainer = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(459px, 1fr));
grid-gap: ${({ theme }) => theme.gridUnit * 8}px;
const CardContainer = styled.div<{ showThumbnails?: boolean }>`
${({ theme, showThumbnails }) => `
display: grid;
grid-gap: ${theme.gridUnit * 12}px ${theme.gridUnit * 4}px;
grid-template-columns: repeat(auto-fit, 300px);
margin-top: ${theme.gridUnit * -6}px;
padding: ${
showThumbnails
? `${theme.gridUnit * 8 + 3}px ${theme.gridUnit * 9}px`
: `${theme.gridUnit * 8 + 1}px ${theme.gridUnit * 9}px`
};
`}
`;
const CardWrapper = styled.div`
@@ -51,6 +60,7 @@ export default function CardCollection({
prepareRow,
renderCard,
rows,
showThumbnails,
}: CardCollectionProps) {
function handleClick(
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
@@ -65,7 +75,7 @@ export default function CardCollection({
if (!renderCard) return null;
return (
<CardContainer>
<CardContainer showThumbnails={showThumbnails}>
{loading &&
rows.length === 0 &&
[...new Array(25)].map((e, i) => (

View File

@@ -221,6 +221,7 @@ export interface ListViewProps<T extends object = any> {
cardSortSelectOptions?: Array<CardSortSelectOption>;
defaultViewMode?: ViewModeType;
highlightRowId?: number;
showThumbnails?: boolean;
emptyState?: {
message?: string;
slot?: React.ReactNode;
@@ -242,6 +243,7 @@ function ListView<T extends object = any>({
disableBulkSelect = () => {},
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
renderCard,
showThumbnails,
cardSortSelectOptions,
defaultViewMode = 'card',
highlightRowId,
@@ -376,6 +378,7 @@ function ListView<T extends object = any>({
renderCard={renderCard}
rows={rows}
loading={loading}
showThumbnails={showThumbnails}
/>
)}
{viewMode === 'table' && (

View File

@@ -69,15 +69,6 @@ const StyledAnchor = styled.a`
padding-left: ${({ theme }) => theme.gridUnit}px;
`;
const WaterMark = styled.span`
font-size: 13px;
color: #b0b4c3;
margin: 0 ${({ theme }) => theme.gridUnit * 4}px;
@media (max-width: 1070px) {
display: none;
}
`;
const { SubMenu } = Menu;
interface RightMenuProps {
@@ -95,9 +86,6 @@ const RightMenu = ({
}: RightMenuProps) => (
<StyledDiv align={align}>
<Menu mode="horizontal">
{navbarRight.show_watermark && (
<WaterMark>{t('Powered by Apache Superset')}</WaterMark>
)}
{!navbarRight.user_is_anonymous && (
<SubMenu
data-test="new-dropdown"
@@ -148,9 +136,11 @@ const RightMenu = ({
<a href={navbarRight.user_profile_url}>{t('Profile')}</a>
</Menu.Item>
)}
<Menu.Item key="info">
<a href={navbarRight.user_info_url}>{t('Info')}</a>
</Menu.Item>
{navbarRight.user_info_url && (
<Menu.Item key="info">
<a href={navbarRight.user_info_url}>{t('Info')}</a>
</Menu.Item>
)}
<Menu.Item key="logout">
<a href={navbarRight.user_logout_url}>{t('Logout')}</a>
</Menu.Item>
@@ -160,6 +150,11 @@ const RightMenu = ({
<Menu.Divider key="version-info-divider" />,
<Menu.ItemGroup key="about-section" title={t('About')}>
<div className="about-section">
{navbarRight.show_watermark && (
<div css={versionInfoStyles}>
{t('Powered by Apache Superset')}
</div>
)}
{navbarRight.version_string && (
<div css={versionInfoStyles}>
Version: {navbarRight.version_string}

View File

@@ -18,7 +18,7 @@
*/
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { t, SupersetTheme, css } from '@superset-ui/core';
import { t, SupersetTheme, css, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { Switch } from 'src/components/Switch';
import { AlertObject } from 'src/views/CRUD/alert/types';
@@ -47,6 +47,7 @@ export default function HeaderReportActionsDropDown({
currentReportDeleting,
setCurrentReportDeleting,
] = useState<AlertObject | null>(null);
const theme = useTheme();
const toggleActiveKey = async (data: AlertObject, checked: boolean) => {
if (data?.id) {
@@ -60,7 +61,7 @@ export default function HeaderReportActionsDropDown({
};
const menu = () => (
<Menu selectable={false}>
<Menu selectable={false} css={{ width: '200px' }}>
<Menu.Item>
{t('Email reports active')}
<Switch
@@ -68,6 +69,7 @@ export default function HeaderReportActionsDropDown({
checked={report?.active}
onClick={(checked: boolean) => toggleActiveKey(report, checked)}
size="small"
css={{ marginLeft: theme.gridUnit * 2 }}
/>
</Menu.Item>
<Menu.Item onClick={showReportModal}>{t('Edit email report')}</Menu.Item>

View File

@@ -38,6 +38,13 @@ const defaultProps = {
userEmail: 'test@test.com',
dashboardId: 1,
creationMethod: 'charts_dashboards',
props: {
chart: {
sliceFormData: {
viz_type: 'table',
},
},
},
};
describe('Email Report Modal', () => {

View File

@@ -29,22 +29,28 @@ import { bindActionCreators } from 'redux';
import { connect, useDispatch, useSelector } from 'react-redux';
import { addReport, editReport } from 'src/reports/actions/reports';
import { AlertObject } from 'src/views/CRUD/alert/types';
import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput';
import TimezoneSelector from 'src/components/TimezoneSelector';
import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput';
import Icons from 'src/components/Icons';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { CronPicker, CronError } from 'src/components/CronPicker';
import { CronError } from 'src/components/CronPicker';
import { RadioChangeEvent } from 'src/common/components';
import {
StyledModal,
StyledTopSection,
StyledBottomSection,
StyledIconWrapper,
StyledScheduleTitle,
StyledCronPicker,
StyledCronError,
noBottomMargin,
StyledFooterButton,
TimezoneHeaderStyle,
SectionHeaderStyle,
StyledMessageContentTitle,
StyledRadio,
StyledRadioGroup,
} from './styles';
interface ReportObject {
@@ -67,6 +73,19 @@ interface ReportObject {
creation_method: string;
}
interface ChartObject {
id: number;
chartAlert: string;
chartStatus: string;
chartUpdateEndTime: number;
chartUpdateStartTime: number;
latestQueryFormData: object;
queryController: { abort: () => {} };
queriesResponse: object;
triggerQuery: boolean;
lastRendered: number;
}
interface ReportProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
@@ -77,26 +96,25 @@ interface ReportProps {
userId: number;
userEmail: string;
dashboardId?: number;
chartId?: number;
chart?: ChartObject;
creationMethod: string;
props: any;
}
enum ActionType {
textChange,
inputChange,
fetched,
reset,
}
interface ReportPayloadType {
name: string;
value: string;
}
enum ActionType {
inputChange,
fetched,
reset,
}
type ReportActionType =
| {
type: ActionType.textChange | ActionType.inputChange;
type: ActionType.inputChange;
payload: ReportPayloadType;
}
| {
@@ -107,27 +125,41 @@ type ReportActionType =
type: ActionType.reset;
};
const TEXT_BASED_VISUALIZATION_TYPES = [
'pivot_table',
'pivot_table_v2',
'table',
'paired_ttest',
];
const NOTIFICATION_FORMATS = {
TEXT: 'TEXT',
PNG: 'PNG',
CSV: 'CSV',
};
const reportReducer = (
state: Partial<ReportObject> | null,
action: ReportActionType,
): Partial<ReportObject> | null => {
const initialState = {
name: state?.name || 'Weekly Report',
...(state || {}),
name: 'Weekly Report',
};
switch (action.type) {
case ActionType.textChange:
case ActionType.inputChange:
return {
...initialState,
...state,
[action.payload.name]: action.payload.value,
};
case ActionType.fetched:
return {
...initialState,
...action.payload,
};
case ActionType.reset:
return null;
return { ...initialState };
default:
return state;
}
@@ -139,6 +171,12 @@ const ReportModal: FunctionComponent<ReportProps> = ({
show = false,
...props
}) => {
const vizType = props.props.chart?.sliceFormData?.viz_type;
const isChart = !!props.props.chart;
const defaultNotificationFormat =
isChart && TEXT_BASED_VISUALIZATION_TYPES.includes(vizType)
? NOTIFICATION_FORMATS.TEXT
: NOTIFICATION_FORMATS.PNG;
const [currentReport, setCurrentReport] = useReducer<
Reducer<Partial<ReportObject> | null, ReportActionType>
>(reportReducer, null);
@@ -151,6 +189,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
// Report fetch logic
const reports = useSelector<any, AlertObject>(state => state.reports);
const isEditMode = reports && Object.keys(reports).length;
useEffect(() => {
if (isEditMode) {
const reportsIds = Object.keys(reports);
@@ -166,7 +205,6 @@ const ReportModal: FunctionComponent<ReportProps> = ({
}
}, [reports]);
const onClose = () => {
// setLoading(false);
onHide();
};
const onSave = async () => {
@@ -174,7 +212,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
const newReportValues: Partial<ReportObject> = {
crontab: currentReport?.crontab,
dashboard: props.props.dashboardId,
chart: props.props.chartId,
chart: props.props.chart?.id,
description: currentReport?.description,
name: currentReport?.name,
owners: [props.props.userId],
@@ -187,9 +225,10 @@ const ReportModal: FunctionComponent<ReportProps> = ({
type: 'Report',
creation_method: props.props.creationMethod,
active: true,
report_format: currentReport?.report_format || defaultNotificationFormat,
timezone: currentReport?.timezone,
};
// setLoading(true);
if (isEditMode) {
await dispatch(
editReport(currentReport?.id, newReportValues as ReportObject),
@@ -217,7 +256,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
const renderModalFooter = (
<>
<StyledFooterButton key="back" onClick={onClose}>
Cancel
{t('Cancel')}
</StyledFooterButton>
<StyledFooterButton
key="submit"
@@ -225,11 +264,42 @@ const ReportModal: FunctionComponent<ReportProps> = ({
onClick={onSave}
disabled={!currentReport?.name}
>
Add
{isEditMode ? t('Save') : t('Add')}
</StyledFooterButton>
</>
);
const renderMessageContentSection = (
<>
<StyledMessageContentTitle>
<h4>{t('Message Content')}</h4>
</StyledMessageContentTitle>
<div className="inline-container">
<StyledRadioGroup
onChange={(event: RadioChangeEvent) => {
onChange(ActionType.inputChange, {
name: 'report_format',
value: event.target.value,
});
}}
value={currentReport?.report_format || defaultNotificationFormat}
>
{TEXT_BASED_VISUALIZATION_TYPES.includes(vizType) && (
<StyledRadio value={NOTIFICATION_FORMATS.TEXT}>
{t('Text embedded in email')}
</StyledRadio>
)}
<StyledRadio value={NOTIFICATION_FORMATS.PNG}>
{t('Image (PNG) embedded in email')}
</StyledRadio>
<StyledRadio value={NOTIFICATION_FORMATS.CSV}>
{t('Formatted CSV attached in email')}
</StyledRadio>
</StyledRadioGroup>
</div>
</>
);
return (
<StyledModal
show={show}
@@ -248,7 +318,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
required
validationMethods={{
onChange: ({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.textChange, {
onChange(ActionType.inputChange, {
name: target.name,
value: target.value,
}),
@@ -266,7 +336,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
value={currentReport?.description || ''}
validationMethods={{
onChange: ({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.textChange, {
onChange(ActionType.inputChange, {
name: target.name,
value: target.value,
}),
@@ -284,16 +354,16 @@ const ReportModal: FunctionComponent<ReportProps> = ({
<StyledBottomSection>
<StyledScheduleTitle>
<h4 css={(theme: SupersetTheme) => SectionHeaderStyle(theme)}>
Schedule
{t('Schedule')}
</h4>
<p>Scheduled reports will be sent to your email as a PNG</p>
<p>{t('Scheduled reports will be sent to your email as a PNG')}</p>
</StyledScheduleTitle>
<CronPicker
<StyledCronPicker
clearButton={false}
value={currentReport?.crontab || '0 12 * * 1'}
setValue={(newValue: string) => {
onChange(ActionType.textChange, {
onChange(ActionType.inputChange, {
name: 'crontab',
value: newValue,
});
@@ -310,12 +380,13 @@ const ReportModal: FunctionComponent<ReportProps> = ({
<TimezoneSelector
onTimezoneChange={value => {
setCurrentReport({
type: ActionType.textChange,
type: ActionType.inputChange,
payload: { name: 'timezone', value },
});
}}
timezone={currentReport?.timezone}
/>
{isChart && renderMessageContentSection}
</StyledBottomSection>
</StyledModal>
);

View File

@@ -20,11 +20,17 @@
import { styled, css, SupersetTheme } from '@superset-ui/core';
import Modal from 'src/components/Modal';
import Button from 'src/components/Button';
import { Radio } from 'src/components/Radio';
import { CronPicker } from 'src/components/CronPicker';
export const StyledModal = styled(Modal)`
.ant-modal-body {
padding: 0;
}
h4 {
font-weight: 600;
}
`;
export const StyledTopSection = styled.div`
@@ -61,6 +67,14 @@ export const StyledIconWrapper = styled.span`
export const StyledScheduleTitle = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit * 7}px;
h4 {
margin-bottom: ${({ theme }) => theme.gridUnit * 3}px;
}
`;
export const StyledCronPicker = styled(CronPicker)`
margin-bottom: ${({ theme }) => theme.gridUnit * 3}px;
`;
export const StyledCronError = styled.p`
@@ -83,3 +97,17 @@ export const SectionHeaderStyle = (theme: SupersetTheme) => css`
margin: ${theme.gridUnit * 3}px 0;
font-weight: ${theme.typography.weights.bold};
`;
export const StyledMessageContentTitle = styled.div`
margin: ${({ theme }) => theme.gridUnit * 8}px 0
${({ theme }) => theme.gridUnit * 4}px;
`;
export const StyledRadio = styled(Radio)`
display: block;
line-height: ${({ theme }) => theme.gridUnit * 8}px;
`;
export const StyledRadioGroup = styled(Radio.Group)`
margin-left: ${({ theme }) => theme.gridUnit * 0.5}px;
`;

View File

@@ -310,10 +310,13 @@ const Select = ({
const handleOnDeselect = (value: string | number | AntdLabeledValue) => {
if (Array.isArray(selectValue)) {
const selectedValues = [
...(selectValue as []).filter(opt => opt !== value),
];
setSelectValue(selectedValues);
if (typeof value === 'number' || typeof value === 'string') {
const array = selectValue as (string | number)[];
setSelectValue(array.filter(element => element !== value));
} else {
const array = selectValue as AntdLabeledValue[];
setSelectValue(array.filter(element => element.value !== value.value));
}
}
setSearchedValue('');
};

View File

@@ -23,7 +23,7 @@ import moment from 'moment-timezone';
import { NativeGraySelect as Select } from 'src/components/Select';
const DEFAULT_TIMEZONE = 'GMT Standard Time';
const MIN_SELECT_WIDTH = '375px';
const MIN_SELECT_WIDTH = '400px';
const offsetsToName = {
'-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],

View File

@@ -187,7 +187,7 @@ export const Table = styled.table`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 300px;
max-width: 320px;
line-height: 1;
vertical-align: middle;
&:first-of-type {

View File

@@ -335,7 +335,7 @@ export const hydrateDashboard = (dashboardData, chartData) => (
dashboardInfo: {
...dashboardData,
metadata,
userId: String(user.userId), // legacy, please use state.user instead
userId: user.userId ? String(user.userId) : null, // legacy, please use state.user instead
dash_edit_perm: canEdit,
dash_save_perm: findPermission('can_save_dash', 'Superset', roles),
dash_share_perm: findPermission(

View File

@@ -96,6 +96,7 @@ test('Should render "appliedCrossFilterIndicators"', () => {
<DetailsPanel {...props}>
<div data-test="details-panel-content">Content</div>
</DetailsPanel>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('details-panel-content'));
@@ -129,6 +130,7 @@ test('Should render "appliedIndicators"', () => {
<DetailsPanel {...props}>
<div data-test="details-panel-content">Content</div>
</DetailsPanel>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('details-panel-content'));
@@ -160,6 +162,7 @@ test('Should render "incompatibleIndicators"', () => {
<DetailsPanel {...props}>
<div data-test="details-panel-content">Content</div>
</DetailsPanel>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('details-panel-content'));
@@ -193,6 +196,7 @@ test('Should render "unsetIndicators"', () => {
<DetailsPanel {...props}>
<div data-test="details-panel-content">Content</div>
</DetailsPanel>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('details-panel-content'));
@@ -227,6 +231,7 @@ test('Should render empty', () => {
<DetailsPanel {...props}>
<div data-test="details-panel-content">Content</div>
</DetailsPanel>,
{ useRedux: true },
);
expect(screen.getByTestId('details-panel-content')).toBeInTheDocument();

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Global, css } from '@emotion/react';
import { t, useTheme } from '@superset-ui/core';
import {
@@ -35,6 +36,7 @@ import {
} from 'src/dashboard/components/FiltersBadge/Styles';
import { Indicator } from 'src/dashboard/components/FiltersBadge/selectors';
import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator';
import { RootState } from 'src/dashboard/types';
export interface DetailsPanelProps {
appliedCrossFilterIndicators: Indicator[];
@@ -55,6 +57,9 @@ const DetailsPanelPopover = ({
}: DetailsPanelProps) => {
const [visible, setVisible] = useState(false);
const theme = useTheme();
const activeTabs = useSelector<RootState>(
state => state.dashboardState?.activeTabs,
);
// we don't need to clean up useEffect, setting { once: true } removes the event listener after handle function is called
useEffect(() => {
@@ -65,6 +70,11 @@ const DetailsPanelPopover = ({
}
}, [visible]);
// if tabs change, popover doesn't close automatically
useEffect(() => {
setVisible(false);
}, [activeTabs]);
const getDefaultActivePanel = () => {
const result = [];
if (appliedCrossFilterIndicators.length) {

View File

@@ -20,7 +20,12 @@ import { NO_TIME_RANGE, TIME_FILTER_MAP } from 'src/explore/constants';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import { ChartConfiguration, Filters } from 'src/dashboard/reducers/types';
import { DataMaskStateWithId, DataMaskType } from 'src/dataMask/types';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
ensureIsArray,
FeatureFlag,
FilterState,
isFeatureEnabled,
} from '@superset-ui/core';
import { Layout } from '../../types';
import { getTreeCheckedItems } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils';
@@ -50,6 +55,16 @@ type Filter = {
datasourceId: string;
};
const extractLabel = (filter?: FilterState): string | null => {
if (filter?.label) {
return filter.label;
}
if (filter?.value) {
return ensureIsArray(filter?.value).join(', ');
}
return null;
};
const selectIndicatorValue = (
columnKey: string,
filter: Filter,
@@ -182,16 +197,16 @@ export const selectNativeIndicatorsForChart = (
const rejectedColumns = getRejectedColumns(chart);
const getStatus = ({
value,
label,
column,
type = DataMaskType.NativeFilters,
}: {
value: any;
label: string | null;
column?: string;
type?: DataMaskType;
}): IndicatorStatus => {
// a filter is only considered unset if it's value is null
const hasValue = value !== null;
const hasValue = label !== null;
if (type === DataMaskType.CrossFilters && hasValue) {
return IndicatorStatus.CrossFilterApplied;
}
@@ -220,19 +235,14 @@ export const selectNativeIndicatorsForChart = (
)
.map(nativeFilter => {
const column = nativeFilter.targets[0]?.column?.name;
let value =
dataMask[nativeFilter.id]?.filterState?.label ??
dataMask[nativeFilter.id]?.filterState?.value ??
null;
if (!Array.isArray(value) && value !== null) {
value = [value];
}
const filterState = dataMask[nativeFilter.id]?.filterState;
const label = extractLabel(filterState);
return {
column,
name: nativeFilter.name,
path: [nativeFilter.id],
status: getStatus({ value, column }),
value,
status: getStatus({ label, column }),
value: label,
};
});
}
@@ -249,23 +259,26 @@ export const selectNativeIndicatorsForChart = (
),
)
.map(chartConfig => {
let value =
dataMask[chartConfig.id]?.filterState?.label ??
dataMask[chartConfig.id]?.filterState?.value ??
null;
if (!Array.isArray(value) && value !== null) {
value = [value];
}
const filterState = dataMask[chartConfig.id]?.filterState;
const label = extractLabel(filterState);
const filtersState = filterState?.filters;
const column = filtersState && Object.keys(filtersState)[0];
const dashboardLayoutItem = Object.values(dashboardLayout).find(
layoutItem => layoutItem?.meta?.chartId === chartConfig.id,
);
return {
name: Object.values(dashboardLayout).find(
layoutItem => layoutItem?.meta?.chartId === chartConfig.id,
)?.meta?.sliceName as string,
path: [`${chartConfig.id}`],
column,
name: dashboardLayoutItem?.meta?.sliceName as string,
path: [
...(dashboardLayoutItem?.parents ?? []),
dashboardLayoutItem?.id,
],
status: getStatus({
value,
label,
type: DataMaskType.CrossFilters,
}),
value,
value: label,
};
})
.filter(filter => filter.status === IndicatorStatus.CrossFilterApplied);

View File

@@ -48,7 +48,9 @@ import {
SAVE_TYPE_OVERWRITE,
DASHBOARD_POSITION_DATA_LIMIT,
} from 'src/dashboard/util/constants';
import setPeriodicRunner from 'src/dashboard/util/setPeriodicRunner';
import setPeriodicRunner, {
stopPeriodicRender,
} from 'src/dashboard/util/setPeriodicRunner';
import { options as PeriodicRefreshOptions } from 'src/dashboard/components/RefreshIntervalModal';
const propTypes = {
@@ -168,18 +170,20 @@ class Header extends React.PureComponent {
componentDidMount() {
const { refreshFrequency, user, dashboardInfo } = this.props;
this.startPeriodicRender(refreshFrequency * 1000);
if (user && isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
if (this.canAddReports()) {
// this is in case there is an anonymous user.
this.props.fetchUISpecificReport(
user.userId,
'dashboard_id',
'dashboards',
dashboardInfo.id,
user.email,
);
}
}
UNSAFE_componentWillReceiveProps(nextProps) {
const { user } = this.props;
if (
UNDO_LIMIT - nextProps.undoLength <= 0 &&
!this.state.didNotifyMaxUndoHistoryToast
@@ -193,9 +197,24 @@ class Header extends React.PureComponent {
) {
this.props.setMaxUndoHistoryExceeded();
}
if (
this.canAddReports() &&
nextProps.dashboardInfo.id !== this.props.dashboardInfo.id
) {
// this is in case there is an anonymous user.
this.props.fetchUISpecificReport(
user.userId,
'dashboard_id',
'dashboards',
nextProps.dashboardInfo.id,
user.email,
);
}
}
componentWillUnmount() {
stopPeriodicRender(this.refreshTimer);
this.props.setRefreshFrequency(0);
clearTimeout(this.ctrlYTimeout);
clearTimeout(this.ctrlZTimeout);
}
@@ -383,32 +402,31 @@ class Header extends React.PureComponent {
renderReportModal() {
const attachedReportExists = !!Object.keys(this.props.reports).length;
const canAddReports = isFeatureEnabled(FeatureFlag.ALERT_REPORTS);
return (
(canAddReports || null) &&
(attachedReportExists ? (
<HeaderReportActionsDropdown
showReportModal={this.showReportModal}
toggleActive={this.props.toggleActive}
deleteActiveReport={this.props.deleteActiveReport}
/>
) : (
<>
<span
role="button"
title={t('Schedule email report')}
tabIndex={0}
className="action-button"
onClick={this.showReportModal}
>
<Icons.Calendar />
</span>
</>
))
return attachedReportExists ? (
<HeaderReportActionsDropdown
showReportModal={this.showReportModal}
toggleActive={this.props.toggleActive}
deleteActiveReport={this.props.deleteActiveReport}
/>
) : (
<>
<span
role="button"
title={t('Schedule email report')}
tabIndex={0}
className="action-button"
onClick={this.showReportModal}
>
<Icons.Calendar />
</span>
</>
);
}
canAddReports() {
if (!isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
return false;
}
const { user } = this.props;
if (!user) {
// this is in the case that there is an anonymous user.
@@ -417,7 +435,7 @@ class Header extends React.PureComponent {
const roles = Object.keys(user.roles || []);
const permissions = roles.map(key =>
user.roles[key].filter(
perms => perms[0] === 'can_add' && perms[1] === 'AlertModelView',
perms => perms[0] === 'menu_access' && perms[1] === 'Manage',
),
);
return permissions[0].length > 0;

View File

@@ -30,6 +30,7 @@ export const FILTER_SUPPORTED_TYPES = {
filter_timegrain: [GenericDataType.TEMPORAL],
filter_timecolumn: [GenericDataType.TEMPORAL],
filter_select: [
GenericDataType.BOOLEAN,
GenericDataType.STRING,
GenericDataType.NUMERIC,
GenericDataType.TEMPORAL,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
const stopPeriodicRender = (refreshTimer?: number) => {
export const stopPeriodicRender = (refreshTimer?: number) => {
if (refreshTimer) {
clearInterval(refreshTimer);
}

View File

@@ -112,7 +112,7 @@ const ColumnButtonWrapper = styled.div`
const checkboxGenerator = (d, onChange) => (
<CheckboxControl value={d} onChange={onChange} />
);
const DATA_TYPES = ['STRING', 'NUMERIC', 'DATETIME'];
const DATA_TYPES = ['STRING', 'NUMERIC', 'DATETIME', 'BOOLEAN'];
const DATASOURCE_TYPES_ARR = [
{ key: 'physical', label: t('Physical (table or view)') },
@@ -390,7 +390,7 @@ class DatasourceEditor extends React.PureComponent {
this.setState(prevState => ({ isEditMode: !prevState.isEditMode }));
}
onDatasourceChange(datasource, callback) {
onDatasourceChange(datasource, callback = this.validateAndChange) {
this.setState({ datasource }, callback);
}
@@ -616,7 +616,13 @@ class DatasourceEditor extends React.PureComponent {
'values from the table. Typically the intent would be to limit the scan ' +
'by applying a relative time filter on a partitioned or indexed time-related field.',
)}
control={<TextControl controlId="fetch_values_predicate" />}
control={
<TextAreaControl
language="sql"
controlId="fetch_values_predicate"
minLines={5}
/>
}
/>
)}
{this.state.isSqla && (

View File

@@ -48,7 +48,7 @@ const createProps = () => ({
cache_timeout: null,
changed_on: '2021-03-19T16:30:56.750230',
changed_on_humanized: '3 days ago',
datasource: 'FCC 2018 Survey',
datasource: 'FCC Survey Results',
description: null,
description_markeddown: '',
edit_url: '/chart/edit/318',

View File

@@ -91,6 +91,7 @@ const StyledHeader = styled.div`
}
.action-button {
color: ${({ theme }) => theme.colors.grayscale.base};
margin: 0 ${({ theme }) => theme.gridUnit * 1.5}px 0
${({ theme }) => theme.gridUnit}px;
}
@@ -116,8 +117,8 @@ export class ExploreChartHeader extends React.PureComponent {
}
componentDidMount() {
const { user, chart } = this.props;
if (user && isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
if (this.canAddReports()) {
const { user, chart } = this.props;
// this is in the case that there is an anonymous user.
this.props.fetchUISpecificReport(
user.userId,
@@ -164,33 +165,32 @@ export class ExploreChartHeader extends React.PureComponent {
renderReportModal() {
const attachedReportExists = !!Object.keys(this.props.reports).length;
const canAddReports = isFeatureEnabled(FeatureFlag.ALERT_REPORTS);
return (
(canAddReports || null) &&
(attachedReportExists ? (
<HeaderReportActionsDropdown
showReportModal={this.showReportModal}
hideReportModal={this.hideReportModal}
toggleActive={this.props.toggleActive}
deleteActiveReport={this.props.deleteActiveReport}
/>
) : (
<>
<span
role="button"
title={t('Schedule email report')}
tabIndex={0}
className="action-button"
onClick={this.showReportModal}
>
<Icons.Calendar />
</span>
</>
))
return attachedReportExists ? (
<HeaderReportActionsDropdown
showReportModal={this.showReportModal}
hideReportModal={this.hideReportModal}
toggleActive={this.props.toggleActive}
deleteActiveReport={this.props.deleteActiveReport}
/>
) : (
<>
<span
role="button"
title={t('Schedule email report')}
tabIndex={0}
className="action-button"
onClick={this.showReportModal}
>
<Icons.Calendar />
</span>
</>
);
}
canAddReports() {
if (!isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
return false;
}
const { user } = this.props;
if (!user) {
// this is in the case that there is an anonymous user.
@@ -199,7 +199,7 @@ export class ExploreChartHeader extends React.PureComponent {
const roles = Object.keys(user.roles || []);
const permissions = roles.map(key =>
user.roles[key].filter(
perms => perms[0] === 'can_add' && perms[1] === 'AlertModelView',
perms => perms[0] === 'menu_access' && perms[1] === 'Manage',
),
);
return permissions[0].length > 0;
@@ -294,7 +294,7 @@ export class ExploreChartHeader extends React.PureComponent {
props={{
userId: this.props.user.userId,
userEmail: this.props.user.email,
chartId: this.props.chart.id,
chart: this.props.chart,
creationMethod: 'charts',
}}
/>

View File

@@ -147,6 +147,7 @@ const ExploreChartPanel = props => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query_context: JSON.stringify(queryContext),
query_context_generation: true,
}),
});
}

View File

@@ -81,7 +81,7 @@ const Styles = styled.div`
text-align: left;
position: relative;
width: 100%;
height: 100%;
max-height: 100%;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
@@ -448,6 +448,7 @@ function ExploreViewContainer(props) {
margin-bottom: 0;
}
body {
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
@@ -458,7 +459,7 @@ function ExploreViewContainer(props) {
#app {
flex-basis: 100%;
overflow: hidden;
height: 100vh;
height: 100%;
}
#app-menu {
flex-shrink: 0;

View File

@@ -29,7 +29,7 @@ const createProps = () => ({
cache_timeout: null,
changed_on: '2021-03-19T16:30:56.750230',
changed_on_humanized: '7 days ago',
datasource: 'FCC 2018 Survey',
datasource: 'FCC Survey Results',
description: null,
description_markeddown: '',
edit_url: '/chart/edit/318',

View File

@@ -125,6 +125,8 @@ const ConditionalFormattingControl = ({
}: ConditionalFormattingConfig) => {
const columnName = (column && verboseMap?.[column]) ?? column;
switch (operator) {
case COMPARATOR.NONE:
return `${columnName}`;
case COMPARATOR.BETWEEN:
return `${targetValueLeft} ${COMPARATOR.LESS_THAN} ${columnName} ${COMPARATOR.LESS_THAN} ${targetValueRight}`;
case COMPARATOR.BETWEEN_OR_EQUAL:

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useMemo } from 'react';
import React from 'react';
import { styled, t } from '@superset-ui/core';
import { Form, FormItem } from 'src/components/Form';
import { Form, FormItem, FormProps } from 'src/components/Form';
import { Select } from 'src/components';
import { Col, InputNumber, Row } from 'src/common/components';
import Button from 'src/components/Button';
@@ -44,6 +44,7 @@ const colorSchemeOptions = [
];
const operatorOptions = [
{ value: COMPARATOR.NONE, label: 'None' },
{ value: COMPARATOR.GREATER_THAN, label: '>' },
{ value: COMPARATOR.LESS_THAN, label: '<' },
{ value: COMPARATOR.GREATER_OR_EQUAL, label: '≥' },
@@ -56,6 +57,127 @@ const operatorOptions = [
{ value: COMPARATOR.BETWEEN_OR_RIGHT_EQUAL, label: '< x ≤' },
];
const targetValueValidator = (
compare: (targetValue: number, compareValue: number) => boolean,
rejectMessage: string,
) => (targetValue: number | string) => (
_: any,
compareValue: number | string,
) => {
if (
!targetValue ||
!compareValue ||
compare(Number(targetValue), Number(compareValue))
) {
return Promise.resolve();
}
return Promise.reject(new Error(rejectMessage));
};
const targetValueLeftValidator = targetValueValidator(
(target: number, val: number) => target > val,
t('This value should be smaller than the right target value'),
);
const targetValueRightValidator = targetValueValidator(
(target: number, val: number) => target < val,
t('This value should be greater than the left target value'),
);
const isOperatorMultiValue = (operator?: COMPARATOR) =>
operator && MULTIPLE_VALUE_COMPARATORS.includes(operator);
const isOperatorNone = (operator?: COMPARATOR) =>
!operator || operator === COMPARATOR.NONE;
const rulesRequired = [{ required: true, message: t('Required') }];
type GetFieldValue = Pick<Required<FormProps>['form'], 'getFieldValue'>;
const rulesTargetValueLeft = [
{ required: true, message: t('Required') },
({ getFieldValue }: GetFieldValue) => ({
validator: targetValueLeftValidator(getFieldValue('targetValueRight')),
}),
];
const rulesTargetValueRight = [
{ required: true, message: t('Required') },
({ getFieldValue }: GetFieldValue) => ({
validator: targetValueRightValidator(getFieldValue('targetValueLeft')),
}),
];
const targetValueLeftDeps = ['targetValueRight'];
const targetValueRightDeps = ['targetValueLeft'];
const shouldFormItemUpdate = (
prevValues: ConditionalFormattingConfig,
currentValues: ConditionalFormattingConfig,
) =>
isOperatorNone(prevValues.operator) !==
isOperatorNone(currentValues.operator) ||
isOperatorMultiValue(prevValues.operator) !==
isOperatorMultiValue(currentValues.operator);
const operatorField = (
<FormItem
name="operator"
label={t('Operator')}
rules={rulesRequired}
initialValue={operatorOptions[0].value}
>
<Select ariaLabel={t('Operator')} options={operatorOptions} />
</FormItem>
);
const renderOperatorFields = ({ getFieldValue }: GetFieldValue) =>
isOperatorNone(getFieldValue('operator')) ? (
<Row gutter={12}>
<Col span={6}>{operatorField}</Col>
</Row>
) : isOperatorMultiValue(getFieldValue('operator')) ? (
<Row gutter={12}>
<Col span={9}>
<FormItem
name="targetValueLeft"
label={t('Left value')}
rules={rulesTargetValueLeft}
dependencies={targetValueLeftDeps}
validateTrigger="onBlur"
trigger="onBlur"
>
<FullWidthInputNumber />
</FormItem>
</Col>
<Col span={6}>{operatorField}</Col>
<Col span={9}>
<FormItem
name="targetValueRight"
label={t('Right value')}
rules={rulesTargetValueRight}
dependencies={targetValueRightDeps}
validateTrigger="onBlur"
trigger="onBlur"
>
<FullWidthInputNumber />
</FormItem>
</Col>
</Row>
) : (
<Row gutter={12}>
<Col span={6}>{operatorField}</Col>
<Col span={18}>
<FormItem
name="targetValue"
label={t('Target value')}
rules={rulesRequired}
>
<FullWidthInputNumber />
</FormItem>
</Col>
</Row>
);
export const FormattingPopoverContent = ({
config,
onChange,
@@ -64,158 +186,44 @@ export const FormattingPopoverContent = ({
config?: ConditionalFormattingConfig;
onChange: (config: ConditionalFormattingConfig) => void;
columns: { label: string; value: string }[];
}) => {
const isOperatorMultiValue = (operator?: COMPARATOR) =>
operator && MULTIPLE_VALUE_COMPARATORS.includes(operator);
const operatorField = useMemo(
() => (
<FormItem
name="operator"
label={t('Operator')}
rules={[{ required: true, message: t('Required') }]}
initialValue={operatorOptions[0].value}
>
<Select ariaLabel={t('Operator')} options={operatorOptions} />
</FormItem>
),
[],
);
const targetValueLeftValidator = useCallback(
(rightValue?: number) => (_: any, value?: number) => {
if (!value || !rightValue || rightValue > value) {
return Promise.resolve();
}
return Promise.reject(
new Error(
t('This value should be smaller than the right target value'),
),
);
},
[],
);
const targetValueRightValidator = useCallback(
(leftValue?: number) => (_: any, value?: number) => {
if (!value || !leftValue || leftValue < value) {
return Promise.resolve();
}
return Promise.reject(
new Error(t('This value should be greater than the left target value')),
);
},
[],
);
return (
<Form
onFinish={onChange}
initialValues={config}
requiredMark="optional"
layout="vertical"
>
<Row gutter={12}>
<Col span={12}>
<FormItem
name="column"
label={t('Column')}
rules={[{ required: true, message: t('Required') }]}
initialValue={columns[0]?.value}
>
<Select ariaLabel={t('Select column')} options={columns} />
</FormItem>
</Col>
<Col span={12}>
<FormItem
name="colorScheme"
label={t('Color scheme')}
rules={[{ required: true, message: t('Required') }]}
initialValue={colorSchemeOptions[0].value}
>
<Select
ariaLabel={t('Color scheme')}
options={colorSchemeOptions}
/>
</FormItem>
</Col>
</Row>
<FormItem
noStyle
shouldUpdate={(
prevValues: ConditionalFormattingConfig,
currentValues: ConditionalFormattingConfig,
) =>
isOperatorMultiValue(prevValues.operator) !==
isOperatorMultiValue(currentValues.operator)
}
>
{({ getFieldValue }) =>
isOperatorMultiValue(getFieldValue('operator')) ? (
<Row gutter={12}>
<Col span={9}>
<FormItem
name="targetValueLeft"
label={t('Left value')}
rules={[
{ required: true, message: t('Required') },
({ getFieldValue }) => ({
validator: targetValueLeftValidator(
getFieldValue('targetValueRight'),
),
}),
]}
dependencies={['targetValueRight']}
validateTrigger="onBlur"
trigger="onBlur"
>
<FullWidthInputNumber />
</FormItem>
</Col>
<Col span={6}>{operatorField}</Col>
<Col span={9}>
<FormItem
name="targetValueRight"
label={t('Right value')}
rules={[
{ required: true, message: t('Required') },
({ getFieldValue }) => ({
validator: targetValueRightValidator(
getFieldValue('targetValueLeft'),
),
}),
]}
dependencies={['targetValueLeft']}
validateTrigger="onBlur"
trigger="onBlur"
>
<FullWidthInputNumber />
</FormItem>
</Col>
</Row>
) : (
<Row gutter={12}>
<Col span={6}>{operatorField}</Col>
<Col span={18}>
<FormItem
name="targetValue"
label={t('Target value')}
rules={[{ required: true, message: t('Required') }]}
>
<FullWidthInputNumber />
</FormItem>
</Col>
</Row>
)
}
</FormItem>
<FormItem>
<JustifyEnd>
<Button htmlType="submit" buttonStyle="primary">
{t('Apply')}
</Button>
</JustifyEnd>
</FormItem>
</Form>
);
};
}) => (
<Form
onFinish={onChange}
initialValues={config}
requiredMark="optional"
layout="vertical"
>
<Row gutter={12}>
<Col span={12}>
<FormItem
name="column"
label={t('Column')}
rules={rulesRequired}
initialValue={columns[0]?.value}
>
<Select ariaLabel={t('Select column')} options={columns} />
</FormItem>
</Col>
<Col span={12}>
<FormItem
name="colorScheme"
label={t('Color scheme')}
rules={rulesRequired}
initialValue={colorSchemeOptions[0].value}
>
<Select ariaLabel={t('Color scheme')} options={colorSchemeOptions} />
</FormItem>
</Col>
</Row>
<FormItem noStyle shouldUpdate={shouldFormItemUpdate}>
{renderOperatorFields}
</FormItem>
<FormItem>
<JustifyEnd>
<Button htmlType="submit" buttonStyle="primary">
{t('Apply')}
</Button>
</JustifyEnd>
</FormItem>
</Form>
);

View File

@@ -22,6 +22,7 @@ import { PopoverProps } from 'antd/lib/popover';
import { ControlComponentProps } from '@superset-ui/chart-controls/lib/shared-controls/components/types';
export enum COMPARATOR {
NONE = 'None',
GREATER_THAN = '>',
LESS_THAN = '<',
GREATER_OR_EQUAL = '≥',

View File

@@ -0,0 +1,223 @@
/**
* 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 camelcase */
import React, { useCallback, useMemo, useState } from 'react';
import Tabs from 'src/components/Tabs';
import Button from 'src/components/Button';
import { NativeSelect as Select } from 'src/components/Select';
import { t, styled } from '@superset-ui/core';
import { Form, FormItem } from 'src/components/Form';
import { StyledColumnOption } from 'src/explore/components/optionRenderers';
import { ColumnMeta } from '@superset-ui/chart-controls';
const StyledSelect = styled(Select)`
.metric-option {
& > svg {
min-width: ${({ theme }) => `${theme.gridUnit * 4}px`};
}
& > .option-label {
overflow: hidden;
text-overflow: ellipsis;
}
}
`;
interface ColumnSelectPopoverProps {
columns: ColumnMeta[];
editedColumn?: ColumnMeta;
onChange: (column: ColumnMeta) => void;
onClose: () => void;
}
const ColumnSelectPopover = ({
columns,
editedColumn,
onChange,
onClose,
}: ColumnSelectPopoverProps) => {
const [
initialCalculatedColumn,
initialSimpleColumn,
] = editedColumn?.expression
? [editedColumn, undefined]
: [undefined, editedColumn];
const [selectedCalculatedColumn, setSelectedCalculatedColumn] = useState(
initialCalculatedColumn,
);
const [selectedSimpleColumn, setSelectedSimpleColumn] = useState(
initialSimpleColumn,
);
const [calculatedColumns, simpleColumns] = useMemo(
() =>
columns?.reduce(
(acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => {
if (column.expression) {
acc[0].push(column);
} else {
acc[1].push(column);
}
return acc;
},
[[], []],
),
[columns],
);
const onCalculatedColumnChange = useCallback(
selectedColumnName => {
const selectedColumn = calculatedColumns.find(
col => col.column_name === selectedColumnName,
);
setSelectedCalculatedColumn(selectedColumn);
setSelectedSimpleColumn(undefined);
},
[calculatedColumns],
);
const onSimpleColumnChange = useCallback(
selectedColumnName => {
const selectedColumn = simpleColumns.find(
col => col.column_name === selectedColumnName,
);
setSelectedCalculatedColumn(undefined);
setSelectedSimpleColumn(selectedColumn);
},
[simpleColumns],
);
const defaultActiveTabKey =
initialSimpleColumn || calculatedColumns.length === 0 ? 'simple' : 'saved';
const onSave = useCallback(() => {
const selectedColumn = selectedCalculatedColumn || selectedSimpleColumn;
if (!selectedColumn) {
return;
}
onChange(selectedColumn);
onClose();
}, [onChange, onClose, selectedCalculatedColumn, selectedSimpleColumn]);
const onResetStateAndClose = useCallback(() => {
setSelectedCalculatedColumn(initialCalculatedColumn);
setSelectedSimpleColumn(initialSimpleColumn);
onClose();
}, [initialCalculatedColumn, initialSimpleColumn, onClose]);
const stateIsValid = selectedCalculatedColumn || selectedSimpleColumn;
const hasUnsavedChanges =
selectedCalculatedColumn?.column_name !==
initialCalculatedColumn?.column_name ||
selectedSimpleColumn?.column_name !== initialSimpleColumn?.column_name;
const filterOption = useCallback(
(input, option) =>
option?.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0,
[],
);
const getPopupContainer = useCallback(
(triggerNode: any) => triggerNode.parentNode,
[],
);
return (
<Form layout="vertical" id="metrics-edit-popover">
<Tabs
id="adhoc-metric-edit-tabs"
defaultActiveKey={defaultActiveTabKey}
className="adhoc-metric-edit-tabs"
allowOverflow
>
<Tabs.TabPane key="saved" tab={t('Saved')}>
<FormItem label={t('Saved expressions')}>
<StyledSelect
value={selectedCalculatedColumn?.column_name}
getPopupContainer={getPopupContainer}
onChange={onCalculatedColumnChange}
allowClear
showSearch
autoFocus={!selectedCalculatedColumn}
filterOption={filterOption}
placeholder={t('%s column(s)', calculatedColumns.length)}
>
{calculatedColumns.map(calculatedColumn => (
<Select.Option
value={calculatedColumn.column_name}
filterBy={
calculatedColumn.verbose_name ||
calculatedColumn.column_name
}
key={calculatedColumn.column_name}
>
<StyledColumnOption column={calculatedColumn} showType />
</Select.Option>
))}
</StyledSelect>
</FormItem>
</Tabs.TabPane>
<Tabs.TabPane key="simple" tab={t('Simple')}>
<FormItem label={t('Column')}>
<Select
value={selectedSimpleColumn?.column_name}
getPopupContainer={getPopupContainer}
onChange={onSimpleColumnChange}
allowClear
showSearch
autoFocus={!selectedSimpleColumn}
filterOption={filterOption}
placeholder={t('%s column(s)', simpleColumns.length)}
>
{simpleColumns.map(simpleColumn => (
<Select.Option
value={simpleColumn.column_name}
filterBy={
simpleColumn.verbose_name || simpleColumn.column_name
}
key={simpleColumn.column_name}
>
<StyledColumnOption column={simpleColumn} showType />
</Select.Option>
))}
</Select>
</FormItem>
</Tabs.TabPane>
</Tabs>
<div>
<Button buttonSize="small" onClick={onResetStateAndClose} cta>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid}
buttonStyle={
hasUnsavedChanges && stateIsValid ? 'primary' : 'default'
}
buttonSize="small"
onClick={onSave}
cta
>
{t('Save')}
</Button>
</div>
</Form>
);
};
export default ColumnSelectPopover;

View File

@@ -0,0 +1,99 @@
/**
* 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 React, { useCallback, useMemo, useState } from 'react';
import { ColumnMeta } from '@superset-ui/chart-controls';
import Popover from 'src/components/Popover';
import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover';
import ColumnSelectPopover from './ColumnSelectPopover';
interface ColumnSelectPopoverTriggerProps {
columns: ColumnMeta[];
editedColumn?: ColumnMeta;
onColumnEdit: (editedColumn: ColumnMeta) => void;
isControlledComponent?: boolean;
visible?: boolean;
togglePopover?: (visible: boolean) => void;
closePopover?: () => void;
children: React.ReactNode;
}
const ColumnSelectPopoverTrigger = ({
columns,
editedColumn,
onColumnEdit,
isControlledComponent,
children,
...props
}: ColumnSelectPopoverTriggerProps) => {
const [popoverVisible, setPopoverVisible] = useState(false);
const togglePopover = useCallback((visible: boolean) => {
setPopoverVisible(visible);
}, []);
const closePopover = useCallback(() => {
setPopoverVisible(false);
}, []);
const {
visible,
handleTogglePopover,
handleClosePopover,
} = isControlledComponent
? {
visible: props.visible,
handleTogglePopover: props.togglePopover!,
handleClosePopover: props.closePopover!,
}
: {
visible: popoverVisible,
handleTogglePopover: togglePopover,
handleClosePopover: closePopover,
};
const overlayContent = useMemo(
() => (
<ExplorePopoverContent>
<ColumnSelectPopover
editedColumn={editedColumn}
columns={columns}
onClose={handleClosePopover}
onChange={onColumnEdit}
/>
</ExplorePopoverContent>
),
[columns, editedColumn, handleClosePopover, onColumnEdit],
);
return (
<Popover
placement="right"
trigger="click"
content={overlayContent}
defaultVisible={visible}
visible={visible}
onVisibleChange={handleTogglePopover}
destroyTooltipOnHide
>
{children}
</Popover>
);
};
export default ColumnSelectPopoverTrigger;

View File

@@ -29,7 +29,7 @@ const defaultProps: LabelProps = {
test('renders with default props', () => {
render(<DndColumnSelect {...defaultProps} />, { useDnd: true });
expect(screen.getByText('Drop columns')).toBeInTheDocument();
expect(screen.getByText('Drop columns here')).toBeInTheDocument();
});
test('renders with value', () => {

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import { tn } from '@superset-ui/core';
import React, { useCallback, useMemo, useState } from 'react';
import { FeatureFlag, isFeatureEnabled, tn } from '@superset-ui/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash';
import { LabelProps } from 'src/explore/components/controls/DndColumnSelectControl/types';
@@ -26,7 +26,8 @@ import OptionWrapper from 'src/explore/components/controls/DndColumnSelectContro
import { OptionSelector } from 'src/explore/components/controls/DndColumnSelectControl/utils';
import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types';
import { DndItemType } from 'src/explore/components/DndItemType';
import { StyledColumnOption } from 'src/explore/components/optionRenderers';
import { useComponentDidUpdate } from 'src/common/hooks/useComponentDidUpdate';
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
export const DndColumnSelect = (props: LabelProps) => {
const {
@@ -39,13 +40,15 @@ export const DndColumnSelect = (props: LabelProps) => {
name,
label,
} = props;
const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false);
const optionSelector = useMemo(
() => new OptionSelector(options, multi, value),
[multi, options, value],
);
// synchronize values in case of dataset changes
useEffect(() => {
const handleOptionsChange = useCallback(() => {
const optionSelectorValues = optionSelector.getValues();
if (typeof value !== typeof optionSelectorValues) {
onChange(optionSelectorValues);
@@ -65,9 +68,12 @@ export const DndColumnSelect = (props: LabelProps) => {
) {
onChange(optionSelectorValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(value), JSON.stringify(optionSelector.getValues())]);
// useComponentDidUpdate to avoid running this for the first render, to avoid
// calling onChange when the initial value is not valid for the dataset
useComponentDidUpdate(handleOptionsChange);
const onDrop = useCallback(
(item: DatasourcePanelDndItem) => {
const column = item.value as ColumnMeta;
@@ -107,41 +113,120 @@ export const DndColumnSelect = (props: LabelProps) => {
[onChange, optionSelector],
);
const popoverOptions = useMemo(
() =>
Object.values(options).filter(
col =>
!optionSelector.values
.map(val => val.column_name)
.includes(col.column_name),
),
[optionSelector.values, options],
);
const valuesRenderer = useCallback(
() =>
optionSelector.values.map((column, idx) => (
<OptionWrapper
key={idx}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={`${DndItemType.ColumnOption}_${name}_${label}`}
canDelete={canDelete}
>
<StyledColumnOption column={column} showType />
</OptionWrapper>
)),
optionSelector.values.map((column, idx) =>
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) ? (
<ColumnSelectPopoverTrigger
columns={popoverOptions}
onColumnEdit={newColumn => {
optionSelector.replace(idx, newColumn.column_name);
onChange(optionSelector.getValues());
}}
editedColumn={column}
>
<OptionWrapper
key={idx}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={`${DndItemType.ColumnOption}_${name}_${label}`}
canDelete={canDelete}
column={column}
withCaret
/>
</ColumnSelectPopoverTrigger>
) : (
<OptionWrapper
key={idx}
index={idx}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={`${DndItemType.ColumnOption}_${name}_${label}`}
canDelete={canDelete}
column={column}
/>
),
),
[
canDelete,
label,
name,
onChange,
onClickClose,
onShiftOptions,
optionSelector.values,
optionSelector,
popoverOptions,
],
);
const addNewColumnWithPopover = useCallback(
(newColumn: ColumnMeta) => {
optionSelector.add(newColumn.column_name);
onChange(optionSelector.getValues());
},
[onChange, optionSelector],
);
const togglePopover = useCallback((visible: boolean) => {
setNewColumnPopoverVisible(visible);
}, []);
const closePopover = useCallback(() => {
togglePopover(false);
}, [togglePopover]);
const openPopover = useCallback(() => {
togglePopover(true);
}, [togglePopover]);
const defaultGhostButtonText = isFeatureEnabled(
FeatureFlag.ENABLE_DND_WITH_CLICK_UX,
)
? tn(
'Drop a column here or click',
'Drop columns here or click',
multi ? 2 : 1,
)
: tn('Drop column here', 'Drop columns here', multi ? 2 : 1);
return (
<DndSelectLabel<string | string[], ColumnMeta[]>
onDrop={onDrop}
canDrop={canDrop}
valuesRenderer={valuesRenderer}
accept={DndItemType.Column}
displayGhostButton={multi || optionSelector.values.length === 0}
ghostButtonText={
ghostButtonText || tn('Drop column', 'Drop columns', multi ? 2 : 1)
}
{...props}
/>
<div>
<DndSelectLabel<string | string[], ColumnMeta[]>
onDrop={onDrop}
canDrop={canDrop}
valuesRenderer={valuesRenderer}
accept={DndItemType.Column}
displayGhostButton={multi || optionSelector.values.length === 0}
ghostButtonText={ghostButtonText || defaultGhostButtonText}
onClickGhostButton={
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
? openPopover
: undefined
}
{...props}
/>
<ColumnSelectPopoverTrigger
columns={popoverOptions}
onColumnEdit={addNewColumnWithPopover}
isControlledComponent
togglePopover={togglePopover}
closePopover={closePopover}
visible={newColumnPopoverVisible}
>
<div />
</ColumnSelectPopoverTrigger>
</div>
);
};

View File

@@ -38,7 +38,7 @@ const defaultProps = {
test('renders with default props', () => {
render(<DndFilterSelect {...defaultProps} />, { useDnd: true });
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
});
test('renders with value', () => {
@@ -56,7 +56,7 @@ test('renders options with saved metric', () => {
render(<DndFilterSelect {...defaultProps} formData={['saved_metric']} />, {
useDnd: true,
});
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
});
test('renders options with column', () => {
@@ -76,7 +76,7 @@ test('renders options with column', () => {
useDnd: true,
},
);
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
});
test('renders options with adhoc metric', () => {
@@ -87,5 +87,5 @@ test('renders options with adhoc metric', () => {
render(<DndFilterSelect {...defaultProps} formData={[adhocMetric]} />, {
useDnd: true,
});
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
});

View File

@@ -26,7 +26,6 @@ import {
t,
} from '@superset-ui/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import { Tooltip } from 'src/components/Tooltip';
import {
OPERATOR_ENUM_TO_OPERATOR_TYPE,
Operators,
@@ -299,6 +298,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
() =>
values.map((adhocFilter: AdhocFilter, index: number) => {
const label = adhocFilter.getDefaultLabel();
const tooltipTitle = adhocFilter.getTooltipTitle();
return (
<AdhocFilterPopoverTrigger
key={index}
@@ -311,14 +311,14 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
<OptionWrapper
key={index}
index={index}
label={label}
tooltipTitle={tooltipTitle}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
type={DndItemType.FilterOption}
withCaret
isExtra={adhocFilter.isExtra}
>
<Tooltip title={label}>{label}</Tooltip>
</OptionWrapper>
/>
</AdhocFilterPopoverTrigger>
);
}),
@@ -333,6 +333,11 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
],
);
const handleClickGhostButton = useCallback(() => {
setDroppedItem(null);
togglePopover(true);
}, [togglePopover]);
const adhocFilter = useMemo(() => {
if (droppedItem?.metric_name) {
return new AdhocFilter({
@@ -351,7 +356,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
const config: Partial<AdhocFilter> = {
subject: (droppedItem as ColumnMeta)?.column_name,
};
if (isFeatureEnabled(FeatureFlag.UX_BETA)) {
if (config.subject && isFeatureEnabled(FeatureFlag.UX_BETA)) {
config.operator = OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.IN].operation;
config.operatorId = Operators.IN;
}
@@ -367,6 +372,10 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
[togglePopover],
);
const ghostButtonText = isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
? t('Drop columns/metrics here or click')
: t('Drop columns or metrics here');
return (
<>
<DndSelectLabel<OptionValueType, OptionValueType[]>
@@ -374,7 +383,12 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
canDrop={canDrop}
valuesRenderer={valuesRenderer}
accept={DND_ACCEPTED_TYPES}
ghostButtonText={t('Drop columns or metrics')}
ghostButtonText={ghostButtonText}
onClickGhostButton={
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
? handleClickGhostButton
: undefined
}
{...props}
/>
<AdhocFilterPopoverTrigger
@@ -387,7 +401,6 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
visible={newFilterPopoverVisible}
togglePopover={togglePopover}
closePopover={closePopover}
createNew
>
<div />
</AdhocFilterPopoverTrigger>

View File

@@ -31,10 +31,10 @@ const defaultProps = {
test('renders with default props', () => {
render(<DndMetricSelect {...defaultProps} />, { useDnd: true });
expect(screen.getByText('Drop column or metric')).toBeInTheDocument();
expect(screen.getByText('Drop column or metric here')).toBeInTheDocument();
});
test('renders with default props and multi = true', () => {
render(<DndMetricSelect {...defaultProps} multi />, { useDnd: true });
expect(screen.getByText('Drop columns or metrics')).toBeInTheDocument();
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
});

View File

@@ -185,6 +185,9 @@ export const DndMetricSelect = (props: any) => {
const onMetricEdit = useCallback(
(changedMetric: Metric | AdhocMetric, oldMetric: Metric | AdhocMetric) => {
if (oldMetric instanceof AdhocMetric && oldMetric.equals(changedMetric)) {
return;
}
const newValue = value.map(value => {
if (
// compare saved metrics
@@ -245,7 +248,10 @@ export const DndMetricSelect = (props: any) => {
[props.savedMetrics, props.value],
);
const handleDropLabel = useCallback(() => onChange(value), [onChange, value]);
const handleDropLabel = useCallback(
() => onChange(multi ? value : value[0]),
[multi, onChange, value],
);
const valueRenderer = useCallback(
(option: Metric | AdhocMetric | string, index: number) => (
@@ -262,12 +268,14 @@ export const DndMetricSelect = (props: any) => {
onMoveLabel={moveLabel}
onDropLabel={handleDropLabel}
type={`${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`}
multi={multi}
/>
),
[
getSavedMetricOptionsForMetric,
handleDropLabel,
moveLabel,
multi,
onMetricEdit,
onRemoveMetric,
props.columns,
@@ -304,6 +312,11 @@ export const DndMetricSelect = (props: any) => {
[onNewMetric, togglePopover],
);
const handleClickGhostButton = useCallback(() => {
setDroppedItem(null);
togglePopover(true);
}, [togglePopover]);
const adhocMetric = useMemo(() => {
if (droppedItem?.type === DndItemType.Column) {
const itemValue = droppedItem?.value as ColumnMeta;
@@ -326,6 +339,18 @@ export const DndMetricSelect = (props: any) => {
return new AdhocMetric({ isNew: true });
}, [droppedItem]);
const ghostButtonText = isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
? tn(
'Drop a column/metric here or click',
'Drop columns/metrics here or click',
multi ? 2 : 1,
)
: tn(
'Drop column or metric here',
'Drop columns or metrics here',
multi ? 2 : 1,
);
return (
<div className="metrics-select">
<DndSelectLabel<OptionValueType, OptionValueType[]>
@@ -333,12 +358,13 @@ export const DndMetricSelect = (props: any) => {
canDrop={canDrop}
valuesRenderer={valuesRenderer}
accept={DND_ACCEPTED_TYPES}
ghostButtonText={tn(
'Drop column or metric',
'Drop columns or metrics',
multi ? 2 : 1,
)}
ghostButtonText={ghostButtonText}
displayGhostButton={multi || value.length === 0}
onClickGhostButton={
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
? handleClickGhostButton
: undefined
}
{...props}
/>
<AdhocMetricPopoverTrigger
@@ -352,7 +378,6 @@ export const DndMetricSelect = (props: any) => {
visible={newMetricPopoverVisible}
togglePopover={togglePopover}
closePopover={closePopover}
createNew
>
<div />
</AdhocMetricPopoverTrigger>

View File

@@ -33,7 +33,7 @@ const defaultProps = {
test('renders with default props', async () => {
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
expect(await screen.findByText('Drop columns')).toBeInTheDocument();
expect(await screen.findByText('Drop columns here')).toBeInTheDocument();
});
test('renders ghost button when empty', async () => {

View File

@@ -53,9 +53,12 @@ export default function DndSelectLabel<T, O>({
function renderGhostButton() {
return (
<AddControlLabel cancelHover>
<AddControlLabel
cancelHover={!props.onClickGhostButton}
onClick={props.onClickGhostButton}
>
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
{t(props.ghostButtonText || 'Drop columns')}
{t(props.ghostButtonText || 'Drop columns here')}
</AddControlLabel>
);
}

View File

@@ -28,9 +28,8 @@ test('renders with default props', () => {
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
>
Option
</OptionWrapper>,
label="Option"
/>,
{ useDnd: true },
);
expect(container).toBeInTheDocument();
@@ -46,17 +45,15 @@ test('triggers onShiftOptions on drop', () => {
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
>
Option 1
</OptionWrapper>
label="Option 1"
/>
<OptionWrapper
index={2}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
>
Option 2
</OptionWrapper>
label="Option 2"
/>
</>,
{ useDnd: true },
);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useMemo, useRef } from 'react';
import React, { useRef } from 'react';
import {
useDrag,
useDrop,
@@ -28,8 +28,19 @@ import {
OptionProps,
OptionItemInterface,
} from 'src/explore/components/controls/DndColumnSelectControl/types';
import { Tooltip } from 'src/components/Tooltip';
import { StyledColumnOption } from 'src/explore/components/optionRenderers';
import { styled } from '@superset-ui/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import Option from './Option';
export const OptionLabel = styled.div`
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export default function OptionWrapper(
props: OptionProps & {
type: string;
@@ -38,26 +49,25 @@ export default function OptionWrapper(
) {
const {
index,
label,
tooltipTitle,
column,
type,
onShiftOptions,
clickClose,
withCaret,
isExtra,
canDelete = true,
children,
...rest
} = props;
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const item: OptionItemInterface = useMemo(
() => ({
dragIndex: index,
const [{ isDragging }, drag] = useDrag({
item: {
type,
}),
[index, type],
);
const [, drag] = useDrag({
item,
dragIndex: index,
},
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(),
}),
@@ -85,8 +95,8 @@ export default function OptionWrapper(
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset?.y
? clientOffset?.y - hoverBoundingRect.top
const hoverClientY = clientOffset
? clientOffset.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
@@ -107,6 +117,51 @@ export default function OptionWrapper(
},
});
const shouldShowTooltip =
(!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
(!isDragging &&
labelRef &&
labelRef.current &&
labelRef.current.scrollWidth > labelRef.current.clientWidth);
const LabelContent = () => {
if (!shouldShowTooltip) {
return <span>{label}</span>;
}
return (
<Tooltip title={tooltipTitle || label}>
<span>{label}</span>
</Tooltip>
);
};
const ColumnOption = () => (
<StyledColumnOption
column={column as ColumnMeta}
labelRef={labelRef}
showTooltip={!!shouldShowTooltip}
showType
/>
);
const Label = () => {
if (label) {
return (
<OptionLabel ref={labelRef}>
<LabelContent />
</OptionLabel>
);
}
if (column) {
return (
<OptionLabel>
<ColumnOption />
</OptionLabel>
);
}
return null;
};
drag(drop(ref));
return (
@@ -118,7 +173,7 @@ export default function OptionWrapper(
isExtra={isExtra}
canDelete={canDelete}
>
{children}
<Label />
</Option>
</DragContainer>
);

View File

@@ -23,8 +23,11 @@ import { DatasourcePanelDndItem } from '../../DatasourcePanel/types';
import { DndItemType } from '../../DndItemType';
export interface OptionProps {
children: ReactNode;
children?: ReactNode;
index: number;
label?: string;
tooltipTitle?: string;
column?: ColumnMeta;
clickClose: (index: number) => void;
withCaret?: boolean;
isExtra?: boolean;
@@ -57,6 +60,7 @@ export interface DndColumnSelectProps<
accept: DndItemType | DndItemType[];
ghostButtonText?: string;
displayGhostButton?: boolean;
onClickGhostButton?: () => void;
}
export type OptionValueType = Record<string, any>;

View File

@@ -329,7 +329,6 @@ class AdhocFilterControl extends React.Component {
options={this.state.options}
onFilterEdit={this.onNewFilter}
partitionColumn={this.state.partitionColumn}
createNew
>
{trigger}
</AdhocFilterPopoverTrigger>

View File

@@ -29,7 +29,6 @@ interface AdhocFilterPopoverTriggerProps {
datasource: Record<string, any>;
onFilterEdit: (editedFilter: AdhocFilter) => void;
partitionColumn?: string;
createNew?: boolean;
isControlledComponent?: boolean;
visible?: boolean;
togglePopover?: (visible: boolean) => void;
@@ -104,7 +103,7 @@ class AdhocFilterPopoverTrigger extends React.PureComponent<
defaultVisible={visible}
visible={visible}
onVisibleChange={togglePopover}
destroyTooltipOnHide={this.props.createNew}
destroyTooltipOnHide
>
{this.props.children}
</Popover>

View File

@@ -37,6 +37,7 @@ const propTypes = {
onDropLabel: PropTypes.func,
index: PropTypes.number,
type: PropTypes.string,
multi: PropTypes.bool,
};
class AdhocMetricOption extends React.PureComponent {
@@ -62,6 +63,7 @@ class AdhocMetricOption extends React.PureComponent {
onDropLabel,
index,
type,
multi,
} = this.props;
return (
@@ -84,6 +86,7 @@ class AdhocMetricOption extends React.PureComponent {
type={type ?? DndItemType.AdhocMetricOption}
withCaret
isFunction
multi={multi}
/>
</AdhocMetricPopoverTrigger>
);

View File

@@ -35,7 +35,6 @@ export type AdhocMetricPopoverTriggerProps = {
savedMetric: savedMetricType;
datasourceType: string;
children: ReactNode;
createNew?: boolean;
isControlledComponent?: boolean;
visible?: boolean;
togglePopover?: (visible: boolean) => void;
@@ -232,7 +231,7 @@ class AdhocMetricPopoverTrigger extends React.PureComponent<
visible={visible}
onVisibleChange={togglePopover}
title={popoverTitle}
destroyTooltipOnHide={this.props.createNew}
destroyTooltipOnHide
>
{this.props.children}
</Popover>

View File

@@ -49,6 +49,7 @@ export default function MetricDefinitionValue({
onDropLabel,
index,
type,
multi,
}) {
const getSavedMetricByName = metricName =>
savedMetrics.find(metric => metric.metric_name === metricName);
@@ -76,6 +77,7 @@ export default function MetricDefinitionValue({
index,
savedMetric: savedMetric ?? {},
type,
multi,
};
return <AdhocMetricOption {...metricOptionProps} />;

View File

@@ -16,16 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { t, withTheme } from '@superset-ui/core';
import { isEqual } from 'lodash';
import { ensureIsArray, t, useTheme } from '@superset-ui/core';
import ControlHeader from 'src/explore/components/ControlHeader';
import {
AGGREGATES_OPTIONS,
sqlaAutoGeneratedMetricNameRegex,
druidAutoGeneratedMetricRegex,
} from 'src/explore/constants';
import Icons from 'src/components/Icons';
import {
AddIconButton,
@@ -34,7 +28,6 @@ import {
LabelsContainer,
} from 'src/explore/components/controls/OptionControls';
import columnType from './columnType';
import MetricDefinitionOption from './MetricDefinitionOption';
import MetricDefinitionValue from './MetricDefinitionValue';
import AdhocMetric from './AdhocMetric';
import savedMetricType from './savedMetricType';
@@ -82,9 +75,9 @@ function isDictionaryForAdhocMetric(value) {
return value && !(value instanceof AdhocMetric) && value.expressionType;
}
function columnsContainAllMetrics(value, nextProps) {
function columnsContainAllMetrics(value, columns, savedMetrics) {
const columnNames = new Set(
[...(nextProps.columns || []), ...(nextProps.savedMetrics || [])]
[...(columns || []), ...(savedMetrics || [])]
// eslint-disable-next-line camelcase
.map(({ column_name, metric_name }) => column_name || metric_name),
);
@@ -123,294 +116,227 @@ function coerceAdhocMetrics(value) {
});
}
class MetricsControl extends React.PureComponent {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
this.onMetricEdit = this.onMetricEdit.bind(this);
this.onNewMetric = this.onNewMetric.bind(this);
this.onRemoveMetric = this.onRemoveMetric.bind(this);
this.moveLabel = this.moveLabel.bind(this);
this.checkIfAggregateInInput = this.checkIfAggregateInInput.bind(this);
this.optionsForSelect = this.optionsForSelect.bind(this);
this.selectFilterOption = this.selectFilterOption.bind(this);
this.isAutoGeneratedMetric = this.isAutoGeneratedMetric.bind(this);
this.optionRenderer = option => <MetricDefinitionOption option={option} />;
this.valueRenderer = (option, index) => (
const emptySavedMetric = { metric_name: '', expression: '' };
const MetricsControl = ({
onChange,
multi,
value: propsValue,
columns,
savedMetrics,
datasource,
datasourceType,
...props
}) => {
const [value, setValue] = useState(coerceAdhocMetrics(propsValue));
const theme = useTheme();
const handleChange = useCallback(
opts => {
// if clear out options
if (opts === null) {
onChange(null);
return;
}
const transformedOpts = ensureIsArray(opts);
const optionValues = transformedOpts
.map(option => {
// pre-defined metric
if (option.metric_name) {
return option.metric_name;
}
return option;
})
.filter(option => option);
onChange(multi ? optionValues : optionValues[0]);
},
[multi, onChange],
);
const onNewMetric = useCallback(
newMetric => {
const newValue = [...value, newMetric];
setValue(newValue);
handleChange(newValue);
},
[handleChange, value],
);
const onMetricEdit = useCallback(
(changedMetric, oldMetric) => {
const newValue = value.map(val => {
if (
// compare saved metrics
val === oldMetric.metric_name ||
// compare adhoc metrics
typeof val.optionName !== 'undefined'
? val.optionName === oldMetric.optionName
: false
) {
return changedMetric;
}
return val;
});
setValue(newValue);
handleChange(newValue);
},
[handleChange, value],
);
const onRemoveMetric = useCallback(
index => {
if (!Array.isArray(value)) {
return;
}
const valuesCopy = [...value];
valuesCopy.splice(index, 1);
setValue(valuesCopy);
handleChange(valuesCopy);
},
[handleChange, value],
);
const moveLabel = useCallback(
(dragIndex, hoverIndex) => {
const newValues = [...value];
[newValues[hoverIndex], newValues[dragIndex]] = [
newValues[dragIndex],
newValues[hoverIndex],
];
setValue(newValues);
},
[value],
);
const isAddNewMetricDisabled = useCallback(() => !multi && value.length > 0, [
multi,
value.length,
]);
const savedMetricOptions = useMemo(
() => getOptionsForSavedMetrics(savedMetrics, propsValue, null),
[propsValue, savedMetrics],
);
const newAdhocMetric = useMemo(() => new AdhocMetric({ isNew: true }), [
value,
]);
const addNewMetricPopoverTrigger = useCallback(
trigger => {
if (isAddNewMetricDisabled()) {
return trigger;
}
return (
<AdhocMetricPopoverTrigger
adhocMetric={newAdhocMetric}
onMetricEdit={onNewMetric}
columns={columns}
savedMetricsOptions={savedMetricOptions}
datasource={datasource}
savedMetric={emptySavedMetric}
datasourceType={datasourceType}
>
{trigger}
</AdhocMetricPopoverTrigger>
);
},
[
columns,
datasource,
datasourceType,
isAddNewMetricDisabled,
newAdhocMetric,
onNewMetric,
savedMetricOptions,
],
);
useEffect(() => {
// Remove all metrics if selected value no longer a valid column
// in the dataset. Must use `nextProps` here because Redux reducers may
// have already updated the value for this control.
if (!columnsContainAllMetrics(propsValue, columns, savedMetrics)) {
handleChange([]);
}
}, [columns, savedMetrics]);
useEffect(() => {
setValue(coerceAdhocMetrics(propsValue));
}, [propsValue]);
const onDropLabel = useCallback(() => handleChange(value), [
handleChange,
value,
]);
const valueRenderer = useCallback(
(option, index) => (
<MetricDefinitionValue
key={index}
index={index}
option={option}
onMetricEdit={this.onMetricEdit}
onRemoveMetric={this.onRemoveMetric}
columns={this.props.columns}
datasource={this.props.datasource}
savedMetrics={this.props.savedMetrics}
onMetricEdit={onMetricEdit}
onRemoveMetric={onRemoveMetric}
columns={columns}
datasource={datasource}
savedMetrics={savedMetrics}
savedMetricsOptions={getOptionsForSavedMetrics(
this.props.savedMetrics,
this.props.value,
this.props.value?.[index],
savedMetrics,
value,
value?.[index],
)}
datasourceType={this.props.datasourceType}
onMoveLabel={this.moveLabel}
onDropLabel={() => this.props.onChange(this.state.value)}
datasourceType={datasourceType}
onMoveLabel={moveLabel}
onDropLabel={onDropLabel}
multi={multi}
/>
);
this.select = null;
this.selectRef = ref => {
if (ref) {
this.select = ref.select;
} else {
this.select = null;
}
};
this.state = {
aggregateInInput: null,
options: this.optionsForSelect(this.props),
value: coerceAdhocMetrics(this.props.value),
};
}
),
[
columns,
datasource,
datasourceType,
moveLabel,
multi,
onDropLabel,
onMetricEdit,
onRemoveMetric,
savedMetrics,
value,
],
);
UNSAFE_componentWillReceiveProps(nextProps) {
const { value } = this.props;
if (
!isEqual(this.props.columns, nextProps.columns) ||
!isEqual(this.props.savedMetrics, nextProps.savedMetrics)
) {
this.setState({ options: this.optionsForSelect(nextProps) });
// Remove all metrics if selected value no longer a valid column
// in the dataset. Must use `nextProps` here because Redux reducers may
// have already updated the value for this control.
if (!columnsContainAllMetrics(nextProps.value, nextProps)) {
this.props.onChange([]);
}
}
if (value !== nextProps.value) {
this.setState({ value: coerceAdhocMetrics(nextProps.value) });
}
}
onNewMetric(newMetric) {
this.setState(
prevState => ({
...prevState,
value: [...prevState.value, newMetric],
}),
() => {
this.onChange(this.state.value);
},
);
}
onMetricEdit(changedMetric, oldMetric) {
this.setState(
prevState => ({
value: prevState.value.map(value => {
if (
// compare saved metrics
value === oldMetric.metric_name ||
// compare adhoc metrics
typeof value.optionName !== 'undefined'
? value.optionName === oldMetric.optionName
: false
) {
return changedMetric;
}
return value;
}),
}),
() => {
this.onChange(this.state.value);
},
);
}
onRemoveMetric(index) {
if (!Array.isArray(this.state.value)) {
return;
}
const valuesCopy = [...this.state.value];
valuesCopy.splice(index, 1);
this.setState(prevState => ({
...prevState,
value: valuesCopy,
}));
this.props.onChange(valuesCopy);
}
onChange(opts) {
// if clear out options
if (opts === null) {
this.props.onChange(null);
return;
}
let transformedOpts;
if (Array.isArray(opts)) {
transformedOpts = opts;
} else {
transformedOpts = opts ? [opts] : [];
}
const optionValues = transformedOpts
.map(option => {
// pre-defined metric
if (option.metric_name) {
return option.metric_name;
}
return option;
})
.filter(option => option);
this.props.onChange(this.props.multi ? optionValues : optionValues[0]);
}
moveLabel(dragIndex, hoverIndex) {
const { value } = this.state;
const newValues = [...value];
[newValues[hoverIndex], newValues[dragIndex]] = [
newValues[dragIndex],
newValues[hoverIndex],
];
this.setState({ value: newValues });
}
isAddNewMetricDisabled() {
return !this.props.multi && this.state.value.length > 0;
}
addNewMetricPopoverTrigger(trigger) {
if (this.isAddNewMetricDisabled()) {
return trigger;
}
return (
<AdhocMetricPopoverTrigger
adhocMetric={new AdhocMetric({ isNew: true })}
onMetricEdit={this.onNewMetric}
columns={this.props.columns}
savedMetricsOptions={getOptionsForSavedMetrics(
this.props.savedMetrics,
this.props.value,
null,
return (
<div className="metrics-select">
<HeaderContainer>
<ControlHeader {...props} />
{addNewMetricPopoverTrigger(
<AddIconButton
disabled={isAddNewMetricDisabled()}
data-test="add-metric-button"
>
<Icons.PlusLarge
iconSize="s"
iconColor={theme.colors.grayscale.light5}
/>
</AddIconButton>,
)}
datasource={this.props.datasource}
savedMetric={{ metric_name: '', expression: '' }}
datasourceType={this.props.datasourceType}
createNew
>
{trigger}
</AdhocMetricPopoverTrigger>
);
}
checkIfAggregateInInput(input) {
const lowercaseInput = input.toLowerCase();
const aggregateInInput =
AGGREGATES_OPTIONS.find(x =>
lowercaseInput.startsWith(`${x.toLowerCase()}(`),
) || null;
this.clearedAggregateInInput = this.state.aggregateInInput;
this.setState({ aggregateInInput });
}
optionsForSelect(props) {
const { columns, savedMetrics } = props;
const aggregates =
columns && columns.length
? AGGREGATES_OPTIONS.map(aggregate => ({
aggregate_name: aggregate,
}))
: [];
const options = [
...(columns || []),
...aggregates,
...(savedMetrics || []),
];
return options.reduce((results, option) => {
if (option.metric_name) {
results.push({ ...option, optionName: option.metric_name });
} else if (option.column_name) {
results.push({ ...option, optionName: `_col_${option.column_name}` });
} else if (option.aggregate_name) {
results.push({
...option,
optionName: `_aggregate_${option.aggregate_name}`,
});
}
return results;
}, []);
}
isAutoGeneratedMetric(savedMetric) {
if (this.props.datasourceType === 'druid') {
return druidAutoGeneratedMetricRegex.test(savedMetric.verbose_name);
}
return sqlaAutoGeneratedMetricNameRegex.test(savedMetric.metric_name);
}
selectFilterOption({ data: option }, filterValue) {
if (this.state.aggregateInInput) {
let endIndex = filterValue.length;
if (filterValue.endsWith(')')) {
endIndex = filterValue.length - 1;
}
const valueAfterAggregate = filterValue.substring(
filterValue.indexOf('(') + 1,
endIndex,
);
return (
option.column_name &&
option.column_name.toLowerCase().indexOf(valueAfterAggregate) >= 0
);
}
return (
option.optionName &&
(!option.metric_name ||
!this.isAutoGeneratedMetric(option) ||
option.verbose_name) &&
(option.optionName.toLowerCase().indexOf(filterValue) >= 0 ||
(option.verbose_name &&
option.verbose_name.toLowerCase().indexOf(filterValue) >= 0))
);
}
render() {
const { theme } = this.props;
return (
<div className="metrics-select">
<HeaderContainer>
<ControlHeader {...this.props} />
{this.addNewMetricPopoverTrigger(
<AddIconButton
disabled={this.isAddNewMetricDisabled()}
data-test="add-metric-button"
>
<Icons.PlusLarge
iconSize="s"
iconColor={theme.colors.grayscale.light5}
/>
</AddIconButton>,
)}
</HeaderContainer>
<LabelsContainer>
{this.state.value.length > 0
? this.state.value.map((value, index) =>
this.valueRenderer(value, index),
)
: this.addNewMetricPopoverTrigger(
<AddControlLabel>
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
{t('Add metric')}
</AddControlLabel>,
)}
</LabelsContainer>
</div>
);
}
}
</HeaderContainer>
<LabelsContainer>
{value.length > 0
? value.map((value, index) => valueRenderer(value, index))
: addNewMetricPopoverTrigger(
<AddControlLabel>
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
{t('Add metric')}
</AddControlLabel>,
)}
</LabelsContainer>
</div>
);
};
MetricsControl.propTypes = propTypes;
MetricsControl.defaultProps = defaultProps;
export default withTheme(MetricsControl);
export default MetricsControl;

View File

@@ -45,11 +45,10 @@ export const OptionControlContainer = styled.div<{
border-radius: 3px;
cursor: ${({ withCaret }) => (withCaret ? 'pointer' : 'default')};
`;
export const Label = styled.div`
${({ theme }) => `
display: flex;
max-width: 100%;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
align-items: center;
@@ -71,6 +70,11 @@ export const Label = styled.div`
`}
`;
const LabelText = styled.span`
overflow: hidden;
text-overflow: ellipsis;
`;
export const CaretContainer = styled.div`
height: 100%;
border-left: solid 1px ${({ theme }) => theme.colors.grayscale.dark2}0C;
@@ -177,6 +181,7 @@ export const OptionControlLabel = ({
index,
isExtra,
tooltipTitle,
multi = true,
...props
}: {
label: string | React.ReactNode;
@@ -192,15 +197,24 @@ export const OptionControlLabel = ({
index: number;
isExtra?: boolean;
tooltipTitle: string;
multi?: boolean;
}) => {
const theme = useTheme();
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const hasMetricName = savedMetric?.metric_name;
const [, drop] = useDrop({
accept: type,
drop() {
if (!multi) {
return;
}
onDropLabel?.();
},
hover(item: DragItem, monitor: DropTargetMonitor) {
if (!multi) {
return;
}
if (!ref.current) {
return;
}
@@ -242,7 +256,7 @@ export const OptionControlLabel = ({
item.index = hoverIndex;
},
});
const [, drag] = useDrag({
const [{ isDragging }, drag] = useDrag({
item: {
type,
index,
@@ -254,10 +268,34 @@ export const OptionControlLabel = ({
});
const getLabelContent = () => {
if (savedMetric?.metric_name) {
return <StyledMetricOption metric={savedMetric} />;
const shouldShowTooltip =
(!isDragging &&
typeof label === 'string' &&
tooltipTitle &&
label &&
tooltipTitle !== label) ||
(!isDragging &&
labelRef &&
labelRef.current &&
labelRef.current.scrollWidth > labelRef.current.clientWidth);
if (savedMetric && hasMetricName) {
return (
<StyledMetricOption
metric={savedMetric}
labelRef={labelRef}
showTooltip={!!shouldShowTooltip}
/>
);
}
return <Tooltip title={tooltipTitle}>{label}</Tooltip>;
if (!shouldShowTooltip) {
return <LabelText ref={labelRef}>{label}</LabelText>;
}
return (
<Tooltip title={tooltipTitle || label}>
<LabelText ref={labelRef}>{label}</LabelText>
</Tooltip>
);
};
const getOptionControlContent = () => (

View File

@@ -120,7 +120,7 @@ describe('VizTypeControl', () => {
expect(visualizations).toHaveTextContent(/Time-series Table/);
expect(visualizations).toHaveTextContent(/Time-series Chart/);
expect(visualizations).toHaveTextContent(/Mixed timeseries chart/);
expect(visualizations).toHaveTextContent(/Mixed Time-Series/);
expect(visualizations).not.toHaveTextContent(/Line Chart/);
});
});

View File

@@ -62,17 +62,22 @@ enum SECTIONS {
const DEFAULT_ORDER = [
'line',
'big_number',
'big_number_total',
'table',
'pivot_table_v2',
'echarts_timeseries_line',
'echarts_area',
'echarts_timeseries_bar',
'echarts_timeseries_scatter',
'pie',
'mixed_timeseries',
'filter_box',
'dist_bar',
'area',
'bar',
'deck_polygon',
'pie',
'time_table',
'pivot_table_v2',
'histogram',
'big_number_total',
'deck_scatter',
'deck_hex',
'time_pivot',
@@ -116,11 +121,7 @@ const OTHER_CATEGORY = t('Other');
const ALL_CHARTS = t('All charts');
const RECOMMENDED_TAGS = [
t('Highly-used'),
t('ECharts'),
t('Advanced-Analytics'),
];
const RECOMMENDED_TAGS = [t('Popular'), t('ECharts'), t('Advanced-Analytics')];
export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control';

View File

@@ -27,6 +27,7 @@ import {
} from '@superset-ui/chart-controls';
const OptionContainer = styled.div`
width: 100%;
> span {
display: flex;
align-items: center;

View File

@@ -19,6 +19,7 @@
/* eslint camelcase: 0 */
import { t, SupersetClient } from '@superset-ui/core';
import rison from 'rison';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { addDangerToast, addSuccessToast } from '../../messageToasts/actions';
export const SET_REPORT = 'SET_REPORT';
@@ -102,15 +103,21 @@ export const addReport = report => dispatch => {
endpoint: `/api/v1/report/`,
jsonPayload: report,
})
.then(() => {
dispatch({ type: ADD_REPORT, report });
.then(({ json }) => {
dispatch({ type: ADD_REPORT, json });
dispatch(addSuccessToast(t('The report has been created')));
})
.catch(() =>
.catch(async e => {
const parsedError = await getClientErrorObject(e);
const errorMessage = parsedError.message;
const errorArr = Object.keys(errorMessage);
const error = errorMessage[errorArr[0]][0];
dispatch(
addDangerToast(t('An error occurred while creating this report.')),
),
);
addDangerToast(
t('An error occurred while editing this report: %s', error),
),
);
});
};
export const EDIT_REPORT = 'EDIT_REPORT';

View File

@@ -25,12 +25,12 @@ import {
import React, { useMemo, useState } from 'react';
import rison from 'rison';
import { uniqBy } from 'lodash';
import moment from 'moment';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import {
createErrorHandler,
createFetchRelated,
handleChartDelete,
CardStylesOverrides,
} from 'src/views/CRUD/utils';
import {
useChartEditModal,
@@ -160,6 +160,9 @@ function ChartList(props: ChartListProps) {
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
const { userId } = props.user;
const userKey = getFromLocalStorage(userId.toString(), null);
const openChartImportModal = () => {
showImportModal(true);
};
@@ -270,23 +273,33 @@ function ChartList(props: ChartListProps) {
Cell: ({
row: {
original: {
changed_by_name: changedByName,
last_saved_by: lastSavedBy,
changed_by_url: changedByUrl,
},
},
}: any) => <a href={changedByUrl}>{changedByName}</a>,
}: any) => (
<a href={changedByUrl}>
{lastSavedBy?.first_name
? `${lastSavedBy?.first_name} ${lastSavedBy?.last_name}`
: null}
</a>
),
Header: t('Modified by'),
accessor: 'changed_by.first_name',
accessor: 'last_saved_by.first_name',
size: 'xl',
},
{
Cell: ({
row: {
original: { changed_on_delta_humanized: changedOn },
original: { last_saved_at: lastSavedAt },
},
}: any) => <span className="no-wrap">{changedOn}</span>,
}: any) => (
<span className="no-wrap">
{lastSavedAt ? moment.utc(lastSavedAt).fromNow() : null}
</span>
),
Header: t('Last modified'),
accessor: 'changed_on_delta_humanized',
accessor: 'last_saved_at',
size: 'xl',
},
{
@@ -532,29 +545,25 @@ function ChartList(props: ChartListProps) {
];
function renderCard(chart: Chart) {
const { userId } = props.user;
const userKey = getFromLocalStorage(userId.toString(), null);
return (
<CardStylesOverrides>
<ChartCard
chart={chart}
showThumbnails={
userKey
? userKey.thumbnails
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
}
hasPerm={hasPerm}
openChartEditModal={openChartEditModal}
bulkSelectEnabled={bulkSelectEnabled}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={refreshData}
loading={loading}
favoriteStatus={favoriteStatus[chart.id]}
saveFavoriteStatus={saveFavoriteStatus}
handleBulkChartExport={handleBulkChartExport}
/>
</CardStylesOverrides>
<ChartCard
chart={chart}
showThumbnails={
userKey
? userKey.thumbnails
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
}
hasPerm={hasPerm}
openChartEditModal={openChartEditModal}
bulkSelectEnabled={bulkSelectEnabled}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={refreshData}
loading={loading}
favoriteStatus={favoriteStatus[chart.id]}
saveFavoriteStatus={saveFavoriteStatus}
handleBulkChartExport={handleBulkChartExport}
/>
);
}
const subMenuButtons: SubMenuProps['buttons'] = [];
@@ -644,6 +653,11 @@ function ChartList(props: ChartListProps) {
loading={loading}
pageSize={PAGE_SIZE}
renderCard={renderCard}
showThumbnails={
userKey
? userKey.thumbnails
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
}
defaultViewMode={
isFeatureEnabled(FeatureFlag.LISTVIEWS_DEFAULT_CARD_VIEW)
? 'card'

View File

@@ -25,7 +25,6 @@ import {
createFetchRelated,
createErrorHandler,
handleDashboardDelete,
CardStylesOverrides,
} from 'src/views/CRUD/utils';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
@@ -140,6 +139,9 @@ function DashboardList(props: DashboardListProps) {
refreshData();
};
const { userId } = props.user;
const userKey = getFromLocalStorage(userId.toString(), null);
const canCreate = hasPerm('can_write');
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
@@ -499,29 +501,25 @@ function DashboardList(props: DashboardListProps) {
];
function renderCard(dashboard: Dashboard) {
const { userId } = props.user;
const userKey = getFromLocalStorage(userId.toString(), null);
return (
<CardStylesOverrides>
<DashboardCard
dashboard={dashboard}
hasPerm={hasPerm}
bulkSelectEnabled={bulkSelectEnabled}
refreshData={refreshData}
showThumbnails={
userKey
? userKey.thumbnails
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
}
loading={loading}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
openDashboardEditModal={openDashboardEditModal}
saveFavoriteStatus={saveFavoriteStatus}
favoriteStatus={favoriteStatus[dashboard.id]}
handleBulkDashboardExport={handleBulkDashboardExport}
/>
</CardStylesOverrides>
<DashboardCard
dashboard={dashboard}
hasPerm={hasPerm}
bulkSelectEnabled={bulkSelectEnabled}
refreshData={refreshData}
showThumbnails={
userKey
? userKey.thumbnails
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
}
loading={loading}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
openDashboardEditModal={openDashboardEditModal}
saveFavoriteStatus={saveFavoriteStatus}
favoriteStatus={favoriteStatus[dashboard.id]}
handleBulkDashboardExport={handleBulkDashboardExport}
/>
);
}
@@ -614,6 +612,11 @@ function DashboardList(props: DashboardListProps) {
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
showThumbnails={
userKey
? userKey.thumbnails
: isFeatureEnabled(FeatureFlag.THUMBNAILS)
}
renderCard={renderCard}
defaultViewMode={
isFeatureEnabled(FeatureFlag.LISTVIEWS_DEFAULT_CARD_VIEW)

View File

@@ -61,6 +61,8 @@ interface FieldPropTypes {
onParametersUploadFileChange: (value: any) => string;
changeMethods: { onParametersChange: (value: any) => string } & {
onChange: (value: any) => string;
} & {
onQueryChange: (value: any) => string;
} & { onParametersUploadFileChange: (value: any) => string } & {
onAddTableCatalog: () => void;
onRemoveTableCatalog: (idx: number) => void;
@@ -415,15 +417,15 @@ const queryField = ({
db,
}: FieldPropTypes) => (
<ValidatedInput
id="query"
name="query"
id="query_input"
name="query_input"
required={required}
value={db?.parameters?.query}
value={db?.query_input || ''}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.query}
placeholder="e.g. param1=value1&param2=value2"
label="Additional Parameters"
onChange={changeMethods.onParametersChange}
onChange={changeMethods.onQueryChange}
helpText={t('Add additional custom parameters')}
/>
);
@@ -475,6 +477,7 @@ const DatabaseConnectionForm = ({
dbModel: { parameters },
onParametersChange,
onChange,
onQueryChange,
onParametersUploadFileChange,
onAddTableCatalog,
onRemoveTableCatalog,
@@ -496,6 +499,9 @@ const DatabaseConnectionForm = ({
onChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onQueryChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onParametersUploadFileChange?: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
@@ -523,6 +529,7 @@ const DatabaseConnectionForm = ({
changeMethods: {
onParametersChange,
onChange,
onQueryChange,
onParametersUploadFileChange,
onAddTableCatalog,
onRemoveTableCatalog,

View File

@@ -169,9 +169,9 @@ const ExtraOptions = ({
<StyledInputContainer css={no_margin_bottom}>
<div className="input-container">
<IndeterminateCheckbox
id="cost_query_enabled"
id="cost_estimate_enabled"
indeterminate={false}
checked={!!db?.extra_json?.cost_query_enabled}
checked={!!db?.extra_json?.cost_estimate_enabled}
onChange={onExtraInputChange}
labelText={t('Enable query cost estimation')}
/>

View File

@@ -76,6 +76,18 @@ import {
} from './styles';
import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader';
const engineSpecificAlertMapping = {
gsheets: {
message: 'Why do I need to create a database?',
description:
'To begin using your Google Sheets, you need to create a database first. ' +
'Databases are used as a way to identify ' +
'your data so that it can be queried and visualized. This ' +
'database will hold all of your individual Google Sheets ' +
'you choose to connect here.',
},
};
const errorAlertMapping = {
CONNECTION_MISSING_PARAMETERS_ERROR: {
message: 'Missing Required Fields',
@@ -134,6 +146,7 @@ enum ActionType {
extraEditorChange,
addTableCatalogSheet,
removeTableCatalogSheet,
queryChange,
}
interface DBReducerPayloadType {
@@ -151,6 +164,7 @@ type DBReducerActionType =
| ActionType.extraEditorChange
| ActionType.extraInputChange
| ActionType.textChange
| ActionType.queryChange
| ActionType.inputChange
| ActionType.editorChange
| ActionType.parametersChange;
@@ -193,7 +207,8 @@ function dbReducer(
const trimmedState = {
...(state || {}),
};
let query = '';
let query = {};
let query_input = '';
let deserializeExtraJSON = {};
let extra_json: DatabaseObject['extra_json'];
@@ -306,6 +321,15 @@ function dbReducer(
...trimmedState,
[action.payload.name]: action.payload.json,
};
case ActionType.queryChange:
return {
...trimmedState,
parameters: {
...trimmedState.parameters,
query: Object.fromEntries(new URLSearchParams(action.payload.value)),
},
query_input: action.payload.value,
};
case ActionType.textChange:
return {
...trimmedState,
@@ -327,6 +351,12 @@ function dbReducer(
};
}
// convert query to a string and store in query_input
query = action.payload?.parameters?.query || {};
query_input = Object.entries(query)
.map(([key, value]) => `${key}=${value}`)
.join('&');
if (
action.payload.backend === 'bigquery' &&
action.payload.configuration_method ===
@@ -338,11 +368,12 @@ function dbReducer(
configuration_method: action.payload.configuration_method,
extra_json: deserializeExtraJSON,
parameters: {
query,
credentials_info: JSON.stringify(
action.payload?.parameters?.credentials_info || '',
),
query,
},
query_input,
};
}
@@ -364,37 +395,18 @@ function dbReducer(
name: e,
value: engineParamsCatalog[e],
})),
query_input,
} as DatabaseObject;
}
if (action.payload?.parameters?.query) {
// convert query into URI params string
query = new URLSearchParams(
action.payload.parameters.query as string,
).toString();
return {
...action.payload,
encrypted_extra: action.payload.encrypted_extra || '',
engine: action.payload.backend || trimmedState.engine,
configuration_method: action.payload.configuration_method,
extra_json: deserializeExtraJSON,
parameters: {
...action.payload.parameters,
query,
},
};
}
return {
...action.payload,
encrypted_extra: action.payload.encrypted_extra || '',
engine: action.payload.backend || trimmedState.engine,
configuration_method: action.payload.configuration_method,
extra_json: deserializeExtraJSON,
parameters: {
...action.payload.parameters,
},
parameters: action.payload.parameters,
query_input,
};
case ActionType.dbSelected:
@@ -454,10 +466,11 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const sslForced = isFeatureEnabled(
FeatureFlag.FORCE_DATABASE_CONNECTIONS_SSL,
);
const hasAlert =
connectionAlert || !!(db?.engine && engineSpecificAlertMapping[db.engine]);
const useSqlAlchemyForm =
db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI;
const useTabLayout = isEditMode || useSqlAlchemyForm;
// Database fetch logic
const {
state: { loading: dbLoading, resource: dbFetched, error: dbErrors },
@@ -471,9 +484,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
addDangerToast,
);
const isDynamic = (engine: string | undefined) =>
availableDbs?.databases.filter(
availableDbs?.databases?.find(
(DB: DatabaseObject) => DB.backend === engine || DB.engine === engine,
)[0].parameters !== undefined;
)?.parameters !== undefined;
const showDBError = validationErrors || dbErrors;
const isEmpty = (data?: Object | null) =>
data && Object.keys(data).length === 0;
@@ -527,21 +540,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
return;
}
if (dbToUpdate?.parameters?.query) {
// convert query params into dictionary
dbToUpdate.parameters.query = JSON.parse(
`{"${decodeURI((dbToUpdate?.parameters?.query as string) || '')
.replace(/"/g, '\\"')
.replace(/&/g, '","')
.replace(/=/g, '":"')}"}`,
);
} else if (
dbToUpdate?.parameters?.query === '' &&
'query' in dbModel.parameters.properties
) {
dbToUpdate.parameters.query = {};
}
const engine = dbToUpdate.backend || dbToUpdate.engine;
if (engine === 'bigquery' && dbToUpdate.parameters?.credentials_info) {
// wrap encrypted_extra in credentials_info only for BigQuery
@@ -834,6 +832,38 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
setTabKey(key);
};
const renderStepTwoAlert = () => {
const { hostname } = window.location;
let ipAlert = connectionAlert?.REGIONAL_IPS?.default || '';
const regionalIPs = connectionAlert?.REGIONAL_IPS || {};
Object.entries(regionalIPs).forEach(([ipRegion, ipRange]) => {
const regex = new RegExp(ipRegion);
if (hostname.match(regex)) {
ipAlert = ipRange;
}
});
return (
db?.engine && (
<StyledAlertMargin>
<Alert
closable={false}
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
type="info"
showIcon
message={
engineSpecificAlertMapping[db.engine]?.message ||
connectionAlert?.DEFAULT?.message
}
description={
engineSpecificAlertMapping[db.engine]?.description ||
connectionAlert?.DEFAULT?.description + ipAlert
}
/>
</StyledAlertMargin>
)
);
};
const errorAlert = () => {
if (
isEmpty(dbErrors) ||
@@ -929,6 +959,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
value: target.value,
})
}
onQueryChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.queryChange, {
name: target.name,
value: target.value,
})
}
onAddTableCatalog={() =>
setDB({ type: ActionType.addTableCatalogSheet })
}
@@ -1050,6 +1086,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
value: target.value,
})
}
onQueryChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.queryChange, {
name: target.name,
value: target.value,
})
}
onAddTableCatalog={() =>
setDB({ type: ActionType.addTableCatalogSheet })
}
@@ -1188,18 +1230,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
dbName={dbName}
dbModel={dbModel}
/>
{connectionAlert && (
<StyledAlertMargin>
<Alert
closable={false}
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
type="info"
showIcon
message={t('IP Allowlist')}
description={connectionAlert.ALLOWED_IPS}
/>
</StyledAlertMargin>
)}
{hasAlert && renderStepTwoAlert()}
<DatabaseConnectionForm
db={db}
sslForced={sslForced}
@@ -1207,6 +1238,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
onAddTableCatalog={() => {
setDB({ type: ActionType.addTableCatalogSheet });
}}
onQueryChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.queryChange, {
name: target.name,
value: target.value,
})
}
onRemoveTableCatalog={(idx: number) => {
setDB({
type: ActionType.removeTableCatalogSheet,

View File

@@ -45,15 +45,12 @@ export type DatabaseObject = {
password?: string;
encryption?: boolean;
credentials_info?: string;
query?: string | object;
catalog?: {};
query?: Record<string, string>;
catalog?: Record<string, string>;
};
configuration_method: CONFIGURATION_METHOD;
engine?: string;
// Gsheets temporary storage
catalog?: Array<CatalogObject>;
// Performance
cache_timeout?: string;
allow_run_async?: boolean;
@@ -85,11 +82,14 @@ export type DatabaseObject = {
allows_virtual_table_explore?: boolean; // in SQL Lab
schemas_allowed_for_csv_upload?: string[]; // in Security
cancel_query_on_windows_unload?: boolean; // in Performance
version?: string;
// todo: ask beto where this should live
cost_query_enabled?: boolean; // in SQL Lab
version?: string;
cost_estimate_enabled?: boolean; // in SQL Lab
};
// Temporary storage
catalog?: Array<CatalogObject>;
query_input?: string;
extra?: string;
};

View File

@@ -17,7 +17,8 @@
* under the License.
*/
// storage keys for welcome page sticky tabs..
// storage keys for welcome page sticky tabs and tables
export const HOMEPAGE_CHART_FILTER = 'homepage_chart_filter';
export const HOMEPAGE_ACTIVITY_FILTER = 'homepage_activity_filter';
export const HOMEPAGE_DASHBOARD_FILTER = 'homepage_dashboard_filter';
export const HOMEPAGE_COLLAPSE_STATE = 'homepage_collapse_state';

View File

@@ -32,7 +32,7 @@ export enum TableTabTypes {
export type Filters = {
col: string;
opr: string;
value: string;
value: string | number;
};
export interface DashboardTableProps {

View File

@@ -132,10 +132,19 @@ export const getRecentAcitivtyObjs = (
) =>
SupersetClient.get({ endpoint: recent }).then(recentsRes => {
const res: any = {};
const filters = [
{
col: 'created_by',
opr: 'rel_o_m',
value: 0,
},
];
const newBatch = [
SupersetClient.get({ endpoint: `/api/v1/chart/?q=${getParams()}` }),
SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${getParams()}`,
endpoint: `/api/v1/chart/?q=${getParams(filters)}`,
}),
SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${getParams(filters)}`,
}),
];
return Promise.all(newBatch)
@@ -269,15 +278,12 @@ export function shortenSQL(sql: string, maxLines: number) {
return lines.join('\n');
}
// loading card count for homepage
export const loadingCardCount = 5;
const breakpoints = [576, 768, 992, 1200];
export const mq = breakpoints.map(bp => `@media (max-width: ${bp}px)`);
export const CardStylesOverrides = styled.div`
.ant-card-cover > div {
height: 264px;
}
`;
export const CardContainer = styled.div<{
showThumbnails?: boolean | undefined;
}>`
@@ -286,7 +292,7 @@ export const CardContainer = styled.div<{
display: grid;
grid-gap: ${theme.gridUnit * 12}px ${theme.gridUnit * 4}px;
grid-template-columns: repeat(auto-fit, 300px);
max-height: ${showThumbnails ? '314' : '140'}px;
max-height: ${showThumbnails ? '314' : '148'}px;
margin-top: ${theme.gridUnit * -6}px;
padding: ${
showThumbnails

View File

@@ -21,9 +21,9 @@ import moment from 'moment';
import { styled, t } from '@superset-ui/core';
import { setInLocalStorage } from 'src/utils/localStorageHelpers';
import Loading from 'src/components/Loading';
import ListViewCard from 'src/components/ListViewCard';
import SubMenu from 'src/components/Menu/SubMenu';
import { LoadingCards, ActivityData } from 'src/views/CRUD/welcome/Welcome';
import {
CardStyles,
getEditedObjects,
@@ -34,7 +34,7 @@ import { Chart } from 'src/types/Chart';
import { Dashboard, SavedQueryObject } from 'src/views/CRUD/types';
import Icons from 'src/components/Icons';
import { ActivityData } from './Welcome';
import EmptyState from './EmptyState';
/**
@@ -230,7 +230,7 @@ export default function ActivityTable({
const doneFetching = loadedCount < 3;
if ((loadingState && !editedObjs) || doneFetching) {
return <Loading position="inline" />;
return <LoadingCards />;
}
return (
<Styles>

View File

@@ -35,6 +35,7 @@ import PropertiesModal from 'src/explore/components/PropertiesModal';
import { User } from 'src/types/bootstrapTypes';
import { CardContainer, PAGE_SIZE } from 'src/views/CRUD/utils';
import { HOMEPAGE_CHART_FILTER } from 'src/views/CRUD/storageKeys';
import { LoadingCards } from 'src/views/CRUD/welcome/Welcome';
import ChartCard from 'src/views/CRUD/chart/ChartCard';
import Chart from 'src/types/Chart';
import handleResourceExport from 'src/utils/export';
@@ -131,6 +132,12 @@ function ChartTable({
operator: 'chart_is_favorite',
value: true,
});
} else if (filterName === 'Examples') {
filters.push({
id: 'created_by',
operator: 'rel_o_m',
value: 0,
});
}
return filters;
};
@@ -177,7 +184,7 @@ function ChartTable({
});
}
if (loading) return <Loading position="inline" />;
if (loading) return <LoadingCards cover={showThumbnails} />;
return (
<ErrorBoundary>
{sliceCurrentlyEditing && (

View File

@@ -31,6 +31,7 @@ import {
setInLocalStorage,
getFromLocalStorage,
} from 'src/utils/localStorageHelpers';
import { LoadingCards } from 'src/views/CRUD/welcome/Welcome';
import {
createErrorHandler,
CardContainer,
@@ -132,8 +133,8 @@ function DashboardTable({
const filters = [];
if (filterName === 'Mine') {
filters.push({
id: 'owners',
operator: 'rel_m_m',
id: 'created_by',
operator: 'rel_o_m',
value: `${user?.userId}`,
});
} else if (filterName === 'Favorite') {
@@ -142,6 +143,12 @@ function DashboardTable({
operator: 'dashboard_is_favorite',
value: true,
});
} else if (filterName === 'Examples') {
filters.push({
id: 'created_by',
operator: 'rel_o_m',
value: 0,
});
}
return filters;
};
@@ -189,7 +196,7 @@ function DashboardTable({
filters: getFilters(filter),
});
if (loading) return <Loading position="inline" />;
if (loading) return <LoadingCards cover={showThumbnails} />;
return (
<>
<SubMenu

View File

@@ -22,7 +22,7 @@ import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import Loading from 'src/components/Loading';
import { LoadingCards } from 'src/views/CRUD/welcome/Welcome';
import { Dropdown, Menu } from 'src/common/components';
import { useListViewResource, copyQueryLink } from 'src/views/CRUD/hooks';
import ListViewCard from 'src/components/ListViewCard';
@@ -240,7 +240,7 @@ const SavedQueries = ({
</Menu>
);
if (loading) return <Loading position="inline" />;
if (loading) return <LoadingCards cover={showThumbnails} />;
return (
<>
{queryDeleteModal && (

View File

@@ -25,15 +25,20 @@ import {
getFromLocalStorage,
setInLocalStorage,
} from 'src/utils/localStorageHelpers';
import ListViewCard from 'src/components/ListViewCard';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import Loading from 'src/components/Loading';
import {
createErrorHandler,
getRecentAcitivtyObjs,
mq,
CardContainer,
getUserOwnedObjects,
loadingCardCount,
} from 'src/views/CRUD/utils';
import { HOMEPAGE_ACTIVITY_FILTER } from 'src/views/CRUD/storageKeys';
import {
HOMEPAGE_ACTIVITY_FILTER,
HOMEPAGE_COLLAPSE_STATE,
} from 'src/views/CRUD/storageKeys';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { Switch } from 'src/common/components';
@@ -54,6 +59,10 @@ export interface ActivityData {
Examples?: Array<object>;
}
interface LoadingProps {
cover?: boolean;
}
const DEFAULT_TAB_ARR = ['2', '3'];
const WelcomeContainer = styled.div`
@@ -96,6 +105,12 @@ const WelcomeContainer = styled.div`
div.ant-collapse-item:last-child .ant-collapse-header {
padding-bottom: ${({ theme }) => theme.gridUnit * 9}px;
}
.loading-cards {
margin-top: ${({ theme }) => theme.gridUnit * 8}px;
.ant-card-cover > div {
height: 168px;
}
}
`;
const WelcomeNav = styled.div`
@@ -118,6 +133,14 @@ const WelcomeNav = styled.div`
}
`;
export const LoadingCards = ({ cover }: LoadingProps) => (
<CardContainer showThumbnails={cover} className="loading-cards">
{[...new Array(loadingCardCount)].map(() => (
<ListViewCard cover={cover ? false : <></>} description="" loading />
))}
</CardContainer>
);
function Welcome({ user, addDangerToast }: WelcomeProps) {
const userid = user.userId;
const id = userid.toString();
@@ -137,16 +160,18 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
null,
);
const [loadedCount, setLoadedCount] = useState(0);
const [activeState, setActiveState] = useState<Array<string>>(
DEFAULT_TAB_ARR,
);
const collapseState = getFromLocalStorage(HOMEPAGE_COLLAPSE_STATE, null);
const [activeState, setActiveState] = useState<Array<string>>(collapseState);
const handleCollapse = (state: Array<string>) => {
setActiveState(state);
setInLocalStorage(HOMEPAGE_COLLAPSE_STATE, state);
};
useEffect(() => {
const activeTab = getFromLocalStorage(HOMEPAGE_ACTIVITY_FILTER, null);
setActiveState(collapseState || DEFAULT_TAB_ARR);
getRecentAcitivtyObjs(user.userId, recent, addDangerToast)
.then(res => {
const data: ActivityData | null = {};
@@ -216,7 +241,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
};
useEffect(() => {
if (queryData?.length) {
if (!collapseState && queryData?.length) {
setActiveState(activeState => [...activeState, '4']);
}
setActivityData(activityData => ({
@@ -230,7 +255,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
}, [chartData, queryData, dashboardData]);
useEffect(() => {
if (activityData?.Viewed?.length) {
if (!collapseState && activityData?.Viewed?.length) {
setActiveState(activeState => ['1', ...activeState]);
}
}, [activityData]);
@@ -263,12 +288,12 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
loadedCount={loadedCount}
/>
) : (
<Loading position="inline" />
<LoadingCards />
)}
</Collapse.Panel>
<Collapse.Panel header={t('Dashboards')} key="2">
{!dashboardData || isRecentActivityLoading ? (
<Loading position="inline" />
<LoadingCards cover={checked} />
) : (
<DashboardTable
user={user}
@@ -280,7 +305,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
</Collapse.Panel>
<Collapse.Panel header={t('Charts')} key="3">
{!chartData || isRecentActivityLoading ? (
<Loading position="inline" />
<LoadingCards cover={checked} />
) : (
<ChartTable
showThumbnails={checked}
@@ -292,7 +317,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
</Collapse.Panel>
<Collapse.Panel header={t('Saved queries')} key="4">
{!queryData ? (
<Loading position="inline" />
<LoadingCards cover={checked} />
) : (
<SavedQueries
showThumbnails={checked}

View File

@@ -27,7 +27,6 @@ const metadata = new ChartMetadata({
'Compare multiple time series charts (as sparklines) and related metrics quickly.',
),
tags: [
t('Advanced-Analytics'),
t('Multi-Variables'),
t('Comparison'),
t('Legacy'),

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { isFeatureEnabled, Preset } from '@superset-ui/core';
import { isFeatureEnabled, Preset, FeatureFlag } from '@superset-ui/core';
import {
BigNumberChartPlugin,
BigNumberTotalChartPlugin,
@@ -56,7 +56,13 @@ import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
import {
EchartsPieChartPlugin,
EchartsBoxPlotChartPlugin,
EchartsAreaChartPlugin,
EchartsTimeseriesChartPlugin,
EchartsTimeseriesBarChartPlugin,
EchartsTimeseriesLineChartPlugin,
EchartsTimeseriesScatterChartPlugin,
EchartsTimeseriesSmoothLineChartPlugin,
EchartsTimeseriesStepChartPlugin,
EchartsGraphChartPlugin,
EchartsGaugeChartPlugin,
EchartsRadarChartPlugin,
@@ -76,7 +82,6 @@ import {
import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table';
import FilterBoxChartPlugin from '../FilterBox/FilterBoxChartPlugin';
import TimeTableChartPlugin from '../TimeTable/TimeTableChartPlugin';
import { FeatureFlag } from '../../featureFlags';
export default class MainPreset extends Preset {
constructor() {
@@ -134,9 +139,27 @@ export default class MainPreset extends Preset {
new TreemapChartPlugin().configure({ key: 'treemap' }),
new WordCloudChartPlugin().configure({ key: 'word_cloud' }),
new WorldMapChartPlugin().configure({ key: 'world_map' }),
new EchartsAreaChartPlugin().configure({
key: 'echarts_area',
}),
new EchartsTimeseriesChartPlugin().configure({
key: 'echarts_timeseries',
}),
new EchartsTimeseriesBarChartPlugin().configure({
key: 'echarts_timeseries_bar',
}),
new EchartsTimeseriesLineChartPlugin().configure({
key: 'echarts_timeseries_line',
}),
new EchartsTimeseriesSmoothLineChartPlugin().configure({
key: 'echarts_timeseries_smooth',
}),
new EchartsTimeseriesScatterChartPlugin().configure({
key: 'echarts_timeseries_scatter',
}),
new EchartsTimeseriesStepChartPlugin().configure({
key: 'echarts_timeseries_step',
}),
new SelectFilterPlugin().configure({ key: 'filter_select' }),
new RangeFilterPlugin().configure({ key: 'filter_range' }),
new TimeFilterPlugin().configure({ key: 'filter_time' }),

View File

@@ -7,7 +7,7 @@
"forceConsistentCasingInFileNames": true,
"importHelpers": false,
"jsx": "preserve",
"lib": ["dom", "esnext"],
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "node",
"noImplicitAny": true,

View File

@@ -107,7 +107,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
RouteMethod.IMPORT,
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
"post_data",
"data",
"get_data",
"data_from_cache",
"viz_types",
@@ -152,6 +152,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
"description_markeddown",
"edit_url",
"id",
"last_saved_at",
"last_saved_by.id",
"last_saved_by.first_name",
"last_saved_by.last_name",
"owners.first_name",
"owners.id",
"owners.last_name",
@@ -170,12 +174,20 @@ class ChartRestApi(BaseSupersetModelRestApi):
"changed_on_delta_humanized",
"datasource_id",
"datasource_name",
"last_saved_at",
"last_saved_by.id",
"last_saved_by.first_name",
"last_saved_by.last_name",
"slice_name",
"viz_type",
]
search_columns = [
"created_by",
"changed_by",
"last_saved_at",
"last_saved_by.id",
"last_saved_by.first_name",
"last_saved_by.last_name",
"datasource_id",
"datasource_name",
"datasource_type",
@@ -490,10 +502,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
# Post-process the data so it matches the data presented in the chart.
# This is needed for sending reports based on text charts that do the
# post-processing of data, eg, the pivot table.
if (
result_type == ChartDataResultType.POST_PROCESSED
and result_format == ChartDataResultFormat.CSV
):
if result_type == ChartDataResultType.POST_PROCESSED:
result = apply_post_process(result, form_data)
if result_format == ChartDataResultFormat.CSV:
@@ -641,7 +650,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.data",
log_to_statsd=False,
)
def post_data(self) -> Response:
def data(self) -> Response:
"""
Takes a query context constructed in the client and returns payload
data response for the given query.
@@ -989,6 +998,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
)
# If not screenshot then send request to compute thumb to celery
if not screenshot:
self.incr_stats("async", self.thumbnail.__name__)
logger.info(
"Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id)
)
@@ -996,11 +1006,13 @@ class ChartRestApi(BaseSupersetModelRestApi):
return self.response(202, message="OK Async")
# If digests
if chart.digest != digest:
self.incr_stats("redirect", self.thumbnail.__name__)
return redirect(
url_for(
f"{self.__class__.__name__}.thumbnail", pk=pk, digest=chart.digest
)
)
self.incr_stats("from_cache", self.thumbnail.__name__)
return Response(
FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True
)

View File

@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from flask_appbuilder.models.sqla import Model
@@ -27,15 +28,15 @@ from superset.charts.commands.exceptions import (
DashboardsNotFoundValidationError,
)
from superset.charts.dao import ChartDAO
from superset.commands.base import BaseCommand
from superset.commands.utils import get_datasource_by_id, populate_owners
from superset.commands.base import BaseCommand, CreateMixin
from superset.commands.utils import get_datasource_by_id
from superset.dao.exceptions import DAOCreateFailedError
from superset.dashboards.dao import DashboardDAO
logger = logging.getLogger(__name__)
class CreateChartCommand(BaseCommand):
class CreateChartCommand(CreateMixin, BaseCommand):
def __init__(self, user: User, data: Dict[str, Any]):
self._actor = user
self._properties = data.copy()
@@ -43,6 +44,8 @@ class CreateChartCommand(BaseCommand):
def run(self) -> Model:
self.validate()
try:
self._properties["last_saved_at"] = datetime.now()
self._properties["last_saved_by"] = self._actor
chart = ChartDAO.create(self._properties)
except DAOCreateFailedError as ex:
logger.exception(ex.exception)
@@ -70,7 +73,7 @@ class CreateChartCommand(BaseCommand):
self._properties["dashboards"] = dashboards
try:
owners = populate_owners(self._actor, owner_ids)
owners = self.populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except ValidationError as ex:
exceptions.append(ex)

View File

@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import json
from typing import Any, Dict, Set
from marshmallow import Schema
@@ -95,4 +96,10 @@ class ImportChartsCommand(ImportModelsCommand):
}
)
config["params"].update({"datasource": dataset.uid})
if config["query_context"]:
# TODO (betodealmeida): export query_context as object, not string
query_context = json.loads(config["query_context"])
query_context["datasource"] = {"id": dataset.id, "type": "table"}
config["query_context"] = json.dumps(query_context)
import_chart(session, config, overwrite=overwrite)

View File

@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from flask_appbuilder.models.sqla import Model
@@ -30,8 +31,8 @@ from superset.charts.commands.exceptions import (
DatasourceTypeUpdateRequiredValidationError,
)
from superset.charts.dao import ChartDAO
from superset.commands.base import BaseCommand
from superset.commands.utils import get_datasource_by_id, populate_owners
from superset.commands.base import BaseCommand, UpdateMixin
from superset.commands.utils import get_datasource_by_id
from superset.dao.exceptions import DAOUpdateFailedError
from superset.dashboards.dao import DashboardDAO
from superset.exceptions import SupersetSecurityException
@@ -41,7 +42,13 @@ from superset.views.base import check_ownership
logger = logging.getLogger(__name__)
class UpdateChartCommand(BaseCommand):
def is_query_context_update(properties: Dict[str, Any]) -> bool:
return set(properties) == {"query_context", "query_context_generation"} and bool(
properties.get("query_context_generation")
)
class UpdateChartCommand(UpdateMixin, BaseCommand):
def __init__(self, user: User, model_id: int, data: Dict[str, Any]):
self._actor = user
self._model_id = model_id
@@ -51,10 +58,13 @@ class UpdateChartCommand(BaseCommand):
def run(self) -> Model:
self.validate()
try:
if self._properties.get("query_context_generation") is None:
self._properties["last_saved_at"] = datetime.now()
self._properties["last_saved_by"] = self._actor
chart = ChartDAO.update(self._model, self._properties)
except DAOUpdateFailedError as ex:
logger.exception(ex.exception)
raise ChartUpdateFailedError()
raise ChartUpdateFailedError() from ex
return chart
def validate(self) -> None:
@@ -73,11 +83,18 @@ class UpdateChartCommand(BaseCommand):
self._model = ChartDAO.find_by_id(self._model_id)
if not self._model:
raise ChartNotFoundError()
# Check ownership
try:
check_ownership(self._model)
except SupersetSecurityException:
raise ChartForbiddenError()
# Check and update ownership; when only updating query context we ignore
# ownership so the update can be performed by report workers
if not is_query_context_update(self._properties):
try:
check_ownership(self._model)
owners = self.populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except SupersetSecurityException as ex:
raise ChartForbiddenError() from ex
except ValidationError as ex:
exceptions.append(ex)
# Validate/Populate datasource
if datasource_id is not None:
@@ -94,12 +111,6 @@ class UpdateChartCommand(BaseCommand):
exceptions.append(DashboardsNotFoundValidationError())
self._properties["dashboards"] = dashboards
# Validate/Populate owner
try:
owners = populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except ValidationError as ex:
exceptions.append(ex)
if exceptions:
exception = ChartInvalidError()
exception.add_list(exceptions)

View File

@@ -27,60 +27,154 @@ for these chart types.
"""
from io import StringIO
from typing import Any, Callable, Dict, Optional, Union
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
from superset.utils.core import DTTM_ALIAS, extract_dataframe_dtypes, get_metric_name
from superset.utils.core import (
ChartDataResultFormat,
DTTM_ALIAS,
extract_dataframe_dtypes,
get_metric_name,
)
def sql_like_sum(series: pd.Series) -> pd.Series:
def get_column_key(label: Tuple[str, ...], metrics: List[str]) -> Tuple[Any, ...]:
"""
A SUM aggregation function that mimics the behavior from SQL.
Sort columns when combining metrics.
MultiIndex labels have the metric name as the last element in the
tuple. We want to sort these according to the list of passed metrics.
"""
return series.sum(min_count=1)
parts: List[Any] = list(label)
metric = parts[-1]
parts[-1] = metrics.index(metric)
return tuple(parts)
def pivot_table(df: pd.DataFrame, form_data: Dict[str, Any]) -> pd.DataFrame:
"""
Pivot table.
"""
if form_data.get("granularity") == "all" and DTTM_ALIAS in df:
del df[DTTM_ALIAS]
def pivot_df( # pylint: disable=too-many-locals, too-many-arguments, too-many-statements, too-many-branches
df: pd.DataFrame,
rows: List[str],
columns: List[str],
metrics: List[str],
aggfunc: str = "Sum",
transpose_pivot: bool = False,
combine_metrics: bool = False,
show_rows_total: bool = False,
show_columns_total: bool = False,
apply_metrics_on_rows: bool = False,
) -> pd.DataFrame:
metric_name = f"Total ({aggfunc})"
metrics = [get_metric_name(m) for m in form_data["metrics"]]
aggfuncs: Dict[str, Union[str, Callable[[Any], Any]]] = {}
for metric in metrics:
aggfunc = form_data.get("pandas_aggfunc") or "sum"
if pd.api.types.is_numeric_dtype(df[metric]):
if aggfunc == "sum":
aggfunc = sql_like_sum
elif aggfunc not in {"min", "max"}:
aggfunc = "max"
aggfuncs[metric] = aggfunc
if transpose_pivot:
rows, columns = columns, rows
groupby = form_data.get("groupby") or []
columns = form_data.get("columns") or []
if form_data.get("transpose_pivot"):
groupby, columns = columns, groupby
# to apply the metrics on the rows we pivot the dataframe, apply the
# metrics to the columns, and pivot the dataframe back before
# returning it
if apply_metrics_on_rows:
rows, columns = columns, rows
axis = {"columns": 0, "rows": 1}
else:
axis = {"columns": 1, "rows": 0}
df = df.pivot_table(
index=groupby,
columns=columns,
values=metrics,
aggfunc=aggfuncs,
margins=form_data.get("pivot_margins"),
)
# pivot data; we'll compute totals and subtotals later
if rows or columns:
df = df.pivot_table(
index=rows,
columns=columns,
values=metrics,
aggfunc=pivot_v2_aggfunc_map[aggfunc],
margins=False,
)
else:
# if there's no rows nor columns we have a single value; update
# the index with the metric name so it shows up in the table
df.index = pd.Index([*df.index[:-1], metric_name], name="metric")
# Re-order the columns adhering to the metric ordering.
df = df[metrics]
# if no rows were passed the metrics will be in the rows, so we
# need to move them back to columns
if columns and not rows:
df = df.stack().to_frame().T
df = df[metrics]
df.index = pd.Index([*df.index[:-1], metric_name], name="metric")
# Display metrics side by side with each column
if form_data.get("combine_metric"):
df = df.stack(0).unstack().reindex(level=-1, columns=metrics)
# combining metrics changes the column hierarchy, moving the metric
# from the top to the bottom, eg:
#
# ('SUM(col)', 'age', 'name') => ('age', 'name', 'SUM(col)')
if combine_metrics and isinstance(df.columns, pd.MultiIndex):
# move metrics to the lowest level
new_order = [*range(1, df.columns.nlevels), 0]
df = df.reorder_levels(new_order, axis=1)
# flatten column names
df.columns = [" ".join(column) for column in df.columns]
# sort columns, combining metrics for each group
decorated_columns = [(col, i) for i, col in enumerate(df.columns)]
grouped_columns = sorted(
decorated_columns, key=lambda t: get_column_key(t[0], metrics)
)
indexes = [i for col, i in grouped_columns]
df = df[df.columns[indexes]]
elif rows:
# if metrics were not combined we sort the dataframe by the list
# of metrics defined by the user
df = df[metrics]
# compute fractions, if needed
if aggfunc.endswith(" as Fraction of Total"):
total = df.sum().sum()
df = df.astype(total.dtypes) / total
elif aggfunc.endswith(" as Fraction of Columns"):
total = df.sum(axis=axis["rows"])
df = df.astype(total.dtypes).div(total, axis=axis["columns"])
elif aggfunc.endswith(" as Fraction of Rows"):
total = df.sum(axis=axis["columns"])
df = df.astype(total.dtypes).div(total, axis=axis["rows"])
# convert to a MultiIndex to simplify logic
if not isinstance(df.index, pd.MultiIndex):
df.index = pd.MultiIndex.from_tuples([(str(i),) for i in df.index])
if not isinstance(df.columns, pd.MultiIndex):
df.columns = pd.MultiIndex.from_tuples([(str(i),) for i in df.columns])
if show_rows_total:
# add subtotal for each group and overall total; we start from the
# overall group, and iterate deeper into subgroups
groups = df.columns
for level in range(df.columns.nlevels):
subgroups = {group[:level] for group in groups}
for subgroup in subgroups:
slice_ = df.columns.get_loc(subgroup)
subtotal = pivot_v2_aggfunc_map[aggfunc](df.iloc[:, slice_], axis=1)
depth = df.columns.nlevels - len(subgroup) - 1
total = metric_name if level == 0 else "Subtotal"
subtotal_name = tuple([*subgroup, total, *([""] * depth)])
# insert column after subgroup
df.insert(int(slice_.stop), subtotal_name, subtotal)
if rows and show_columns_total:
# add subtotal for each group and overall total; we start from the
# overall group, and iterate deeper into subgroups
groups = df.index
for level in range(df.index.nlevels):
subgroups = {group[:level] for group in groups}
for subgroup in subgroups:
slice_ = df.index.get_loc(subgroup)
subtotal = pivot_v2_aggfunc_map[aggfunc](
df.iloc[slice_, :].apply(pd.to_numeric), axis=0
)
depth = df.index.nlevels - len(subgroup) - 1
total = metric_name if level == 0 else "Subtotal"
subtotal.name = tuple([*subgroup, total, *([""] * depth)])
# insert row after subgroup
df = pd.concat(
[df[: slice_.stop], subtotal.to_frame().T, df[slice_.stop :]]
)
# if we want to apply the metrics on the rows we need to pivot the
# dataframe back
if apply_metrics_on_rows:
df = df.T
return df
@@ -125,61 +219,49 @@ def pivot_table_v2( # pylint: disable=too-many-branches
if form_data.get("granularity_sqla") == "all" and DTTM_ALIAS in df:
del df[DTTM_ALIAS]
# TODO (betodealmeida): implement metricsLayout
metrics = [get_metric_name(m) for m in form_data["metrics"]]
aggregate_function = form_data.get("aggregateFunction", "Sum")
groupby = form_data.get("groupbyRows") or []
columns = form_data.get("groupbyColumns") or []
if form_data.get("transposePivot"):
groupby, columns = columns, groupby
df = df.pivot_table(
index=groupby,
columns=columns,
values=metrics,
aggfunc=pivot_v2_aggfunc_map[aggregate_function],
margins=True,
return pivot_df(
df,
rows=form_data.get("groupbyRows") or [],
columns=form_data.get("groupbyColumns") or [],
metrics=[get_metric_name(m) for m in form_data["metrics"]],
aggfunc=form_data.get("aggregateFunction", "Sum"),
transpose_pivot=bool(form_data.get("transposePivot")),
combine_metrics=bool(form_data.get("combineMetric")),
show_rows_total=bool(form_data.get("rowTotals")),
show_columns_total=bool(form_data.get("colTotals")),
apply_metrics_on_rows=form_data.get("metricsLayout") == "ROWS",
)
# The pandas `pivot_table` method either brings both row/column
# totals, or none at all. We pass `margin=True` to get both, and
# remove any dimension that was not requests.
if not form_data.get("rowTotals"):
df.drop(df.columns[len(df.columns) - 1], axis=1, inplace=True)
if not form_data.get("colTotals"):
df = df[:-1]
# Compute fractions, if needed. If `colTotals` or `rowTotals` are
# present we need to adjust for including them in the sum
if aggregate_function.endswith(" as Fraction of Total"):
total = df.sum().sum()
df = df.astype(total.dtypes) / total
if form_data.get("colTotals"):
df *= 2
if form_data.get("rowTotals"):
df *= 2
elif aggregate_function.endswith(" as Fraction of Columns"):
total = df.sum(axis=0)
df = df.astype(total.dtypes).div(total, axis=1)
if form_data.get("colTotals"):
df *= 2
elif aggregate_function.endswith(" as Fraction of Rows"):
total = df.sum(axis=1)
df = df.astype(total.dtypes).div(total, axis=0)
if form_data.get("rowTotals"):
df *= 2
def pivot_table(df: pd.DataFrame, form_data: Dict[str, Any]) -> pd.DataFrame:
"""
Pivot table (v1).
"""
if form_data.get("granularity") == "all" and DTTM_ALIAS in df:
del df[DTTM_ALIAS]
# Re-order the columns adhering to the metric ordering.
df = df[metrics]
# v1 func names => v2 func names
func_map = {
"sum": "Sum",
"mean": "Average",
"min": "Minimum",
"max": "Maximum",
"std": "Sample Standard Deviation",
"var": "Sample Variance",
}
# Display metrics side by side with each column
if form_data.get("combineMetric"):
df = df.stack(0).unstack().reindex(level=-1, columns=metrics)
# flatten column names
df.columns = [" ".join(column) for column in df.columns]
return df
return pivot_df(
df,
rows=form_data.get("groupby") or [],
columns=form_data.get("columns") or [],
metrics=[get_metric_name(m) for m in form_data["metrics"]],
aggfunc=func_map.get(form_data.get("pandas_aggfunc", "sum"), "Sum"),
transpose_pivot=bool(form_data.get("transpose_pivot")),
combine_metrics=bool(form_data.get("combine_metric")),
show_rows_total=bool(form_data.get("pivot_margins")),
show_columns_total=bool(form_data.get("pivot_margins")),
apply_metrics_on_rows=False,
)
post_processors = {
@@ -200,16 +282,42 @@ def apply_post_process(
post_processor = post_processors[viz_type]
for query in result["queries"]:
df = pd.read_csv(StringIO(query["data"]))
if query["result_format"] == ChartDataResultFormat.JSON:
df = pd.DataFrame.from_dict(query["data"])
elif query["result_format"] == ChartDataResultFormat.CSV:
df = pd.read_csv(StringIO(query["data"]))
else:
raise Exception(f"Result format {query['result_format']} not supported")
processed_df = post_processor(df, form_data)
buf = StringIO()
processed_df.to_csv(buf)
buf.seek(0)
query["data"] = buf.getvalue()
query["colnames"] = list(processed_df.columns)
query["indexnames"] = list(processed_df.index)
query["coltypes"] = extract_dataframe_dtypes(processed_df)
query["rowcount"] = len(processed_df.index)
# Flatten hierarchical columns/index since they are represented as
# `Tuple[str]`. Otherwise encoding to JSON later will fail because
# maps cannot have tuples as their keys in JSON.
processed_df.columns = [
" ".join(str(name) for name in column).strip()
if isinstance(column, tuple)
else column
for column in processed_df.columns
]
processed_df.index = [
" ".join(str(name) for name in index).strip()
if isinstance(index, tuple)
else index
for index in processed_df.index
]
if query["result_format"] == ChartDataResultFormat.JSON:
query["data"] = processed_df.to_dict()
elif query["result_format"] == ChartDataResultFormat.CSV:
buf = StringIO()
processed_df.to_csv(buf)
buf.seek(0)
query["data"] = buf.getvalue()
return result

View File

@@ -82,6 +82,11 @@ query_context_description = (
"in order to generate the data the visualization, and in what "
"format the data should be returned."
)
query_context_generation_description = (
"The query context generation represents whether the query_context"
"is user generated or not so that it does not update user modfied"
"state."
)
cache_timeout_description = (
"Duration (in seconds) of the caching timeout "
"for this chart. Note this defaults to the datasource/table"
@@ -177,6 +182,9 @@ class ChartPostSchema(Schema):
allow_none=True,
validate=utils.validate_json,
)
query_context_generation = fields.Boolean(
description=query_context_generation_description, allow_none=True
)
cache_timeout = fields.Integer(
description=cache_timeout_description, allow_none=True
)
@@ -212,6 +220,9 @@ class ChartPutSchema(Schema):
query_context = fields.String(
description=query_context_description, allow_none=True
)
query_context_generation = fields.Boolean(
description=query_context_generation_description, allow_none=True
)
cache_timeout = fields.Integer(
description=cache_timeout_description, allow_none=True
)
@@ -294,6 +305,13 @@ class ChartDataAdhocMetricSchema(Schema):
"will be generated.",
example="metric_aec60732-fac0-4b17-b736-93f1a5c93e30",
)
timeGrain = fields.String(
description="Optional time grain for temporal filters", example="PT1M",
)
isExtra = fields.Boolean(
description="Indicates if the filter has been added by a filter component as "
"opposed to being a part of the original query."
)
class ChartDataAggregateConfigField(fields.Dict):
@@ -772,6 +790,13 @@ class ChartDataFilterSchema(Schema):
"integer, decimal or list, depending on the operator.",
example=["China", "France", "Japan"],
)
grain = fields.String(
description="Optional time grain for temporal filters", example="PT1M",
)
isExtra = fields.Boolean(
description="Indicates if the filter has been added by a filter component as "
"opposed to being a part of the original query."
)
class ChartDataExtrasSchema(Schema):

View File

@@ -124,8 +124,9 @@ def load_examples_run(
examples.load_css_templates()
print("Loading energy related dataset")
examples.load_energy(only_metadata, force)
if load_test_data:
print("Loading energy related dataset")
examples.load_energy(only_metadata, force)
print("Loading [World Bank's Health Nutrition and Population Stats]")
examples.load_world_bank_health_n_pop(only_metadata, force)
@@ -133,25 +134,17 @@ def load_examples_run(
print("Loading [Birth names]")
examples.load_birth_names(only_metadata, force)
print("Loading [Tabbed dashboard]")
examples.load_tabbed_dashboard(only_metadata)
if load_test_data:
print("Loading [Tabbed dashboard]")
examples.load_tabbed_dashboard(only_metadata)
if not load_test_data:
print("Loading [Random time series data]")
examples.load_random_time_series_data(only_metadata, force)
print("Loading [Random long/lat data]")
examples.load_long_lat_data(only_metadata, force)
print("Loading [Country Map data]")
examples.load_country_map_data(only_metadata, force)
print("Loading [Multiformat time series]")
examples.load_multiformat_time_series(only_metadata, force)
print("Loading [Paris GeoJson]")
examples.load_paris_iris_geojson(only_metadata, force)
print("Loading [San Francisco population polygons]")
examples.load_sf_population_polygons(only_metadata, force)

View File

@@ -15,7 +15,11 @@
# specific language governing permissions and limitations
# under the License.
from abc import ABC, abstractmethod
from typing import Any
from typing import Any, List, Optional
from flask_appbuilder.security.sqla.models import User
from superset.commands.utils import populate_owners
class BaseCommand(ABC):
@@ -37,3 +41,38 @@ class BaseCommand(ABC):
Will raise exception if validation fails
:raises: CommandException
"""
class CreateMixin:
@staticmethod
def populate_owners(
user: User, owner_ids: Optional[List[int]] = None
) -> List[User]:
"""
Populate list of owners, defaulting to the current user if `owner_ids` is
undefined or empty. If current user is missing in `owner_ids`, current user
is added unless belonging to the Admin role.
:param user: current user
:param owner_ids: list of owners by id's
:raises OwnersNotFoundValidationError: if at least one owner can't be resolved
:returns: Final list of owners
"""
return populate_owners(user, owner_ids, default_to_user=True)
class UpdateMixin:
@staticmethod
def populate_owners(
user: User, owner_ids: Optional[List[int]] = None
) -> List[User]:
"""
Populate list of owners. If current user is missing in `owner_ids`, current user
is added unless belonging to the Admin role.
:param user: current user
:param owner_ids: list of owners by id's
:raises OwnersNotFoundValidationError: if at least one owner can't be resolved
:returns: Final list of owners
"""
return populate_owners(user, owner_ids, default_to_user=False)

View File

@@ -29,17 +29,25 @@ from superset.datasets.commands.exceptions import DatasetNotFoundError
from superset.extensions import db, security_manager
def populate_owners(user: User, owner_ids: Optional[List[int]] = None) -> List[User]:
def populate_owners(
user: User, owner_ids: Optional[List[int]], default_to_user: bool,
) -> List[User]:
"""
Helper function for commands, will fetch all users from owners id's
Can raise ValidationError
:param user: The current user
:param owner_ids: A List of owners by id's
:param user: current user
:param owner_ids: list of owners by id's
:param default_to_user: make user the owner if `owner_ids` is None or empty
:raises OwnersNotFoundValidationError: if at least one owner id can't be resolved
:returns: Final list of owners
"""
owner_ids = owner_ids or []
owners = list()
if not owner_ids:
if not owner_ids and default_to_user:
return [user]
if user.id not in owner_ids:
if user.id not in owner_ids and "admin" not in [
role.name.lower() for role in user.roles
]:
# make sure non-admins can't remove themselves as owner by mistake
owners.append(user)
for owner_id in owner_ids:
owner = security_manager.get_user_by_id(owner_id)

View File

@@ -100,8 +100,10 @@ def _get_full(
status = payload["status"]
if status != QueryStatus.FAILED:
payload["colnames"] = list(df.columns)
payload["indexnames"] = list(df.index)
payload["coltypes"] = extract_dataframe_dtypes(df)
payload["data"] = query_context.get_data(df)
payload["result_format"] = query_context.result_format
del payload["df"]
filters = query_obj.filter

View File

@@ -36,6 +36,7 @@ from superset.utils.core import (
get_metric_names,
is_adhoc_metric,
json_int_dttm_ser,
QueryObjectFilterClause,
)
from superset.utils.date_parser import get_since_until, parse_human_timedelta
from superset.utils.hashing import md5_sha_from_dict
@@ -85,7 +86,7 @@ class QueryObject:
metrics: Optional[List[Metric]]
row_limit: int
row_offset: int
filter: List[Dict[str, Any]]
filter: List[QueryObjectFilterClause]
timeseries_limit: int
timeseries_limit_metric: Optional[Metric]
order_desc: bool
@@ -108,7 +109,7 @@ class QueryObject:
granularity: Optional[str] = None,
metrics: Optional[List[Metric]] = None,
groupby: Optional[List[str]] = None,
filters: Optional[List[Dict[str, Any]]] = None,
filters: Optional[List[QueryObjectFilterClause]] = None,
time_range: Optional[str] = None,
time_shift: Optional[str] = None,
is_timeseries: Optional[bool] = None,

View File

@@ -384,6 +384,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
"OMNIBAR": False,
"DASHBOARD_RBAC": False,
"ENABLE_EXPLORE_DRAG_AND_DROP": False,
"ENABLE_DND_WITH_CLICK_UX": False,
# Enabling ALERTS_ATTACH_REPORTS, the system sends email and slack message
# with screenshot and link
# Disables ALERTS_ATTACH_REPORTS, the system DOES NOT generate screenshot
@@ -1229,6 +1230,9 @@ GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "ws://127.0.0.1:8080/"
#
DATASET_HEALTH_CHECK: Optional[Callable[["SqlaTable"], str]] = None
# Do not show user info or profile in the menu
MENU_HIDE_USER_INFO = False
# SQLalchemy link doc reference
SQLALCHEMY_DOCS_URL = "https://docs.sqlalchemy.org/en/13/core/engines.html"
SQLALCHEMY_DISPLAY_TEXT = "SQLAlchemy docs"

View File

@@ -375,6 +375,8 @@ class BaseDatasource(
return None
if value == "<empty string>":
return ""
if target_column_type == utils.GenericDataType.BOOLEAN:
return utils.cast_to_boolean(value)
return value
if isinstance(values, (list, tuple)):

View File

@@ -86,7 +86,11 @@ from superset.models.helpers import AuditMixinNullable, QueryResult
from superset.sql_parse import ParsedQuery
from superset.typing import AdhocMetric, Metric, OrderBy, QueryObjectDict
from superset.utils import core as utils
from superset.utils.core import GenericDataType, remove_duplicates
from superset.utils.core import (
GenericDataType,
QueryObjectFilterClause,
remove_duplicates,
)
config = app.config
metadata = Model.metadata # pylint: disable=no-member
@@ -303,13 +307,15 @@ class TableColumn(Model, BaseColumn):
pdf = self.python_date_format
is_epoch = pdf in ("epoch_s", "epoch_ms")
column_spec = self.db_engine_spec.get_column_spec(self.type)
type_ = column_spec.sqla_type if column_spec else DateTime
if not self.expression and not time_grain and not is_epoch:
sqla_col = column(self.column_name, type_=DateTime)
sqla_col = column(self.column_name, type_=type_)
return self.table.make_sqla_column_compatible(sqla_col, label)
if self.expression:
col = literal_column(self.expression)
col = literal_column(self.expression, type_=type_)
else:
col = column(self.column_name)
col = column(self.column_name, type_=type_)
time_expr = self.db_engine_spec.get_timestamp_expr(
col, pdf, time_grain, self.type
)
@@ -496,7 +502,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
table_name = Column(String(250), nullable=False)
main_dttm_col = Column(String(250))
database_id = Column(Integer, ForeignKey("dbs.id"), nullable=False)
fetch_values_predicate = Column(String(1000))
fetch_values_predicate = Column(Text)
owners = relationship(owner_class, secondary=sqlatable_user, backref="tables")
database: Database = relationship(
"Database",
@@ -935,7 +941,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
columns: Optional[List[str]] = None,
groupby: Optional[List[str]] = None,
filter: Optional[ # pylint: disable=redefined-builtin
List[Dict[str, Any]]
List[QueryObjectFilterClause]
] = None,
is_timeseries: bool = True,
timeseries_limit: int = 15,
@@ -1056,6 +1062,8 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
# filter out the pseudo column __timestamp from columns
columns = columns or []
columns = [col for col in columns if col != utils.DTTM_ALIAS]
time_grain = extras.get("time_grain_sqla")
dttm_col = columns_by_name.get(granularity) if granularity else None
if need_groupby:
# dedup columns while preserving order
@@ -1063,7 +1071,6 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
for selected in columns:
# if groupby field/expr equals granularity field/expr
if selected == granularity:
time_grain = extras.get("time_grain_sqla")
sqla_col = columns_by_name[selected]
outer = sqla_col.get_timestamp_expression(time_grain, selected)
# if groupby field equals a selected column
@@ -1087,15 +1094,13 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
groupby_exprs_with_timestamp = OrderedDict(groupby_exprs_sans_timestamp.items())
if granularity:
if granularity not in columns_by_name:
if granularity not in columns_by_name or not dttm_col:
raise QueryObjectValidationError(
_(
'Time column "%(col)s" does not exist in dataset',
col=granularity,
)
)
dttm_col = columns_by_name[granularity]
time_grain = extras.get("time_grain_sqla")
time_filters = []
if is_timeseries:
@@ -1150,7 +1155,12 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
col = flt["col"]
val = flt.get("val")
op = flt["op"].upper()
col_obj = columns_by_name.get(col)
col_obj = (
dttm_col
if col == utils.DTTM_ALIAS and is_timeseries and dttm_col
else columns_by_name.get(col)
)
filter_grain = flt.get("grain")
if is_feature_enabled("ENABLE_TEMPLATE_REMOVE_FILTERS"):
if col in removed_filters:
@@ -1158,6 +1168,10 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
continue
if col_obj:
if filter_grain:
sqla_col = col_obj.get_timestamp_expression(filter_grain)
else:
sqla_col = col_obj.get_sqla_col()
col_spec = db_engine_spec.get_column_spec(col_obj.type)
is_list_target = op in (
utils.FilterOperator.IN.value,
@@ -1180,24 +1194,24 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
)
if None in eq:
eq = [x for x in eq if x is not None]
is_null_cond = col_obj.get_sqla_col().is_(None)
is_null_cond = sqla_col.is_(None)
if eq:
cond = or_(is_null_cond, col_obj.get_sqla_col().in_(eq))
cond = or_(is_null_cond, sqla_col.in_(eq))
else:
cond = is_null_cond
else:
cond = col_obj.get_sqla_col().in_(eq)
cond = sqla_col.in_(eq)
if op == utils.FilterOperator.NOT_IN.value:
cond = ~cond
where_clause_and.append(cond)
elif op == utils.FilterOperator.IS_NULL.value:
where_clause_and.append(col_obj.get_sqla_col().is_(None))
where_clause_and.append(sqla_col.is_(None))
elif op == utils.FilterOperator.IS_NOT_NULL.value:
where_clause_and.append(col_obj.get_sqla_col().isnot(None))
where_clause_and.append(sqla_col.isnot(None))
elif op == utils.FilterOperator.IS_TRUE.value:
where_clause_and.append(col_obj.get_sqla_col().is_(True))
where_clause_and.append(sqla_col.is_(True))
elif op == utils.FilterOperator.IS_FALSE.value:
where_clause_and.append(col_obj.get_sqla_col().is_(False))
where_clause_and.append(sqla_col.is_(False))
else:
if eq is None:
raise QueryObjectValidationError(
@@ -1207,21 +1221,21 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
)
)
if op == utils.FilterOperator.EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() == eq)
where_clause_and.append(sqla_col == eq)
elif op == utils.FilterOperator.NOT_EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() != eq)
where_clause_and.append(sqla_col != eq)
elif op == utils.FilterOperator.GREATER_THAN.value:
where_clause_and.append(col_obj.get_sqla_col() > eq)
where_clause_and.append(sqla_col > eq)
elif op == utils.FilterOperator.LESS_THAN.value:
where_clause_and.append(col_obj.get_sqla_col() < eq)
where_clause_and.append(sqla_col < eq)
elif op == utils.FilterOperator.GREATER_THAN_OR_EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() >= eq)
where_clause_and.append(sqla_col >= eq)
elif op == utils.FilterOperator.LESS_THAN_OR_EQUALS.value:
where_clause_and.append(col_obj.get_sqla_col() <= eq)
where_clause_and.append(sqla_col <= eq)
elif op == utils.FilterOperator.LIKE.value:
where_clause_and.append(col_obj.get_sqla_col().like(eq))
where_clause_and.append(sqla_col.like(eq))
elif op == utils.FilterOperator.ILIKE.value:
where_clause_and.append(col_obj.get_sqla_col().ilike(eq))
where_clause_and.append(sqla_col.ilike(eq))
else:
raise QueryObjectValidationError(
_("Invalid filter operation type: %(op)s", op=op)
@@ -1281,6 +1295,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
and timeseries_limit
and not time_groupby_inline
and groupby_exprs_sans_timestamp
and dttm_col
):
if db_engine_spec.allows_joins:
# some sql dialects require for order by expressions

View File

@@ -125,6 +125,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"get_datasets": "read",
"function_names": "read",
"available": "read",
"get_data": "read",
}
EXTRA_FORM_DATA_APPEND_KEYS = {

View File

@@ -820,10 +820,12 @@ class DashboardRestApi(BaseSupersetModelRestApi):
).get_from_cache(cache=thumbnail_cache)
# If the screenshot does not exist, request one from the workers
if not screenshot:
self.incr_stats("async", self.thumbnail.__name__)
cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True)
return self.response(202, message="OK Async")
# If digests
if dashboard.digest != digest:
self.incr_stats("redirect", self.thumbnail.__name__)
return redirect(
url_for(
f"{self.__class__.__name__}.thumbnail",
@@ -831,6 +833,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
digest=dashboard.digest,
)
)
self.incr_stats("from_cache", self.thumbnail.__name__)
return Response(
FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True
)

View File

@@ -21,8 +21,8 @@ from flask_appbuilder.models.sqla import Model
from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.utils import populate_owners, populate_roles
from superset.commands.base import BaseCommand, CreateMixin
from superset.commands.utils import populate_roles
from superset.dao.exceptions import DAOCreateFailedError
from superset.dashboards.commands.exceptions import (
DashboardCreateFailedError,
@@ -34,7 +34,7 @@ from superset.dashboards.dao import DashboardDAO
logger = logging.getLogger(__name__)
class CreateDashboardCommand(BaseCommand):
class CreateDashboardCommand(CreateMixin, BaseCommand):
def __init__(self, user: User, data: Dict[str, Any]):
self._actor = user
self._properties = data.copy()
@@ -60,7 +60,7 @@ class CreateDashboardCommand(BaseCommand):
exceptions.append(DashboardSlugExistsValidationError())
try:
owners = populate_owners(self._actor, owner_ids)
owners = self.populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except ValidationError as ex:
exceptions.append(ex)

View File

@@ -67,7 +67,9 @@ class ImportDashboardsCommand(ImportModelsCommand):
for file_name, config in configs.items():
if file_name.startswith("dashboards/"):
chart_uuids.update(find_chart_uuids(config["position"]))
dataset_uuids.update(find_native_filter_datasets(config["metadata"]))
dataset_uuids.update(
find_native_filter_datasets(config.get("metadata", {}))
)
# discover datasets associated with charts
for file_name, config in configs.items():

View File

@@ -21,8 +21,8 @@ from flask_appbuilder.models.sqla import Model
from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.utils import populate_owners, populate_roles
from superset.commands.base import BaseCommand, UpdateMixin
from superset.commands.utils import populate_roles
from superset.dao.exceptions import DAOUpdateFailedError
from superset.dashboards.commands.exceptions import (
DashboardForbiddenError,
@@ -39,7 +39,7 @@ from superset.views.base import check_ownership
logger = logging.getLogger(__name__)
class UpdateDashboardCommand(BaseCommand):
class UpdateDashboardCommand(UpdateMixin, BaseCommand):
def __init__(self, user: User, model_id: int, data: Dict[str, Any]):
self._actor = user
self._model_id = model_id
@@ -80,7 +80,7 @@ class UpdateDashboardCommand(BaseCommand):
if owners_ids is None:
owners_ids = [owner.id for owner in self._model.owners]
try:
owners = populate_owners(self._actor, owners_ids)
owners = self.populate_owners(self._actor, owners_ids)
self._properties["owners"] = owners
except ValidationError as ex:
exceptions.append(ex)

View File

@@ -22,8 +22,7 @@ from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from sqlalchemy.exc import SQLAlchemyError
from superset.commands.base import BaseCommand
from superset.commands.utils import populate_owners
from superset.commands.base import BaseCommand, CreateMixin
from superset.dao.exceptions import DAOCreateFailedError
from superset.datasets.commands.exceptions import (
DatabaseNotFoundValidationError,
@@ -38,7 +37,7 @@ from superset.extensions import db, security_manager
logger = logging.getLogger(__name__)
class CreateDatasetCommand(BaseCommand):
class CreateDatasetCommand(CreateMixin, BaseCommand):
def __init__(self, user: User, data: Dict[str, Any]):
self._actor = user
self._properties = data.copy()
@@ -90,7 +89,7 @@ class CreateDatasetCommand(BaseCommand):
exceptions.append(TableNotFoundValidationError(table_name))
try:
owners = populate_owners(self._actor, owner_ids)
owners = self.populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except ValidationError as ex:
exceptions.append(ex)

View File

@@ -22,8 +22,7 @@ from flask_appbuilder.models.sqla import Model
from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.utils import populate_owners
from superset.commands.base import BaseCommand, UpdateMixin
from superset.connectors.sqla.models import SqlaTable
from superset.dao.exceptions import DAOUpdateFailedError
from superset.datasets.commands.exceptions import (
@@ -47,7 +46,7 @@ from superset.views.base import check_ownership
logger = logging.getLogger(__name__)
class UpdateDatasetCommand(BaseCommand):
class UpdateDatasetCommand(UpdateMixin, BaseCommand):
def __init__(
self,
user: User,
@@ -101,7 +100,7 @@ class UpdateDatasetCommand(BaseCommand):
exceptions.append(DatabaseChangeValidationError())
# Validate/Populate owner
try:
owners = populate_owners(self._actor, owner_ids)
owners = self.populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except ValidationError as ex:
exceptions.append(ex)

View File

@@ -44,7 +44,7 @@ from flask import current_app, g
from flask_babel import gettext as __, lazy_gettext as _
from marshmallow import fields, Schema
from marshmallow.validate import Range
from sqlalchemy import column, DateTime, select, types
from sqlalchemy import column, select, types
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.interfaces import Compiled, Dialect
from sqlalchemy.engine.reflection import Inspector
@@ -381,7 +381,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
elif pdf == "epoch_ms":
time_expr = time_expr.replace("{col}", cls.epoch_ms_to_dttm())
return TimestampExpression(time_expr, col, type_=DateTime)
return TimestampExpression(time_expr, col, type_=col.type)
@classmethod
def get_time_grains(cls) -> Tuple[TimeGrain, ...]:
@@ -1410,7 +1410,8 @@ class BasicParametersMixin:
parameters: BasicParametersType,
encryted_extra: Optional[Dict[str, str]] = None,
) -> str:
query = parameters.get("query", {})
# make a copy so that we don't update the original
query = parameters.get("query", {}).copy()
if parameters.get("encryption"):
if not cls.encryption_parameters:
raise Exception("Unable to build a URL with encryption enabled")
@@ -1433,6 +1434,11 @@ class BasicParametersMixin:
cls, uri: str, encrypted_extra: Optional[Dict[str, Any]] = None
) -> BasicParametersType:
url = make_url(uri)
query = {
key: value
for (key, value) in url.query.items()
if (key, value) not in cls.encryption_parameters.items()
}
encryption = all(
item in url.query.items() for item in cls.encryption_parameters.items()
)
@@ -1442,7 +1448,7 @@ class BasicParametersMixin:
"host": url.host,
"port": url.port,
"database": url.database,
"query": url.query,
"query": query,
"encryption": encryption,
}

View File

@@ -27,7 +27,7 @@ from .helpers import get_example_data, get_table_connector_registry
def load_bart_lines(only_metadata: bool = False, force: bool = False) -> None:
tbl_name = "bart_lines"
tbl_name = "San Franciso BART Lines"
database = get_example_database()
table_exists = database.has_table_by_name(tbl_name)

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