From 9832bbd469faec7613f024d665f156c3a66d9692 Mon Sep 17 00:00:00 2001 From: Yann Jouanique Date: Fri, 4 Nov 2022 17:10:06 +0100 Subject: [PATCH 01/22] build: Mutli-stage build and slim image for websocket container (#21954) --- .github/workflows/docker_build_push.sh | 13 +++++++++++++ superset-websocket/Dockerfile | 25 ++++++++++++++++++++----- superset-websocket/package-lock.json | 2 +- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker_build_push.sh b/.github/workflows/docker_build_push.sh index 4feea5d802d4f..1c3723611f364 100755 --- a/.github/workflows/docker_build_push.sh +++ b/.github/workflows/docker_build_push.sh @@ -56,6 +56,19 @@ docker build --target lean \ --label "build_actor=${GITHUB_ACTOR}" \ . +# +# Build the "websocket" image +# +docker build \ + -t "${REPO_NAME}-websocket:${SHA}" \ + -t "${REPO_NAME}-websocket:${REFSPEC}" \ + -t "${REPO_NAME}-websocket:${LATEST_TAG}" \ + --label "sha=${SHA}" \ + --label "built_at=$(date)" \ + --label "target=lean" \ + --label "build_actor=${GITHUB_ACTOR}" \ + superset-websocket + # # Build the dev image # diff --git a/superset-websocket/Dockerfile b/superset-websocket/Dockerfile index 85ff66520dd29..4cc2117f07a3b 100644 --- a/superset-websocket/Dockerfile +++ b/superset-websocket/Dockerfile @@ -12,13 +12,28 @@ # 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. -FROM node:16 +FROM node:16-alpine as build WORKDIR /home/superset-websocket -COPY . . +COPY . ./ -RUN npm ci -RUN npm run build +RUN npm ci && \ + npm run build -CMD ["npm", "start"] + +FROM node:16-alpine + +ENV NODE_ENV=production +WORKDIR /home/superset-websocket + +COPY --from=build /home/superset-websocket/dist ./dist +COPY package*.json ./ + +# Only install production dependencies +RUN npm ci --omit=dev + +# Don't run as root! +USER node + +CMD [ "npm", "start" ] diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index c979bcc6462d9..dc43cfff8ff5b 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -37,7 +37,7 @@ }, "engines": { "node": "^16.9.1", - "npm": "^7.5.4" + "npm": "^7.5.4 || ^8.1.2" } }, "node_modules/@babel/code-frame": { From c870fbe9e290e9305e6019bb4e9932bbd736b6dc Mon Sep 17 00:00:00 2001 From: Jack Fragassi Date: Fri, 4 Nov 2022 11:53:57 -0700 Subject: [PATCH 02/22] feat: Add 3 new extension points for inserting custom icons (#22027) --- .../src/ui-overrides/ExtensionsRegistry.ts | 20 +++++++++++ .../HeaderReportDropdown/index.tsx | 28 +++++++++++++++- .../src/views/CRUD/alert/AlertList.tsx | 33 +++++++++++++++++-- .../src/views/components/RightMenu.tsx | 22 +++++++++++-- 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/ui-overrides/ExtensionsRegistry.ts b/superset-frontend/packages/superset-ui-core/src/ui-overrides/ExtensionsRegistry.ts index 82e68efcf0e96..2da3231ee57bb 100644 --- a/superset-frontend/packages/superset-ui-core/src/ui-overrides/ExtensionsRegistry.ts +++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/ExtensionsRegistry.ts @@ -35,16 +35,36 @@ type ReturningDisplayable

= (props: P) => string | React.ReactElement; * When defining a new option here, take care to keep any parameters to functions (or components) minimal. * Any removal or alteration to a parameter will be considered a breaking change. */ + +// from src/views/components/Menu, not imported since this is a separate package +interface MenuObjectChildProps { + label: string; + name?: string; + icon?: string; + index?: number; + url?: string; + isFrontendRoute?: boolean; + perm?: string | boolean; + view?: string; + disable?: boolean; +} + type ConfigDetailsProps = { embeddedId: string; }; +type RightMenuItemIconProps = { + menuChild: MenuObjectChildProps; +}; export type Extensions = Partial<{ + 'alertsreports.header.icon': React.ComponentType; 'embedded.documentation.configuration_details': React.ComponentType; 'embedded.documentation.description': ReturningDisplayable; 'embedded.documentation.url': string; 'dashboard.nav.right': React.ComponentType; + 'navbar.right-menu.item.icon': React.ComponentType; 'navbar.right': React.ComponentType; + 'report-modal.dropdown.item.icon': React.ComponentType; 'welcome.message': React.ComponentType; 'welcome.banner': React.ComponentType; 'welcome.main.replacement': React.ComponentType; diff --git a/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx b/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx index 92388f9f8a0a7..f7bb9ba55cf2f 100644 --- a/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx +++ b/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx @@ -23,9 +23,11 @@ import { t, SupersetTheme, css, + styled, useTheme, FeatureFlag, isFeatureEnabled, + getExtensionsRegistry, } from '@superset-ui/core'; import Icons from 'src/components/Icons'; import { Switch } from 'src/components/Switch'; @@ -46,6 +48,8 @@ import { import { reportSelector } from 'src/views/CRUD/hooks'; import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index'; +const extensionsRegistry = getExtensionsRegistry(); + const deleteColor = (theme: SupersetTheme) => css` color: ${theme.colors.error.base}; `; @@ -70,6 +74,21 @@ const onMenuItemHover = (theme: SupersetTheme) => css` background-color: ${theme.colors.secondary.light5}; } `; + +const StyledDropdownItemWithIcon = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + > *:first-child { + margin-right: ${({ theme }) => theme.gridUnit}px; + } +`; + +const DropdownItemExtension = extensionsRegistry.get( + 'report-modal.dropdown.item.icon', +); + export enum CreationMethod { CHARTS = 'charts', DASHBOARDS = 'dashboards', @@ -204,7 +223,14 @@ export default function HeaderReportDropDown({ ) : (

- {t('Set up an email report')} + {DropdownItemExtension ? ( + +
{t('Set up an email report')}
+ +
+ ) : ( + t('Set up an email report') + )}
diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index 5abe754a63469..826b5519f4a1a 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -19,7 +19,13 @@ import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; -import { t, SupersetClient, makeApi, styled } from '@superset-ui/core'; +import { + t, + SupersetClient, + makeApi, + styled, + getExtensionsRegistry, +} from '@superset-ui/core'; import moment from 'moment'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; import FacePile from 'src/components/FacePile'; @@ -49,6 +55,8 @@ import Owner from 'src/types/Owner'; import AlertReportModal from './AlertReportModal'; import { AlertObject, AlertState } from './types'; +const extensionsRegistry = getExtensionsRegistry(); + const PAGE_SIZE = 25; const AlertStateLabel: Record = { @@ -82,6 +90,18 @@ const RefreshContainer = styled.div` background-color: ${({ theme }) => theme.colors.grayscale.light5}; `; +const StyledHeaderWithIcon = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + > *:first-child { + margin-right: ${({ theme }) => theme.gridUnit}px; + } +`; + +const HeaderExtension = extensionsRegistry.get('alertsreports.header.icon'); + function AlertList({ addDangerToast, isReportEnabled = false, @@ -495,11 +515,20 @@ function AlertList({ [], ); + const header = HeaderExtension ? ( + +
{t('Alerts & reports')}
+ +
+ ) : ( + t('Alerts & reports') + ); + return ( <> ` } `; +const StyledMenuItemWithIcon = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + const StyledAnchor = styled.a` padding-right: ${({ theme }) => theme.gridUnit}px; padding-left: ${({ theme }) => theme.gridUnit}px; @@ -330,6 +337,9 @@ const RightMenu = ({ return null; }; const RightMenuExtension = extensionsRegistry.get('navbar.right'); + const RightMenuItemIconExtension = extensionsRegistry.get( + 'navbar.right-menu.item.icon', + ); const handleDatabaseAdd = () => setQuery({ databaseAdded: true }); const handleDatasetAdd = () => setQuery({ datasetAdded: true }); @@ -447,12 +457,20 @@ const RightMenu = ({ {section?.childs?.map?.(child => { if (typeof child !== 'string') { + const menuItemDisplay = RightMenuItemIconExtension ? ( + + {child.label} + + + ) : ( + child.label + ); return ( {isFrontendRoute(child.url) ? ( - {child.label} + {menuItemDisplay} ) : ( - {child.label} + {menuItemDisplay} )} ); From 358a4ecedd13a20b3491ca9f536d773d87b6ca65 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Mon, 7 Nov 2022 08:55:15 +0000 Subject: [PATCH 03/22] fix: deprecate approve and request_access endpoint (#22022) Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com> --- UPDATING.md | 1 + superset/views/core.py | 16 ++++++++++++++-- tests/integration_tests/access_tests.py | 6 +++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index ddda9e51f6161..ec3a15a157aae 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,6 +24,7 @@ assists people when migrating to a new version. ## Next +- [22022](https://github.com/apache/superset/pull/22022): HTTP API endpoints `/superset/approve` and `/superset/request_access` have been deprecated and their HTTP methods were changed from GET to POST - [21895](https://github.com/apache/superset/pull/21895): Markdown components had their security increased by adhering to the same sanitization process enforced by Github. This means that some HTML elements found in markdowns are not allowed anymore due to the security risks they impose. If you're deploying Superset in a trusted environment and wish to use some of the blocked elements, then you can use the HTML_SANITIZATION_SCHEMA_EXTENSIONS configuration to extend the default sanitization schema. There's also the option to disable HTML sanitization using the HTML_SANITIZATION configuration but we do not recommend this approach because of the security risks. Given the provided configurations, we don't view the improved sanitization as a breaking change but as a security patch. - [20606](https://github.com/apache/superset/pull/20606): When user clicks on chart title or "Edit chart" button in Dashboard page, Explore opens in the same tab. Clicking while holding cmd/ctrl opens Explore in a new tab. To bring back the old behaviour (always opening Explore in a new tab), flip feature flag `DASHBOARD_EDIT_CHART_IN_NEW_TAB` to `True`. - [20799](https://github.com/apache/superset/pull/20799): Presto and Trino engine will now display tracking URL for running queries in SQL Lab. If for some reason you don't want to show the tracking URL (for example, when your data warehouse hasn't enable access for to Presto or Trino UI), update `TRACKING_URL_TRANSFORMER` in `config.py` to return `None`. diff --git a/superset/views/core.py b/superset/views/core.py index 371a632f29535..353671ef01e4c 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -277,8 +277,14 @@ def override_role_permissions(self) -> FlaskResponse: @has_access @event_logger.log_this - @expose("/request_access/") + @expose("/request_access/", methods=["POST"]) def request_access(self) -> FlaskResponse: + logger.warning( + "%s.approve " + "This API endpoint is deprecated and will be removed in version 3.0.0", + self.__class__.__name__, + ) + datasources = set() dashboard_id = request.args.get("dashboard_id") if dashboard_id: @@ -320,7 +326,7 @@ def request_access(self) -> FlaskResponse: @has_access @event_logger.log_this - @expose("/approve") + @expose("/approve", methods=["POST"]) def approve(self) -> FlaskResponse: # pylint: disable=too-many-locals,no-self-use def clean_fulfilled_requests(session: Session) -> None: for dar in session.query(DAR).all(): @@ -332,6 +338,12 @@ def clean_fulfilled_requests(session: Session) -> None: session.delete(dar) session.commit() + logger.warning( + "%s.approve " + "This API endpoint is deprecated and will be removed in version 3.0.0", + self.__class__.__name__, + ) + datasource_type = request.args["datasource_type"] datasource_id = request.args["datasource_id"] created_by_username = request.args.get("created_by") diff --git a/tests/integration_tests/access_tests.py b/tests/integration_tests/access_tests.py index ae8b39a8d289a..38fd10524019f 100644 --- a/tests/integration_tests/access_tests.py +++ b/tests/integration_tests/access_tests.py @@ -270,7 +270,7 @@ def test_clean_requests_after_alpha_grant(self): session.commit() access_requests = self.get_access_requests("gamma", "table", ds_1_id) self.assertTrue(access_requests) - self.client.get( + self.client.post( EXTEND_ROLE_REQUEST.format("table", ds_1_id, "gamma2", TEST_ROLE_2) ) access_requests = self.get_access_requests("gamma", "table", ds_1_id) @@ -309,7 +309,7 @@ def test_clean_requests_after_db_grant(self): access_requests = self.get_access_requests("gamma", "table", ds_1_id) self.assertTrue(access_requests) # gamma2 request gets fulfilled - self.client.get( + self.client.post( EXTEND_ROLE_REQUEST.format("table", ds_1_id, "gamma2", TEST_ROLE_2) ) access_requests = self.get_access_requests("gamma", "table", ds_1_id) @@ -353,7 +353,7 @@ def test_clean_requests_after_schema_grant(self): gamma_user.roles.append(security_manager.find_role(SCHEMA_ACCESS_ROLE)) session.commit() # gamma2 request gets fulfilled - self.client.get( + self.client.post( EXTEND_ROLE_REQUEST.format("table", ds_1_id, "gamma2", TEST_ROLE_2) ) access_requests = self.get_access_requests("gamma", "table", ds_1_id) From e33a08693bf789284d21f493074263712f17116f Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Mon, 7 Nov 2022 10:33:24 +0000 Subject: [PATCH 04/22] fix: datasource save, improve data validation (#22038) --- superset/config.py | 3 ++ superset/utils/urls.py | 19 ++++++++++- superset/views/datasource/views.py | 17 ++++++++- tests/integration_tests/datasource_tests.py | 38 +++++++++++++++++++++ tests/unit_tests/utils/urls_tests.py | 26 ++++++++++++++ 5 files changed, 101 insertions(+), 2 deletions(-) diff --git a/superset/config.py b/superset/config.py index 36e5e547db9ef..f163997c6ee4b 100644 --- a/superset/config.py +++ b/superset/config.py @@ -1328,6 +1328,9 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # Typically these should not be allowed. PREVENT_UNSAFE_DB_CONNECTIONS = True +# Prevents unsafe default endpoints to be registered on datasets. +PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = True + # Path used to store SSL certificates that are generated when using custom certs. # Defaults to temporary directory. # Example: SSL_CERT_PATH = "/certs" diff --git a/superset/utils/urls.py b/superset/utils/urls.py index a8a6148813d96..c31bfb1a5103c 100644 --- a/superset/utils/urls.py +++ b/superset/utils/urls.py @@ -14,10 +14,12 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import unicodedata import urllib from typing import Any +from urllib.parse import urlparse -from flask import current_app, url_for +from flask import current_app, request, url_for def get_url_host(user_friendly: bool = False) -> str: @@ -48,3 +50,18 @@ def modify_url_query(url: str, **kwargs: Any) -> str: parts[3] = "&".join(f"{k}={urllib.parse.quote(v[0])}" for k, v in params.items()) return urllib.parse.urlunsplit(parts) + + +def is_safe_url(url: str) -> bool: + if url.startswith("///"): + return False + try: + ref_url = urlparse(request.host_url) + test_url = urlparse(url) + except ValueError: + return False + if unicodedata.category(url[0])[0] == "C": + return False + if test_url.scheme != ref_url.scheme or ref_url.netloc != test_url.netloc: + return False + return True diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py index 2c137fab79610..c2db174cb1daf 100644 --- a/superset/views/datasource/views.py +++ b/superset/views/datasource/views.py @@ -18,7 +18,7 @@ from collections import Counter from typing import Any -from flask import redirect, request +from flask import current_app, redirect, request from flask_appbuilder import expose, permission_name from flask_appbuilder.api import rison from flask_appbuilder.security.decorators import has_access, has_access_api @@ -40,6 +40,7 @@ from superset.models.core import Database from superset.superset_typing import FlaskResponse from superset.utils.core import DatasourceType +from superset.utils.urls import is_safe_url from superset.views.base import ( api, BaseSupersetView, @@ -77,6 +78,20 @@ def save(self) -> FlaskResponse: datasource_id = datasource_dict.get("id") datasource_type = datasource_dict.get("type") database_id = datasource_dict["database"].get("id") + default_endpoint = datasource_dict["default_endpoint"] + if ( + default_endpoint + and not is_safe_url(default_endpoint) + and current_app.config["PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET"] + ): + return json_error_response( + _( + "The submitted URL is not considered safe," + " only use URLs with the same domain as Superset." + ), + status=400, + ) + orm_datasource = DatasourceDAO.get_datasource( db.session, DatasourceType(datasource_type), datasource_id ) diff --git a/tests/integration_tests/datasource_tests.py b/tests/integration_tests/datasource_tests.py index 0896971743a34..edee0028467f1 100644 --- a/tests/integration_tests/datasource_tests.py +++ b/tests/integration_tests/datasource_tests.py @@ -297,6 +297,44 @@ def test_save(self): print(k) self.assertEqual(resp[k], datasource_post[k]) + def test_save_default_endpoint_validation_fail(self): + self.login(username="admin") + tbl_id = self.get_table(name="birth_names").id + + datasource_post = get_datasource_post() + datasource_post["id"] = tbl_id + datasource_post["owners"] = [1] + datasource_post["default_endpoint"] = "http://www.google.com" + data = dict(data=json.dumps(datasource_post)) + resp = self.client.post("/datasource/save/", data=data) + assert resp.status_code == 400 + + def test_save_default_endpoint_validation_unsafe(self): + self.app.config["PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET"] = False + self.login(username="admin") + tbl_id = self.get_table(name="birth_names").id + + datasource_post = get_datasource_post() + datasource_post["id"] = tbl_id + datasource_post["owners"] = [1] + datasource_post["default_endpoint"] = "http://www.google.com" + data = dict(data=json.dumps(datasource_post)) + resp = self.client.post("/datasource/save/", data=data) + assert resp.status_code == 200 + self.app.config["PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET"] = True + + def test_save_default_endpoint_validation_success(self): + self.login(username="admin") + tbl_id = self.get_table(name="birth_names").id + + datasource_post = get_datasource_post() + datasource_post["id"] = tbl_id + datasource_post["owners"] = [1] + datasource_post["default_endpoint"] = "http://localhost/superset/1" + data = dict(data=json.dumps(datasource_post)) + resp = self.client.post("/datasource/save/", data=data) + assert resp.status_code == 200 + def save_datasource_from_dict(self, datasource_post): data = dict(data=json.dumps(datasource_post)) resp = self.get_json_resp("/datasource/save/", data) diff --git a/tests/unit_tests/utils/urls_tests.py b/tests/unit_tests/utils/urls_tests.py index f62c276f89ae6..a3893953b8ba1 100644 --- a/tests/unit_tests/utils/urls_tests.py +++ b/tests/unit_tests/utils/urls_tests.py @@ -15,6 +15,8 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import pytest + from superset.utils.urls import modify_url_query EXPLORE_CHART_LINK = "http://localhost:9000/explore/?form_data=%7B%22slice_id%22%3A+76%7D&standalone=true&force=false" @@ -33,3 +35,27 @@ def test_convert_chart_link() -> None: def test_convert_dashboard_link() -> None: test_url = modify_url_query(EXPLORE_DASHBOARD_LINK, standalone="0") assert test_url == "http://localhost:9000/superset/dashboard/3/?standalone=0" + + +@pytest.mark.parametrize( + "url,is_safe", + [ + ("http://localhost/", True), + ("http://localhost/superset/1", True), + ("https://localhost/", False), + ("https://localhost/superset/1", False), + ("localhost/superset/1", False), + ("ftp://localhost/superset/1", False), + ("http://external.com", False), + ("https://external.com", False), + ("external.com", False), + ("///localhost", False), + ("xpto://localhost:[3/1/", False), + ], +) +def test_is_safe_url(url: str, is_safe: bool) -> None: + from superset import app + from superset.utils.urls import is_safe_url + + with app.test_request_context("/"): + assert is_safe_url(url) == is_safe From aa48cae6fbbf9f319a3956052df56b51dd01683c Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Mon, 7 Nov 2022 10:11:28 -0500 Subject: [PATCH 05/22] chore: Adds RTL tests to DropdownContainer (#22041) --- .../DropdownContainer.stories.tsx | 6 +- .../DropdownContainer.test.tsx | 143 ++++++++++++++++++ .../DropdownContainer/Overview.stories.mdx | 17 +++ .../components/DropdownContainer/index.tsx | 54 +++---- .../src/components/Select/styles.tsx | 3 + 5 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 superset-frontend/src/components/DropdownContainer/DropdownContainer.test.tsx create mode 100644 superset-frontend/src/components/DropdownContainer/Overview.stories.mdx diff --git a/superset-frontend/src/components/DropdownContainer/DropdownContainer.stories.tsx b/superset-frontend/src/components/DropdownContainer/DropdownContainer.stories.tsx index e2fe280dd470f..d72b1bdd39736 100644 --- a/superset-frontend/src/components/DropdownContainer/DropdownContainer.stories.tsx +++ b/superset-frontend/src/components/DropdownContainer/DropdownContainer.stories.tsx @@ -31,7 +31,7 @@ export default { const ITEMS_COUNT = 6; const ITEM_OPTIONS = 10; const MIN_WIDTH = 700; -const MAX_WIDTH = 1500; +const MAX_WIDTH = 1300; const HEIGHT = 400; const itemsOptions = Array.from({ length: ITEM_OPTIONS }).map((_, i) => ({ @@ -47,10 +47,10 @@ const generateItems = (overflowingState?: OverflowingState) => Array.from({ length: ITEMS_COUNT }).map((_, i) => ({ id: `el-${i}`, element: ( -
+
{ From ef6b9a97d594f748ab710e27281d41ee5250d33a Mon Sep 17 00:00:00 2001 From: "JUST.in DO IT" Date: Tue, 8 Nov 2022 13:06:19 -0800 Subject: [PATCH 08/22] feat(dashboard): confirm overwrite to prevent unintended changes (#21819) --- RESOURCES/FEATURE_FLAGS.md | 1 + superset-frontend/package-lock.json | 122 +++++++++- superset-frontend/package.json | 2 + .../src/utils/featureFlags.ts | 1 + .../spec/fixtures/mockDashboardState.js | 86 +++++++ .../src/dashboard/actions/dashboardState.js | 114 ++++++++-- .../dashboard/actions/dashboardState.test.js | 80 ++++++- .../src/dashboard/components/Header/index.jsx | 3 + .../OverwriteConfirm.test.tsx | 50 +++++ .../OverwriteConfirmModal.test.tsx | 90 ++++++++ .../OverwriteConfirmModal.tsx | 209 ++++++++++++++++++ .../components/OverwriteConfirm/index.tsx | 41 ++++ superset-frontend/src/dashboard/constants.ts | 1 + .../src/dashboard/reducers/dashboardState.js | 7 + superset-frontend/src/dashboard/types.ts | 11 + .../src/dashboard/util/constants.ts | 1 + .../dashboard/util/getOverwriteItems.test.ts | 57 +++++ .../src/dashboard/util/getOverwriteItems.ts | 44 ++++ superset-frontend/src/logger/LogUtils.ts | 3 + 19 files changed, 895 insertions(+), 28 deletions(-) create mode 100644 superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirm.test.tsx create mode 100644 superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.test.tsx create mode 100644 superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.tsx create mode 100644 superset-frontend/src/dashboard/components/OverwriteConfirm/index.tsx create mode 100644 superset-frontend/src/dashboard/util/getOverwriteItems.test.ts create mode 100644 superset-frontend/src/dashboard/util/getOverwriteItems.ts diff --git a/RESOURCES/FEATURE_FLAGS.md b/RESOURCES/FEATURE_FLAGS.md index 40db66f80b2cd..aa4d6c635565d 100644 --- a/RESOURCES/FEATURE_FLAGS.md +++ b/RESOURCES/FEATURE_FLAGS.md @@ -51,6 +51,7 @@ These features are **finished** but currently being tested. They are usable, but - ALERT_REPORTS: [(docs)](https://superset.apache.org/docs/installation/alerts-reports) - ALLOW_FULL_CSV_EXPORT - CACHE_IMPERSONATION +- CONFIRM_DASHBOARD_DIFF - DASHBOARD_EDIT_CHART_IN_NEW_TAB - DASHBOARD_FILTERS_EXPERIMENTAL - DASHBOARD_NATIVE_FILTERS diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 2d5debb43583e..cf956ff74d4b2 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -100,12 +100,14 @@ "react-checkbox-tree": "^1.5.1", "react-color": "^2.13.8", "react-datetime": "^3.0.4", + "react-diff-viewer": "^3.1.1", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.13.0", "react-draggable": "^4.4.3", "react-gravatar": "^2.6.1", "react-hot-loader": "^4.12.20", + "react-intersection-observer": "^8.26.2", "react-js-cron": "^1.2.0", "react-json-tree": "^0.11.2", "react-jsonschema-form": "^1.2.0", @@ -24790,6 +24792,28 @@ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, + "node_modules/create-emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-10.0.27.tgz", + "integrity": "sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg==", + "dependencies": { + "@emotion/cache": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + } + }, + "node_modules/create-emotion/node_modules/@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "dependencies": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -27124,7 +27148,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, "engines": { "node": ">=0.3.1" } @@ -27585,6 +27608,15 @@ "node": ">= 4" } }, + "node_modules/emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/emotion/-/emotion-10.0.27.tgz", + "integrity": "sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g==", + "dependencies": { + "babel-plugin-emotion": "^10.0.27", + "create-emotion": "^10.0.27" + } + }, "node_modules/emotion-rgba": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz", @@ -45270,6 +45302,26 @@ "react": "^16.5.0" } }, + "node_modules/react-diff-viewer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz", + "integrity": "sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw==", + "dependencies": { + "classnames": "^2.2.6", + "create-emotion": "^10.0.14", + "diff": "^4.0.1", + "emotion": "^10.0.14", + "memoize-one": "^5.0.4", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">= 8" + }, + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0", + "react-dom": "^15.3.0 || ^16.0.0" + } + }, "node_modules/react-dnd": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz", @@ -45505,6 +45557,17 @@ "react": "^16.8.4 || ^17.0.0" } }, + "node_modules/react-intersection-observer": { + "version": "8.26.2", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-8.26.2.tgz", + "integrity": "sha512-GmSjLNK+oV7kS+BHfrJSaA4wF61ELA33gizKHmN+tk59UT6/aW8kkqvlrFGPwxGoaIzLKS2evfG5fgkw5MIIsg==", + "dependencies": { + "tiny-invariant": "^1.1.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/react-is": { "version": "16.6.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", @@ -77238,6 +77301,30 @@ } } }, + "create-emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-10.0.27.tgz", + "integrity": "sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg==", + "requires": { + "@emotion/cache": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + }, + "dependencies": { + "@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "requires": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + } + } + }, "create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -79012,8 +79099,7 @@ "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" }, "diff-match-patch": { "version": "1.0.5", @@ -79431,6 +79517,15 @@ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" }, + "emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/emotion/-/emotion-10.0.27.tgz", + "integrity": "sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g==", + "requires": { + "babel-plugin-emotion": "^10.0.27", + "create-emotion": "^10.0.27" + } + }, "emotion-rgba": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz", @@ -92970,6 +93065,19 @@ "prop-types": "^15.5.7" } }, + "react-diff-viewer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz", + "integrity": "sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw==", + "requires": { + "classnames": "^2.2.6", + "create-emotion": "^10.0.14", + "diff": "^4.0.1", + "emotion": "^10.0.14", + "memoize-one": "^5.0.4", + "prop-types": "^15.6.2" + } + }, "react-dnd": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz", @@ -93154,6 +93262,14 @@ "prop-types": "^15.0.0" } }, + "react-intersection-observer": { + "version": "8.26.2", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-8.26.2.tgz", + "integrity": "sha512-GmSjLNK+oV7kS+BHfrJSaA4wF61ELA33gizKHmN+tk59UT6/aW8kkqvlrFGPwxGoaIzLKS2evfG5fgkw5MIIsg==", + "requires": { + "tiny-invariant": "^1.1.0" + } + }, "react-is": { "version": "16.6.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 67f7af9b7c542..d121f296dc5cd 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -164,12 +164,14 @@ "react-checkbox-tree": "^1.5.1", "react-color": "^2.13.8", "react-datetime": "^3.0.4", + "react-diff-viewer": "^3.1.1", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.13.0", "react-draggable": "^4.4.3", "react-gravatar": "^2.6.1", "react-hot-loader": "^4.12.20", + "react-intersection-observer": "^8.26.2", "react-js-cron": "^1.2.0", "react-json-tree": "^0.11.2", "react-jsonschema-form": "^1.2.0", diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 4eb90ea0afffa..012bb4ebaf9ec 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -28,6 +28,7 @@ export enum FeatureFlag { DASHBOARD_CROSS_FILTERS = 'DASHBOARD_CROSS_FILTERS', DASHBOARD_EDIT_CHART_IN_NEW_TAB = 'DASHBOARD_EDIT_CHART_IN_NEW_TAB', DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL', + CONFIRM_DASHBOARD_DIFF = 'CONFIRM_DASHBOARD_DIFF', DASHBOARD_NATIVE_FILTERS = 'DASHBOARD_NATIVE_FILTERS', DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET', DASHBOARD_VIRTUALIZATION = 'DASHBOARD_VIRTUALIZATION', diff --git a/superset-frontend/spec/fixtures/mockDashboardState.js b/superset-frontend/spec/fixtures/mockDashboardState.js index 49b71053710a8..b605443af6b02 100644 --- a/superset-frontend/spec/fixtures/mockDashboardState.js +++ b/superset-frontend/spec/fixtures/mockDashboardState.js @@ -1,3 +1,4 @@ +/* eslint-disable theme-colors/no-literal-colors */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -30,3 +31,88 @@ export default { focusedFilterField: null, refreshFrequency: 0, }; + +export const overwriteConfirmMetadata = { + updatedAt: '2022-10-07T16:35:30.924212', + updatedBy: 'Superset Admin', + overwriteConfirmItems: [ + { + keyPath: 'css', + oldValue: '', + newValue: ` + .navbar { + transition: opacity 0.5s ease; + } +`, + }, + { + keyPath: 'json_metadata.filter_scopes', + oldValue: `{ + "122": { + "ethnic_minority": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "gender": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "developer_type": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "lang_at_home": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "country_live": { + "scope": ["ROOT_ID"], + "immune": [] + } + } +}`, + newValue: `{ + "122": { + "ethnic_minority": { + "scope": ["ROOT_ID"], + "immune": [ + 131, + 115, + 123, + 89, + 94, + 71 + ] + }, + "gender": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "developer_type": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "lang_at_home": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "country_live": { + "scope": ["ROOT_ID"], + "immune": [] + } + } +}`, + }, + ], + dashboardId: 9, + data: { + certified_by: '', + certification_details: '', + css: ".navbar {\n transition: opacity 0.5s ease;\n opacity: 0.05;\n}\n.navbar:hover {\n opacity: 1;\n}\n.chart-header .header{\n font-weight: @font-weight-normal;\n font-size: 12px;\n}\n/*\nvar bnbColors = [\n //rausch hackb kazan babu lima beach tirol\n '#ff5a5f', '#7b0051', '#007A87', '#00d1c1', '#8ce071', '#ffb400', '#b4a76c',\n '#ff8083', '#cc0086', '#00a1b3', '#00ffeb', '#bbedab', '#ffd266', '#cbc29a',\n '#ff3339', '#ff1ab1', '#005c66', '#00b3a5', '#55d12e', '#b37e00', '#988b4e',\n ];\n*/\n", + dashboard_title: 'FCC New Coder Survey 2018', + slug: null, + owners: [], + json_metadata: + '{"timed_refresh_immune_slices":[],"expanded_slices":{},"refresh_frequency":0,"default_filters":"{}","color_scheme":"supersetColors","label_colors":{"0":"#FCC700","1":"#A868B7","15":"#3CCCCB","30":"#A38F79","45":"#8FD3E4","age":"#1FA8C9","Yes,":"#1FA8C9","Female":"#454E7C","Prefer":"#5AC189","No,":"#FF7F44","Male":"#666666","Prefer not to say":"#E04355","Ph.D.":"#FCC700","associate\'s degree":"#A868B7","bachelor\'s degree":"#3CCCCB","high school diploma or equivalent (GED)":"#A38F79","master\'s degree (non-professional)":"#8FD3E4","no high school (secondary school)":"#A1A6BD","professional degree (MBA, MD, JD, etc.)":"#ACE1C4","some college credit, no degree":"#FEC0A1","some high school":"#B2B2B2","trade, technical, or vocational training":"#EFA1AA","No, not an ethnic minority":"#1FA8C9","Yes, an ethnic minority":"#454E7C","":"#5AC189","Yes":"#FF7F44","No":"#666666","last_yr_income":"#E04355","More":"#A1A6BD","Less":"#ACE1C4","I":"#FEC0A1","expected_earn":"#B2B2B2","Yes: Willing To":"#EFA1AA","No: Not Willing to":"#FDE380","No Answer":"#D3B3DA","In an Office (with Other Developers)":"#9EE5E5","No Preference":"#D1C6BC","From Home":"#1FA8C9"},"show_native_filters":true,"color_scheme_domain":["#1FA8C9","#454E7C","#5AC189","#FF7F44","#666666","#E04355","#FCC700","#A868B7","#3CCCCB","#A38F79","#8FD3E4","#A1A6BD","#ACE1C4","#FEC0A1","#B2B2B2","#EFA1AA","#FDE380","#D3B3DA","#9EE5E5","#D1C6BC"],"shared_label_colors":{"Male":"#5ac19e","Female":"#1f86c9","":"#5AC189","Prefer not to say":"#47457c","No Answer":"#e05043","Yes, an ethnic minority":"#666666","No, not an ethnic minority":"#ffa444","age":"#1FA8C9"},"filter_scopes":{},"chart_configuration":{},"positions":{}}', + }, +}; diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index 027288cdbc9b4..9bbe618c88da7 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -32,7 +32,10 @@ import { import { chart as initChart } from 'src/components/Chart/chartReducer'; import { applyDefaultFormData } from 'src/explore/store'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; -import { SAVE_TYPE_OVERWRITE } from 'src/dashboard/util/constants'; +import { + SAVE_TYPE_OVERWRITE, + SAVE_TYPE_OVERWRITE_CONFIRMED, +} from 'src/dashboard/util/constants'; import { addSuccessToast, addWarningToast, @@ -43,6 +46,8 @@ import serializeFilterScopes from 'src/dashboard/util/serializeFilterScopes'; import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; import { safeStringify } from 'src/utils/safeStringify'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; +import { logEvent } from 'src/logger/actions'; +import { LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA } from 'src/logger/LogUtils'; import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout'; import { setChartConfiguration, @@ -56,6 +61,7 @@ import { updateDirectPathToFilter, } from './dashboardFilters'; import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters'; +import getOverwriteItems from '../util/getOverwriteItems'; export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES'; export function setUnsavedChanges(hasUnsavedChanges) { @@ -189,6 +195,14 @@ export function saveDashboardRequestSuccess(lastModifiedTime) { }; } +export const SET_OVERRIDE_CONFIRM = 'SET_OVERRIDE_CONFIRM'; +export function setOverrideConfirm(overwriteConfirmMetadata) { + return { + type: SET_OVERRIDE_CONFIRM, + overwriteConfirmMetadata, + }; +} + export function saveDashboardRequest(data, id, saveType) { return (dispatch, getState) => { dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST }); @@ -316,6 +330,7 @@ export function saveDashboardRequest(data, id, saveType) { ); dispatch(addSuccessToast(t('This dashboard was saved successfully.'))); + dispatch(setOverrideConfirm(undefined)); return response; }; @@ -335,34 +350,85 @@ export function saveDashboardRequest(data, id, saveType) { dispatch(addDangerToast(errorText)); }; - if (saveType === SAVE_TYPE_OVERWRITE) { + if ( + [SAVE_TYPE_OVERWRITE, SAVE_TYPE_OVERWRITE_CONFIRMED].includes(saveType) + ) { let chartConfiguration = {}; if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { chartConfiguration = handleChartConfiguration(); } - const updatedDashboard = { - certified_by: cleanedData.certified_by, - certification_details: cleanedData.certification_details, - css: cleanedData.css, - dashboard_title: cleanedData.dashboard_title, - slug: cleanedData.slug, - owners: cleanedData.owners, - roles: cleanedData.roles, - json_metadata: safeStringify({ - ...(cleanedData?.metadata || {}), - default_filters: safeStringify(serializedFilters), - filter_scopes: serializedFilterScopes, - chart_configuration: chartConfiguration, - }), - }; - - return SupersetClient.put({ - endpoint: `/api/v1/dashboard/${id}`, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedDashboard), + const updatedDashboard = + saveType === SAVE_TYPE_OVERWRITE_CONFIRMED + ? data + : { + certified_by: cleanedData.certified_by, + certification_details: cleanedData.certification_details, + css: cleanedData.css, + dashboard_title: cleanedData.dashboard_title, + slug: cleanedData.slug, + owners: cleanedData.owners, + roles: cleanedData.roles, + json_metadata: safeStringify({ + ...(cleanedData?.metadata || {}), + default_filters: safeStringify(serializedFilters), + filter_scopes: serializedFilterScopes, + chart_configuration: chartConfiguration, + }), + }; + + const updateDashboard = () => + SupersetClient.put({ + endpoint: `/api/v1/dashboard/${id}`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedDashboard), + }) + .then(response => onUpdateSuccess(response)) + .catch(response => onError(response)); + return new Promise((resolve, reject) => { + if ( + !isFeatureEnabled(FeatureFlag.CONFIRM_DASHBOARD_DIFF) || + saveType === SAVE_TYPE_OVERWRITE_CONFIRMED + ) { + // skip overwrite precheck + resolve(); + return; + } + + // precheck for overwrite items + SupersetClient.get({ + endpoint: `/api/v1/dashboard/${id}`, + }).then(response => { + const dashboard = response.json.result; + const overwriteConfirmItems = getOverwriteItems( + dashboard, + updatedDashboard, + ); + if (overwriteConfirmItems.length > 0) { + dispatch( + setOverrideConfirm({ + updatedAt: dashboard.changed_on, + updatedBy: dashboard.changed_by_name, + overwriteConfirmItems, + dashboardId: id, + data: updatedDashboard, + }), + ); + return reject(overwriteConfirmItems); + } + return resolve(); + }); }) - .then(response => onUpdateSuccess(response)) - .catch(response => onError(response)); + .then(updateDashboard) + .catch(overwriteConfirmItems => { + const errorText = t('Please confirm the overwrite values.'); + dispatch( + logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, { + dashboard_id: id, + items: overwriteConfirmItems, + }), + ); + dispatch(addDangerToast(errorText)); + }); } // changing the data as the endpoint requires const copyData = { ...cleanedData }; diff --git a/superset-frontend/src/dashboard/actions/dashboardState.test.js b/superset-frontend/src/dashboard/actions/dashboardState.test.js index c985496f892d8..25aa54ed6638a 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.test.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.test.js @@ -18,14 +18,21 @@ */ import sinon from 'sinon'; import { SupersetClient } from '@superset-ui/core'; +import { waitFor } from '@testing-library/react'; import { removeSliceFromDashboard, saveDashboardRequest, + SET_OVERRIDE_CONFIRM, } from 'src/dashboard/actions/dashboardState'; import { REMOVE_FILTER } from 'src/dashboard/actions/dashboardFilters'; +import * as featureFlags from 'src/featureFlags'; import { UPDATE_COMPONENTS_PARENTS_LIST } from 'src/dashboard/actions/dashboardLayout'; -import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants'; +import { + DASHBOARD_GRID_ID, + SAVE_TYPE_OVERWRITE, + SAVE_TYPE_OVERWRITE_CONFIRMED, +} from 'src/dashboard/util/constants'; import { filterId, sliceEntitiesForDashboard as sliceEntities, @@ -55,13 +62,32 @@ describe('dashboardState actions', () => { const newDashboardData = mockDashboardData; let postStub; + let getStub; + let putStub; + const updatedCss = '.updated_css_value {\n color: black;\n}'; + beforeEach(() => { postStub = sinon .stub(SupersetClient, 'post') .resolves('the value you want to return'); + getStub = sinon.stub(SupersetClient, 'get').resolves({ + json: { + result: { + ...mockDashboardData, + css: updatedCss, + }, + }, + }); + putStub = sinon.stub(SupersetClient, 'put').resolves({ + json: { + result: mockDashboardData, + }, + }); }); afterEach(() => { postStub.restore(); + getStub.restore(); + putStub.restore(); }); function setup(stateOverrides) { @@ -111,6 +137,58 @@ describe('dashboardState actions', () => { mockParentsList, ); }); + + describe('FeatureFlag.CONFIRM_DASHBOARD_DIFF', () => { + let isFeatureEnabledMock; + beforeEach(() => { + isFeatureEnabledMock = jest + .spyOn(featureFlags, 'isFeatureEnabled') + .mockImplementation(feature => feature === 'CONFIRM_DASHBOARD_DIFF'); + }); + + afterEach(() => { + isFeatureEnabledMock.mockRestore(); + }); + + it('dispatches SET_OVERRIDE_CONFIRM when an inspect value has diff', async () => { + const id = 192; + const { getState, dispatch } = setup(); + const thunk = saveDashboardRequest( + newDashboardData, + id, + SAVE_TYPE_OVERWRITE, + ); + thunk(dispatch, getState); + expect(getStub.callCount).toBe(1); + expect(postStub.callCount).toBe(0); + await waitFor(() => + expect(dispatch.getCall(1).args[0].type).toBe(SET_OVERRIDE_CONFIRM), + ); + expect( + dispatch.getCall(1).args[0].overwriteConfirmMetadata.dashboardId, + ).toBe(id); + }); + + it('should post dashboard data with after confirm the overwrite values', async () => { + const id = 192; + const { getState, dispatch } = setup(); + const confirmedDashboardData = { + ...newDashboardData, + css: updatedCss, + }; + const thunk = saveDashboardRequest( + confirmedDashboardData, + id, + SAVE_TYPE_OVERWRITE_CONFIRMED, + ); + thunk(dispatch, getState); + expect(getStub.callCount).toBe(0); + expect(postStub.callCount).toBe(0); + await waitFor(() => expect(putStub.callCount).toBe(1)); + const { body } = putStub.getCall(0).args[0]; + expect(body).toBe(JSON.stringify(confirmedDashboardData)); + }); + }); }); it('should dispatch removeFilter if a removed slice is a filter_box', () => { diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index c8b4c0cdde50b..ca3566345b6dd 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -56,6 +56,7 @@ import setPeriodicRunner, { import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import { DashboardEmbedModal } from '../DashboardEmbedControls'; +import OverwriteConfirm from '../OverwriteConfirm'; const uiOverrideRegistry = getUiOverrideRegistry(); @@ -696,6 +697,8 @@ class Header extends React.PureComponent { /> )} + + {userCanCurate && ( { + const { queryByText } = render(, { + useRedux: true, + store: mockStore({ dashboardState: {} }), + }); + expect(queryByText('Confirm overwrite')).not.toBeInTheDocument(); +}); + +test('renders confirm modal on overwriteConfirmMetadata is provided', async () => { + const { queryByText } = render(, { + useRedux: true, + store: mockStore({ + dashboardState: { + overwriteConfirmMetadata, + }, + }), + }); + await waitFor(() => + expect(queryByText('Confirm overwrite')).toBeInTheDocument(), + ); +}); diff --git a/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.test.tsx b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.test.tsx new file mode 100644 index 0000000000000..751def6ebb65b --- /dev/null +++ b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.test.tsx @@ -0,0 +1,90 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; +import fetchMock from 'fetch-mock'; +import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils'; + +import { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; +import { overwriteConfirmMetadata } from 'spec/fixtures/mockDashboardState'; +import OverwriteConfirmModal from './OverwriteConfirmModal'; + +const middlewares = [thunk]; +const mockStore = configureStore(middlewares); + +jest.mock('react-diff-viewer', () => () => ( +
+)); + +test('renders diff viewer when it contains overwriteConfirmMetadata', async () => { + const { queryByText, findAllByTestId } = render( + , + { + useRedux: true, + store: mockStore(), + }, + ); + expect(queryByText('Confirm overwrite')).toBeInTheDocument(); + const diffViewers = await findAllByTestId('mock-diff-viewer'); + expect(diffViewers).toHaveLength( + overwriteConfirmMetadata.overwriteConfirmItems.length, + ); +}); + +test('requests update dashboard api when save button is clicked', async () => { + const updateDashboardEndpoint = `glob:*/api/v1/dashboard/${overwriteConfirmMetadata.dashboardId}`; + fetchMock.put(updateDashboardEndpoint, { + id: overwriteConfirmMetadata.dashboardId, + last_modified_time: +new Date(), + result: overwriteConfirmMetadata.data, + }); + const store = mockStore({ + dashboardLayout: {}, + dashboardFilters: {}, + }); + const { findByTestId } = render( + , + { + useRedux: true, + store, + }, + ); + const saveButton = await findByTestId('overwrite-confirm-save-button'); + expect(fetchMock.calls(updateDashboardEndpoint)).toHaveLength(0); + fireEvent.click(saveButton); + expect(fetchMock.calls(updateDashboardEndpoint)).toHaveLength(0); + mockAllIsIntersecting(true); + fireEvent.click(saveButton); + await waitFor(() => + expect(fetchMock.calls(updateDashboardEndpoint)?.[0]?.[1]?.body).toEqual( + JSON.stringify(overwriteConfirmMetadata.data), + ), + ); + await waitFor(() => + expect(store.getActions()).toContainEqual({ + type: 'SET_OVERRIDE_CONFIRM', + overwriteConfirmMetadata: undefined, + }), + ); +}); diff --git a/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.tsx b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.tsx new file mode 100644 index 0000000000000..32fea9b4fc140 --- /dev/null +++ b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.tsx @@ -0,0 +1,209 @@ +/** + * 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, { useMemo, useCallback, RefObject, createRef } from 'react'; +import moment from 'moment'; +import { useDispatch } from 'react-redux'; +import ReactDiffViewer from 'react-diff-viewer'; +import { useInView } from 'react-intersection-observer'; +import Modal from 'src/components/Modal'; +import Button from 'src/components/Button'; +import { DashboardState } from 'src/dashboard/types'; +import { + saveDashboardRequest, + setOverrideConfirm, +} from 'src/dashboard/actions/dashboardState'; +import { t, styled } from '@superset-ui/core'; +import { SAVE_TYPE_OVERWRITE_CONFIRMED } from 'src/dashboard/util/constants'; + +const STICKY_HEADER_TOP = 16; +const STICKY_HEADER_HEIGHT = 32; + +const StyledTitle = styled.h2` + ${({ theme }) => ` + color: ${theme.colors.grayscale.dark1} + `} +`; + +const StyledEditor = styled.div` + ${({ theme }) => ` + table { + border: 1px ${theme.colors.grayscale.light2} solid; + } + pre { + font-size: 11px; + padding: 0px; + background-color: transparent; + border: 0px; + line-height: 110%; + } + `} +`; + +const StackableHeader = styled(Button)<{ top: number }>` + ${({ theme, top }) => ` + position: sticky; + top: ${top}px; + background-color: ${theme.colors.grayscale.light5}; + margin: 0px; + padding: 8px 4px; + z-index: 1; + border: 0px; + border-radius: 0px; + width: 100%; + justify-content: flex-start; + border-bottom: 1px ${theme.colors.grayscale.light1} solid; + &::before { + display: inline-block; + position: relative; + opacity: 1; + content: "\\00BB"; + } + `} +`; + +const StyledBottom = styled.div<{ inView: boolean }>` + ${({ inView }) => ` + margin: 8px auto; + text-align: center; + opacity: ${inView ? 0 : 1}; + `} +`; + +type Props = { + overwriteConfirmMetadata: DashboardState['overwriteConfirmMetadata']; +}; + +const OverrideConfirmModal = ({ overwriteConfirmMetadata }: Props) => { + const [bottomRef, hasReviewed] = useInView({ triggerOnce: true }); + const dispatch = useDispatch(); + const onHide = useCallback( + () => dispatch(setOverrideConfirm(undefined)), + [dispatch], + ); + const anchors = useMemo[]>( + () => + overwriteConfirmMetadata + ? overwriteConfirmMetadata.overwriteConfirmItems.map(() => + createRef(), + ) + : [], + [overwriteConfirmMetadata], + ); + const onAnchorClicked = useCallback( + (index: number) => { + anchors[index]?.current?.scrollIntoView({ behavior: 'smooth' }); + }, + [anchors], + ); + const onConfirmOverwrite = useCallback(() => { + if (overwriteConfirmMetadata) { + dispatch( + saveDashboardRequest( + overwriteConfirmMetadata.data, + overwriteConfirmMetadata.dashboardId, + SAVE_TYPE_OVERWRITE_CONFIRMED, + ), + ); + } + }, [dispatch, overwriteConfirmMetadata]); + + return ( + + {t('Scroll down to the bottom to enable overwriting changes. ')} + + + + } + onHide={onHide} + > + {overwriteConfirmMetadata && ( + <> + + {t('Are you sure you intend to overwrite the following values?')} + + + {overwriteConfirmMetadata.overwriteConfirmItems.map( + ({ keyPath, oldValue, newValue }, index) => ( + +
+ onAnchorClicked(index)} + > + {keyPath} + + + + ), + )} + + {/* Add submit button at the bottom in case of intersection-observer fallback */} + + + + + )} + + ); +}; + +export default OverrideConfirmModal; diff --git a/superset-frontend/src/dashboard/components/OverwriteConfirm/index.tsx b/superset-frontend/src/dashboard/components/OverwriteConfirm/index.tsx new file mode 100644 index 0000000000000..46e4f3ab50146 --- /dev/null +++ b/superset-frontend/src/dashboard/components/OverwriteConfirm/index.tsx @@ -0,0 +1,41 @@ +/** + * 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 from 'react'; +import { useSelector } from 'react-redux'; +import AsyncEsmComponent from 'src/components/AsyncEsmComponent'; +import { DashboardState, RootState } from 'src/dashboard/types'; + +const Modal = AsyncEsmComponent(() => import('./OverwriteConfirmModal')); + +const OverrideConfirm = () => { + const overwriteConfirmMetadata = useSelector< + RootState, + DashboardState['overwriteConfirmMetadata'] + >(({ dashboardState }) => dashboardState.overwriteConfirmMetadata); + + return ( + <> + {overwriteConfirmMetadata && ( + + )} + + ); +}; + +export default OverrideConfirm; diff --git a/superset-frontend/src/dashboard/constants.ts b/superset-frontend/src/dashboard/constants.ts index ab9d9b5967a95..97753abe45c54 100644 --- a/superset-frontend/src/dashboard/constants.ts +++ b/superset-frontend/src/dashboard/constants.ts @@ -41,3 +41,4 @@ export const OPEN_FILTER_BAR_MAX_WIDTH = 550; export const FILTER_BAR_HEADER_HEIGHT = 80; export const FILTER_BAR_TABS_HEIGHT = 46; export const BUILDER_SIDEPANEL_WIDTH = 374; +export const OVERWRITE_INSPECT_FIELDS = ['css', 'json_metadata.filter_scopes']; diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js index 14b35caef04a7..1c339001d17d6 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.js @@ -42,6 +42,7 @@ import { ON_FILTERS_REFRESH, ON_FILTERS_REFRESH_SUCCESS, SET_DATASETS_STATUS, + SET_OVERRIDE_CONFIRM, } from '../actions/dashboardState'; import { HYDRATE_DASHBOARD } from '../actions/hydrate'; @@ -173,6 +174,12 @@ export default function dashboardStateReducer(state = {}, action) { activeTabs: Array.from(newActiveTabs), }; }, + [SET_OVERRIDE_CONFIRM]() { + return { + ...state, + overwriteConfirmMetadata: action.overwriteConfirmMetadata, + }; + }, [SET_FOCUSED_FILTER_FIELD]() { return { ...state, diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 023120f62fbcd..b809f405ac026 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -77,6 +77,17 @@ export type DashboardState = { chartId: number; column: string; }; + overwriteConfirmMetadata?: { + updatedAt: string; + updatedBy: string; + overwriteConfirmItems: { + keyPath: string; + oldValue: string; + newValue: string; + }[]; + dashboardId: number; + data: JsonObject; + }; }; export type DashboardInfo = { id: number; diff --git a/superset-frontend/src/dashboard/util/constants.ts b/superset-frontend/src/dashboard/util/constants.ts index 640028eb4e947..0743d7a5a42d3 100644 --- a/superset-frontend/src/dashboard/util/constants.ts +++ b/superset-frontend/src/dashboard/util/constants.ts @@ -58,6 +58,7 @@ export const UNDO_LIMIT = 50; // save dash options export const SAVE_TYPE_OVERWRITE = 'overwrite'; +export const SAVE_TYPE_OVERWRITE_CONFIRMED = 'overwriteConfirmed'; export const SAVE_TYPE_NEWDASHBOARD = 'newDashboard'; // default dashboard layout data size limit diff --git a/superset-frontend/src/dashboard/util/getOverwriteItems.test.ts b/superset-frontend/src/dashboard/util/getOverwriteItems.test.ts new file mode 100644 index 0000000000000..e4328fb08897d --- /dev/null +++ b/superset-frontend/src/dashboard/util/getOverwriteItems.test.ts @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import getOverwriteItems from './getOverwriteItems'; + +test('returns diff items', () => { + const prevFilterScopes = { + filter1: { + scope: ['abc'], + immune: [], + }, + }; + const nextFilterScopes = { + scope: ['ROOT_ID'], + immune: ['efg'], + }; + + const prevValue = { + css: '', + json_metadata: JSON.stringify({ + filter_scopes: prevFilterScopes, + default_filters: {}, + }), + }; + + const nextValue = { + css: '.updated_css {color: white;}', + json_metadata: JSON.stringify({ + filter_scopes: nextFilterScopes, + default_filters: {}, + }), + }; + expect(getOverwriteItems(prevValue, nextValue)).toEqual([ + { keyPath: 'css', newValue: nextValue.css, oldValue: prevValue.css }, + { + keyPath: 'json_metadata.filter_scopes', + newValue: JSON.stringify(nextFilterScopes, null, 2), + oldValue: JSON.stringify(prevFilterScopes, null, 2), + }, + ]); +}); diff --git a/superset-frontend/src/dashboard/util/getOverwriteItems.ts b/superset-frontend/src/dashboard/util/getOverwriteItems.ts new file mode 100644 index 0000000000000..5301cb03af4de --- /dev/null +++ b/superset-frontend/src/dashboard/util/getOverwriteItems.ts @@ -0,0 +1,44 @@ +/** + * 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 { JsonObject } from '@superset-ui/core'; +import { OVERWRITE_INSPECT_FIELDS } from 'src/dashboard/constants'; + +const JSON_KEYS = new Set(['json_metadata', 'position_json']); + +function extractValue(object: JsonObject, keyPath: string) { + return keyPath.split('.').reduce((obj: JsonObject, key: string) => { + const value = obj?.[key]; + return JSON_KEYS.has(key) && value ? JSON.parse(value) : value; + }, object); +} + +export default function getOverwriteItems(prev: JsonObject, next: JsonObject) { + return OVERWRITE_INSPECT_FIELDS.map(keyPath => ({ + keyPath, + ...(keyPath.split('.').find(key => JSON_KEYS.has(key)) + ? { + oldValue: JSON.stringify(extractValue(prev, keyPath), null, 2) || '', + newValue: JSON.stringify(extractValue(next, keyPath), null, 2) || '', + } + : { + oldValue: extractValue(prev, keyPath) || '', + newValue: extractValue(next, keyPath) || '', + }), + })).filter(({ oldValue, newValue }) => oldValue !== newValue); +} diff --git a/superset-frontend/src/logger/LogUtils.ts b/superset-frontend/src/logger/LogUtils.ts index 986bde816fb69..60f0fe183301e 100644 --- a/superset-frontend/src/logger/LogUtils.ts +++ b/superset-frontend/src/logger/LogUtils.ts @@ -45,6 +45,8 @@ export const LOG_ACTIONS_DATASET_CREATION_TABLE_CANCELLATION = 'dataset_creation_table_cancellation'; export const LOG_ACTIONS_DATASET_CREATION_SUCCESS = 'dataset_creation_success'; export const LOG_ACTIONS_SPA_NAVIGATION = 'spa_navigation'; +export const LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA = + 'confirm_overwrite_dashboard_metadata'; // Log event types -------------------------------------------------------------- export const LOG_EVENT_TYPE_TIMING = new Set([ @@ -64,6 +66,7 @@ export const LOG_EVENT_TYPE_USER = new Set([ LOG_ACTIONS_FORCE_REFRESH_DASHBOARD, LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, LOG_ACTIONS_MOUNT_EXPLORER, + LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, ]); export const LOG_EVENT_DATASET_TYPE_DATASET_CREATION = [ From cdec09243be1814e8564acc3c71432ce76892332 Mon Sep 17 00:00:00 2001 From: Corbin Robb <31329271+corbinrobb@users.noreply.github.com> Date: Tue, 8 Nov 2022 14:24:43 -0700 Subject: [PATCH 09/22] test: Fix act errors in DndColumnSelectControl tests (#22068) --- .../DndColumnSelectControl/Option.test.tsx | 24 ++++++++++++------- .../OptionWrapper.test.tsx | 12 ++++++---- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx index 744fe03a0955c..042cd73a763b1 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx @@ -21,47 +21,53 @@ import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import Option from 'src/explore/components/controls/DndColumnSelectControl/Option'; -test('renders with default props', () => { +test('renders with default props', async () => { const { container } = render( , ); expect(container).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'x-small' })).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'x-small' }), + ).toBeInTheDocument(); expect( screen.queryByRole('img', { name: 'caret-right' }), ).not.toBeInTheDocument(); }); -test('renders with caret', () => { +test('renders with caret', async () => { render( , ); - expect(screen.getByRole('img', { name: 'x-small' })).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'caret-right' })).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'x-small' }), + ).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'caret-right' }), + ).toBeInTheDocument(); }); -test('renders with extra triangle', () => { +test('renders with extra triangle', async () => { render( , ); expect( - screen.getByRole('button', { name: 'Show info tooltip' }), + await screen.findByRole('button', { name: 'Show info tooltip' }), ).toBeInTheDocument(); }); -test('triggers onClose', () => { +test('triggers onClose', async () => { const clickClose = jest.fn(); render( , ); - userEvent.click(screen.getByRole('img', { name: 'x-small' })); + userEvent.click(await screen.findByRole('img', { name: 'x-small' })); expect(clickClose).toHaveBeenCalled(); }); diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx index e237cea989a5c..d7d362996fe1e 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx @@ -21,7 +21,7 @@ import { render, screen, fireEvent } from 'spec/helpers/testing-library'; import { DndItemType } from 'src/explore/components/DndItemType'; import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper'; -test('renders with default props', () => { +test('renders with default props', async () => { const { container } = render( { { useDnd: true }, ); expect(container).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'x-small' })).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'x-small' }), + ).toBeInTheDocument(); }); -test('triggers onShiftOptions on drop', () => { +test('triggers onShiftOptions on drop', async () => { const onShiftOptions = jest.fn(); render( <> @@ -58,7 +60,7 @@ test('triggers onShiftOptions on drop', () => { { useDnd: true }, ); - fireEvent.dragStart(screen.getByText('Option 1')); - fireEvent.drop(screen.getByText('Option 2')); + fireEvent.dragStart(await screen.findByText('Option 1')); + fireEvent.drop(await screen.findByText('Option 2')); expect(onShiftOptions).toHaveBeenCalled(); }); From 4496748cd942b037a9e497edc336bd3e3d48717a Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Tue, 8 Nov 2022 15:03:58 -0800 Subject: [PATCH 10/22] chore: update Druid (#22067) --- requirements/development.txt | 2 +- setup.py | 2 +- superset/db_engine_specs/druid.py | 8 +------- superset/db_engine_specs/trino.py | 2 +- superset/models/core.py | 4 ++-- superset/views/core.py | 2 +- 6 files changed, 7 insertions(+), 13 deletions(-) diff --git a/requirements/development.txt b/requirements/development.txt index 9990012ab32ee..1bce530eab733 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -78,7 +78,7 @@ pure-eval==0.2.2 # via stack-data pure-sasl==0.6.2 # via thrift-sasl -pydruid==0.6.2 +pydruid==0.6.5 # via apache-superset pygments==2.12.0 # via ipython diff --git a/setup.py b/setup.py index cad76a9572997..a3c7407f9603d 100644 --- a/setup.py +++ b/setup.py @@ -141,7 +141,7 @@ def get_git_sha() -> str: "db2": ["ibm-db-sa>=0.3.5, <0.4"], "dremio": ["sqlalchemy-dremio>=1.1.5, <1.3"], "drill": ["sqlalchemy-drill==0.1.dev"], - "druid": ["pydruid>=0.6.1,<0.7"], + "druid": ["pydruid>=0.6.5,<0.7"], "solr": ["sqlalchemy-solr >= 0.2.0"], "elasticsearch": ["elasticsearch-dbapi>=0.2.9, <0.3.0"], "exasol": ["sqlalchemy-exasol >= 2.4.0, <3.0"], diff --git a/superset/db_engine_specs/druid.py b/superset/db_engine_specs/druid.py index 1484429bafbce..6cdc9f85e3ec2 100644 --- a/superset/db_engine_specs/druid.py +++ b/superset/db_engine_specs/druid.py @@ -19,7 +19,6 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING -from sqlalchemy import types from sqlalchemy.engine.reflection import Inspector from superset import is_feature_enabled @@ -131,16 +130,11 @@ def get_columns( """ Update the Druid type map. """ - # pylint: disable=import-outside-toplevel - from pydruid.db.sqlalchemy import type_map - - type_map["complex"] = types.BLOB - return super().get_columns(inspector, table_name, schema) @classmethod def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]: - # pylint: disable=import-error,import-outside-toplevel + # pylint: disable=import-outside-toplevel from requests import exceptions as requests_exceptions return { diff --git a/superset/db_engine_specs/trino.py b/superset/db_engine_specs/trino.py index 490a36ab2bbc2..c62a9d58b4343 100644 --- a/superset/db_engine_specs/trino.py +++ b/superset/db_engine_specs/trino.py @@ -224,7 +224,7 @@ def update_params_from_encrypted_extra( @classmethod def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]: - # pylint: disable=import-error,import-outside-toplevel + # pylint: disable=import-outside-toplevel from requests import exceptions as requests_exceptions return { diff --git a/superset/models/core.py b/superset/models/core.py index 4985ecf9f8170..86b9eb1bde759 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -563,7 +563,7 @@ def get_all_table_names_in_schema( # pylint: disable=unused-argument database=self, inspector=self.inspector, schema=schema ) return [(table, schema) for table in tables] - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: raise self.db_engine_spec.get_dbapi_mapped_exception(ex) @cache_util.memoized_func( @@ -593,7 +593,7 @@ def get_all_view_names_in_schema( # pylint: disable=unused-argument database=self, inspector=self.inspector, schema=schema ) return [(view, schema) for view in views] - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: raise self.db_engine_spec.get_dbapi_mapped_exception(ex) @cache_util.memoized_func( diff --git a/superset/views/core.py b/superset/views/core.py index 353671ef01e4c..cc1865452a856 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -327,7 +327,7 @@ def request_access(self) -> FlaskResponse: @has_access @event_logger.log_this @expose("/approve", methods=["POST"]) - def approve(self) -> FlaskResponse: # pylint: disable=too-many-locals,no-self-use + def approve(self) -> FlaskResponse: # pylint: disable=too-many-locals def clean_fulfilled_requests(session: Session) -> None: for dar in session.query(DAR).all(): datasource = DatasourceDAO.get_datasource( From 53ed8f2d5a64a655cf508d38f1b617df435d3628 Mon Sep 17 00:00:00 2001 From: waynewenswag <112917482+waynewenswag@users.noreply.github.com> Date: Thu, 10 Nov 2022 02:41:40 +0800 Subject: [PATCH 11/22] feat(helm): add `metadata namespace` (#22020) --- helm/superset/Chart.yaml | 2 +- helm/superset/README.md | 2 +- helm/superset/templates/configmap-superset.yaml | 1 + helm/superset/templates/deployment-beat.yaml | 1 + helm/superset/templates/deployment-flower.yaml | 1 + helm/superset/templates/deployment-worker.yaml | 1 + helm/superset/templates/deployment-ws.yaml | 1 + helm/superset/templates/deployment.yaml | 1 + helm/superset/templates/ingress.yaml | 1 + helm/superset/templates/init-job.yaml | 1 + helm/superset/templates/secret-env.yaml | 1 + helm/superset/templates/secret-superset-config.yaml | 1 + helm/superset/templates/secret-ws.yaml | 1 + helm/superset/templates/service-account.yaml | 1 + helm/superset/templates/service.yaml | 3 +++ 15 files changed, 17 insertions(+), 2 deletions(-) diff --git a/helm/superset/Chart.yaml b/helm/superset/Chart.yaml index cfe3d3fdd92ae..7394d150cafbd 100644 --- a/helm/superset/Chart.yaml +++ b/helm/superset/Chart.yaml @@ -29,7 +29,7 @@ maintainers: - name: craig-rueda email: craig@craigrueda.com url: https://github.com/craig-rueda -version: 0.7.6 +version: 0.7.7 dependencies: - name: postgresql version: 11.1.22 diff --git a/helm/superset/README.md b/helm/superset/README.md index 5b704ab78af5e..3564c205f5e7c 100644 --- a/helm/superset/README.md +++ b/helm/superset/README.md @@ -19,7 +19,7 @@ # superset -![Version: 0.7.6](https://img.shields.io/badge/Version-0.7.6-informational?style=flat-square) +![Version: 0.7.7](https://img.shields.io/badge/Version-0.7.7-informational?style=flat-square) Apache Superset is a modern, enterprise-ready business intelligence web application diff --git a/helm/superset/templates/configmap-superset.yaml b/helm/superset/templates/configmap-superset.yaml index a7d7b09339a0a..eb8564619b187 100644 --- a/helm/superset/templates/configmap-superset.yaml +++ b/helm/superset/templates/configmap-superset.yaml @@ -24,6 +24,7 @@ metadata: chart: {{ template "superset.chart" . }} release: {{ .Release.Name }} heritage: {{ .Release.Service }} + namespace: {{ .Release.Namespace }} data: {{- range $path, $config := .Values.extraConfigs }} {{ $path }}: | diff --git a/helm/superset/templates/deployment-beat.yaml b/helm/superset/templates/deployment-beat.yaml index 2988c7755367a..01e66a83b645a 100644 --- a/helm/superset/templates/deployment-beat.yaml +++ b/helm/superset/templates/deployment-beat.yaml @@ -28,6 +28,7 @@ metadata: annotations: {{- toYaml .Values.supersetCeleryBeat.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: # This must be a singleton replicas: 1 diff --git a/helm/superset/templates/deployment-flower.yaml b/helm/superset/templates/deployment-flower.yaml index 35b31cf55e238..197aa5822fdcf 100644 --- a/helm/superset/templates/deployment-flower.yaml +++ b/helm/superset/templates/deployment-flower.yaml @@ -28,6 +28,7 @@ metadata: annotations: {{- toYaml .Values.supersetCeleryFlower.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetCeleryFlower.replicaCount }} selector: diff --git a/helm/superset/templates/deployment-worker.yaml b/helm/superset/templates/deployment-worker.yaml index c2e924438e7c0..06b52a7c7c663 100644 --- a/helm/superset/templates/deployment-worker.yaml +++ b/helm/superset/templates/deployment-worker.yaml @@ -27,6 +27,7 @@ metadata: annotations: {{- toYaml .Values.supersetWorker.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetWorker.replicaCount }} selector: diff --git a/helm/superset/templates/deployment-ws.yaml b/helm/superset/templates/deployment-ws.yaml index 735edc8330ffb..1713ee74c5337 100644 --- a/helm/superset/templates/deployment-ws.yaml +++ b/helm/superset/templates/deployment-ws.yaml @@ -28,6 +28,7 @@ metadata: annotations: {{- toYaml .Values.supersetWebsockets.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetWebsockets.replicaCount }} selector: diff --git a/helm/superset/templates/deployment.yaml b/helm/superset/templates/deployment.yaml index a02c9cf293dcf..d668cb7a0b358 100644 --- a/helm/superset/templates/deployment.yaml +++ b/helm/superset/templates/deployment.yaml @@ -27,6 +27,7 @@ metadata: annotations: {{- toYaml .Values.supersetNode.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetNode.replicaCount }} {{- if .Values.supersetNode.strategy }} diff --git a/helm/superset/templates/ingress.yaml b/helm/superset/templates/ingress.yaml index c0df1e90e6f7f..d166149c00ba1 100644 --- a/helm/superset/templates/ingress.yaml +++ b/helm/superset/templates/ingress.yaml @@ -29,6 +29,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: {{- if .Values.ingress.ingressClassName }} ingressClassName: {{ .Values.ingress.ingressClassName }} diff --git a/helm/superset/templates/init-job.yaml b/helm/superset/templates/init-job.yaml index 878e93095853b..96b063ff4f043 100644 --- a/helm/superset/templates/init-job.yaml +++ b/helm/superset/templates/init-job.yaml @@ -22,6 +22,7 @@ metadata: annotations: "helm.sh/hook": post-install,post-upgrade "helm.sh/hook-delete-policy": "before-hook-creation" + namespace: {{ .Release.Namespace }} spec: template: metadata: diff --git a/helm/superset/templates/secret-env.yaml b/helm/superset/templates/secret-env.yaml index 4126507324439..0164d96a8c129 100644 --- a/helm/superset/templates/secret-env.yaml +++ b/helm/superset/templates/secret-env.yaml @@ -23,6 +23,7 @@ metadata: chart: {{ template "superset.chart" . }} release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" + namespace: {{ .Release.Namespace }} type: Opaque stringData: REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }} diff --git a/helm/superset/templates/secret-superset-config.yaml b/helm/superset/templates/secret-superset-config.yaml index ddf0befcd2f2b..c1f4102858d93 100644 --- a/helm/superset/templates/secret-superset-config.yaml +++ b/helm/superset/templates/secret-superset-config.yaml @@ -23,6 +23,7 @@ metadata: chart: {{ template "superset.chart" . }} release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" + namespace: {{ .Release.Namespace }} type: Opaque stringData: superset_config.py: | diff --git a/helm/superset/templates/secret-ws.yaml b/helm/superset/templates/secret-ws.yaml index 0e48e0377e591..c3ac55d96cb07 100644 --- a/helm/superset/templates/secret-ws.yaml +++ b/helm/superset/templates/secret-ws.yaml @@ -24,6 +24,7 @@ metadata: chart: {{ template "superset.chart" . }} release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" + namespace: {{ .Release.Namespace }} type: Opaque stringData: config.json: | diff --git a/helm/superset/templates/service-account.yaml b/helm/superset/templates/service-account.yaml index 8d1171fad4445..994ad8333afd8 100755 --- a/helm/superset/templates/service-account.yaml +++ b/helm/superset/templates/service-account.yaml @@ -31,4 +31,5 @@ metadata: {{- if .Values.serviceAccount.annotations }} annotations: {{- toYaml .Values.serviceAccount.annotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} {{- end -}} diff --git a/helm/superset/templates/service.yaml b/helm/superset/templates/service.yaml index 431d03704e551..6ac950d1da6b2 100644 --- a/helm/superset/templates/service.yaml +++ b/helm/superset/templates/service.yaml @@ -27,6 +27,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: type: {{ .Values.service.type }} ports: @@ -55,6 +56,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: type: {{ .Values.supersetCeleryFlower.service.type }} ports: @@ -84,6 +86,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: type: {{ .Values.supersetWebsockets.service.type }} ports: From 9f7bd1e63fbd4084b1dd1ad9b1dd718ff43c7e7c Mon Sep 17 00:00:00 2001 From: John Bodley <4567245+john-bodley@users.noreply.github.com> Date: Wed, 9 Nov 2022 14:30:49 -0800 Subject: [PATCH 12/22] fix(presto/trino): Ensure get_table_names only returns real tables (#21794) --- UPDATING.md | 1 + requirements/base.txt | 2 - requirements/docker.txt | 2 + requirements/testing.txt | 2 +- setup.py | 2 +- superset/databases/api.py | 4 +- superset/db_engine_specs/base.py | 28 +++-- superset/db_engine_specs/presto.py | 82 ++++++++++---- .../db_engine_specs/hive_tests.py | 4 - .../db_engine_specs/presto_tests.py | 100 ++++++------------ 10 files changed, 118 insertions(+), 109 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index ec3a15a157aae..4ca468ecbce3c 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -31,6 +31,7 @@ assists people when migrating to a new version. - [21002](https://github.com/apache/superset/pull/21002): Support Python 3.10 and bump pandas 1.4 and pyarrow 6. - [21163](https://github.com/apache/superset/pull/21163): When `GENERIC_CHART_AXES` feature flags set to `True`, the Time Grain control will move below the X-Axis control. - [21284](https://github.com/apache/superset/pull/21284): The non-functional `MAX_TABLE_NAMES` config key has been removed. +- [21794](https://github.com/apache/superset/pull/21794): Deprecates the undocumented `PRESTO_SPLIT_VIEWS_FROM_TABLES` feature flag. Now for Presto, like other engines, only physical tables are treated as tables. ### Breaking Changes diff --git a/requirements/base.txt b/requirements/base.txt index 8387d380bc5ab..3646395b4f59d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -108,8 +108,6 @@ geopy==2.2.0 # via apache-superset graphlib-backport==1.0.3 # via apache-superset -greenlet==1.1.2 - # via sqlalchemy gunicorn==20.1.0 # via apache-superset hashids==1.3.1 diff --git a/requirements/docker.txt b/requirements/docker.txt index 0c2d36159e4d0..307064dbdeddf 100644 --- a/requirements/docker.txt +++ b/requirements/docker.txt @@ -12,6 +12,8 @@ # -r requirements/docker.in gevent==21.8.0 # via -r requirements/docker.in +greenlet==1.1.3.post0 + # via gevent psycopg2-binary==2.9.1 # via apache-superset zope-event==4.5.0 diff --git a/requirements/testing.txt b/requirements/testing.txt index 191fa99c07f5a..8068f3718c818 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -130,7 +130,7 @@ rsa==4.7.2 # via google-auth statsd==3.3.0 # via -r requirements/testing.in -trino==0.315.0 +trino==0.319.0 # via apache-superset typing-inspect==0.7.1 # via libcst diff --git a/setup.py b/setup.py index a3c7407f9603d..4ca6b910865eb 100644 --- a/setup.py +++ b/setup.py @@ -160,7 +160,7 @@ def get_git_sha() -> str: "pinot": ["pinotdb>=0.3.3, <0.4"], "postgres": ["psycopg2-binary==2.9.1"], "presto": ["pyhive[presto]>=0.6.5"], - "trino": ["trino>=0.313.0"], + "trino": ["trino>=0.319.0"], "prophet": ["prophet>=1.0.1, <1.1", "pystan<3.0"], "redshift": ["sqlalchemy-redshift>=0.8.1, < 0.9"], "rockset": ["rockset>=0.8.10, <0.9"], diff --git a/superset/databases/api.py b/superset/databases/api.py index c3efda9501d4b..aced8e7c6faa3 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -19,7 +19,7 @@ import logging from datetime import datetime from io import BytesIO -from typing import Any, Dict, List, Optional +from typing import Any, cast, Dict, List, Optional from zipfile import is_zipfile, ZipFile from flask import request, Response, send_file @@ -611,7 +611,7 @@ def table_extra_metadata( self.incr_stats("init", self.table_metadata.__name__) parsed_schema = parse_js_uri_path_item(schema_name, eval_undefined=True) - table_name = parse_js_uri_path_item(table_name) # type: ignore + table_name = cast(str, parse_js_uri_path_item(table_name)) payload = database.db_engine_spec.extra_table_metadata( database, table_name, parsed_schema ) diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 55a026541ade3..2a1363e0b6957 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -1018,13 +1018,17 @@ def get_table_names( # pylint: disable=unused-argument schema: Optional[str], ) -> List[str]: """ - Get all tables from schema + Get all the real table names within the specified schema. - :param database: The database to get info - :param inspector: SqlAlchemy inspector - :param schema: Schema to inspect. If omitted, uses default schema for database - :return: All tables in schema + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The physical table names """ + try: tables = inspector.get_table_names(schema) except Exception as ex: @@ -1042,13 +1046,17 @@ def get_view_names( # pylint: disable=unused-argument schema: Optional[str], ) -> List[str]: """ - Get all views from schema + Get all the view names within the specified schema. - :param database: The database to get info - :param inspector: SqlAlchemy inspector - :param schema: Schema name. If omitted, uses default schema for database - :return: All views in schema + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The view names """ + try: views = inspector.get_view_names(schema) except Exception as ex: diff --git a/superset/db_engine_specs/presto.py b/superset/db_engine_specs/presto.py index 22e4f7594ccf6..e959eb219506a 100644 --- a/superset/db_engine_specs/presto.py +++ b/superset/db_engine_specs/presto.py @@ -19,13 +19,13 @@ import logging import re -import textwrap import time from abc import ABCMeta from collections import defaultdict, deque from contextlib import closing from datetime import datetime from distutils.version import StrictVersion +from textwrap import dedent from typing import Any, cast, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING, Union from urllib import parse @@ -392,46 +392,84 @@ def update_impersonation_config( @classmethod def get_table_names( - cls, database: Database, inspector: Inspector, schema: Optional[str] + cls, + database: Database, + inspector: Inspector, + schema: Optional[str], ) -> List[str]: - tables = super().get_table_names(database, inspector, schema) - if not is_feature_enabled("PRESTO_SPLIT_VIEWS_FROM_TABLES"): - return tables + """ + Get all the real table names within the specified schema. + + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. - views = set(cls.get_view_names(database, inspector, schema)) - actual_tables = set(tables) - views - return list(actual_tables) + Note that PyHive's Hive and Presto SQLAlchemy dialects do not adhere to the + specification where the `get_table_names` method returns both real tables and + views. Futhermore the dialects wrongfully infer the request as schema agnostic + when the schema is omitted. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The physical table names + """ + + return sorted( + list( + set(super().get_table_names(database, inspector, schema)) + - set(cls.get_view_names(database, inspector, schema)) + ) + ) @classmethod def get_view_names( - cls, database: Database, inspector: Inspector, schema: Optional[str] + cls, + database: Database, + inspector: Inspector, + schema: Optional[str], ) -> List[str]: - """Returns an empty list + """ + Get all the view names within the specified schema. - get_table_names() function returns all table names and view names, - and get_view_names() is not implemented in sqlalchemy_presto.py - https://github.com/dropbox/PyHive/blob/e25fc8440a0686bbb7a5db5de7cb1a77bdb4167a/pyhive/sqlalchemy_presto.py + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. + + Note that PyHive's Hive and Presto SQLAlchemy dialects do not implement the + `get_view_names` method. To ensure consistency with the `get_table_names` method + the request is deemed schema agnostic when the schema is omitted. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The view names """ - if not is_feature_enabled("PRESTO_SPLIT_VIEWS_FROM_TABLES"): - return [] if schema: - sql = ( - "SELECT table_name FROM information_schema.views " - "WHERE table_schema=%(schema)s" - ) + sql = dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = %(schema)s + AND table_type = 'VIEW' + """ + ).strip() params = {"schema": schema} else: - sql = "SELECT table_name FROM information_schema.views" + sql = dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_type = 'VIEW' + """ + ).strip() params = {} engine = cls.get_engine(database, schema=schema) + with closing(engine.raw_connection()) as conn: cursor = conn.cursor() cursor.execute(sql, params) results = cursor.fetchall() - return [row[0] for row in results] + return sorted([row[0] for row in results]) @classmethod def _create_column_info( @@ -1087,7 +1125,7 @@ def _partition_query( # pylint: disable=too-many-arguments,too-many-locals else f"SHOW PARTITIONS FROM {table_name}" ) - sql = textwrap.dedent( + sql = dedent( f"""\ {partition_select_clause} {where_clause} diff --git a/tests/integration_tests/db_engine_specs/hive_tests.py b/tests/integration_tests/db_engine_specs/hive_tests.py index ad80f8397ffe1..991d3f759c5ee 100644 --- a/tests/integration_tests/db_engine_specs/hive_tests.py +++ b/tests/integration_tests/db_engine_specs/hive_tests.py @@ -150,10 +150,6 @@ def test_hive_error_msg(): ) -def test_hive_get_view_names_return_empty_list(): # pylint: disable=invalid-name - assert HiveEngineSpec.get_view_names(mock.ANY, mock.ANY, mock.ANY) == [] - - def test_convert_dttm(): dttm = datetime.strptime("2019-01-02 03:04:05.678900", "%Y-%m-%d %H:%M:%S.%f") assert HiveEngineSpec.convert_dttm("DATE", dttm) == "CAST('2019-01-02' AS DATE)" diff --git a/tests/integration_tests/db_engine_specs/presto_tests.py b/tests/integration_tests/db_engine_specs/presto_tests.py index 2d6cf7b8622c0..d37a04645f8cb 100644 --- a/tests/integration_tests/db_engine_specs/presto_tests.py +++ b/tests/integration_tests/db_engine_specs/presto_tests.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. from collections import namedtuple +from textwrap import dedent from unittest import mock, skipUnless import pandas as pd @@ -33,49 +34,47 @@ class TestPrestoDbEngineSpec(TestDbEngineSpec): def test_get_datatype_presto(self): self.assertEqual("STRING", PrestoEngineSpec.get_datatype("string")) - def test_presto_get_view_names_return_empty_list( - self, - ): # pylint: disable=invalid-name - self.assertEqual( - [], PrestoEngineSpec.get_view_names(mock.ANY, mock.ANY, mock.ANY) - ) - - @mock.patch("superset.db_engine_specs.presto.is_feature_enabled") - def test_get_view_names(self, mock_is_feature_enabled): - mock_is_feature_enabled.return_value = True - mock_execute = mock.MagicMock() - mock_fetchall = mock.MagicMock(return_value=[["a", "b,", "c"], ["d", "e"]]) + def test_get_view_names_with_schema(self): database = mock.MagicMock() + mock_execute = mock.MagicMock() database.get_sqla_engine.return_value.raw_connection.return_value.cursor.return_value.execute = ( mock_execute ) - database.get_sqla_engine.return_value.raw_connection.return_value.cursor.return_value.fetchall = ( - mock_fetchall + database.get_sqla_engine.return_value.raw_connection.return_value.cursor.return_value.fetchall = mock.MagicMock( + return_value=[["a", "b,", "c"], ["d", "e"]] ) - result = PrestoEngineSpec.get_view_names(database, mock.Mock(), None) + schema = "schema" + result = PrestoEngineSpec.get_view_names(database, mock.Mock(), schema) mock_execute.assert_called_once_with( - "SELECT table_name FROM information_schema.views", {} + dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = %(schema)s + AND table_type = 'VIEW' + """ + ).strip(), + {"schema": schema}, ) assert result == ["a", "d"] - @mock.patch("superset.db_engine_specs.presto.is_feature_enabled") - def test_get_view_names_with_schema(self, mock_is_feature_enabled): - mock_is_feature_enabled.return_value = True - mock_execute = mock.MagicMock() - mock_fetchall = mock.MagicMock(return_value=[["a", "b,", "c"], ["d", "e"]]) + def test_get_view_names_without_schema(self): database = mock.MagicMock() + mock_execute = mock.MagicMock() database.get_sqla_engine.return_value.raw_connection.return_value.cursor.return_value.execute = ( mock_execute ) - database.get_sqla_engine.return_value.raw_connection.return_value.cursor.return_value.fetchall = ( - mock_fetchall + database.get_sqla_engine.return_value.raw_connection.return_value.cursor.return_value.fetchall = mock.MagicMock( + return_value=[["a", "b,", "c"], ["d", "e"]] ) - schema = "schema" - result = PrestoEngineSpec.get_view_names(database, mock.Mock(), schema) + result = PrestoEngineSpec.get_view_names(database, mock.Mock(), None) mock_execute.assert_called_once_with( - "SELECT table_name FROM information_schema.views " - "WHERE table_schema=%(schema)s", - {"schema": schema}, + dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_type = 'VIEW' + """ + ).strip(), + {}, ) assert result == ["a", "d"] @@ -663,50 +662,17 @@ def test_get_sqla_column_type(self): sqla_type = PrestoEngineSpec.get_sqla_column_type(None) assert sqla_type is None - @mock.patch( - "superset.utils.feature_flag_manager.FeatureFlagManager.is_feature_enabled" - ) @mock.patch("superset.db_engine_specs.base.BaseEngineSpec.get_table_names") @mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.get_view_names") - def test_get_table_names_no_split_views_from_tables( - self, mock_get_view_names, mock_get_table_names, mock_is_feature_enabled - ): - mock_get_view_names.return_value = ["view1", "view2"] - table_names = ["table1", "table2", "view1", "view2"] - mock_get_table_names.return_value = table_names - mock_is_feature_enabled.return_value = False - tables = PrestoEngineSpec.get_table_names(mock.Mock(), mock.Mock(), None) - assert tables == table_names - - @mock.patch( - "superset.utils.feature_flag_manager.FeatureFlagManager.is_feature_enabled" - ) - @mock.patch("superset.db_engine_specs.base.BaseEngineSpec.get_table_names") - @mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.get_view_names") - def test_get_table_names_split_views_from_tables( - self, mock_get_view_names, mock_get_table_names, mock_is_feature_enabled + def test_get_table_names( + self, + mock_get_view_names, + mock_get_table_names, ): mock_get_view_names.return_value = ["view1", "view2"] - table_names = ["table1", "table2", "view1", "view2"] - mock_get_table_names.return_value = table_names - mock_is_feature_enabled.return_value = True - tables = PrestoEngineSpec.get_table_names(mock.Mock(), mock.Mock(), None) - assert sorted(tables) == sorted(table_names) - - @mock.patch( - "superset.utils.feature_flag_manager.FeatureFlagManager.is_feature_enabled" - ) - @mock.patch("superset.db_engine_specs.base.BaseEngineSpec.get_table_names") - @mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.get_view_names") - def test_get_table_names_split_views_from_tables_no_tables( - self, mock_get_view_names, mock_get_table_names, mock_is_feature_enabled - ): - mock_get_view_names.return_value = [] - table_names = [] - mock_get_table_names.return_value = table_names - mock_is_feature_enabled.return_value = True + mock_get_table_names.return_value = ["table1", "table2", "view1", "view2"] tables = PrestoEngineSpec.get_table_names(mock.Mock(), mock.Mock(), None) - assert tables == [] + assert tables == ["table1", "table2"] def test_get_full_name(self): names = [ From 736b53418a3b3394dc967458d03d4c0ebcadabdd Mon Sep 17 00:00:00 2001 From: Eric Briscoe Date: Wed, 9 Nov 2022 14:33:27 -0800 Subject: [PATCH 13/22] feat: create table component based on ant design Table (#21520) Co-authored-by: Lyndsi Kay Williams <55605634+lyndsiWilliams@users.noreply.github.com> Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com> --- superset-frontend/.storybook/main.js | 3 +- superset-frontend/.storybook/preview.jsx | 10 +- .../src/components/Button/index.tsx | 4 +- .../src/components/DesignSystem.stories.mdx | 25 + .../src/components/Dropdown/index.tsx | 26 +- .../components/Loading/Loading.stories.tsx | 4 +- .../src/components/Loading/Loading.test.tsx | 4 +- .../src/components/Loading/index.tsx | 6 +- ...ew.stories.mdx => MetadataBar.stories.mdx} | 20 +- .../MetadataBar/MetadataBar.stories.tsx | 10 +- .../src/components/Table/Table.overview.mdx | 260 +++++++++++ .../src/components/Table/Table.stories.tsx | 432 ++++++++++++++++++ .../src/components/Table/Table.test.tsx | 80 ++++ .../ActionCell/ActionCell.overview.mdx | 69 +++ .../ActionCell/ActionCell.stories.tsx | 36 ++ .../ActionCell/ActionCell.test.tsx | 50 ++ .../cell-renderers/ActionCell/fixtures.ts | 47 ++ .../Table/cell-renderers/ActionCell/index.tsx | 145 ++++++ .../ButtonCell/ButtonCell.stories.tsx | 62 +++ .../ButtonCell/ButtonCell.test.tsx | 40 ++ .../Table/cell-renderers/ButtonCell/index.tsx | 58 +++ .../NumericCell/NumericCell.stories.tsx | 47 ++ .../NumericCell/NumericCell.test.tsx | 49 ++ .../cell-renderers/NumericCell/index.tsx | 418 +++++++++++++++++ .../Table/cell-renderers/fixtures.ts | 25 + .../src/components/Table/index.tsx | 326 +++++++++++++ .../src/components/Table/sorters.test.ts | 100 ++++ .../src/components/Table/sorters.ts | 36 ++ .../Table/utils/InteractiveTableUtils.ts | 233 ++++++++++ .../src/components/Table/utils/utils.test.ts | 48 ++ .../src/components/Table/utils/utils.ts | 40 ++ .../src/components/atomic-design.png | Bin 0 -> 163100 bytes superset-frontend/src/types/files.d.ts | 1 + superset-frontend/webpack.config.js | 2 +- 34 files changed, 2692 insertions(+), 24 deletions(-) create mode 100644 superset-frontend/src/components/DesignSystem.stories.mdx rename superset-frontend/src/components/MetadataBar/{Overview.stories.mdx => MetadataBar.stories.mdx} (88%) create mode 100644 superset-frontend/src/components/Table/Table.overview.mdx create mode 100644 superset-frontend/src/components/Table/Table.stories.tsx create mode 100644 superset-frontend/src/components/Table/Table.test.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx create mode 100644 superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts create mode 100644 superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx create mode 100644 superset-frontend/src/components/Table/cell-renderers/fixtures.ts create mode 100644 superset-frontend/src/components/Table/index.tsx create mode 100644 superset-frontend/src/components/Table/sorters.test.ts create mode 100644 superset-frontend/src/components/Table/sorters.ts create mode 100644 superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts create mode 100644 superset-frontend/src/components/Table/utils/utils.test.ts create mode 100644 superset-frontend/src/components/Table/utils/utils.ts create mode 100644 superset-frontend/src/components/atomic-design.png diff --git a/superset-frontend/.storybook/main.js b/superset-frontend/.storybook/main.js index 8a004ba3e2928..b8f15b569f3d9 100644 --- a/superset-frontend/.storybook/main.js +++ b/superset-frontend/.storybook/main.js @@ -24,7 +24,8 @@ module.exports = { builder: 'webpack5', }, stories: [ - '../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx|mdx)', + '../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx)', + '../src/@(components|common|filters|explore)/**/*.*.@(mdx)', ], addons: [ '@storybook/addon-essentials', diff --git a/superset-frontend/.storybook/preview.jsx b/superset-frontend/.storybook/preview.jsx index d98a55506eaca..fa0c9088735a2 100644 --- a/superset-frontend/.storybook/preview.jsx +++ b/superset-frontend/.storybook/preview.jsx @@ -68,7 +68,15 @@ addParameters({ ['Controls', 'Display', 'Feedback', 'Input', '*'], ['Overview', 'Examples', '*'], 'Design System', - ['Foundations', 'Components', 'Patterns', '*'], + [ + 'Introduction', + 'Foundations', + 'Components', + ['Overview', 'Examples', '*'], + 'Patterns', + '*', + ], + ['Overview', 'Examples', '*'], '*', ], }, diff --git a/superset-frontend/src/components/Button/index.tsx b/superset-frontend/src/components/Button/index.tsx index b4152ea98dc17..05a1a3ad79881 100644 --- a/superset-frontend/src/components/Button/index.tsx +++ b/superset-frontend/src/components/Button/index.tsx @@ -39,11 +39,13 @@ export type ButtonStyle = | 'link' | 'dashed'; +export type ButtonSize = 'default' | 'small' | 'xsmall'; + export type ButtonProps = Omit & Pick & { tooltip?: string; className?: string; - buttonSize?: 'default' | 'small' | 'xsmall'; + buttonSize?: ButtonSize; buttonStyle?: ButtonStyle; cta?: boolean; showMarginRight?: boolean; diff --git a/superset-frontend/src/components/DesignSystem.stories.mdx b/superset-frontend/src/components/DesignSystem.stories.mdx new file mode 100644 index 0000000000000..e00612c5be40a --- /dev/null +++ b/superset-frontend/src/components/DesignSystem.stories.mdx @@ -0,0 +1,25 @@ +import { Meta, Source } from '@storybook/addon-docs'; +import AtomicDesign from './atomic-design.png'; + + + +# Superset Design System + +A design system is a complete set of standards intended to manage design at scale using reusable components and patterns. + +You can get an overview of Atomic Design concepts and a link to the full book on the topic here: + + + Intro to Atomic Design + + +While the Superset Design System will use Atomic Design principles, we choose a different language to describe the elements. + +| Atomic Design | Atoms | Molecules | Organisms | Templates | Pages / Screens | +| :-------------- | :---------: | :--------: | :-------: | :-------: | :-------------: | +| Superset Design | Foundations | Components | Patterns | Templates | Features | + +Atoms = Foundations, Molecules = Components, Organisms = Patterns, Templates = Templates, Pages / Screens = Features diff --git a/superset-frontend/src/components/Dropdown/index.tsx b/superset-frontend/src/components/Dropdown/index.tsx index bd01aabb4d558..c40f479579d2e 100644 --- a/superset-frontend/src/components/Dropdown/index.tsx +++ b/superset-frontend/src/components/Dropdown/index.tsx @@ -20,6 +20,7 @@ import React, { RefObject } from 'react'; import { AntdDropdown } from 'src/components'; import { DropDownProps } from 'antd/lib/dropdown'; import { styled } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; const MenuDots = styled.div` width: ${({ theme }) => theme.gridUnit * 0.75}px; @@ -66,14 +67,35 @@ const MenuDotsWrapper = styled.div` padding-left: ${({ theme }) => theme.gridUnit}px; `; +export enum IconOrientation { + VERTICAL = 'vertical', + HORIZONTAL = 'horizontal', +} export interface DropdownProps extends DropDownProps { overlay: React.ReactElement; + iconOrientation?: IconOrientation; } -export const Dropdown = ({ overlay, ...rest }: DropdownProps) => ( +const RenderIcon = ( + iconOrientation: IconOrientation = IconOrientation.VERTICAL, +) => { + const component = + iconOrientation === IconOrientation.HORIZONTAL ? ( + + ) : ( + + ); + return component; +}; + +export const Dropdown = ({ + overlay, + iconOrientation = IconOrientation.VERTICAL, + ...rest +}: DropdownProps) => ( - + {RenderIcon(iconOrientation)} ); diff --git a/superset-frontend/src/components/Loading/Loading.stories.tsx b/superset-frontend/src/components/Loading/Loading.stories.tsx index 9f079848b8a2e..0c80c6f0ff618 100644 --- a/superset-frontend/src/components/Loading/Loading.stories.tsx +++ b/superset-frontend/src/components/Loading/Loading.stories.tsx @@ -40,7 +40,7 @@ export const LoadingGallery = () => ( }} >

{position}

- +
))} @@ -71,7 +71,7 @@ InteractiveLoading.story = { }; InteractiveLoading.args = { - image: '/src/assets/images/loading.gif', + image: '', className: '', }; diff --git a/superset-frontend/src/components/Loading/Loading.test.tsx b/superset-frontend/src/components/Loading/Loading.test.tsx index d6ea8581c5105..7325c9304b587 100644 --- a/superset-frontend/src/components/Loading/Loading.test.tsx +++ b/superset-frontend/src/components/Loading/Loading.test.tsx @@ -26,11 +26,9 @@ test('Rerendering correctly with default props', () => { render(); const loading = screen.getByRole('status'); const classNames = loading.getAttribute('class')?.split(' '); - const imagePath = loading.getAttribute('src'); const ariaLive = loading.getAttribute('aria-live'); const ariaLabel = loading.getAttribute('aria-label'); expect(loading).toBeInTheDocument(); - expect(imagePath).toBe('/static/assets/images/loading.gif'); expect(classNames).toContain('floating'); expect(classNames).toContain('loading'); expect(ariaLive).toContain('polite'); @@ -56,7 +54,7 @@ test('support for extra classes', () => { expect(classNames).toContain('extra-class'); }); -test('Diferent image path', () => { +test('Different image path', () => { render(); const loading = screen.getByRole('status'); const imagePath = loading.getAttribute('src'); diff --git a/superset-frontend/src/components/Loading/index.tsx b/superset-frontend/src/components/Loading/index.tsx index 6ba6fb45c5443..97cd553ad5b7b 100644 --- a/superset-frontend/src/components/Loading/index.tsx +++ b/superset-frontend/src/components/Loading/index.tsx @@ -20,6 +20,7 @@ import React from 'react'; import { styled } from '@superset-ui/core'; import cls from 'classnames'; +import Loader from 'src/assets/images/loading.gif'; export type PositionOption = | 'floating' @@ -35,6 +36,7 @@ export interface Props { const LoaderImg = styled.img` z-index: 99; width: 50px; + height: unset; position: relative; margin: 10px; &.inline { @@ -57,14 +59,14 @@ const LoaderImg = styled.img` `; export default function Loading({ position = 'floating', - image = '/static/assets/images/loading.gif', + image, className, }: Props) { return ( + -# Usage +# Metadata bar -The metadata bar component is used to display additional information about an entity. Some of the common applications in Superset are: +The metadata bar component is used to display additional information about an entity. + +## Usage + +Some of the common applications in Superset are: - Display the chart's metadata in Explore to help the user understand what dashboards this chart is added to and get to know the details of the chart - Display the database's metadata in a drill to detail modal to help the user understand what data they are looking at while accessing the feature in the dashboard -# Variations +## Basic example + + + +## Variations The metadata bar is by default a static component (besides the links in text). The variations in this component are related to content and entity type as all of the details are predefined @@ -25,7 +33,7 @@ have the same icon and when hovered it will present who created the entity, its To extend the list of content types, a developer needs to request the inclusion of the new type in the design system. This process is important to make sure the new type is reviewed by the design team, improving Superset consistency. -To check each content type in detail and its interactions, check the [MetadataBar](/story/metadatabar--component) page. +To check each content type in detail and its interactions, check the [MetadataBar](/story/design-system-components-metadatabar-examples--basic) page. Below you can find the configurations for each content type: + +# Table + +A table is UI that allows the user to explore data in a tabular format. + +## Usage + +Common table applications in Superset: + +- Display lists of user-generated entities (e.g. dashboard, charts, queries) for further exploration and use +- Display data that can help the user make a decision (e.g. query results) + +This component provides a general use Table. + +--- + +### [Basic example](./?path=/docs/design-system-components-table-examples--basic) + + + +### Data and Columns + +To set the visible columns and data for the table you use the `columns` and `data` props. + +
+ +The basic table example for the `columns` prop is: + +``` +const basicColumns: = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 150, + sorter: (a: BasicData, b: BasicData) => + alphabeticalSort('name', a, b), + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + sorter: (a: BasicData, b: BasicData) => + alphabeticalSort('category', a, b), + }, + { + title: 'Price', + dataIndex: 'price', + key: 'price', + sorter: (a: BasicData, b: BasicData) => + numericalSort('price', a, b), + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + }, +]; +``` + +The data prop is: + +``` +const basicData: = [ + { + key: 1, + name: 'Floppy Disk 10 pack', + category: 'Disk Storage', + price: '9.99' + description: 'A real blast from the past', + }, + { + key: 2, + name: 'DVD 100 pack', + category: 'Optical Storage', + price: '27.99' + description: 'Still pretty ancient', + }, + { + key: 3, + name: '128 GB SSD', + category: 'Hardrive', + price: '49.99' + description: 'Reliable and fast data storage', + }, +]; +``` + +
+ +### Column Sort Functions + +To ensure consistency for column sorting and to avoid redundant definitions for common column sorters, reusable sort functions are provided. +When defining the object for the `columns` prop you can provide an optional attribute `sorter`. +The function provided in the `sorter` prop is given the entire record representing a row as props `a` and `b`. +When using a provided sorter function the pattern is to wrap the call to the sorter with an inline function, then specify the specific attribute value from `dataIndex`, representing a column +of the data object for that row, as the first argument of the sorter function. + +#### alphabeticalSort + +The alphabeticalSort is for columns that display a string of text. + +
+ +``` +import { alphabeticalSort } from 'src/components/Table/sorters'; + +const basicColumns = [ + { + title: 'Column Name', + dataIndex: 'columnName', + key: 'columnName', + sorter: (a, b) => + alphabeticalSort('columnName', a, b), + } +] +``` + +
+ +#### numericSort + +The numericalSort is for columns that display a numeric value. + +
+ +``` +import { numericalSort } from './sorters'; + +const basicColumns = [ + { + title: 'Height', + dataIndex: 'height', + key: 'height', + sorter: (a, b) => + numericalSort('height', a, b), + } +] +``` + +
+ +If a different sort option is needed, consider adding it as a reusable sort function following the pattern provided above. + +--- + +### Cell Content Renderers + +By default, each column will render the value as simple text. Often you will want to show formatted values, such as a numeric column showing as currency, or a more complex component such as a button or action menu as a cell value. +Cell Renderers are React components provided to the optional `render` attribute on a column definition that enables injecting a specific React component to enable this. + + + +For convenience and consistency, the Table component provides pre-built Cell Renderers for: +The following data types can be displayed in table cells. + +- Text (default) +- [Button Cell](./?path=/docs/design-system-components-table-cell-renderers-buttoncell--basic) +- [Numeric Cell](./docs/design-system-components-table-cell-renderers-numericcell--basic) + - Support Locale and currency formatting + - w/ icons - Coming Soon +- [Action Menu Cell](./?path=/docs/design-system-components-table-cell-renderers-actioncell-overview--page) +- Provide a list of menu options with callback functions that retain a reference to the row the menu is defined for +- Custom + - You can provide your own React component as a cell renderer in cases not supported + +--- + +### Loading + +The table can be set to a loading state simply by setting the loading prop to true | false + + + +--- + +### Pagination + +The table displays a set number of rows at a time, the user navigates the table via pagination. Use in scenarios where the user is searching for a specific piece of content. +The default page size and page size options for the menu are configurable via the `pageSizeOptions` and `defaultPageSize` props. +NOTE: Pagination controls will only display when the data for the table has more records than the default page size. + + + +``` + +``` + +--- + +## Integration Checklist + +The following specifications are required every time a table is used. These choices should be intentional based on the specific user needs for the table instance. + +
+ +- [ ] Size + - Large + - Small +- Columns + - [ ] Number of + - [ ] Contents + - [ ] Order + - [ ] Widths +- Column headers + - [ ] Labels + - [ ] Has tooltip + - [ ] Tooltip text +- [ ] Default sort +- Functionality + - [ ] Can sort columns + - [ ] Can filter columns +- [ ] Loading + - Pagination + - [ ] Number of rows per page + - Infinite scroll +- [ ] Has toolbar + - [ ] Has table title + - [ ] Label + - [ ] Has buttons + - [ ] Labels + - [ ] Actions + - [ ] Has search + +
+ +--- + +## Experimental features + +The Table component has features that are still experimental and can be used at your own risk. +These features are intended to be made fully stable in future releases. + +### Resizable Columns + +The prop `resizable` enables table columns to be resized by the user dragging from the right edge of each +column to increase or decrease the columns' width + + + +### Drag & Drop Columns + +The prop `reorderable` can enable column drag and drop reordering as well as dragging a column to another component. If you want to accept the drop event of a Table Column +you can register `onDragOver` and `onDragDrop` event handlers on the destination component. In the `onDragDrop` handler you can check for `SUPERSET_TABLE_COLUMN` +as the getData key as shown below. + +``` +import { SUPERSET_TABLE_COLUMN } from 'src/components/table'; + +const handleDrop = (ev:Event) => { + const json = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN); + const data = JSON.parse(json); + // ... do something with the data here +} +``` + + diff --git a/superset-frontend/src/components/Table/Table.stories.tsx b/superset-frontend/src/components/Table/Table.stories.tsx new file mode 100644 index 0000000000000..90ee3448c67ec --- /dev/null +++ b/superset-frontend/src/components/Table/Table.stories.tsx @@ -0,0 +1,432 @@ +/** + * 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, { useState } from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { supersetTheme, ThemeProvider } from '@superset-ui/core'; +import { Table, TableSize, SUPERSET_TABLE_COLUMN, ColumnsType } from './index'; +import { numericalSort, alphabeticalSort } from './sorters'; +import ButtonCell from './cell-renderers/ButtonCell'; +import ActionCell from './cell-renderers/ActionCell'; +import { exampleMenuOptions } from './cell-renderers/ActionCell/fixtures'; +import NumericCell, { + CurrencyCode, + LocaleCode, + Style, +} from './cell-renderers/NumericCell'; + +export default { + title: 'Design System/Components/Table/Examples', + component: Table, + argTypes: { onClick: { action: 'clicked' } }, +} as ComponentMeta; + +export interface BasicData { + name: string; + category: string; + price: number; + description?: string; + key: number; +} + +export interface RendererData { + key: number; + buttonCell: string; + textCell: string; + euroCell: number; + dollarCell: number; +} + +export interface ExampleData { + title: string; + name: string; + age: number; + address: string; + tags?: string[]; + key: number; +} + +function generateValues(amount: number): object { + const cells = {}; + for (let i = 0; i < amount; i += 1) { + cells[`col-${i}`] = `Text ${i}`; + } + return cells; +} + +function generateColumns(amount: number): ColumnsType[] { + const newCols: any[] = []; + for (let i = 0; i < amount; i += 1) { + newCols.push({ + title: `Column Header ${i}`, + dataIndex: `col-${i}`, + key: `col-${i}`, + }); + } + return newCols as ColumnsType[]; +} +const recordCount = 200; +const columnCount = 12; +const randomCols: ColumnsType[] = generateColumns(columnCount); + +const basicData: BasicData[] = [ + { + key: 1, + name: 'Floppy Disk 10 pack', + category: 'Disk Storage', + price: 9.99, + description: 'A real blast from the past', + }, + { + key: 2, + name: 'DVD 100 pack', + category: 'Optical Storage', + price: 27.99, + description: 'Still pretty ancient', + }, + { + key: 3, + name: '128 GB SSD', + category: 'Hardrive', + price: 49.99, + description: 'Reliable and fast data storage', + }, +]; + +const basicColumns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 150, + sorter: (a: BasicData, b: BasicData) => alphabeticalSort('name', a, b), + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + sorter: (a: BasicData, b: BasicData) => alphabeticalSort('category', a, b), + }, + { + title: 'Price', + dataIndex: 'price', + key: 'price', + sorter: (a: BasicData, b: BasicData) => numericalSort('price', a, b), + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + }, +]; + +const bigColumns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (text: string, row: object, index: number) => ( + + ), + width: 150, + }, + { + title: 'Age', + dataIndex: 'age', + key: 'age', + }, + { + title: 'Address', + dataIndex: 'address', + key: 'address', + }, + ...(randomCols as ColumnsType), +]; + +const rendererColumns: ColumnsType = [ + { + title: 'Button Cell', + dataIndex: 'buttonCell', + key: 'buttonCell', + width: 150, + render: (text: string, data: object, index: number) => ( + + ), + }, + { + title: 'Text Cell', + dataIndex: 'textCell', + key: 'textCell', + }, + { + title: 'Euro Cell', + dataIndex: 'euroCell', + key: 'euroCell', + render: (value: number) => ( + + ), + }, + { + title: 'Dollar Cell', + dataIndex: 'dollarCell', + key: 'dollarCell', + render: (value: number) => ( + + ), + }, + { + dataIndex: 'actions', + key: 'actions', + render: (text: string, row: object) => ( + + ), + width: 32, + fixed: 'right', + }, +]; + +const baseData: any[] = [ + { + key: 1, + name: 'John Brown', + age: 32, + address: 'New York No. 1 Lake Park', + tags: ['nice', 'developer'], + ...generateValues(columnCount), + }, + { + key: 2, + name: 'Jim Green', + age: 42, + address: 'London No. 1 Lake Park', + tags: ['loser'], + ...generateValues(columnCount), + }, + { + key: 3, + name: 'Joe Black', + age: 32, + address: 'Sidney No. 1 Lake Park', + tags: ['cool', 'teacher'], + ...generateValues(columnCount), + }, +]; + +const bigdata: any[] = []; +for (let i = 0; i < recordCount; i += 1) { + bigdata.push({ + key: i + baseData.length, + name: `Dynamic record ${i}`, + age: 32 + i, + address: `DynamoCity, Dynamic Lane no. ${i}`, + ...generateValues(columnCount), + }); +} + +export const Basic: ComponentStory = args => ( + +
+
+ + +); + +function handlers(record: object, rowIndex: number) { + return { + onClick: action( + `row onClick, row: ${rowIndex}, record: ${JSON.stringify(record)}`, + ), // click row + onDoubleClick: action( + `row onDoubleClick, row: ${rowIndex}, record: ${JSON.stringify(record)}`, + ), // double click row + onContextMenu: action( + `row onContextMenu, row: ${rowIndex}, record: ${JSON.stringify(record)}`, + ), // right button click row + onMouseEnter: action(`Mouse Enter, row: ${rowIndex}`), // mouse enter row + onMouseLeave: action(`Mouse Leave, row: ${rowIndex}`), // mouse leave row + }; +} + +Basic.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + onRow: handlers, + pageSizeOptions: ['5', '10', '15', '20', '25'], + defaultPageSize: 10, +}; + +export const ManyColumns: ComponentStory = args => ( + +
+
+ + +); + +ManyColumns.args = { + data: bigdata, + columns: bigColumns, + size: TableSize.SMALL, + resizable: true, + reorderable: true, + height: 350, +}; + +export const Loading: ComponentStory = args => ( + +
+ +); + +Loading.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + loading: true, +}; + +export const ResizableColumns: ComponentStory = args => ( + +
+
+ + +); + +ResizableColumns.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + resizable: true, +}; + +export const ReorderableColumns: ComponentStory = args => { + const [droppedItem, setDroppedItem] = useState(); + const dragOver = (ev: React.DragEvent) => { + ev.preventDefault(); + const element: HTMLElement | null = ev?.currentTarget as HTMLElement; + if (element?.style) { + element.style.border = '1px dashed green'; + } + }; + + const dragOut = (ev: React.DragEvent) => { + ev.preventDefault(); + const element: HTMLElement | null = ev?.currentTarget as HTMLElement; + if (element?.style) { + element.style.border = '1px solid grey'; + } + }; + + const dragDrop = (ev: React.DragEvent) => { + const data = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN); + const element: HTMLElement | null = ev?.currentTarget as HTMLElement; + if (element?.style) { + element.style.border = '1px solid grey'; + } + setDroppedItem(data); + }; + return ( + +
+
) => dragOver(ev)} + onDragLeave={(ev: React.DragEvent) => dragOut(ev)} + onDrop={(ev: React.DragEvent) => dragDrop(ev)} + style={{ + width: '100%', + height: '40px', + border: '1px solid grey', + marginBottom: '8px', + padding: '8px', + borderRadius: '4px', + }} + > + {droppedItem ?? 'Drop column here...'} +
+
+ + + ); +}; + +ReorderableColumns.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + reorderable: true, +}; + +const rendererData: RendererData[] = [ + { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, + }, + { + key: 2, + buttonCell: 'I am a button', + textCell: 'More text', + euroCell: 1700, + dollarCell: 1700, + }, + { + key: 3, + buttonCell: 'Button 3', + textCell: 'The third string of text', + euroCell: 500.567, + dollarCell: 500.567, + }, +]; + +export const CellRenderers: ComponentStory = args => ( + +
+
+ + +); + +CellRenderers.args = { + data: rendererData, + columns: rendererColumns, + size: TableSize.SMALL, + reorderable: true, +}; diff --git a/superset-frontend/src/components/Table/Table.test.tsx b/superset-frontend/src/components/Table/Table.test.tsx new file mode 100644 index 0000000000000..eded7efeb96f3 --- /dev/null +++ b/superset-frontend/src/components/Table/Table.test.tsx @@ -0,0 +1,80 @@ +/** + * 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 from 'react'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import type { ColumnsType } from 'antd/es/table'; +import { Table, TableSize } from './index'; + +interface BasicData { + columnName: string; + columnType: string; + dataType: string; +} + +const testData: BasicData[] = [ + { + columnName: 'Number', + columnType: 'Numerical', + dataType: 'number', + }, + { + columnName: 'String', + columnType: 'Physical', + dataType: 'string', + }, + { + columnName: 'Date', + columnType: 'Virtual', + dataType: 'date', + }, +]; + +const testColumns: ColumnsType = [ + { + title: 'Column Name', + dataIndex: 'columnName', + key: 'columnName', + }, + { + title: 'Column Type', + dataIndex: 'columnType', + key: 'columnType', + }, + { + title: 'Data Type', + dataIndex: 'dataType', + key: 'dataType', + }, +]; + +test('renders with default props', async () => { + render( +
, + ); + await waitFor(() => + testColumns.forEach(column => + expect(screen.getByText(column.title as string)).toBeInTheDocument(), + ), + ); + testData.forEach(row => { + expect(screen.getByText(row.columnName)).toBeInTheDocument(); + expect(screen.getByText(row.columnType)).toBeInTheDocument(); + expect(screen.getByText(row.dataType)).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx new file mode 100644 index 0000000000000..09e1b5ed6b17b --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx @@ -0,0 +1,69 @@ +import { Meta, Source, Story, ArgsTable } from '@storybook/addon-docs'; + + + +# ActionCell + +An ActionCell is used to display an overflow icon that opens a menu allowing the user to take actions +specific to the data in the table row that the cell is a member of. + +### [Basic example](./?path=/docs/design-system-components-table-cell-renderers-actioncell--basic) + + + +--- + +## Usage + +The action cell accepts an array of objects that define the label, tooltip, onClick callback functions, +and an optional data payload to be provided back to the onClick handler function. + +### [Basic example](./?path=/docs/design-system-components-table-cell-renderers-actioncell--basic) + + + +``` +import { ActionMenuItem } from 'src/components/Table/cell-renderers/index'; + +export const exampleMenuOptions: ActionMenuItem[] = [ + { + label: 'Action 1', + tooltip: "This is a tip, don't spend it all in one place", + onClick: (item: ActionMenuItem) => { + // eslint-disable-next-line no-alert + alert(JSON.stringify(item)); + }, + payload: { + taco: 'spicy chicken', + }, + }, + { + label: 'Action 2', + tooltip: 'This is another tip', + onClick: (item: ActionMenuItem) => { + // eslint-disable-next-line no-alert + alert(JSON.stringify(item)); + }, + payload: { + taco: 'saucy tofu', + }, + }, +]; + +``` + +Within the context of adding an action cell to cell definitions provided to the table using the ActionCell component +for the return value from the render function on the cell definition. See the [Basic example](./?path=/docs/design-system-components-table-examples--basic) + +``` +import ActionCell from './index'; + +const cellExample = [ + { + title: 'Actions', + dataIndex: 'actions', + key: 'actions', + render: () => , + } +] +``` diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx new file mode 100644 index 0000000000000..d51dbcc559fdd --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx @@ -0,0 +1,36 @@ +/** + * 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 from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import ActionCell from './index'; +import { exampleMenuOptions, exampleRow } from './fixtures'; + +export default { + title: 'Design System/Components/Table/Cell Renderers/ActionCell', + component: ActionCell, +} as ComponentMeta; + +export const Basic: ComponentStory = args => ( + +); + +Basic.args = { + menuOptions: exampleMenuOptions, + row: exampleRow, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx new file mode 100644 index 0000000000000..5da7453aa9d7f --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx @@ -0,0 +1,50 @@ +/** + * 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 from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import ActionCell, { appendDataToMenu } from './index'; +import { exampleMenuOptions, exampleRow } from './fixtures'; + +test('renders with default props', async () => { + const clickHandler = jest.fn(); + exampleMenuOptions[0].onClick = clickHandler; + render(); + // Open the menu + userEvent.click(await screen.findByTestId('dropdown-trigger')); + // verify all of the menu items are being displayed + exampleMenuOptions.forEach((item, index) => { + expect(screen.getByText(item.label)).toBeInTheDocument(); + if (index === 0) { + // verify the menu items' onClick gets invoked + userEvent.click(screen.getByText(item.label)); + } + }); + expect(clickHandler).toHaveBeenCalled(); +}); + +/** + * Validate that the appendDataToMenu utility function used within the + * Action cell menu rendering works as expected + */ +test('appendDataToMenu utility', () => { + exampleMenuOptions.forEach(item => expect(item?.row).toBeUndefined()); + const modifiedMenuOptions = appendDataToMenu(exampleMenuOptions, exampleRow); + modifiedMenuOptions.forEach(item => expect(item?.row).toBeDefined()); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts b/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts new file mode 100644 index 0000000000000..a0569b69906bc --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts @@ -0,0 +1,47 @@ +/** + * 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 { action } from '@storybook/addon-actions'; +import { ActionMenuItem } from './index'; + +export const exampleMenuOptions: ActionMenuItem[] = [ + { + label: 'Action 1', + tooltip: "This is a tip, don't spend it all in one place", + onClick: action('menu item onClick'), + payload: { + taco: 'spicy chicken', + }, + }, + { + label: 'Action 2', + tooltip: 'This is another tip', + onClick: action('menu item onClick'), + payload: { + taco: 'saucy tofu', + }, + }, +]; + +export const exampleRow = { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx new file mode 100644 index 0000000000000..b6ba57420c6b4 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx @@ -0,0 +1,145 @@ +/** + * 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, { useState, useEffect } from 'react'; +import { styled } from '@superset-ui/core'; +import { Dropdown, IconOrientation } from 'src/components/Dropdown'; +import { Menu } from 'src/components/Menu'; +import { MenuProps } from 'antd/lib/menu'; + +/** + * Props interface for Action Cell Renderer + */ +export interface ActionCellProps { + /** + * The Menu option presented to user when menu displays + */ + menuOptions: ActionMenuItem[]; + /** + * Object representing the data rendering the Table row with attribute for each column + */ + row: object; +} + +export interface ActionMenuItem { + /** + * Click handler specific to the menu item + * @param menuItem The definition of the menu item that was clicked + * @returns ActionMenuItem + */ + onClick: (menuItem: ActionMenuItem) => void; + /** + * Label user will see displayed in the list of menu options + */ + label: string; + /** + * Optional tooltip user will see if they hover over the menu option to get more context + */ + tooltip?: string; + /** + * Optional variable that can contain data relevant to the menu item that you + * want easy access to in the callback function for the menu + */ + payload?: any; + /** + * Object representing the data rendering the Table row with attribute for each column + */ + row?: object; +} + +/** + * Props interface for ActionMenu + */ +export interface ActionMenuProps { + menuOptions: ActionMenuItem[]; + setVisible: (visible: boolean) => void; +} + +const SHADOW = + 'box-shadow: 0px 3px 6px -4px rgba(0, 0, 0, 0.12), 0px 9px 28px 8px rgba(0, 0, 0, 0.05)'; +const FILTER = 'drop-shadow(0px 6px 16px rgba(0, 0, 0, 0.08))'; + +const StyledMenu = styled(Menu)` + box-shadow: ${SHADOW} !important; + filter: ${FILTER} !important; + border-radius: 2px !important; + -webkit-box-shadow: ${SHADOW} !important; +`; + +export const appendDataToMenu = ( + options: ActionMenuItem[], + row: object, +): ActionMenuItem[] => { + const newOptions = options?.map?.(option => ({ + ...option, + row, + })); + return newOptions; +}; + +function ActionMenu(props: ActionMenuProps) { + const { menuOptions, setVisible } = props; + const handleClick: MenuProps['onClick'] = ({ key }) => { + setVisible?.(false); + const menuItem = menuOptions[key]; + if (menuItem) { + menuItem?.onClick?.(menuItem); + } + }; + + return ( + + {menuOptions?.map?.((option: ActionMenuItem, index: number) => ( + {option?.label} + ))} + + ); +} + +export function ActionCell(props: ActionCellProps) { + const { menuOptions, row } = props; + const [visible, setVisible] = useState(false); + const [appendedMenuOptions, setAppendedMenuOptions] = useState( + appendDataToMenu(menuOptions, row), + ); + + useEffect(() => { + const newOptions = appendDataToMenu(menuOptions, row); + setAppendedMenuOptions(newOptions); + }, [menuOptions, row]); + + const handleVisibleChange = (flag: boolean) => { + setVisible(flag); + }; + return ( + + } + disabled={ + !(appendedMenuOptions?.length && appendedMenuOptions.length > 0) + } + visible={visible} + /> + ); +} + +export default ActionCell; diff --git a/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx new file mode 100644 index 0000000000000..707e758eedb81 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx @@ -0,0 +1,62 @@ +/** + * 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 from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { ButtonCell } from './index'; + +export default { + title: 'Design System/Components/Table/Cell Renderers/ButtonCell', + component: ButtonCell, +} as ComponentMeta; + +const clickHandler = action('button cell onClick'); + +export const Basic: ComponentStory = args => ( + +); + +Basic.args = { + onClick: clickHandler, + label: 'Primary', + row: { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, + }, +}; + +export const Secondary: ComponentStory = args => ( + +); + +Secondary.args = { + onClick: clickHandler, + label: 'Secondary', + buttonStyle: 'secondary', + row: { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, + }, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx new file mode 100644 index 0000000000000..dbdb8fd4f2f2e --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx @@ -0,0 +1,40 @@ +/** + * 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 from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import ButtonCell from './index'; +import { exampleRow } from '../fixtures'; + +test('renders with default props', async () => { + const clickHandler = jest.fn(); + const BUTTON_LABEL = 'Button Label'; + + render( + , + ); + await userEvent.click(screen.getByText(BUTTON_LABEL)); + expect(clickHandler).toHaveBeenCalled(); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx new file mode 100644 index 0000000000000..c5739a386ced8 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx @@ -0,0 +1,58 @@ +/** + * 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 from 'react'; +import Button, { ButtonStyle, ButtonSize } from 'src/components/Button'; + +type onClickFunction = (row: object, index: number) => void; + +export interface ButtonCellProps { + label: string; + onClick: onClickFunction; + row: object; + index: number; + tooltip?: string; + buttonStyle?: ButtonStyle; + buttonSize?: ButtonSize; +} + +export function ButtonCell(props: ButtonCellProps) { + const { + label, + onClick, + row, + index, + tooltip, + buttonStyle = 'primary', + buttonSize = 'small', + } = props; + + return ( + + ); +} + +export default ButtonCell; diff --git a/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx new file mode 100644 index 0000000000000..bb0b52fe625f4 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx @@ -0,0 +1,47 @@ +/** + * 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 from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { CurrencyCode, LocaleCode, NumericCell, Style } from './index'; + +export default { + title: 'Design System/Components/Table/Cell Renderers/NumericCell', + component: NumericCell, +} as ComponentMeta; + +export const Basic: ComponentStory = args => ( + +); + +Basic.args = { + value: 5678943, +}; + +export const FrenchLocale: ComponentStory = args => ( + +); + +FrenchLocale.args = { + value: 5678943, + locale: LocaleCode.fr, + options: { + style: Style.CURRENCY, + currency: CurrencyCode.EUR, + }, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx new file mode 100644 index 0000000000000..b76a5bef65f87 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx @@ -0,0 +1,49 @@ +/** + * 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 from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import NumericCell, { CurrencyCode, LocaleCode, Style } from './index'; + +test('renders with French locale and Euro currency format', () => { + render( + , + ); + expect(screen.getByText('5 678 943,00 €')).toBeInTheDocument(); +}); + +test('renders with English US locale and USD currency format', () => { + render( + , + ); + expect(screen.getByText('$5,678,943.00')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx b/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx new file mode 100644 index 0000000000000..5e6d61aa47829 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx @@ -0,0 +1,418 @@ +/** + * 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 from 'react'; +import { logging } from '@superset-ui/core'; + +export interface NumericCellProps { + /** + * The number to display (before optional formatting applied) + */ + value: number; + /** + * ISO 639-1 language code with optional region or script modifier (e.g. en_US). + */ + locale?: LocaleCode; + /** + * Options for number formatting + */ + options?: NumberOptions; +} + +interface NumberOptions { + /** + * Style of number to display + */ + style?: Style; + + /** + * ISO 4217 currency code + */ + currency?: CurrencyCode; + + /** + * Languages in the form of a ISO 639-1 language code with optional region or script modifier (e.g. de_AT). + */ + maximumFractionDigits?: number; + + /** + * A number from 1 to 21 (default is 21) + */ + maximumSignificantDigits?: number; + + /** + * A number from 0 to 20 (default is 3) + */ + minimumFractionDigits?: number; + + /** + * A number from 1 to 21 (default is 1) + */ + minimumIntegerDigits?: number; + + /** + * A number from 1 to 21 (default is 21) + */ + minimumSignificantDigits?: number; +} + +export enum Style { + CURRENCY = 'currency', + DECIMAL = 'decimal', + PERCENT = 'percent', +} + +export enum CurrencyDisplay { + SYMBOL = 'symbol', + CODE = 'code', + NAME = 'name', +} + +export enum LocaleCode { + af = 'af', + ak = 'ak', + sq = 'sq', + am = 'am', + ar = 'ar', + hy = 'hy', + as = 'as', + az = 'az', + bm = 'bm', + bn = 'bn', + eu = 'eu', + be = 'be', + bs = 'bs', + br = 'br', + bg = 'bg', + my = 'my', + ca = 'ca', + ce = 'ce', + zh = 'zh', + zh_Hans = 'zh-Hans', + zh_Hant = 'zh-Hant', + cu = 'cu', + kw = 'kw', + co = 'co', + hr = 'hr', + cs = 'cs', + da = 'da', + nl = 'nl', + nl_BE = 'nl-BE', + dz = 'dz', + en = 'en', + en_AU = 'en-AU', + en_CA = 'en-CA', + en_GB = 'en-GB', + en_US = 'en-US', + eo = 'eo', + et = 'et', + ee = 'ee', + fo = 'fo', + fi = 'fi', + fr = 'fr', + fr_CA = 'fr-CA', + fr_CH = 'fr-CH', + ff = 'ff', + gl = 'gl', + lg = 'lg', + ka = 'ka', + de = 'de', + de_AT = 'de-AT', + de_CH = 'de-CH', + el = 'el', + gu = 'gu', + ht = 'ht', + ha = 'ha', + he = 'he', + hi = 'hi', + hu = 'hu', + is = 'is', + ig = 'ig', + id = 'id', + ia = 'ia', + ga = 'ga', + it = 'it', + ja = 'ja', + jv = 'jv', + kl = 'kl', + kn = 'kn', + ks = 'ks', + kk = 'kk', + km = 'km', + ki = 'ki', + rw = 'rw', + ko = 'ko', + ku = 'ku', + ky = 'ky', + lo = 'lo', + la = 'la', + lv = 'lv', + ln = 'ln', + lt = 'lt', + lu = 'lu', + lb = 'lb', + mk = 'mk', + mg = 'mg', + ms = 'ms', + ml = 'ml', + mt = 'mt', + gv = 'gv', + mi = 'mi', + mr = 'mr', + mn = 'mn', + ne = 'ne', + nd = 'nd', + se = 'se', + nb = 'nb', + nn = 'nn', + ny = 'ny', + or = 'or', + om = 'om', + os = 'os', + ps = 'ps', + fa = 'fa', + fa_AF = 'fa-AF', + pl = 'pl', + pt = 'pt', + pt_BR = 'pt-BR', + pt_PT = 'pt-PT', + pa = 'pa', + qu = 'qu', + ro = 'ro', + ro_MD = 'ro-MD', + rm = 'rm', + rn = 'rn', + ru = 'ru', + sm = 'sm', + sg = 'sg', + sa = 'sa', + gd = 'gd', + sr = 'sr', + sn = 'sn', + ii = 'ii', + sd = 'sd', + si = 'si', + sk = 'sk', + sl = 'sl', + so = 'so', + st = 'st', + es = 'es', + es_ES = 'es-ES', + es_MX = 'es-MX', + su = 'su', + sw = 'sw', + sw_CD = 'sw-CD', + sv = 'sv', + tg = 'tg', + ta = 'ta', + tt = 'tt', + te = 'te', + th = 'th', + bo = 'bo', + ti = 'ti', + to = 'to', + tr = 'tr', + tk = 'tk', + uk = 'uk', + ur = 'ur', + ug = 'ug', + uz = 'uz', + vi = 'vi', + vo = 'vo', + cy = 'cy', + fy = 'fy', + wo = 'wo', + xh = 'xh', + yi = 'yi', + yo = 'yo', + zu = 'zu', +} + +export enum CurrencyCode { + AED = 'AED', + AFN = 'AFN', + ALL = 'ALL', + AMD = 'AMD', + ANG = 'ANG', + AOA = 'AOA', + ARS = 'ARS', + AUD = 'AUD', + AWG = 'AWG', + AZN = 'AZN', + BAM = 'BAM', + BBD = 'BBD', + BDT = 'BDT', + BGN = 'BGN', + BHD = 'BHD', + BIF = 'BIF', + BMD = 'BMD', + BND = 'BND', + BOB = 'BOB', + BRL = 'BRL', + BSD = 'BSD', + BTN = 'BTN', + BWP = 'BWP', + BYN = 'BYN', + BZD = 'BZD', + CAD = 'CAD', + CDF = 'CDF', + CHF = 'CHF', + CLP = 'CLP', + CNY = 'CNY', + COP = 'COP', + CRC = 'CRC', + CUC = 'CUC', + CUP = 'CUP', + CVE = 'CVE', + CZK = 'CZK', + DJF = 'DJF', + DKK = 'DKK', + DOP = 'DOP', + DZD = 'DZD', + EGP = 'EGP', + ERN = 'ERN', + ETB = 'ETB', + EUR = 'EUR', + FJD = 'FJD', + FKP = 'FKP', + GBP = 'GBP', + GEL = 'GEL', + GHS = 'GHS', + GIP = 'GIP', + GMD = 'GMD', + GNF = 'GNF', + GTQ = 'GTQ', + GYD = 'GYD', + HKD = 'HKD', + HNL = 'HNL', + HRK = 'HRK', + HTG = 'HTG', + HUF = 'HUF', + IDR = 'IDR', + ILS = 'ILS', + INR = 'INR', + IQD = 'IQD', + IRR = 'IRR', + ISK = 'ISK', + JMD = 'JMD', + JOD = 'JOD', + JPY = 'JPY', + KES = 'KES', + KGS = 'KGS', + KHR = 'KHR', + KMF = 'KMF', + KPW = 'KPW', + KRW = 'KRW', + KWD = 'KWD', + KYD = 'KYD', + KZT = 'KZT', + LAK = 'LAK', + LBP = 'LBP', + LKR = 'LKR', + LRD = 'LRD', + LSL = 'LSL', + LYD = 'LYD', + MAD = 'MAD', + MDL = 'MDL', + MGA = 'MGA', + MKD = 'MKD', + MMK = 'MMK', + MNT = 'MNT', + MOP = 'MOP', + MRU = 'MRU', + MUR = 'MUR', + MVR = 'MVR', + MWK = 'MWK', + MXN = 'MXN', + MYR = 'MYR', + MZN = 'MZN', + NAD = 'NAD', + NGN = 'NGN', + NIO = 'NIO', + NOK = 'NOK', + NPR = 'NPR', + NZD = 'NZD', + OMR = 'OMR', + PAB = 'PAB', + PEN = 'PEN', + PGK = 'PGK', + PHP = 'PHP', + PKR = 'PKR', + PLN = 'PLN', + PYG = 'PYG', + QAR = 'QAR', + RON = 'RON', + RSD = 'RSD', + RUB = 'RUB', + RWF = 'RWF', + SAR = 'SAR', + SBD = 'SBD', + SCR = 'SCR', + SDG = 'SDG', + SEK = 'SEK', + SGD = 'SGD', + SHP = 'SHP', + SLL = 'SLL', + SOS = 'SOS', + SRD = 'SRD', + SSP = 'SSP', + STN = 'STN', + SVC = 'SVC', + SYP = 'SYP', + SZL = 'SZL', + THB = 'THB', + TJS = 'TJS', + TMT = 'TMT', + TND = 'TND', + TOP = 'TOP', + TRY = 'TRY', + TTD = 'TTD', + TWD = 'TWD', + TZS = 'TZS', + UAH = 'UAH', + UGX = 'UGX', + USD = 'USD', + UYU = 'UYU', + UZS = 'UZS', + VES = 'VES', + VND = 'VND', + VUV = 'VUV', + WST = 'WST', + XAF = 'XAF', + XCD = 'XCD', + XOF = 'XOF', + XPF = 'XPF', + YER = 'YER', + ZAR = 'ZAR', + ZMW = 'ZMW', + ZWL = 'ZWL', +} + +export function NumericCell(props: NumericCellProps) { + const { value, locale = LocaleCode.en_US, options } = props; + let displayValue = value?.toString() ?? value; + try { + displayValue = value?.toLocaleString?.(locale, options); + } catch (e) { + logging.error(e); + } + + return {displayValue}; +} + +export default NumericCell; diff --git a/superset-frontend/src/components/Table/cell-renderers/fixtures.ts b/superset-frontend/src/components/Table/cell-renderers/fixtures.ts new file mode 100644 index 0000000000000..9b2070b0359bb --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/fixtures.ts @@ -0,0 +1,25 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * License); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const exampleRow = { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, +}; diff --git a/superset-frontend/src/components/Table/index.tsx b/superset-frontend/src/components/Table/index.tsx new file mode 100644 index 0000000000000..d5f449c752875 --- /dev/null +++ b/superset-frontend/src/components/Table/index.tsx @@ -0,0 +1,326 @@ +/** + * 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, { useState, useEffect, useRef, ReactElement } from 'react'; +import { Table as AntTable, ConfigProvider } from 'antd'; +import type { + ColumnType, + ColumnGroupType, + TableProps as AntTableProps, +} from 'antd/es/table'; +import { t, useTheme, logging } from '@superset-ui/core'; +import Loading from 'src/components/Loading'; +import styled, { StyledComponent } from '@emotion/styled'; +import InteractiveTableUtils from './utils/InteractiveTableUtils'; + +export const SUPERSET_TABLE_COLUMN = 'superset/table-column'; +export interface TableDataType { + key: React.Key; +} + +export declare type ColumnsType = ( + | ColumnGroupType + | ColumnType +)[]; + +export enum SelectionType { + 'DISABLED' = 'disabled', + 'SINGLE' = 'single', + 'MULTI' = 'multi', +} + +export interface Locale { + /** + * Text contained within the Table UI. + */ + filterTitle: string; + filterConfirm: string; + filterReset: string; + filterEmptyText: string; + filterCheckall: string; + filterSearchPlaceholder: string; + emptyText: string; + selectAll: string; + selectInvert: string; + selectNone: string; + selectionAll: string; + sortTitle: string; + expand: string; + collapse: string; + triggerDesc: string; + triggerAsc: string; + cancelSort: string; +} + +export interface TableProps extends AntTableProps { + /** + * Data that will populate the each row and map to the column key. + */ + data: object[]; + /** + * Table column definitions. + */ + columns: ColumnsType; + /** + * Array of row keys to represent list of selected rows. + */ + selectedRows?: React.Key[]; + /** + * Callback function invoked when a row is selected by user. + */ + handleRowSelection?: Function; + /** + * Controls the size of the table. + */ + size: TableSize; + /** + * Adjusts the padding around elements for different amounts of spacing between elements. + */ + selectionType?: SelectionType; + /* + * Places table in visual loading state. Use while waiting to retrieve data or perform an async operation that will update the table. + */ + loading?: boolean; + /** + * Uses a sticky header which always displays when vertically scrolling the table. Default: true + */ + sticky?: boolean; + /** + * Controls if columns are resizable by user. + */ + resizable?: boolean; + /** + * EXPERIMENTAL: Controls if columns are re-orderable by user drag drop. + */ + reorderable?: boolean; + /** + * Default number of rows table will display per page of data. + */ + defaultPageSize?: number; + /** + * Array of numeric options for the number of rows table will display per page of data. + * The user can select from these options in the page size drop down menu. + */ + pageSizeOptions?: string[]; + /** + * Set table to display no data even if data has been provided + */ + hideData?: boolean; + /** + * emptyComponent + */ + emptyComponent?: ReactElement; + /** + * Enables setting the text displayed in various components and tooltips within the Table UI. + */ + locale?: Locale; + /** + * Restricts the visible height of the table and allows for internal scrolling within the table + * when the number of rows exceeds the visible space. + */ + height?: number; +} + +export enum TableSize { + SMALL = 'small', + MIDDLE = 'middle', +} + +const defaultRowSelection: React.Key[] = []; +// This accounts for the tables header and pagination if user gives table instance a height. this is a temp solution +const HEIGHT_OFFSET = 108; + +const StyledTable: StyledComponent = styled(AntTable)` + ${({ theme, height }) => ` + .ant-table-body { + overflow: scroll; + height: ${height ? `${height - HEIGHT_OFFSET}px` : undefined}; + } + + th.ant-table-cell { + font-weight: ${theme.typography.weights.bold}; + color: ${theme.colors.grayscale.dark1}; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ant-pagination-item-active { + border-color: ${theme.colors.primary.base}; + } + `} +`; + +const defaultLocale = { + filterTitle: t('Filter menu'), + filterConfirm: t('OK'), + filterReset: t('Reset'), + filterEmptyText: t('No filters'), + filterCheckall: t('Select all items'), + filterSearchPlaceholder: t('Search in filters'), + emptyText: t('No data'), + selectAll: t('Select current page'), + selectInvert: t('Invert current page'), + selectNone: t('Clear all data'), + selectionAll: t('Select all data'), + sortTitle: t('Sort'), + expand: t('Expand row'), + collapse: t('Collapse row'), + triggerDesc: t('Click to sort descending'), + triggerAsc: t('Click to sort ascending'), + cancelSort: t('Click to cancel sorting'), +}; + +const selectionMap = {}; +selectionMap[SelectionType.MULTI] = 'checkbox'; +selectionMap[SelectionType.SINGLE] = 'radio'; +selectionMap[SelectionType.DISABLED] = null; + +export function Table(props: TableProps) { + const { + data, + columns, + selectedRows = defaultRowSelection, + handleRowSelection, + size, + selectionType = SelectionType.DISABLED, + sticky = true, + loading = false, + resizable = false, + reorderable = false, + defaultPageSize = 15, + pageSizeOptions = ['5', '15', '25', '50', '100'], + hideData = false, + emptyComponent, + locale, + ...rest + } = props; + + const wrapperRef = useRef(null); + const [derivedColumns, setDerivedColumns] = useState(columns); + const [pageSize, setPageSize] = useState(defaultPageSize); + const [mergedLocale, setMergedLocale] = useState({ ...defaultLocale }); + const [selectedRowKeys, setSelectedRowKeys] = + useState(selectedRows); + const interactiveTableUtils = useRef(null); + + const onSelectChange = (newSelectedRowKeys: React.Key[]) => { + setSelectedRowKeys(newSelectedRowKeys); + handleRowSelection?.(newSelectedRowKeys); + }; + + const selectionTypeValue = selectionMap[selectionType]; + const rowSelection = { + type: selectionTypeValue, + selectedRowKeys, + onChange: onSelectChange, + }; + + const renderEmpty = () => + emptyComponent ??
{mergedLocale.emptyText}
; + + // Log use of experimental features + useEffect(() => { + if (reorderable === true) { + logging.warn( + 'EXPERIMENTAL FEATURE ENABLED: The "reorderable" prop of Table is experimental and NOT recommended for use in production deployments.', + ); + } + if (resizable === true) { + logging.warn( + 'EXPERIMENTAL FEATURE ENABLED: The "resizable" prop of Table is experimental and NOT recommended for use in production deployments.', + ); + } + }, [reorderable, resizable]); + + useEffect(() => { + let updatedLocale; + if (locale) { + // This spread allows for locale to only contain a subset of locale overrides on props + updatedLocale = { ...defaultLocale, ...locale }; + } else { + updatedLocale = { ...defaultLocale }; + } + setMergedLocale(updatedLocale); + }, [locale]); + + useEffect(() => { + if (interactiveTableUtils.current) { + interactiveTableUtils.current?.clearListeners(); + } + const table = wrapperRef.current?.getElementsByTagName('table')[0]; + if (table) { + interactiveTableUtils.current = new InteractiveTableUtils( + table, + derivedColumns, + setDerivedColumns, + ); + if (reorderable) { + interactiveTableUtils?.current?.initializeDragDropColumns( + reorderable, + table, + ); + } + if (resizable) { + interactiveTableUtils?.current?.initializeResizableColumns( + resizable, + table, + ); + } + } + return () => { + interactiveTableUtils?.current?.clearListeners?.(); + }; + /** + * We DO NOT want this effect to trigger when derivedColumns changes as it will break functionality + * The exclusion from the effect dependencies is intentional and should not be modified + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wrapperRef, reorderable, resizable, interactiveTableUtils]); + + const theme = useTheme(); + + return ( + +
+ }} + hasData={hideData ? false : data} + rowSelection={selectionTypeValue ? rowSelection : undefined} + columns={derivedColumns} + dataSource={hideData ? [undefined] : data} + size={size} + sticky={sticky} + pagination={{ + hideOnSinglePage: true, + pageSize, + pageSizeOptions, + onShowSizeChange: (page: number, size: number) => setPageSize(size), + }} + showSorterTooltip={false} + locale={mergedLocale} + theme={theme} + /> +
+
+ ); +} + +export default Table; diff --git a/superset-frontend/src/components/Table/sorters.test.ts b/superset-frontend/src/components/Table/sorters.test.ts new file mode 100644 index 0000000000000..80bc0a20c42c5 --- /dev/null +++ b/superset-frontend/src/components/Table/sorters.test.ts @@ -0,0 +1,100 @@ +/** + * 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 { alphabeticalSort, numericalSort } from './sorters'; + +const rows = [ + { + name: 'Deathstar Lamp', + category: 'Lamp', + cost: 75.99, + }, + { + name: 'Desk Lamp', + category: 'Lamp', + cost: 15.99, + }, + { + name: 'Bedside Lamp', + category: 'Lamp', + cost: 15.99, + }, + { name: 'Drafting Desk', category: 'Desk', cost: 125 }, + { name: 'Sit / Stand Desk', category: 'Desk', cost: 275.99 }, +]; + +/** + * NOTE: Sorters for antd table use < 0, 0, > 0 for sorting + * -1 or less means the first item comes after the second item + * 0 means the items sort values is equivalent + * 1 or greater means the first item comes before the second item + */ +test('alphabeticalSort sorts correctly', () => { + expect(alphabeticalSort('name', rows[0], rows[1])).toBe(-1); + expect(alphabeticalSort('name', rows[1], rows[0])).toBe(1); + expect(alphabeticalSort('category', rows[1], rows[0])).toBe(0); +}); + +test('numericalSort sorts correctly', () => { + expect(numericalSort('cost', rows[1], rows[2])).toBe(0); + expect(numericalSort('cost', rows[1], rows[0])).toBeLessThan(0); + expect(numericalSort('cost', rows[4], rows[1])).toBeGreaterThan(0); +}); + +/** + * We want to make sure our sorters do not throw runtime errors given bad inputs. + * Runtime Errors in a sorter will cause a catastrophic React lifecycle error and produce white screen of death + * In the case the sorter cannot perform the comparison it should return undefined and the next sort step will proceed without error + */ +test('alphabeticalSort bad inputs no errors', () => { + // @ts-ignore + expect(alphabeticalSort('name', null, null)).toBe(undefined); + // incorrect non-object values + // @ts-ignore + expect(alphabeticalSort('name', 3, [])).toBe(undefined); + // incorrect object values without specificed key + expect(alphabeticalSort('name', {}, {})).toBe(undefined); + // Object as value for name when it should be a string + expect( + alphabeticalSort( + 'name', + { name: { title: 'the name attribute should not be an object' } }, + { name: 'Doug' }, + ), + ).toBe(undefined); +}); + +test('numericalSort bad inputs no errors', () => { + // @ts-ignore + expect(numericalSort('name', undefined, undefined)).toBe(NaN); + // @ts-ignore + expect(numericalSort('name', null, null)).toBe(NaN); + // incorrect non-object values + // @ts-ignore + expect(numericalSort('name', 3, [])).toBe(NaN); + // incorrect object values without specified key + expect(numericalSort('name', {}, {})).toBe(NaN); + // Object as value for name when it should be a string + expect( + numericalSort( + 'name', + { name: { title: 'the name attribute should not be an object' } }, + { name: 'Doug' }, + ), + ).toBe(NaN); +}); diff --git a/superset-frontend/src/components/Table/sorters.ts b/superset-frontend/src/components/Table/sorters.ts new file mode 100644 index 0000000000000..3f06071aacc69 --- /dev/null +++ b/superset-frontend/src/components/Table/sorters.ts @@ -0,0 +1,36 @@ +/** + * 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. + */ + +/** + * @param key The name of the row's attribute used to compare values for alphabetical sorting + * @param a First row object to compare + * @param b Second row object to compare + * @returns number + */ +export const alphabeticalSort = (key: string, a: object, b: object): number => + a?.[key]?.localeCompare?.(b?.[key]); + +/** + * @param key The name of the row's attribute used to compare values for numerical sorting + * @param a First row object to compare + * @param b Second row object to compare + * @returns number + */ +export const numericalSort = (key: string, a: object, b: object): number => + a?.[key] - b?.[key]; diff --git a/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts b/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts new file mode 100644 index 0000000000000..94977413e2cdc --- /dev/null +++ b/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts @@ -0,0 +1,233 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ColumnsType } from 'antd/es/table'; +import { SUPERSET_TABLE_COLUMN } from 'src/components/Table'; +import { withinRange } from './utils'; + +interface IInteractiveColumn extends HTMLElement { + mouseDown: boolean; + oldX: number; + oldWidth: number; + draggable: boolean; +} +export default class InteractiveTableUtils { + tableRef: HTMLTableElement | null; + + columnRef: IInteractiveColumn | null; + + setDerivedColumns: Function; + + isDragging: boolean; + + resizable: boolean; + + reorderable: boolean; + + derivedColumns: ColumnsType; + + RESIZE_INDICATOR_THRESHOLD: number; + + constructor( + tableRef: HTMLTableElement, + derivedColumns: ColumnsType, + setDerivedColumns: Function, + ) { + this.setDerivedColumns = setDerivedColumns; + this.tableRef = tableRef; + this.isDragging = false; + this.RESIZE_INDICATOR_THRESHOLD = 8; + this.resizable = false; + this.reorderable = false; + this.derivedColumns = [...derivedColumns]; + document.addEventListener('mouseup', this.handleMouseup); + } + + clearListeners = () => { + document.removeEventListener('mouseup', this.handleMouseup); + this.initializeResizableColumns(false, this.tableRef); + this.initializeDragDropColumns(false, this.tableRef); + }; + + setTableRef = (table: HTMLTableElement) => { + this.tableRef = table; + }; + + getColumnIndex = (): number => { + let index = -1; + const parent = this.columnRef?.parentNode; + if (parent) { + index = Array.prototype.indexOf.call(parent.children, this.columnRef); + } + return index; + }; + + handleColumnDragStart = (ev: DragEvent): void => { + const target = ev?.currentTarget as IInteractiveColumn; + if (target) { + this.columnRef = target; + } + this.isDragging = true; + const index = this.getColumnIndex(); + const columnData = this.derivedColumns[index]; + const dragData = { index, columnData }; + ev?.dataTransfer?.setData(SUPERSET_TABLE_COLUMN, JSON.stringify(dragData)); + }; + + handleDragDrop = (ev: DragEvent): void => { + const data = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN); + if (data) { + ev.preventDefault(); + const parent = (ev.currentTarget as HTMLElement) + ?.parentNode as HTMLElement; + const dropIndex = Array.prototype.indexOf.call( + parent.children, + ev.currentTarget, + ); + const dragIndex = this.getColumnIndex(); + const columnsCopy = [...this.derivedColumns]; + const removedItem = columnsCopy.slice(dragIndex, dragIndex + 1); + columnsCopy.splice(dragIndex, 1); + columnsCopy.splice(dropIndex, 0, removedItem[0]); + this.derivedColumns = [...columnsCopy]; + this.setDerivedColumns(columnsCopy); + } + }; + + allowDrop = (ev: DragEvent): void => { + ev.preventDefault(); + }; + + handleMouseDown = (event: MouseEvent) => { + const target = event?.currentTarget as IInteractiveColumn; + if (target) { + this.columnRef = target; + if ( + event && + withinRange( + event.offsetX, + target.offsetWidth, + this.RESIZE_INDICATOR_THRESHOLD, + ) + ) { + target.mouseDown = true; + target.oldX = event.x; + target.oldWidth = target.offsetWidth; + target.draggable = false; + } else if (this.reorderable) { + target.draggable = true; + } + } + }; + + handleMouseMove = (event: MouseEvent) => { + if (this.resizable === true && !this.isDragging) { + const target = event.currentTarget as IInteractiveColumn; + if ( + event && + withinRange( + event.offsetX, + target.offsetWidth, + this.RESIZE_INDICATOR_THRESHOLD, + ) + ) { + target.style.cursor = 'col-resize'; + } else { + target.style.cursor = 'default'; + } + + const column = this.columnRef; + if (column?.mouseDown) { + let width = column.oldWidth; + const diff = event.x - column.oldX; + if (column.oldWidth + (event.x - column.oldX) > 0) { + width = column.oldWidth + diff; + } + const colIndex = this.getColumnIndex(); + if (!Number.isNaN(colIndex)) { + const columnDef = { ...this.derivedColumns[colIndex] }; + columnDef.width = width; + this.derivedColumns[colIndex] = columnDef; + this.setDerivedColumns([...this.derivedColumns]); + } + } + } + }; + + handleMouseup = () => { + if (this.columnRef) { + this.columnRef.mouseDown = false; + this.columnRef.style.cursor = 'default'; + this.columnRef.draggable = false; + } + this.isDragging = false; + }; + + initializeResizableColumns = ( + resizable = false, + table: HTMLTableElement | null, + ) => { + this.tableRef = table; + const header: HTMLTableRowElement | undefined = this.tableRef?.rows?.[0]; + if (header) { + const { cells } = header; + const len = cells.length; + for (let i = 0; i < len; i += 1) { + const cell = cells[i]; + if (resizable === true) { + this.resizable = true; + cell.addEventListener('mousedown', this.handleMouseDown); + cell.addEventListener('mousemove', this.handleMouseMove, true); + } else { + this.resizable = false; + cell.removeEventListener('mousedown', this.handleMouseDown); + cell.removeEventListener('mousemove', this.handleMouseMove, true); + } + } + } + }; + + initializeDragDropColumns = ( + reorderable = false, + table: HTMLTableElement | null, + ) => { + this.tableRef = table; + const header: HTMLTableRowElement | undefined = this.tableRef?.rows?.[0]; + if (header) { + const { cells } = header; + const len = cells.length; + for (let i = 0; i < len; i += 1) { + const cell = cells[i]; + if (reorderable === true) { + this.reorderable = true; + cell.addEventListener('mousedown', this.handleMouseDown); + cell.addEventListener('dragover', this.allowDrop); + cell.addEventListener('dragstart', this.handleColumnDragStart); + cell.addEventListener('drop', this.handleDragDrop); + } else { + this.reorderable = false; + cell.draggable = false; + cell.removeEventListener('mousedown', this.handleMouseDown); + cell.removeEventListener('dragover', this.allowDrop); + cell.removeEventListener('dragstart', this.handleColumnDragStart); + cell.removeEventListener('drop', this.handleDragDrop); + } + } + } + }; +} diff --git a/superset-frontend/src/components/Table/utils/utils.test.ts b/superset-frontend/src/components/Table/utils/utils.test.ts new file mode 100644 index 0000000000000..eff50f1580fc0 --- /dev/null +++ b/superset-frontend/src/components/Table/utils/utils.test.ts @@ -0,0 +1,48 @@ +/** + * 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 { withinRange } from './utils'; + +test('withinRange supported positive numbers', () => { + // Valid inputs within range + expect(withinRange(50, 60, 16)).toBeTruthy(); + + // Valid inputs outside of range + expect(withinRange(40, 60, 16)).toBeFalsy(); +}); + +test('withinRange unsupported negative numbers', () => { + // Negative numbers not supported + expect(withinRange(65, 60, -16)).toBeFalsy(); + expect(withinRange(-60, -65, 16)).toBeFalsy(); + expect(withinRange(-60, -65, 16)).toBeFalsy(); + expect(withinRange(-60, 65, 16)).toBeFalsy(); +}); + +test('withinRange invalid inputs', () => { + // Invalid inputs should return falsy and not throw an error + // We need ts-ignore here to be able to pass invalid values and pass linting + // @ts-ignore + expect(withinRange(null, 60, undefined)).toBeFalsy(); + // @ts-ignore + expect(withinRange([], 'hello', {})).toBeFalsy(); + // @ts-ignore + expect(withinRange([], undefined, {})).toBeFalsy(); + // @ts-ignore + expect(withinRange([], 'hello', {})).toBeFalsy(); +}); diff --git a/superset-frontend/src/components/Table/utils/utils.ts b/superset-frontend/src/components/Table/utils/utils.ts new file mode 100644 index 0000000000000..5b4e4d13baa85 --- /dev/null +++ b/superset-frontend/src/components/Table/utils/utils.ts @@ -0,0 +1,40 @@ +/** + * 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. + */ + +/** + * Method to check if a number is within inclusive range between a maximum value minus a threshold + * Invalid non numeric inputs will not error, but will return false + * + * @param value number coordinate to determine if it is within bounds of the targetCoordinate - threshold. Must be positive and less than maximum. + * @param maximum number max value for the test range. Must be positive and greater than value + * @param threshold number values to determine a range from maximum - threshold. Must be positive and greater than zero. + * @returns boolean + */ +export const withinRange = ( + value: number, + maximum: number, + threshold: number, +): boolean => { + let within = false; + const diff = maximum - value; + if (diff > 0 && diff <= threshold) { + within = true; + } + return within; +}; diff --git a/superset-frontend/src/components/atomic-design.png b/superset-frontend/src/components/atomic-design.png new file mode 100644 index 0000000000000000000000000000000000000000..e44c5f34a54ebbdd26a30adc43339e40b954914c GIT binary patch literal 163100 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%0DCrlvHPL7w{ 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 Zw1XC2;iH1K&DRJYZB2vwW$HFz{{!AVv(Nwl literal 0 HcmV?d00001 diff --git a/superset-frontend/src/types/files.d.ts b/superset-frontend/src/types/files.d.ts index c694d13cfbf22..c4f304b57f636 100644 --- a/superset-frontend/src/types/files.d.ts +++ b/superset-frontend/src/types/files.d.ts @@ -18,3 +18,4 @@ */ declare module '*.svg'; +declare module '*.gif'; diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 10284097e8150..9994b1dd7911d 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -447,7 +447,7 @@ const config = { type: 'asset/resource', }, { - test: /\.(stories|story)\.mdx$/, + test: /\.mdx$/, use: [ { loader: 'babel-loader', From 95b4c7b7fea8ad7c88016d7b332ad68e80ecb0e2 Mon Sep 17 00:00:00 2001 From: Antonio Rivero Martinez <38889534+Antonio-RiveroMartnez@users.noreply.github.com> Date: Thu, 10 Nov 2022 00:56:08 -0300 Subject: [PATCH 14/22] chore(bigquery): Add extra logging for BigQuery exceptions so we can have better insight on exceptions (#22024) --- superset/db_engine_specs/base.py | 11 ++++- superset/db_engine_specs/bigquery.py | 9 ++++ .../db_engine_specs/test_bigquery.py | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 2a1363e0b6957..fb1430ba5efab 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -430,6 +430,15 @@ def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]: """ return {} + @classmethod + def parse_error_exception(cls, exception: Exception) -> Exception: + """ + Each engine can implement and converge its own specific parser method + + :return: An Exception with a parsed string off the original exception + """ + return exception + @classmethod def get_dbapi_mapped_exception(cls, exception: Exception) -> Exception: """ @@ -443,7 +452,7 @@ def get_dbapi_mapped_exception(cls, exception: Exception) -> Exception: """ new_exception = cls.get_dbapi_exception_mapping().get(type(exception)) if not new_exception: - return exception + return cls.parse_error_exception(exception) return new_exception(str(exception)) @classmethod diff --git a/superset/db_engine_specs/bigquery.py b/superset/db_engine_specs/bigquery.py index ffeff31b17787..373fc2f747375 100644 --- a/superset/db_engine_specs/bigquery.py +++ b/superset/db_engine_specs/bigquery.py @@ -578,3 +578,12 @@ def _get_fields(cls, cols: List[Dict[str, Any]]) -> List[Any]: "author__name" and "author__email", respectively. """ return [column(c["name"]).label(c["name"].replace(".", "__")) for c in cols] + + @classmethod + def parse_error_exception(cls, exception: Exception) -> Exception: + try: + return Exception(str(exception).splitlines()[0].rsplit(":")[1].strip()) + except Exception: # pylint: disable=broad-except + # If for some reason we get an exception, for example, no new line + # We will return the original exception + return exception diff --git a/tests/unit_tests/db_engine_specs/test_bigquery.py b/tests/unit_tests/db_engine_specs/test_bigquery.py index 8c33489faf598..4a5c741531dd7 100644 --- a/tests/unit_tests/db_engine_specs/test_bigquery.py +++ b/tests/unit_tests/db_engine_specs/test_bigquery.py @@ -244,3 +244,44 @@ def test_mask_encrypted_extra_when_empty() -> None: from superset.db_engine_specs.bigquery import BigQueryEngineSpec assert BigQueryEngineSpec.mask_encrypted_extra(None) is None + + +def test_parse_error_message() -> None: + """ + Test that we parse a received message and just extract the useful information. + + Example errors: + bigquery error: 400 Table \"case_detail_all_suites\" must be qualified with a dataset (e.g. dataset.table). + + (job ID: ddf30b05-44e8-4fbf-aa29-40bfccaed886) + -----Query Job SQL Follows----- + | . | . | . |\n 1:select * from case_detail_all_suites\n 2:LIMIT 1001\n | . | . | . | + """ + from superset.db_engine_specs.bigquery import BigQueryEngineSpec + + message = 'bigquery error: 400 Table "case_detail_all_suites" must be qualified with a dataset (e.g. dataset.table).\n\n(job ID: ddf30b05-44e8-4fbf-aa29-40bfccaed886)\n\n -----Query Job SQL Follows----- \n\n | . | . | . |\n 1:select * from case_detail_all_suites\n 2:LIMIT 1001\n | . | . | . |' + expected_result = '400 Table "case_detail_all_suites" must be qualified with a dataset (e.g. dataset.table).' + assert ( + str(BigQueryEngineSpec.parse_error_exception(Exception(message))) + == expected_result + ) + + +def test_parse_error_raises_exception() -> None: + """ + Test that we handle any exception we might get from calling the parse_error_exception method. + + Example errors: + 400 Syntax error: Expected "(" or keyword UNNEST but got "@" at [4:80] + bigquery error: 400 Table \"case_detail_all_suites\" must be qualified with a dataset (e.g. dataset.table). + """ + from superset.db_engine_specs.bigquery import BigQueryEngineSpec + + message = 'bigquery error: 400 Table "case_detail_all_suites" must be qualified with a dataset (e.g. dataset.table).' + message_2 = "6" + expected_result = '400 Table "case_detail_all_suites" must be qualified with a dataset (e.g. dataset.table).' + assert ( + str(BigQueryEngineSpec.parse_error_exception(Exception(message))) + == expected_result + ) + assert str(BigQueryEngineSpec.parse_error_exception(Exception(message_2))) == "6" From 35e0e5bfe6de4adee3f824a877ab71818287ed0a Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 10 Nov 2022 21:47:55 +0000 Subject: [PATCH 15/22] chore: improve performance on bootstrap permissions (#22083) --- requirements/base.txt | 2 +- setup.py | 2 +- superset/views/utils.py | 21 +++++++++------------ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 3646395b4f59d..30dc9c0a9187c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -76,7 +76,7 @@ flask==2.0.3 # flask-migrate # flask-sqlalchemy # flask-wtf -flask-appbuilder==4.1.4 +flask-appbuilder==4.1.6 # via apache-superset flask-babel==1.0.0 # via flask-appbuilder diff --git a/setup.py b/setup.py index 4ca6b910865eb..5f18427f47850 100644 --- a/setup.py +++ b/setup.py @@ -83,7 +83,7 @@ def get_git_sha() -> str: "cryptography>=3.3.2", "deprecation>=2.1.0, <2.2.0", "flask>=2.0.0, <3.0.0", - "flask-appbuilder>=4.1.4, <5.0.0", + "flask-appbuilder>=4.1.6, <5.0.0", "flask-caching>=1.10.0", "flask-compress", "flask-talisman", diff --git a/superset/views/utils.py b/superset/views/utils.py index 6b6d5a0fb8579..835c8eda0d8d6 100644 --- a/superset/views/utils.py +++ b/superset/views/utils.py @@ -103,23 +103,20 @@ def bootstrap_user_data(user: User, include_perms: bool = False) -> Dict[str, An def get_permissions( user: User, -) -> Tuple[Dict[str, List[List[str]]], DefaultDict[str, List[str]]]: +) -> Tuple[Dict[str, List[Tuple[str]]], DefaultDict[str, List[str]]]: if not user.roles: raise AttributeError("User object does not have roles") - roles = defaultdict(list) - permissions = defaultdict(set) - - for role in user.roles: - permissions_ = security_manager.get_role_permissions(role) - for permission in permissions_: + data_permissions = defaultdict(set) + roles_permissions = security_manager.get_user_roles_permissions(user) + for _, permissions in roles_permissions.items(): + for permission in permissions: if permission[0] in ("datasource_access", "database_access"): - permissions[permission[0]].add(permission[1]) - roles[role.name].append([permission[0], permission[1]]) + data_permissions[permission[0]].add(permission[1]) transformed_permissions = defaultdict(list) - for perm in permissions: - transformed_permissions[perm] = list(permissions[perm]) - return roles, transformed_permissions + for perm in data_permissions: + transformed_permissions[perm] = list(data_permissions[perm]) + return roles_permissions, transformed_permissions def get_viz( From defe5c8ba71851d6870985bbbc1c6c0650aa87b8 Mon Sep 17 00:00:00 2001 From: Eric Briscoe Date: Thu, 10 Nov 2022 16:02:51 -0800 Subject: [PATCH 16/22] feat: Integrate ant d table component into DatasetPanel (#21948) Co-authored-by: Lyndsi Kay Williams <55605634+lyndsiWilliams@users.noreply.github.com> Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com> --- superset-frontend/.storybook/main.js | 4 +- .../src/assets/images/no-columns.svg | 22 ++ .../src/components/EmptyState/index.tsx | 8 +- .../DatasetPanel/DatasetPanel.stories.tsx | 44 ++++ .../DatasetPanel/DatasetPanel.test.tsx | 118 ++++++++- .../AddDataset/DatasetPanel/DatasetPanel.tsx | 237 ++++++++++++++++++ .../DatasetPanel/MessageContent.tsx | 107 ++++++++ .../AddDataset/DatasetPanel/fixtures.ts | 34 +++ .../dataset/AddDataset/DatasetPanel/index.tsx | 160 +++++++----- .../dataset/AddDataset/DatasetPanel/types.ts | 92 +++++++ .../dataset/AddDataset/Footer/Footer.test.tsx | 1 + .../data/dataset/AddDataset/Footer/index.tsx | 10 +- .../dataset/AddDataset/LeftPanel/index.tsx | 23 +- .../CRUD/data/dataset/AddDataset/index.tsx | 12 +- .../src/views/CRUD/data/dataset/styles.ts | 2 +- 15 files changed, 769 insertions(+), 105 deletions(-) create mode 100644 superset-frontend/src/assets/images/no-columns.svg create mode 100644 superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.stories.tsx create mode 100644 superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.tsx create mode 100644 superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/MessageContent.tsx create mode 100644 superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/fixtures.ts create mode 100644 superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/types.ts diff --git a/superset-frontend/.storybook/main.js b/superset-frontend/.storybook/main.js index b8f15b569f3d9..814e53cf58411 100644 --- a/superset-frontend/.storybook/main.js +++ b/superset-frontend/.storybook/main.js @@ -24,8 +24,8 @@ module.exports = { builder: 'webpack5', }, stories: [ - '../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx)', - '../src/@(components|common|filters|explore)/**/*.*.@(mdx)', + '../src/@(components|common|filters|explore|views)/**/*.stories.@(tsx|jsx)', + '../src/@(components|common|filters|explore|views)/**/*.*.@(mdx)', ], addons: [ '@storybook/addon-essentials', diff --git a/superset-frontend/src/assets/images/no-columns.svg b/superset-frontend/src/assets/images/no-columns.svg new file mode 100644 index 0000000000000..2fc8fe0661bfe --- /dev/null +++ b/superset-frontend/src/assets/images/no-columns.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/src/components/EmptyState/index.tsx b/superset-frontend/src/components/EmptyState/index.tsx index 3f5d586cf8d92..982cace6672fb 100644 --- a/superset-frontend/src/components/EmptyState/index.tsx +++ b/superset-frontend/src/components/EmptyState/index.tsx @@ -31,7 +31,7 @@ export enum EmptyStateSize { export interface EmptyStateSmallProps { title: ReactNode; description?: ReactNode; - image: ReactNode; + image?: ReactNode; } export interface EmptyStateProps extends EmptyStateSmallProps { @@ -156,7 +156,7 @@ export const EmptyStateBig = ({ className, }: EmptyStateProps) => ( - + {image && } css` @@ -187,7 +187,7 @@ export const EmptyStateMedium = ({ buttonText, }: EmptyStateProps) => ( - + {image && } css` @@ -216,7 +216,7 @@ export const EmptyStateSmall = ({ description, }: EmptyStateSmallProps) => ( - + {image && } css` diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.stories.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.stories.tsx new file mode 100644 index 0000000000000..8a7fd7d6438fe --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.stories.tsx @@ -0,0 +1,44 @@ +/** + * 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 from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { supersetTheme, ThemeProvider } from '@superset-ui/core'; +import DatasetPanel from './DatasetPanel'; +import { exampleColumns } from './fixtures'; + +export default { + title: 'Superset App/views/CRUD/data/dataset/DatasetPanel', + component: DatasetPanel, +} as ComponentMeta; + +export const Basic: ComponentStory = args => ( + +
+ +
+
+); + +Basic.args = { + tableName: 'example_table', + loading: false, + hasError: false, + columnList: exampleColumns, +}; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.test.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.test.tsx index b03b7cad92a63..6800594bd8eb1 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.test.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.test.tsx @@ -18,24 +18,124 @@ */ import React from 'react'; import { render, screen } from 'spec/helpers/testing-library'; -import DatasetPanel from 'src/views/CRUD/data/dataset/AddDataset/DatasetPanel'; +import DatasetPanel, { + REFRESHING, + ALT_LOADING, + tableColumnDefinition, + COLUMN_TITLE, +} from './DatasetPanel'; +import { exampleColumns } from './fixtures'; +import { + SELECT_MESSAGE, + CREATE_MESSAGE, + VIEW_DATASET_MESSAGE, + SELECT_TABLE_TITLE, + NO_COLUMNS_TITLE, + NO_COLUMNS_DESCRIPTION, + ERROR_TITLE, + ERROR_DESCRIPTION, +} from './MessageContent'; + +jest.mock( + 'src/components/Icons/Icon', + () => + ({ fileName }: { fileName: string }) => + , +); describe('DatasetPanel', () => { it('renders a blank state DatasetPanel', () => { - render(); + render(); const blankDatasetImg = screen.getByRole('img', { name: /empty/i }); - const blankDatasetTitle = screen.getByText(/select dataset source/i); - const blankDatasetDescription = screen.getByText( - /datasets can be created from database tables or sql queries\. select a database table to the left or to open sql lab\. from there you can save the query as a dataset\./i, - ); + expect(blankDatasetImg).toBeVisible(); + const blankDatasetTitle = screen.getByText(SELECT_TABLE_TITLE); + expect(blankDatasetTitle).toBeVisible(); + const blankDatasetDescription1 = screen.getByText(SELECT_MESSAGE, { + exact: false, + }); + expect(blankDatasetDescription1).toBeVisible(); + const blankDatasetDescription2 = screen.getByText(VIEW_DATASET_MESSAGE, { + exact: false, + }); + expect(blankDatasetDescription2).toBeVisible(); const sqlLabLink = screen.getByRole('button', { - name: /create dataset from sql query/i, + name: CREATE_MESSAGE, }); + expect(sqlLabLink).toBeVisible(); + }); + + it('renders a no columns screen', () => { + render( + , + ); + const blankDatasetImg = screen.getByRole('img', { name: /empty/i }); expect(blankDatasetImg).toBeVisible(); + const noColumnsTitle = screen.getByText(NO_COLUMNS_TITLE); + expect(noColumnsTitle).toBeVisible(); + const noColumnsDescription = screen.getByText(NO_COLUMNS_DESCRIPTION); + expect(noColumnsDescription).toBeVisible(); + }); + + it('renders a loading screen', () => { + render( + , + ); + + const blankDatasetImg = screen.getByAltText(ALT_LOADING); + expect(blankDatasetImg).toBeVisible(); + const blankDatasetTitle = screen.getByText(REFRESHING); expect(blankDatasetTitle).toBeVisible(); - expect(blankDatasetDescription).toBeVisible(); - expect(sqlLabLink).toBeVisible(); + }); + + it('renders an error screen', () => { + render( + , + ); + + const errorTitle = screen.getByText(ERROR_TITLE); + expect(errorTitle).toBeVisible(); + const errorDescription = screen.getByText(ERROR_DESCRIPTION); + expect(errorDescription).toBeVisible(); + }); + + it('renders a table with columns displayed', async () => { + const tableName = 'example_name'; + render( + , + ); + expect(await screen.findByText(tableName)).toBeVisible(); + expect(screen.getByText(COLUMN_TITLE)).toBeVisible(); + expect( + screen.getByText(tableColumnDefinition[0].title as string), + ).toBeInTheDocument(); + expect( + screen.getByText(tableColumnDefinition[1].title as string), + ).toBeInTheDocument(); + exampleColumns.forEach(row => { + expect(screen.getByText(row.name)).toBeInTheDocument(); + expect(screen.getByText(row.type)).toBeInTheDocument(); + }); }); }); diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.tsx new file mode 100644 index 0000000000000..2a5e12cea888d --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/DatasetPanel.tsx @@ -0,0 +1,237 @@ +/** + * 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 from 'react'; +import { supersetTheme, t, styled } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; +import Table, { ColumnsType, TableSize } from 'src/components/Table'; +import { alphabeticalSort } from 'src/components/Table/sorters'; +// @ts-ignore +import LOADING_GIF from 'src/assets/images/loading.gif'; +import { ITableColumn } from './types'; +import MessageContent from './MessageContent'; + +/** + * Enum defining CSS position options + */ +enum EPosition { + ABSOLUTE = 'absolute', + RELATIVE = 'relative', +} + +/** + * Interface for StyledHeader + */ +interface StyledHeaderProps { + /** + * Determine the CSS positioning type + * Vertical centering of loader, No columns screen, and select table screen + * gets offset when the header position is relative and needs to be absolute, but table + * needs this positioned relative to render correctly + */ + position: EPosition; +} + +const LOADER_WIDTH = 200; +const SPINNER_WIDTH = 120; +const HALF = 0.5; +const MARGIN_MULTIPLIER = 3; + +const StyledHeader = styled.div` + position: ${(props: StyledHeaderProps) => props.position}; + margin: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px; + margin-top: ${({ theme }) => theme.gridUnit * (MARGIN_MULTIPLIER + 1)}px; + font-size: ${({ theme }) => theme.gridUnit * 6}px; + font-weight: ${({ theme }) => theme.typography.weights.medium}; + padding-bottom: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .anticon:first-of-type { + margin-right: ${({ theme }) => theme.gridUnit * (MARGIN_MULTIPLIER + 1)}px; + } + + .anticon:nth-of-type(2) { + margin-left: ${({ theme }) => theme.gridUnit * (MARGIN_MULTIPLIER + 1)}px; + } +`; + +const StyledTitle = styled.div` + margin-left: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px; + margin-bottom: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px; + font-weight: ${({ theme }) => theme.typography.weights.bold}; +`; + +const LoaderContainer = styled.div` + padding: ${({ theme }) => theme.gridUnit * 8}px + ${({ theme }) => theme.gridUnit * 6}px; + + display: flex; + align-items: center; + justify-content: center; + height: 100%; +`; + +const StyledLoader = styled.div` + max-width: 50%; + width: ${LOADER_WIDTH}px; + + img { + width: ${SPINNER_WIDTH}px; + margin-left: ${(LOADER_WIDTH - SPINNER_WIDTH) * HALF}px; + } + + div { + width: 100%; + margin-top: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px; + text-align: center; + font-weight: ${({ theme }) => theme.typography.weights.normal}; + font-size: ${({ theme }) => theme.typography.sizes.l}px; + color: ${({ theme }) => theme.colors.grayscale.light1}; + } +`; + +const TableContainer = styled.div` + position: relative; + margin: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px; + overflow: scroll; + height: calc(100% - ${({ theme }) => theme.gridUnit * 36}px); +`; + +const StyledTable = styled(Table)` + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; +`; + +export const REFRESHING = t('Refreshing columns'); +export const COLUMN_TITLE = t('Table columns'); +export const ALT_LOADING = t('Loading'); + +const pageSizeOptions = ['5', '10', '15', '25']; + +// Define the columns for Table instance +export const tableColumnDefinition: ColumnsType = [ + { + title: 'Column Name', + dataIndex: 'name', + key: 'name', + sorter: (a: ITableColumn, b: ITableColumn) => + alphabeticalSort('name', a, b), + }, + { + title: 'Datatype', + dataIndex: 'type', + key: 'type', + width: '100px', + sorter: (a: ITableColumn, b: ITableColumn) => + alphabeticalSort('type', a, b), + }, +]; + +/** + * Props interface for DatasetPanel + */ +export interface IDatasetPanelProps { + /** + * Name of the database table + */ + tableName?: string | null; + /** + * Array of ITableColumn instances with name and type attributes + */ + columnList: ITableColumn[]; + /** + * Boolean indicating if there is an error state + */ + hasError: boolean; + /** + * Boolean indicating if the component is in a loading state + */ + loading: boolean; +} + +const DatasetPanel = ({ + tableName, + columnList, + loading, + hasError, +}: IDatasetPanelProps) => { + const hasColumns = columnList?.length > 0 ?? false; + + let component; + if (loading) { + component = ( + + + {ALT_LOADING} +
{REFRESHING}
+
+
+ ); + } else if (tableName && hasColumns && !hasError) { + component = ( + <> + {COLUMN_TITLE} + + + + + ); + } else { + component = ( + + ); + } + + return ( + <> + {tableName && ( + + {tableName && ( + + )} + {tableName} + + )} + {component} + + ); +}; + +export default DatasetPanel; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/MessageContent.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/MessageContent.tsx new file mode 100644 index 0000000000000..5d0ef5eda736e --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/MessageContent.tsx @@ -0,0 +1,107 @@ +/** + * 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 from 'react'; +import { t, styled } from '@superset-ui/core'; +import { EmptyStateBig } from 'src/components/EmptyState'; + +const StyledContainer = styled.div` + padding: ${({ theme }) => theme.gridUnit * 8}px + ${({ theme }) => theme.gridUnit * 6}px; + + display: flex; + align-items: center; + justify-content: center; + height: 100%; +`; + +const StyledEmptyStateBig = styled(EmptyStateBig)` + max-width: 50%; + + p { + width: ${({ theme }) => theme.gridUnit * 115}px; + } +`; + +export const SELECT_MESSAGE = t( + 'Datasets can be created from database tables or SQL queries. Select a database table to the left or ', +); +export const CREATE_MESSAGE = t('create dataset from SQL query'); +export const VIEW_DATASET_MESSAGE = t( + ' to open SQL Lab. From there you can save the query as a dataset.', +); + +const renderEmptyDescription = () => ( + <> + {SELECT_MESSAGE} + { + window.location.href = `/superset/sqllab`; + }} + tabIndex={0} + > + {CREATE_MESSAGE} + + {VIEW_DATASET_MESSAGE} + +); + +export const SELECT_TABLE_TITLE = t('Select dataset source'); +export const NO_COLUMNS_TITLE = t('No table columns'); +export const NO_COLUMNS_DESCRIPTION = t( + 'This database table does not contain any data. Please select a different table.', +); +export const ERROR_TITLE = t('An Error Occurred'); +export const ERROR_DESCRIPTION = t( + 'Unable to load columns for the selected table. Please select a different table.', +); + +interface MessageContentProps { + hasError: boolean; + tableName?: string | null; + hasColumns: boolean; +} + +export const MessageContent = (props: MessageContentProps) => { + const { hasError, tableName, hasColumns } = props; + let currentImage: string | undefined = 'empty-dataset.svg'; + let currentTitle = SELECT_TABLE_TITLE; + let currentDescription = renderEmptyDescription(); + if (hasError) { + currentTitle = ERROR_TITLE; + currentDescription = <>{ERROR_DESCRIPTION}; + currentImage = undefined; + } else if (tableName && !hasColumns) { + currentImage = 'no-columns.svg'; + currentTitle = NO_COLUMNS_TITLE; + currentDescription = <>{NO_COLUMNS_DESCRIPTION}; + } + return ( + + + + ); +}; + +export default MessageContent; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/fixtures.ts b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/fixtures.ts new file mode 100644 index 0000000000000..2199190c99067 --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/fixtures.ts @@ -0,0 +1,34 @@ +/** + * 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 { ITableColumn } from './types'; + +export const exampleColumns: ITableColumn[] = [ + { + name: 'name', + type: 'STRING', + }, + { + name: 'height_in_inches', + type: 'NUMBER', + }, + { + name: 'birth_date', + type: 'DATE', + }, +]; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/index.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/index.tsx index d4065ba3596bc..e390c781fd2e7 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/index.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/index.tsx @@ -16,78 +16,112 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; -import { supersetTheme, t, styled } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; -import { EmptyStateBig } from 'src/components/EmptyState'; +import React, { useEffect, useState, useRef } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import DatasetPanel from './DatasetPanel'; +import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types'; -type DatasetPanelProps = { - tableName?: string | null; -}; +/** + * Interface for the getTableMetadata API call + */ +interface IColumnProps { + /** + * Unique id of the database + */ + dbId: number; + /** + * Name of the table + */ + tableName: string; + /** + * Name of the schema + */ + schema: string; +} -const StyledEmptyStateBig = styled(EmptyStateBig)` - p { - width: ${({ theme }) => theme.gridUnit * 115}px; - } -`; +export interface IDatasetPanelWrapperProps { + /** + * Name of the database table + */ + tableName?: string | null; + /** + * Database ID + */ + dbId?: number; + /** + * The selected schema for the database + */ + schema?: string | null; + setHasColumns?: Function; +} -const StyledDatasetPanel = styled.div` - padding: ${({ theme }) => theme.gridUnit * 8}px - ${({ theme }) => theme.gridUnit * 6}px; +const DatasetPanelWrapper = ({ + tableName, + dbId, + schema, + setHasColumns, +}: IDatasetPanelWrapperProps) => { + const [columnList, setColumnList] = useState([]); + const [loading, setLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const tableNameRef = useRef(tableName); - .table-name { - font-size: ${({ theme }) => theme.gridUnit * 6}px; - font-weight: ${({ theme }) => theme.typography.weights.medium}; - padding-bottom: ${({ theme }) => theme.gridUnit * 20}px; - margin: 0; + const getTableMetadata = async (props: IColumnProps) => { + const { dbId, tableName, schema } = props; + setLoading(true); + setHasColumns?.(false); + const path = `/api/v1/database/${dbId}/table/${tableName}/${schema}/`; + try { + const response = await SupersetClient.get({ + endpoint: path, + }); - .anticon:first-of-type { - margin-right: ${({ theme }) => theme.gridUnit * 4}px; + if (isIDatabaseTable(response?.json)) { + const table: IDatabaseTable = response.json as IDatabaseTable; + /** + * The user is able to click other table columns while the http call for last selected table column is made + * This check ensures we process the response that matches the last selected table name and ignore the others + */ + if (table.name === tableNameRef.current) { + setColumnList(table.columns); + setHasColumns?.(table.columns.length > 0); + setHasError(false); + } + } else { + setColumnList([]); + setHasColumns?.(false); + setHasError(true); + // eslint-disable-next-line no-console + console.error( + `The API response from ${path} does not match the IDatabaseTable interface.`, + ); + } + } catch (error) { + setColumnList([]); + setHasColumns?.(false); + setHasError(true); + } finally { + setLoading(false); } + }; - .anticon:nth-of-type(2) { - margin-left: ${({ theme }) => theme.gridUnit * 4}px; + useEffect(() => { + tableNameRef.current = tableName; + if (tableName && schema && dbId) { + getTableMetadata({ tableName, dbId, schema }); } - } - - span { - font-weight: ${({ theme }) => theme.typography.weights.bold}; - } -`; + // getTableMetadata is a const and should not be independency array + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableName, dbId, schema]); -const renderEmptyDescription = () => ( - <> - {t( - 'Datasets can be created from database tables or SQL queries. Select a database table to the left or ', - )} - { - window.location.href = `/superset/sqllab`; - }} - tabIndex={0} - > - {t('create dataset from SQL query')} - - {t(' to open SQL Lab. From there you can save the query as a dataset.')} - -); - -const DatasetPanel = ({ tableName }: DatasetPanelProps) => - tableName ? ( - -
- - {tableName} -
- {t('Table columns')} -
- ) : ( - ); +}; -export default DatasetPanel; +export default DatasetPanelWrapper; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/types.ts b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/types.ts new file mode 100644 index 0000000000000..c2330f3f10a48 --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/DatasetPanel/types.ts @@ -0,0 +1,92 @@ +/** + * 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. + */ + +/** + * Interface for table columns dataset + */ +export interface ITableColumn { + /** + * Name of the column + */ + name: string; + /** + * Datatype of the column + */ + type: string; +} + +/** + * Checks if a given item matches the ITableColumn interface + * @param item Object to check if it matches the ITableColumn interface + * @returns boolean true if matches interface + */ +export const isITableColumn = (item: any): boolean => { + let match = true; + const BASE_ERROR = + 'The object provided to isITableColumn does match the interface.'; + if (typeof item?.name !== 'string') { + match = false; + // eslint-disable-next-line no-console + console.error( + `${BASE_ERROR} The property 'name' is required and must be a string`, + ); + } + if (match && typeof item?.type !== 'string') { + match = false; + // eslint-disable-next-line no-console + console.error( + `${BASE_ERROR} The property 'type' is required and must be a string`, + ); + } + return match; +}; + +export interface IDatabaseTable { + name: string; + columns: ITableColumn[]; +} + +/** + * Checks if a given item matches the isIDatabsetTable interface + * @param item Object to check if it matches the isIDatabsetTable interface + * @returns boolean true if matches interface + */ +export const isIDatabaseTable = (item: any): boolean => { + let match = true; + if (typeof item?.name !== 'string') { + match = false; + } + if (match && !Array.isArray(item.columns)) { + match = false; + } + if (match && item.columns.length > 0) { + const invalid = item.columns.some((column: any, index: number) => { + const valid = isITableColumn(column); + if (!valid) { + // eslint-disable-next-line no-console + console.error( + `The provided object does not match the IDatabaseTable interface. columns[${index}] is invalid and does not match the ITableColumn interface`, + ); + } + return !valid; + }); + match = !invalid; + } + return match; +}; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/Footer.test.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/Footer.test.tsx index 44724ad597870..bb51c4de86447 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/Footer.test.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/Footer.test.tsx @@ -36,6 +36,7 @@ const mockPropsWithDataset = { dataset_name: 'Untitled', table_name: 'real_info', }, + hasColumns: true, }; describe('Footer', () => { diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/index.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/index.tsx index 2148f114cde57..7e08f3b9dfcc1 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/index.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/Footer/index.tsx @@ -36,6 +36,7 @@ interface FooterProps { addDangerToast: () => void; datasetObject?: Partial | null; onDatasetAdd?: (dataset: DatasetObject) => void; + hasColumns?: boolean; } const INPUT_FIELDS = ['db', 'schema', 'table_name']; @@ -46,7 +47,12 @@ const LOG_ACTIONS = [ LOG_ACTIONS_DATASET_CREATION_TABLE_CANCELLATION, ]; -function Footer({ url, datasetObject, addDangerToast }: FooterProps) { +function Footer({ + url, + datasetObject, + addDangerToast, + hasColumns = false, +}: FooterProps) { const { createResource } = useSingleViewResource>( 'dataset', t('dataset'), @@ -107,7 +113,7 @@ function Footer({ url, datasetObject, addDangerToast }: FooterProps) {