From 55aef4db3c85d54064498a0828a55afe97fa2b85 Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Mon, 16 May 2022 15:22:47 -0500 Subject: [PATCH 01/49] fix(chart & alert): make to show metrics properly (#19939) * fix(chart & alert): make to show metrics properly * fix(chart & alert): make to remove duplicate metrics * fix(chart & alert): make to restore metrics control alert slice * fix(chart & alert): make to fix lint issue --- .../src/components/AlteredSliceTag/index.jsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/components/AlteredSliceTag/index.jsx b/superset-frontend/src/components/AlteredSliceTag/index.jsx index 26e72915e6973..ecae44ad0c814 100644 --- a/superset-frontend/src/components/AlteredSliceTag/index.jsx +++ b/superset-frontend/src/components/AlteredSliceTag/index.jsx @@ -109,15 +109,19 @@ export default class AlteredSliceTag extends React.Component { if (controlsMap[key]?.type === 'CollectionControl') { return value.map(v => safeStringify(v)).join(', '); } - if (controlsMap[key]?.type === 'MetricsControl' && Array.isArray(value)) { - const formattedValue = value.map(v => (v.label ? v.label : v)); + if ( + controlsMap[key]?.type === 'MetricsControl' && + value.constructor === Array + ) { + const formattedValue = value.map(v => v?.label ?? v); return formattedValue.length ? formattedValue.join(', ') : '[]'; } if (typeof value === 'boolean') { return value ? 'true' : 'false'; } if (value.constructor === Array) { - return value.length ? value.join(', ') : '[]'; + const formattedValue = value.map(v => v?.label ?? v); + return formattedValue.length ? formattedValue.join(', ') : '[]'; } if (typeof value === 'string' || typeof value === 'number') { return value; From e69f6292c210d32548308769acd8e670630e9ecd Mon Sep 17 00:00:00 2001 From: AAfghahi <48933336+AAfghahi@users.noreply.github.com> Date: Mon, 16 May 2022 17:56:46 -0400 Subject: [PATCH 02/49] chore: Set limit for a query in execute_sql_statement (#20066) * test for query limit * fixed tests --- superset/sqllab/sqllab_execution_context.py | 2 ++ tests/integration_tests/celery_tests.py | 30 ++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/superset/sqllab/sqllab_execution_context.py b/superset/sqllab/sqllab_execution_context.py index b44ec61aaa4e7..f8e9ac64dfa26 100644 --- a/superset/sqllab/sqllab_execution_context.py +++ b/superset/sqllab/sqllab_execution_context.py @@ -159,6 +159,7 @@ def create_query(self) -> Query: start_time=start_time, tab_name=self.tab_name, status=self.status, + limit=self.limit, sql_editor_id=self.sql_editor_id, tmp_table_name=self.create_table_as_select.target_table_name, # type: ignore tmp_schema_name=self.create_table_as_select.target_schema_name, # type: ignore @@ -172,6 +173,7 @@ def create_query(self) -> Query: select_as_cta=False, start_time=start_time, tab_name=self.tab_name, + limit=self.limit, status=self.status, sql_editor_id=self.sql_editor_id, user_id=self.user_id, diff --git a/tests/integration_tests/celery_tests.py b/tests/integration_tests/celery_tests.py index 3d4ba5e901f08..9a9447640f120 100644 --- a/tests/integration_tests/celery_tests.py +++ b/tests/integration_tests/celery_tests.py @@ -135,13 +135,13 @@ def cta_result(ctas_method: CtasMethod): # TODO(bkyryliuk): quote table and schema names for all databases -def get_select_star(table: str, schema: Optional[str] = None): +def get_select_star(table: str, limit: int, schema: Optional[str] = None): if backend() in {"presto", "hive"}: schema = quote_f(schema) table = quote_f(table) if schema: - return f"SELECT *\nFROM {schema}.{table}" - return f"SELECT *\nFROM {table}" + return f"SELECT *\nFROM {schema}.{table}\nLIMIT {limit}" + return f"SELECT *\nFROM {table}\nLIMIT {limit}" @pytest.mark.parametrize("ctas_method", [CtasMethod.TABLE, CtasMethod.VIEW]) @@ -236,8 +236,9 @@ def test_run_sync_query_cta_config(setup_sqllab, ctas_method): f"CREATE {ctas_method} {CTAS_SCHEMA_NAME}.{tmp_table_name} AS \n{QUERY}" == query.executed_sql ) - - assert query.select_sql == get_select_star(tmp_table_name, schema=CTAS_SCHEMA_NAME) + assert query.select_sql == get_select_star( + tmp_table_name, limit=query.limit, schema=CTAS_SCHEMA_NAME + ) results = run_sql(query.select_sql) assert QueryStatus.SUCCESS == results["status"], result @@ -266,7 +267,10 @@ def test_run_async_query_cta_config(setup_sqllab, ctas_method): query = wait_for_success(result) assert QueryStatus.SUCCESS == query.status - assert get_select_star(tmp_table_name, schema=CTAS_SCHEMA_NAME) == query.select_sql + assert ( + get_select_star(tmp_table_name, limit=query.limit, schema=CTAS_SCHEMA_NAME) + == query.select_sql + ) assert ( f"CREATE {ctas_method} {CTAS_SCHEMA_NAME}.{tmp_table_name} AS \n{QUERY}" == query.executed_sql @@ -290,7 +294,7 @@ def test_run_async_cta_query(setup_sqllab, ctas_method): query = wait_for_success(result) assert QueryStatus.SUCCESS == query.status - assert get_select_star(table_name) in query.select_sql + assert get_select_star(table_name, query.limit) in query.select_sql assert f"CREATE {ctas_method} {table_name} AS \n{QUERY}" == query.executed_sql assert QUERY == query.sql @@ -313,14 +317,20 @@ def test_run_async_cta_query_with_lower_limit(setup_sqllab, ctas_method): QUERY, cta=True, ctas_method=ctas_method, async_=True, tmp_table=tmp_table ) query = wait_for_success(result) - assert QueryStatus.SUCCESS == query.status - assert get_select_star(tmp_table) == query.select_sql + sqllite_select_sql = f"SELECT *\nFROM {tmp_table}\nLIMIT {query.limit}\nOFFSET 0" + assert query.select_sql == ( + sqllite_select_sql + if backend() == "sqlite" + else get_select_star(tmp_table, query.limit) + ) + assert f"CREATE {ctas_method} {tmp_table} AS \n{QUERY}" == query.executed_sql assert QUERY == query.sql + assert query.rows == (1 if backend() == "presto" else 0) - assert query.limit is None + assert query.limit == 10000 assert query.select_as_cta assert query.select_as_cta_used From 9cdaa280429ec297db16d56c94fd77b5d2aff107 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Tue, 17 May 2022 14:56:31 +0200 Subject: [PATCH 03/49] Revert "feat(explore): Show confirmation modal if user exits Explore without saving changes (#19993)" (#20092) This reverts commit ca9766c109ae0849748e791405554f54e5d13249. --- .../AlteredSliceTag/AlteredSliceTag.test.jsx | 30 +++++++++++ .../src/components/AlteredSliceTag/index.jsx | 42 ++++++++++++++- .../components/ExploreViewContainer/index.jsx | 45 ++-------------- .../src/explore/exploreUtils/formData.test.ts | 24 +-------- .../src/explore/exploreUtils/formData.ts | 54 +------------------ 5 files changed, 76 insertions(+), 119 deletions(-) diff --git a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx index a460e81939534..7501ce6382a4c 100644 --- a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx +++ b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx @@ -308,4 +308,34 @@ describe('AlteredSliceTag', () => { ).toBe(expected); }); }); + describe('isEqualish', () => { + it('considers null, undefined, {} and [] as equal', () => { + const inst = wrapper.instance(); + expect(inst.isEqualish(null, undefined)).toBe(true); + expect(inst.isEqualish(null, [])).toBe(true); + expect(inst.isEqualish(null, {})).toBe(true); + expect(inst.isEqualish(undefined, {})).toBe(true); + }); + it('considers empty strings are the same as null', () => { + const inst = wrapper.instance(); + expect(inst.isEqualish(undefined, '')).toBe(true); + expect(inst.isEqualish(null, '')).toBe(true); + }); + it('considers deeply equal objects as equal', () => { + const inst = wrapper.instance(); + expect(inst.isEqualish('', '')).toBe(true); + expect(inst.isEqualish({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 })).toBe( + true, + ); + // Out of order + expect(inst.isEqualish({ a: 1, b: 2, c: 3 }, { b: 2, a: 1, c: 3 })).toBe( + true, + ); + + // Actually not equal + expect(inst.isEqualish({ a: 1, b: 2, z: 9 }, { a: 1, b: 2, c: 3 })).toBe( + false, + ); + }); + }); }); diff --git a/superset-frontend/src/components/AlteredSliceTag/index.jsx b/superset-frontend/src/components/AlteredSliceTag/index.jsx index ecae44ad0c814..dd5dfb3c87868 100644 --- a/superset-frontend/src/components/AlteredSliceTag/index.jsx +++ b/superset-frontend/src/components/AlteredSliceTag/index.jsx @@ -20,7 +20,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { isEqual, isEmpty } from 'lodash'; import { styled, t } from '@superset-ui/core'; -import { getFormDataDiffs } from 'src/explore/exploreUtils/formData'; +import { sanitizeFormData } from 'src/explore/exploreUtils/formData'; import getControlsForVizType from 'src/utils/getControlsForVizType'; import { safeStringify } from 'src/utils/safeStringify'; import { Tooltip } from 'src/components/Tooltip'; @@ -44,6 +44,24 @@ const StyledLabel = styled.span` `} `; +function alterForComparison(value) { + // Considering `[]`, `{}`, `null` and `undefined` as identical + // for this purpose + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value === 'object') { + if (Array.isArray(value) && value.length === 0) { + return null; + } + const keys = Object.keys(value); + if (keys && keys.length === 0) { + return null; + } + } + return value; +} + export default class AlteredSliceTag extends React.Component { constructor(props) { super(props); @@ -77,7 +95,27 @@ export default class AlteredSliceTag extends React.Component { getDiffs(props) { // Returns all properties that differ in the // current form data and the saved form data - return getFormDataDiffs(props.origFormData, props.currentFormData); + const ofd = sanitizeFormData(props.origFormData); + const cfd = sanitizeFormData(props.currentFormData); + + const fdKeys = Object.keys(cfd); + const diffs = {}; + fdKeys.forEach(fdKey => { + if (!ofd[fdKey] && !cfd[fdKey]) { + return; + } + if (['filters', 'having', 'having_filters', 'where'].includes(fdKey)) { + return; + } + if (!this.isEqualish(ofd[fdKey], cfd[fdKey])) { + diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] }; + } + }); + return diffs; + } + + isEqualish(val1, val2) { + return isEqual(alterForComparison(val1), alterForComparison(val2)); } formatValue(value, key, controlsMap) { diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 354e95b5ab2da..97e30d335b7a6 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -17,18 +17,12 @@ * under the License. */ /* eslint camelcase: 0 */ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { styled, t, css, useTheme, logging } from '@superset-ui/core'; -import { debounce, pick, isEmpty } from 'lodash'; +import { debounce, pick } from 'lodash'; import { Resizable } from 're-resizable'; import { useChangeEffect } from 'src/hooks/useChangeEffect'; import { usePluginContext } from 'src/components/DynamicPlugins'; @@ -49,11 +43,7 @@ import * as chartActions from 'src/components/Chart/chartAction'; import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources'; import { chartPropShape } from 'src/dashboard/util/propShapes'; import { mergeExtraFormData } from 'src/dashboard/components/nativeFilters/utils'; -import { - getFormDataDiffs, - postFormData, - putFormData, -} from 'src/explore/exploreUtils/formData'; +import { postFormData, putFormData } from 'src/explore/exploreUtils/formData'; import { useTabId } from 'src/hooks/useTabId'; import ExploreChartPanel from '../ExploreChartPanel'; import ConnectedControlPanelsContainer from '../ControlPanelsContainer'; @@ -226,11 +216,6 @@ const updateHistory = debounce( 1000, ); -const handleUnloadEvent = e => { - e.preventDefault(); - e.returnValue = 'Controls changed'; -}; - function ExploreViewContainer(props) { const dynamicPluginContext = usePluginContext(); const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType]; @@ -251,9 +236,6 @@ function ExploreViewContainer(props) { const theme = useTheme(); - const isBeforeUnloadActive = useRef(false); - const initialFormData = useRef(props.form_data); - const defaultSidebarsWidth = { controls_width: 320, datasource_width: 300, @@ -383,27 +365,6 @@ function ExploreViewContainer(props) { }; }, [handleKeydown, previousHandleKeyDown]); - useEffect(() => { - const formDataChanged = !isEmpty( - getFormDataDiffs(initialFormData.current, props.form_data), - ); - if (formDataChanged && !isBeforeUnloadActive.current) { - window.addEventListener('beforeunload', handleUnloadEvent); - isBeforeUnloadActive.current = true; - } - if (!formDataChanged && isBeforeUnloadActive.current) { - window.removeEventListener('beforeunload', handleUnloadEvent); - isBeforeUnloadActive.current = false; - } - }, [props.form_data]); - - // cleanup beforeunload event listener - // we use separate useEffect to call it only on component unmount instead of on every form data change - useEffect( - () => () => window.removeEventListener('beforeunload', handleUnloadEvent), - [], - ); - useEffect(() => { if (wasDynamicPluginLoading && !isDynamicPluginLoading) { // reload the controls now that we actually have the control config diff --git a/superset-frontend/src/explore/exploreUtils/formData.test.ts b/superset-frontend/src/explore/exploreUtils/formData.test.ts index cfe583dd62b27..f14455b51bd8e 100644 --- a/superset-frontend/src/explore/exploreUtils/formData.test.ts +++ b/superset-frontend/src/explore/exploreUtils/formData.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { sanitizeFormData, isEqualish } from './formData'; +import { sanitizeFormData } from './formData'; test('sanitizeFormData removes temporary control values', () => { expect( @@ -26,25 +26,3 @@ test('sanitizeFormData removes temporary control values', () => { }), ).toEqual({ metrics: ['foo', 'bar'] }); }); - -test('isEqualish', () => { - // considers null, undefined, {} and [] as equal - expect(isEqualish(null, undefined)).toBe(true); - expect(isEqualish(null, [])).toBe(true); - expect(isEqualish(null, {})).toBe(true); - expect(isEqualish(undefined, {})).toBe(true); - - // considers empty strings are the same as null - expect(isEqualish(undefined, '')).toBe(true); - expect(isEqualish(null, '')).toBe(true); - - // considers deeply equal objects as equal - expect(isEqualish('', '')).toBe(true); - expect(isEqualish({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 })).toBe(true); - - // Out of order - expect(isEqualish({ a: 1, b: 2, c: 3 }, { b: 2, a: 1, c: 3 })).toBe(true); - - // Actually not equal - expect(isEqualish({ a: 1, b: 2, z: 9 }, { a: 1, b: 2, c: 3 })).toBe(false); -}); diff --git a/superset-frontend/src/explore/exploreUtils/formData.ts b/superset-frontend/src/explore/exploreUtils/formData.ts index eedfb62d8fb8c..9987b5d8cfa76 100644 --- a/superset-frontend/src/explore/exploreUtils/formData.ts +++ b/superset-frontend/src/explore/exploreUtils/formData.ts @@ -16,8 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { isEqual, omit } from 'lodash'; -import { SupersetClient, JsonObject, JsonValue } from '@superset-ui/core'; +import { omit } from 'lodash'; +import { SupersetClient, JsonObject } from '@superset-ui/core'; type Payload = { dataset_id: number; @@ -30,56 +30,6 @@ const TEMPORARY_CONTROLS = ['url_params']; export const sanitizeFormData = (formData: JsonObject): JsonObject => omit(formData, TEMPORARY_CONTROLS); -export const alterForComparison = (value: JsonValue | undefined) => { - // Considering `[]`, `{}`, `null` and `undefined` as identical - // for this purpose - if ( - value === undefined || - value === null || - value === '' || - (Array.isArray(value) && value.length === 0) || - (typeof value === 'object' && Object.keys(value).length === 0) - ) { - return null; - } - if (Array.isArray(value)) { - // omit prototype for comparison of class instances with json objects - return value.map(v => (typeof v === 'object' ? omit(v, ['__proto__']) : v)); - } - if (typeof value === 'object') { - return omit(value, ['__proto__']); - } - return value; -}; - -export const isEqualish = ( - val1: JsonValue | undefined, - val2: JsonValue | undefined, -) => isEqual(alterForComparison(val1), alterForComparison(val2)); - -export const getFormDataDiffs = ( - formData1: JsonObject, - formData2: JsonObject, -) => { - const ofd = sanitizeFormData(formData1); - const cfd = sanitizeFormData(formData2); - - const fdKeys = Object.keys(cfd); - const diffs = {}; - fdKeys.forEach(fdKey => { - if (!ofd[fdKey] && !cfd[fdKey]) { - return; - } - if (['filters', 'having', 'having_filters', 'where'].includes(fdKey)) { - return; - } - if (!isEqualish(ofd[fdKey], cfd[fdKey])) { - diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] }; - } - }); - return diffs; -}; - const assembleEndpoint = (key?: string, tabId?: string) => { let endpoint = 'api/v1/explore/form_data'; if (key) { From 5111011de9de614e68c3c373dc9e938a9df3791f Mon Sep 17 00:00:00 2001 From: Phillip Kelley-Dotson Date: Tue, 17 May 2022 10:04:52 -0700 Subject: [PATCH 04/49] fix: dbmodal test connection error timeout (#20068) * fix: check for connect on ping * clean up merge * fix merge * precommit --- .../databases/commands/test_connection.py | 67 ++++++++++--------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/superset/databases/commands/test_connection.py b/superset/databases/commands/test_connection.py index 7066974128591..2e217ab01a807 100644 --- a/superset/databases/commands/test_connection.py +++ b/superset/databases/commands/test_connection.py @@ -23,6 +23,7 @@ from flask_appbuilder.security.sqla.models import User from flask_babel import gettext as _ from func_timeout import func_timeout, FunctionTimedOut +from sqlalchemy.engine import Engine from sqlalchemy.exc import DBAPIError, NoSuchModuleError from superset.commands.base import BaseCommand @@ -82,36 +83,41 @@ def run(self) -> None: action="test_connection_attempt", engine=database.db_engine_spec.__name__, ) - with closing(engine.raw_connection()) as conn: - try: - alive = func_timeout( - int( - app.config[ - "TEST_DATABASE_CONNECTION_TIMEOUT" - ].total_seconds() - ), - engine.dialect.do_ping, - args=(conn,), - ) - except (sqlite3.ProgrammingError, RuntimeError): - # SQLite can't run on a separate thread, so ``func_timeout`` fails - # RuntimeError catches the equivalent error from duckdb. - alive = engine.dialect.do_ping(conn) - except FunctionTimedOut as ex: - raise SupersetTimeoutException( - error_type=SupersetErrorType.CONNECTION_DATABASE_TIMEOUT, - message=( - "Please check your connection details and database " - "settings, and ensure that your database is accepting " - "connections, then try connecting again." - ), - level=ErrorLevel.ERROR, - extra={"sqlalchemy_uri": database.sqlalchemy_uri}, - ) from ex - except Exception: # pylint: disable=broad-except - alive = False - if not alive: - raise DBAPIError(None, None, None) + + def ping(engine: Engine) -> bool: + with closing(engine.raw_connection()) as conn: + return engine.dialect.do_ping(conn) + + try: + alive = func_timeout( + int( + app.config[ + "TEST_DATABASE_CONNECTION_TIMEOUT" + ].total_seconds() + ), + ping, + args=(engine,), + ) + + except (sqlite3.ProgrammingError, RuntimeError): + # SQLite can't run on a separate thread, so ``func_timeout`` fails + # RuntimeError catches the equivalent error from duckdb. + alive = engine.dialect.do_ping(engine) + except FunctionTimedOut as ex: + raise SupersetTimeoutException( + error_type=SupersetErrorType.CONNECTION_DATABASE_TIMEOUT, + message=( + "Please check your connection details and database settings, " + "and ensure that your database is accepting connections, " + "then try connecting again." + ), + level=ErrorLevel.ERROR, + extra={"sqlalchemy_uri": database.sqlalchemy_uri}, + ) from ex + except Exception: # pylint: disable=broad-except + alive = False + if not alive: + raise DBAPIError(None, None, None) # Log succesful connection test with engine event_logger.log_with_context( @@ -144,6 +150,7 @@ def run(self) -> None: ) raise DatabaseSecurityUnsafeError(message=str(ex)) from ex except SupersetTimeoutException as ex: + event_logger.log_with_context( action=f"test_connection_error.{ex.__class__.__name__}", engine=database.db_engine_spec.__name__, From 62447282561b114b46be9e704c4ae8a7f02b9e34 Mon Sep 17 00:00:00 2001 From: Smart-Codi Date: Tue, 17 May 2022 13:38:54 -0400 Subject: [PATCH 05/49] fix: Add cypress test for report page direct link issue (#20099) * add cypress test for report page direct link issue * add licence --- .../alerts_and_reports/alert_report.helper.ts | 20 +++++++++ .../alerts_and_reports/alerts.test.ts | 44 +++++++++++++++++++ .../alerts_and_reports/reports.test.ts | 44 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alert_report.helper.ts create mode 100644 superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.ts create mode 100644 superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.ts diff --git a/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alert_report.helper.ts b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alert_report.helper.ts new file mode 100644 index 0000000000000..dcf8b2b4a2931 --- /dev/null +++ b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alert_report.helper.ts @@ -0,0 +1,20 @@ +/** + * 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 ALERT_LIST = '/alert/list/'; +export const REPORT_LIST = '/report/list/'; diff --git a/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.ts b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.ts new file mode 100644 index 0000000000000..bc27e831b5afe --- /dev/null +++ b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.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 { ALERT_LIST } from './alert_report.helper'; + +describe('alert list view', () => { + beforeEach(() => { + cy.login(); + }); + + afterEach(() => { + cy.eyesClose(); + }); + + it('should load alert lists', () => { + cy.visit(ALERT_LIST); + + cy.get('[data-test="listview-table"]').should('be.visible'); + // check alert list view header + cy.get('[data-test="sort-header"]').eq(1).contains('Last run'); + cy.get('[data-test="sort-header"]').eq(2).contains('Name'); + cy.get('[data-test="sort-header"]').eq(3).contains('Schedule'); + cy.get('[data-test="sort-header"]').eq(4).contains('Notification method'); + cy.get('[data-test="sort-header"]').eq(5).contains('Created by'); + cy.get('[data-test="sort-header"]').eq(6).contains('Owners'); + cy.get('[data-test="sort-header"]').eq(7).contains('Active'); + cy.get('[data-test="sort-header"]').eq(8).contains('Actions'); + }); +}); diff --git a/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.ts b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.ts new file mode 100644 index 0000000000000..c3007cc1a87d7 --- /dev/null +++ b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.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 { REPORT_LIST } from './alert_report.helper'; + +describe('report list view', () => { + beforeEach(() => { + cy.login(); + }); + + afterEach(() => { + cy.eyesClose(); + }); + + it('should load report lists', () => { + cy.visit(REPORT_LIST); + + cy.get('[data-test="listview-table"]').should('be.visible'); + // check report list view header + cy.get('[data-test="sort-header"]').eq(1).contains('Last run'); + cy.get('[data-test="sort-header"]').eq(2).contains('Name'); + cy.get('[data-test="sort-header"]').eq(3).contains('Schedule'); + cy.get('[data-test="sort-header"]').eq(4).contains('Notification method'); + cy.get('[data-test="sort-header"]').eq(5).contains('Created by'); + cy.get('[data-test="sort-header"]').eq(6).contains('Owners'); + cy.get('[data-test="sort-header"]').eq(7).contains('Active'); + cy.get('[data-test="sort-header"]').eq(8).contains('Actions'); + }); +}); From 1e469026020f50dce81f25ef180c4da1261b6273 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Tue, 17 May 2022 17:15:29 -0400 Subject: [PATCH 06/49] chore: Update aiohttp to 3.8.1 (#20102) --- requirements/base.in | 3 ++- requirements/base.txt | 22 ++++++++++++++-------- requirements/development.txt | 2 -- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/requirements/base.in b/requirements/base.in index 68d7f7ddc9865..debdc6762cbaf 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -20,4 +20,5 @@ pyrsistent>=0.16.1,<0.17 zipp==3.4.1 sasl==0.3.1 wrapt==1.12.1 # required by astroid<2.9 until we bump pylint -aiohttp==3.7.4 +aiohttp==3.8.1 +charset-normalizer==2.0.4 diff --git a/requirements/base.txt b/requirements/base.txt index 5d4f02a35da7b..6b133eec5e02f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:36b61211c8ad8c91cf047ad9212f9489e3eeefda +# SHA1:8c236813f9d0bf56d87d845cbffd6ca3f07c3e14 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -7,17 +7,19 @@ # -e file:. # via -r requirements/base.in -aiohttp==3.7.4 +aiohttp==3.8.1 # via # -r requirements/base.in # slackclient +aiosignal==1.2.0 + # via aiohttp alembic==1.6.5 # via flask-migrate amqp==5.1.0 # via kombu apispec[yaml]==3.3.2 # via flask-appbuilder -async-timeout==3.0.1 +async-timeout==4.0.2 # via aiohttp attrs==21.2.0 # via @@ -39,8 +41,10 @@ celery==5.2.2 # via apache-superset cffi==1.14.6 # via cryptography -chardet==3.0.4 - # via aiohttp +charset-normalizer==2.0.4 + # via + # -r requirements/base.in + # aiohttp click==8.0.4 # via # apache-superset @@ -110,6 +114,10 @@ flask-wtf==0.14.3 # via # apache-superset # flask-appbuilder +frozenlist==1.3.0 + # via + # aiohttp + # aiosignal func-timeout==4.3.5 # via apache-superset geographiclib==1.52 @@ -276,9 +284,7 @@ sqlparse==0.3.0 tabulate==0.8.9 # via apache-superset typing-extensions==3.10.0.0 - # via - # aiohttp - # apache-superset + # via apache-superset urllib3==1.26.6 # via selenium vine==5.0.0 diff --git a/requirements/development.txt b/requirements/development.txt index d4c906e679c14..4702c4b6c3da7 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -18,8 +18,6 @@ cached-property==1.5.2 # via tableschema certifi==2021.10.8 # via requests -charset-normalizer==2.0.4 - # via requests et-xmlfile==1.1.0 # via openpyxl flask-cors==3.0.10 From 63702c48ab77ee73b7e304c92fc74ce02748107e Mon Sep 17 00:00:00 2001 From: Krishna Gopal Date: Tue, 17 May 2022 19:58:53 -0500 Subject: [PATCH 07/49] fix: add primary button loading state to modals (#20018) --- superset-frontend/src/components/Modal/Modal.tsx | 3 +++ .../src/views/CRUD/data/dataset/AddDatasetModal.tsx | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/components/Modal/Modal.tsx b/superset-frontend/src/components/Modal/Modal.tsx index 9e53589d8321a..389982cc22021 100644 --- a/superset-frontend/src/components/Modal/Modal.tsx +++ b/superset-frontend/src/components/Modal/Modal.tsx @@ -34,6 +34,7 @@ export interface ModalProps { className?: string; children: React.ReactNode; disablePrimaryButton?: boolean; + primaryButtonLoading?: boolean; onHide: () => void; onHandledPrimaryAction?: () => void; primaryButtonName?: string; @@ -190,6 +191,7 @@ export const StyledModal = styled(BaseModal)` const CustomModal = ({ children, disablePrimaryButton = false, + primaryButtonLoading = false, onHide, onHandledPrimaryAction, primaryButtonName = t('OK'), @@ -240,6 +242,7 @@ const CustomModal = ({ key="submit" buttonStyle={primaryButtonType} disabled={disablePrimaryButton} + loading={primaryButtonLoading} onClick={onHandledPrimaryAction} cta data-test="modal-confirm-button" diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx index 7e7e7429bddd3..10a3b7bb77e4a 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx @@ -56,7 +56,10 @@ const DatasetModal: FunctionComponent = ({ const [currentSchema, setSchema] = useState(''); const [currentTableName, setTableName] = useState(''); const [disableSave, setDisableSave] = useState(true); - const { createResource } = useSingleViewResource>( + const { + createResource, + state: { loading }, + } = useSingleViewResource>( 'dataset', t('dataset'), addDangerToast, @@ -114,6 +117,7 @@ const DatasetModal: FunctionComponent = ({ return ( Date: Wed, 18 May 2022 08:58:56 +0200 Subject: [PATCH 08/49] Update INTHEWILD.md (#20103) Added `The GRAPH Network` --- RESOURCES/INTHEWILD.md | 1 + 1 file changed, 1 insertion(+) diff --git a/RESOURCES/INTHEWILD.md b/RESOURCES/INTHEWILD.md index 2f8d5a1f3b61d..17f03d32ec93c 100644 --- a/RESOURCES/INTHEWILD.md +++ b/RESOURCES/INTHEWILD.md @@ -123,6 +123,7 @@ Join our growing community! - [Udemy](https://www.udemy.com/) [@sungjuly] - [VIPKID](https://www.vipkid.com.cn/) [@illpanda] - [Sunbird](https://www.sunbird.org/) [@eksteporg] +- [The GRAPH Network](https://thegraphnetwork.org/)[@fccoelho] ### Energy - [Airboxlab](https://foobot.io) [@antoine-galataud] From 1fe30f1f0e511289575d1bbe543c8a93be7cfc8f Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Wed, 18 May 2022 16:26:29 +0200 Subject: [PATCH 09/49] Add tnum property to tables (#20093) --- .../legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx | 4 ++++ .../plugin-chart-pivot-table/src/react-pivottable/Styles.js | 4 ++++ superset-frontend/src/components/Chart/Chart.jsx | 4 ++++ superset-frontend/src/components/TableCollection/index.tsx | 1 + 4 files changed, 13 insertions(+) diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx index 33870b03ead36..670d97e7c8421 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.jsx @@ -59,6 +59,10 @@ const StyledDiv = styled.div` margin-left: ${theme.gridUnit}px; } + .reactable-data tr { + font-feature-settings: 'tnum' 1; + } + .reactable-data tr, .reactable-header-sortable { -webkit-transition: ease-in-out 0.1s; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js index 7ff2c0e5267b3..1360e0dc921ad 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js @@ -36,6 +36,10 @@ export const Styles = styled.div` top: 0; } + table tbody tr { + font-feature-settings: 'tnum' 1; + } + table.pvtTable thead tr th, table.pvtTable tbody tr th { background-color: ${theme.colors.grayscale.light5}; diff --git a/superset-frontend/src/components/Chart/Chart.jsx b/superset-frontend/src/components/Chart/Chart.jsx index 7df33d0c5d7cb..624354f1b65eb 100644 --- a/superset-frontend/src/components/Chart/Chart.jsx +++ b/superset-frontend/src/components/Chart/Chart.jsx @@ -105,6 +105,10 @@ const Styles = styled.div` .slice_container { height: ${p => p.height}px; + + .pivot_table tbody tr { + font-feature-settings: 'tnum' 1; + } } `; diff --git a/superset-frontend/src/components/TableCollection/index.tsx b/superset-frontend/src/components/TableCollection/index.tsx index bb68b773e73cb..ad83f3b9027f2 100644 --- a/superset-frontend/src/components/TableCollection/index.tsx +++ b/superset-frontend/src/components/TableCollection/index.tsx @@ -172,6 +172,7 @@ export const Table = styled.table` } .table-cell { + font-feature-settings: 'tnum' 1; text-overflow: ellipsis; overflow: hidden; max-width: 320px; From 1d410eb763d70dceece7977fd39b42d77b534f65 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Wed, 18 May 2022 19:04:07 +0300 Subject: [PATCH 10/49] chore: fix INTHEWILD sort order and indentation (#20104) --- RESOURCES/INTHEWILD.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/RESOURCES/INTHEWILD.md b/RESOURCES/INTHEWILD.md index 17f03d32ec93c..f9155d00bb326 100644 --- a/RESOURCES/INTHEWILD.md +++ b/RESOURCES/INTHEWILD.md @@ -120,10 +120,10 @@ Join our growing community! ### Education - [Brilliant.org](https://brilliant.org/) -- [Udemy](https://www.udemy.com/) [@sungjuly] -- [VIPKID](https://www.vipkid.com.cn/) [@illpanda] - [Sunbird](https://www.sunbird.org/) [@eksteporg] - [The GRAPH Network](https://thegraphnetwork.org/)[@fccoelho] +- [Udemy](https://www.udemy.com/) [@sungjuly] +- [VIPKID](https://www.vipkid.com.cn/) [@illpanda] ### Energy - [Airboxlab](https://foobot.io) [@antoine-galataud] @@ -144,10 +144,10 @@ Join our growing community! - [Symmetrics](https://www.symmetrics.fyi) ### Others - - [Dropbox](https://www.dropbox.com/) [@bkyryliuk] - - [Grassroot](https://www.grassrootinstitute.org/) - - [komoot](https://www.komoot.com/) [@christophlingg] - - [Let's Roam](https://www.letsroam.com/) - - [Twitter](https://twitter.com/) - - [VLMedia](https://www.vlmedia.com.tr/) [@ibotheperfect] - - [Yahoo!](https://yahoo.com/) +- [Dropbox](https://www.dropbox.com/) [@bkyryliuk] +- [Grassroot](https://www.grassrootinstitute.org/) +- [komoot](https://www.komoot.com/) [@christophlingg] +- [Let's Roam](https://www.letsroam.com/) +- [Twitter](https://twitter.com/) +- [VLMedia](https://www.vlmedia.com.tr/) [@ibotheperfect] +- [Yahoo!](https://yahoo.com/) From 660af409a426806ead2d21fe80bff60c5480c264 Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Wed, 18 May 2022 13:11:14 -0400 Subject: [PATCH 11/49] feat: Save column data into json_metadata for all Query executions (#20059) * add save_metadata function to QueryDAO * use set_extra_json_key * added test * Update queries_test.py * fix pylint * add to session * add to session * refactor * forgot the return --- superset/queries/dao.py | 8 +++ superset/sql_lab.py | 2 +- superset/sqllab/command.py | 25 +++++---- .../sqllab/execution_context_convertor.py | 47 ++++++++-------- superset/views/core.py | 8 +-- tests/unit_tests/dao/queries_test.py | 55 +++++++++++++++++++ 6 files changed, 105 insertions(+), 40 deletions(-) create mode 100644 tests/unit_tests/dao/queries_test.py diff --git a/superset/queries/dao.py b/superset/queries/dao.py index 2f438bdb369ff..c7d59343e8587 100644 --- a/superset/queries/dao.py +++ b/superset/queries/dao.py @@ -16,6 +16,7 @@ # under the License. import logging from datetime import datetime +from typing import Any, Dict from superset.dao.base import BaseDAO from superset.extensions import db @@ -48,3 +49,10 @@ def update_saved_query_exec_info(query_id: int) -> None: saved_query.rows = query.rows saved_query.last_run = datetime.now() db.session.commit() + + @staticmethod + def save_metadata(query: Query, payload: Dict[str, Any]) -> None: + # pull relevant data from payload and store in extra_json + columns = payload.get("columns", {}) + db.session.add(query) + query.set_extra_json_key("columns", columns) diff --git a/superset/sql_lab.py b/superset/sql_lab.py index cd3684b1d8ea8..2eeb2976b4126 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -187,7 +187,7 @@ def get_sql_results( # pylint: disable=too-many-arguments return handle_query_error(ex, query, session) -def execute_sql_statement( # pylint: disable=too-many-arguments,too-many-locals,too-many-statements +def execute_sql_statement( # pylint: disable=too-many-arguments,too-many-statements sql_statement: str, query: Query, session: Session, diff --git a/superset/sqllab/command.py b/superset/sqllab/command.py index ff50e18eda9e5..690a8c4c9ebe5 100644 --- a/superset/sqllab/command.py +++ b/superset/sqllab/command.py @@ -34,6 +34,7 @@ QueryIsForbiddenToAccessException, SqlLabException, ) +from superset.sqllab.execution_context_convertor import ExecutionContextConvertor from superset.sqllab.limiting_factor import LimitingFactor if TYPE_CHECKING: @@ -42,6 +43,7 @@ from superset.sqllab.sql_json_executer import SqlJsonExecutor from superset.sqllab.sqllab_execution_context import SqlJsonExecutionContext + logger = logging.getLogger(__name__) CommandResult = Dict[str, Any] @@ -94,11 +96,19 @@ def run( # pylint: disable=too-many-statements,useless-suppression status = SqlJsonExecutionStatus.QUERY_ALREADY_CREATED else: status = self._run_sql_json_exec_from_scratch() + + self._execution_context_convertor.set_payload( + self._execution_context, status + ) + + # save columns into metadata_json + self._query_dao.save_metadata( + self._execution_context.query, self._execution_context_convertor.payload + ) + return { "status": status, - "payload": self._execution_context_convertor.to_payload( - self._execution_context, status - ), + "payload": self._execution_context_convertor.serialize_payload(), } except (SqlLabException, SupersetErrorsException) as ex: raise ex @@ -209,12 +219,3 @@ def validate(self, query: Query) -> None: class SqlQueryRender: def render(self, execution_context: SqlJsonExecutionContext) -> str: raise NotImplementedError() - - -class ExecutionContextConvertor: - def to_payload( - self, - execution_context: SqlJsonExecutionContext, - execution_status: SqlJsonExecutionStatus, - ) -> str: - raise NotImplementedError() diff --git a/superset/sqllab/execution_context_convertor.py b/superset/sqllab/execution_context_convertor.py index 20eabfa7b3f4d..f49fbd9a31db5 100644 --- a/superset/sqllab/execution_context_convertor.py +++ b/superset/sqllab/execution_context_convertor.py @@ -16,52 +16,53 @@ # under the License. from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import Any, Dict, TYPE_CHECKING import simplejson as json import superset.utils.core as utils -from superset.sqllab.command import ExecutionContextConvertor from superset.sqllab.command_status import SqlJsonExecutionStatus from superset.sqllab.utils import apply_display_max_row_configuration_if_require +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from superset.models.sql_lab import Query from superset.sqllab.sql_json_executer import SqlResults from superset.sqllab.sqllab_execution_context import SqlJsonExecutionContext -class ExecutionContextConvertorImpl(ExecutionContextConvertor): +class ExecutionContextConvertor: _max_row_in_display_configuration: int # pylint: disable=invalid-name + _exc_status: SqlJsonExecutionStatus + payload: Dict[str, Any] def set_max_row_in_display(self, value: int) -> None: self._max_row_in_display_configuration = value # pylint: disable=invalid-name - def to_payload( + def set_payload( self, execution_context: SqlJsonExecutionContext, execution_status: SqlJsonExecutionStatus, - ) -> str: - + ) -> None: + self._exc_status = execution_status if execution_status == SqlJsonExecutionStatus.HAS_RESULTS: - return self._to_payload_results_based( - execution_context.get_execution_result() or {} - ) - return self._to_payload_query_based(execution_context.query) + self.payload = execution_context.get_execution_result() or {} + else: + self.payload = execution_context.query.to_dict() - def _to_payload_results_based(self, execution_result: SqlResults) -> str: - return json.dumps( - apply_display_max_row_configuration_if_require( - execution_result, self._max_row_in_display_configuration - ), - default=utils.pessimistic_json_iso_dttm_ser, - ignore_nan=True, - encoding=None, - ) + def serialize_payload(self) -> str: + if self._exc_status == SqlJsonExecutionStatus.HAS_RESULTS: + return json.dumps( + apply_display_max_row_configuration_if_require( + self.payload, self._max_row_in_display_configuration + ), + default=utils.pessimistic_json_iso_dttm_ser, + ignore_nan=True, + encoding=None, + ) - def _to_payload_query_based( # pylint: disable=no-self-use - self, query: Query - ) -> str: return json.dumps( - {"query": query.to_dict()}, default=utils.json_int_dttm_ser, ignore_nan=True + {"query": self.payload}, default=utils.json_int_dttm_ser, ignore_nan=True ) diff --git a/superset/views/core.py b/superset/views/core.py index fa3437e47b9c0..15ff3b1620e92 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -113,7 +113,7 @@ QueryIsForbiddenToAccessException, SqlLabException, ) -from superset.sqllab.execution_context_convertor import ExecutionContextConvertorImpl +from superset.sqllab.execution_context_convertor import ExecutionContextConvertor from superset.sqllab.limiting_factor import LimitingFactor from superset.sqllab.query_render import SqlQueryRenderImpl from superset.sqllab.sql_json_executer import ( @@ -1331,7 +1331,7 @@ def add_slices( # pylint: disable=no-self-use @has_access_api @event_logger.log_this @expose("/testconn", methods=["POST", "GET"]) - def testconn(self) -> FlaskResponse: # pylint: disable=no-self-use + def testconn(self) -> FlaskResponse: """Tests a sqla connection""" logger.warning( "%s.testconn " @@ -2306,7 +2306,7 @@ def stop_query(self) -> FlaskResponse: @event_logger.log_this @expose("/validate_sql_json/", methods=["POST", "GET"]) def validate_sql_json( - # pylint: disable=too-many-locals,no-self-use + # pylint: disable=too-many-locals self, ) -> FlaskResponse: """Validates that arbitrary sql is acceptable for the given database. @@ -2406,7 +2406,7 @@ def _create_sql_json_command( sql_json_executor = Superset._create_sql_json_executor( execution_context, query_dao ) - execution_context_convertor = ExecutionContextConvertorImpl() + execution_context_convertor = ExecutionContextConvertor() execution_context_convertor.set_max_row_in_display( int(config.get("DISPLAY_MAX_ROW")) # type: ignore ) diff --git a/tests/unit_tests/dao/queries_test.py b/tests/unit_tests/dao/queries_test.py new file mode 100644 index 0000000000000..8df6d2066aaca --- /dev/null +++ b/tests/unit_tests/dao/queries_test.py @@ -0,0 +1,55 @@ +# 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 json +from typing import Iterator + +import pytest +from sqlalchemy.orm.session import Session + + +def test_query_dao_save_metadata(app_context: None, session: Session) -> None: + from superset.models.core import Database + from superset.models.sql_lab import Query + + engine = session.get_bind() + Query.metadata.create_all(engine) # pylint: disable=no-member + + db = Database(database_name="my_database", sqlalchemy_uri="sqlite://") + + query_obj = Query( + client_id="foo", + database=db, + tab_name="test_tab", + sql_editor_id="test_editor_id", + sql="select * from bar", + select_sql="select * from bar", + executed_sql="select * from bar", + limit=100, + select_as_cta=False, + rows=100, + error_message="none", + results_key="abc", + ) + + session.add(db) + session.add(query_obj) + + from superset.queries.dao import QueryDAO + + query = session.query(Query).one() + QueryDAO.save_metadata(query=query, payload={"columns": []}) + assert query.extra.get("columns", None) == [] From 0b3d3dd4caa7f4c31c1ba7229966a40ba0469e85 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Thu, 19 May 2022 13:21:25 +0300 Subject: [PATCH 12/49] fix(generic-chart-axes): set x-axis if unset and ff is enabled (#20107) * fix(generic-chart-axes): set x-axis if unset and ff is enabled * simplify * simplify * continue cleanup * yet more cleanup --- .../src/controlPanel.tsx | 13 +++++----- .../plugin-chart-echarts/src/controls.tsx | 26 +++++++++++++++++-- .../explore/controlUtils/getControlState.ts | 11 +++----- superset-frontend/src/explore/store.js | 9 ++++--- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx b/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx index fd5ab5b5ddf06..11a0b88987883 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx +++ b/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx @@ -19,13 +19,14 @@ import React from 'react'; import { t, validateNonEmpty } from '@superset-ui/core'; import { - formatSelectOptionsForRange, - ColumnOption, columnChoices, + ColumnOption, + ColumnMeta, ControlPanelConfig, + ControlState, + formatSelectOptionsForRange, sections, SelectControlConfig, - ColumnMeta, } from '@superset-ui/chart-controls'; const config: ControlPanelConfig = { @@ -46,10 +47,8 @@ const config: ControlPanelConfig = { choices: columnChoices(state?.datasource), }), // choices is from `mapStateToProps` - default: (control: { choices?: string[] }) => - control.choices && control.choices.length > 0 - ? control.choices[0][0] - : null, + default: (control: ControlState) => + control.choices?.[0]?.[0] || null, validators: [validateNonEmpty], }, }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index df050e6dbb418..eca472388774f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -17,11 +17,18 @@ * under the License. */ import React from 'react'; -import { t, validateNonEmpty } from '@superset-ui/core'; +import { + FeatureFlag, + isFeatureEnabled, + t, + validateNonEmpty, +} from '@superset-ui/core'; import { ControlPanelsContainerProps, + ControlPanelState, ControlSetItem, ControlSetRow, + ControlState, sharedControls, } from '@superset-ui/chart-controls'; import { DEFAULT_LEGEND_FORM_DATA } from './types'; @@ -143,7 +150,22 @@ export const xAxisControl: ControlSetItem = { config: { ...sharedControls.groupby, label: t('X-axis'), - default: null, + default: ( + control: ControlState, + controlPanel: Partial, + ) => { + // default to the chosen time column if x-axis is unset and the + // GENERIC_CHART_AXES feature flag is enabled + const { value } = control; + if (value) { + return value; + } + const timeColumn = controlPanel?.form_data?.granularity_sqla; + if (isFeatureEnabled(FeatureFlag.GENERIC_CHART_AXES) && timeColumn) { + return timeColumn; + } + return null; + }, multi: false, description: t('Dimension to use on x-axis.'), validators: [validateNonEmpty], diff --git a/superset-frontend/src/explore/controlUtils/getControlState.ts b/superset-frontend/src/explore/controlUtils/getControlState.ts index ae0cbf531d179..5014cd3c1a40c 100644 --- a/superset-frontend/src/explore/controlUtils/getControlState.ts +++ b/superset-frontend/src/explore/controlUtils/getControlState.ts @@ -85,7 +85,7 @@ function handleMissingChoice(control: ControlState) { export function applyMapStateToPropsToControl( controlState: ControlState, - controlPanelState: Partial, + controlPanelState: Partial | null, ) { const { mapStateToProps } = controlState; let state = { ...controlState }; @@ -120,7 +120,7 @@ export function applyMapStateToPropsToControl( export function getControlStateFromControlConfig( controlConfig: ControlConfig | null, - controlPanelState: Partial, + controlPanelState: Partial | null, value?: JsonValue, ) { // skip invalid config values @@ -130,10 +130,7 @@ export function getControlStateFromControlConfig( const controlState = { ...controlConfig, value } as ControlState; // only apply mapStateToProps when control states have been initialized // or when explicitly didn't provide control panel state (mostly for testing) - if ( - (controlPanelState && controlPanelState.controls) || - controlPanelState === null - ) { + if (controlPanelState?.controls || controlPanelState === null) { return applyMapStateToPropsToControl(controlState, controlPanelState); } return controlState; @@ -155,7 +152,7 @@ export function getControlState( export function getAllControlsState( vizType: string, datasourceType: DatasourceType, - state: ControlPanelState, + state: ControlPanelState | null, formData: QueryFormData, ) { const controlsState = {}; diff --git a/superset-frontend/src/explore/store.js b/superset-frontend/src/explore/store.js index 16ad2324c0922..80ad75e3e5bdf 100644 --- a/superset-frontend/src/explore/store.js +++ b/superset-frontend/src/explore/store.js @@ -64,9 +64,12 @@ export function getControlsState(state, inputFormData) { export function applyDefaultFormData(inputFormData) { const datasourceType = inputFormData.datasource.split('__')[1]; const vizType = inputFormData.viz_type; - const controlsState = getAllControlsState(vizType, datasourceType, null, { - ...inputFormData, - }); + const controlsState = getAllControlsState( + vizType, + datasourceType, + null, + inputFormData, + ); const controlFormData = getFormDataFromControls(controlsState); const formData = {}; From b2a7fadba951c09fad5867676aaa0470404856df Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Thu, 19 May 2022 12:27:16 +0200 Subject: [PATCH 13/49] feat(dashboard): Add create chart button in dashboard edit mode (#20126) --- .../src/dashboard/components/SliceAdder.jsx | 45 ++++++++++++++++++- .../stylesheets/builder-sidepane.less | 1 + 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/dashboard/components/SliceAdder.jsx b/superset-frontend/src/dashboard/components/SliceAdder.jsx index 8c2fc920e9b20..eeb83d7c56e46 100644 --- a/superset-frontend/src/dashboard/components/SliceAdder.jsx +++ b/superset-frontend/src/dashboard/components/SliceAdder.jsx @@ -21,10 +21,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import { List } from 'react-virtualized'; import { createFilter } from 'react-search-input'; -import { t, styled, isFeatureEnabled, FeatureFlag } from '@superset-ui/core'; +import { + t, + styled, + isFeatureEnabled, + FeatureFlag, + css, +} from '@superset-ui/core'; import { Input } from 'src/components/Input'; import { Select } from 'src/components'; import Loading from 'src/components/Loading'; +import Button from 'src/components/Button'; +import Icons from 'src/components/Icons'; import { CHART_TYPE, NEW_COMPONENT_SOURCE_TYPE, @@ -79,6 +87,7 @@ const Controls = styled.div` display: flex; flex-direction: row; padding: ${({ theme }) => theme.gridUnit * 3}px; + padding-top: ${({ theme }) => theme.gridUnit * 4}px; `; const StyledSelect = styled(Select)` @@ -86,6 +95,28 @@ const StyledSelect = styled(Select)` min-width: 150px; `; +const NewChartButtonContainer = styled.div` + ${({ theme }) => css` + display: flex; + justify-content: flex-end; + padding-right: ${theme.gridUnit * 2}px; + `} +`; + +const NewChartButton = styled(Button)` + ${({ theme }) => css` + height: auto; + & > .anticon + span { + margin-left: 0; + } + & > [role='img']:first-of-type { + margin-right: ${theme.gridUnit}px; + padding-bottom: 1px; + line-height: 0; + } + `} +`; + class SliceAdder extends React.Component { static sortByComparator(attr) { const desc = attr === 'changed_on' ? -1 : 1; @@ -240,6 +271,18 @@ class SliceAdder extends React.Component { MARGIN_BOTTOM; return (
+ + + window.open('/chart/add', '_blank', 'noopener noreferrer') + } + > + + {t('Create new chart')} + + Date: Thu, 19 May 2022 13:51:52 +0300 Subject: [PATCH 14/49] feat(plugin-chart-echarts): add support for generic axis to mixed chart (#20097) * feat(plugin-chart-echarts): add support for generic axis to mixed chart * fix tests + add new tests * address review comments * simplify control panel * fix types and tests --- .../superset-ui-chart-controls/src/types.ts | 8 +- .../test/types.test.ts | 61 ++++---- .../src/MixedTimeseries/buildQuery.ts | 32 +++-- .../src/MixedTimeseries/controlPanel.tsx | 11 +- .../src/MixedTimeseries/index.ts | 25 ++-- .../src/MixedTimeseries/transformProps.ts | 38 ++++- .../src/Timeseries/buildQuery.ts | 6 +- .../src/Timeseries/transformProps.ts | 27 +--- .../plugin-chart-echarts/src/constants.ts | 9 ++ .../plugin-chart-echarts/src/utils/series.ts | 12 ++ .../test/MixedTimeseries/buildQuery.test.ts | 131 +++++++++++++++--- .../controlUtils/getSectionsToRender.ts | 4 +- 12 files changed, 263 insertions(+), 101 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 73985bfc743b9..53e3a198bd7ce 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -341,7 +341,7 @@ export interface ControlPanelSectionConfig { } export interface ControlPanelConfig { - controlPanelSections: ControlPanelSectionConfig[]; + controlPanelSections: (ControlPanelSectionConfig | null)[]; controlOverrides?: ControlOverrides; sectionOverrides?: SectionOverrides; onInit?: (state: ControlStateMapping) => void; @@ -413,3 +413,9 @@ export function isAdhocColumn( ): column is AdhocColumn { return 'label' in column && 'sqlExpression' in column; } + +export function isControlPanelSectionConfig( + section: ControlPanelSectionConfig | null, +): section is ControlPanelSectionConfig { + return section !== null; +} diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/types.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/types.test.ts index bd3197210e72e..5c53fbcf10c60 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/types.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/types.test.ts @@ -18,10 +18,12 @@ */ import { AdhocColumn } from '@superset-ui/core'; import { + ColumnMeta, + ControlPanelSectionConfig, isAdhocColumn, isColumnMeta, + isControlPanelSectionConfig, isSavedExpression, - ColumnMeta, } from '../src'; const ADHOC_COLUMN: AdhocColumn = { @@ -37,37 +39,46 @@ const SAVED_EXPRESSION: ColumnMeta = { column_name: 'Saved expression', expression: 'case when 1 = 1 then 1 else 2 end', }; +const CONTROL_PANEL_SECTION_CONFIG: ControlPanelSectionConfig = { + label: 'My Section', + description: 'My Description', + controlSetRows: [], +}; -describe('isColumnMeta', () => { - it('returns false for AdhocColumn', () => { - expect(isColumnMeta(ADHOC_COLUMN)).toEqual(false); - }); +test('isColumnMeta returns false for AdhocColumn', () => { + expect(isColumnMeta(ADHOC_COLUMN)).toEqual(false); +}); - it('returns true for ColumnMeta', () => { - expect(isColumnMeta(COLUMN_META)).toEqual(true); - }); +test('isColumnMeta returns true for ColumnMeta', () => { + expect(isColumnMeta(COLUMN_META)).toEqual(true); }); -describe('isAdhocColumn', () => { - it('returns true for AdhocColumn', () => { - expect(isAdhocColumn(ADHOC_COLUMN)).toEqual(true); - }); +test('isAdhocColumn returns true for AdhocColumn', () => { + expect(isAdhocColumn(ADHOC_COLUMN)).toEqual(true); +}); - it('returns false for ColumnMeta', () => { - expect(isAdhocColumn(COLUMN_META)).toEqual(false); - }); +test('isAdhocColumn returns false for ColumnMeta', () => { + expect(isAdhocColumn(COLUMN_META)).toEqual(false); }); -describe('isSavedExpression', () => { - it('returns false for AdhocColumn', () => { - expect(isSavedExpression(ADHOC_COLUMN)).toEqual(false); - }); +test('isSavedExpression returns false for AdhocColumn', () => { + expect(isSavedExpression(ADHOC_COLUMN)).toEqual(false); +}); - it('returns false for ColumnMeta without expression', () => { - expect(isSavedExpression(COLUMN_META)).toEqual(false); - }); +test('isSavedExpression returns false for ColumnMeta without expression', () => { + expect(isSavedExpression(COLUMN_META)).toEqual(false); +}); + +test('isSavedExpression returns true for ColumnMeta with expression', () => { + expect(isSavedExpression(SAVED_EXPRESSION)).toEqual(true); +}); + +test('isControlPanelSectionConfig returns true for section', () => { + expect(isControlPanelSectionConfig(CONTROL_PANEL_SECTION_CONFIG)).toEqual( + true, + ); +}); - it('returns true for ColumnMeta with expression', () => { - expect(isSavedExpression(SAVED_EXPRESSION)).toEqual(true); - }); +test('isControlPanelSectionConfig returns true for null value', () => { + expect(isControlPanelSectionConfig(null)).toEqual(false); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts index a8255b7d999fb..ac3a96b2c7376 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts @@ -18,10 +18,12 @@ */ import { buildQueryContext, - QueryFormData, - QueryObject, + DTTM_ALIAS, + ensureIsArray, normalizeOrderBy, PostProcessingPivot, + QueryFormData, + QueryObject, } from '@superset-ui/core'; import { pivotOperator, @@ -39,12 +41,13 @@ import { } from '../utils/formDataSuffix'; export default function buildQuery(formData: QueryFormData) { + const { x_axis: index } = formData; + const is_timeseries = index === DTTM_ALIAS || !index; const baseFormData = { ...formData, - is_timeseries: true, - columns: formData.groupby, - columns_b: formData.groupby_b, + is_timeseries, }; + const formData1 = removeFormDataSuffix(baseFormData, '_b'); const formData2 = retainFormDataSuffix(baseFormData, '_b'); @@ -52,7 +55,9 @@ export default function buildQuery(formData: QueryFormData) { buildQueryContext(fd, baseQueryObject => { const queryObject = { ...baseQueryObject, - is_timeseries: true, + columns: [...ensureIsArray(index), ...ensureIsArray(fd.groupby)], + series_columns: fd.groupby, + is_timeseries, }; const pivotOperatorInRuntime: PostProcessingPivot = isTimeComparison( @@ -60,7 +65,12 @@ export default function buildQuery(formData: QueryFormData) { queryObject, ) ? timeComparePivotOperator(fd, queryObject) - : pivotOperator(fd, queryObject); + : pivotOperator(fd, { + ...queryObject, + columns: fd.groupby, + index, + is_timeseries, + }); const tmpQueryObject = { ...queryObject, @@ -70,9 +80,13 @@ export default function buildQuery(formData: QueryFormData) { rollingWindowOperator(fd, queryObject), timeCompareOperator(fd, queryObject), resampleOperator(fd, queryObject), - renameOperator(fd, queryObject), + renameOperator(fd, { + ...queryObject, + columns: fd.groupby, + is_timeseries, + }), flattenOperator(fd, queryObject), - ], + ].filter(Boolean), } as QueryObject; return [normalizeOrderBy(tmpQueryObject)]; }), diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx index e839637885e41..e39c023661696 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { t } from '@superset-ui/core'; +import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core'; import { cloneDeep } from 'lodash'; import { ControlPanelConfig, @@ -31,7 +31,7 @@ import { import { DEFAULT_FORM_DATA } from './types'; import { EchartsTimeseriesSeriesType } from '../Timeseries/types'; -import { legendSection, richTooltipSection } from '../controls'; +import { legendSection, richTooltipSection, xAxisControl } from '../controls'; const { area, @@ -278,6 +278,13 @@ function createAdvancedAnalyticsSection( const config: ControlPanelConfig = { controlPanelSections: [ sections.legacyTimeseriesTime, + isFeatureEnabled(FeatureFlag.GENERIC_CHART_AXES) + ? { + label: t('Shared query fields'), + expanded: true, + controlSetRows: [[xAxisControl]], + } + : null, createQuerySection(t('Query A'), ''), createAdvancedAnalyticsSection(t('Advanced analytics Query A'), ''), createQuerySection(t('Query B'), '_b'), diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts index 4729a8174c71a..a5bd7ddf95a39 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts @@ -17,19 +17,21 @@ * under the License. */ import { - t, - ChartMetadata, - ChartPlugin, AnnotationType, Behavior, + ChartMetadata, + ChartPlugin, + FeatureFlag, + isFeatureEnabled, + t, } from '@superset-ui/core'; import buildQuery from './buildQuery'; import controlPanel from './controlPanel'; import transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; import { - EchartsMixedTimeseriesProps, EchartsMixedTimeseriesFormData, + EchartsMixedTimeseriesProps, } from './types'; export default class EchartsTimeseriesChartPlugin extends ChartPlugin< @@ -55,16 +57,22 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin< behaviors: [Behavior.INTERACTIVE_CHART], category: t('Evolution'), credits: ['https://echarts.apache.org'], - description: t( - 'Visualize two different time series using the same x-axis time range. Note that each time series can be visualized differently (e.g. 1 using bars and 1 using a line).', - ), + description: isFeatureEnabled(FeatureFlag.GENERIC_CHART_AXES) + ? t( + 'Visualize two different series using the same x-axis. Note that both series can be visualized with a different chart type (e.g. 1 using bars and 1 using a line).', + ) + : t( + 'Visualize two different time series using the same x-axis. Note that each time series can be visualized differently (e.g. 1 using bars and 1 using a line).', + ), supportedAnnotationTypes: [ AnnotationType.Event, AnnotationType.Formula, AnnotationType.Interval, AnnotationType.Timeseries, ], - name: t('Mixed Time-Series'), + name: isFeatureEnabled(FeatureFlag.GENERIC_CHART_AXES) + ? t('Mixed Chart') + : t('Mixed Time-Series'), thumbnail, tags: [ t('Advanced-Analytics'), @@ -73,7 +81,6 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin< t('Experimental'), t('Line'), t('Multi-Variables'), - t('Predictive'), t('Time'), t('Transformable'), ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 5072aedf71411..10dd33ff1ac4e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -21,12 +21,15 @@ import { AnnotationLayer, CategoricalColorNamespace, DataRecordValue, - TimeseriesDataRecord, + DTTM_ALIAS, + GenericDataType, + getColumnLabel, getNumberFormatter, isEventAnnotationLayer, isFormulaAnnotationLayer, isIntervalAnnotationLayer, isTimeseriesAnnotationLayer, + TimeseriesDataRecord, } from '@superset-ui/core'; import { EChartsCoreOption, SeriesOption } from 'echarts'; import { @@ -41,6 +44,8 @@ import { currentSeries, dedupSeries, extractSeries, + getAxisType, + getColtypesMapping, getLegendProps, } from '../utils/series'; import { extractAnnotationLabels } from '../utils/annotation'; @@ -62,7 +67,7 @@ import { transformSeries, transformTimeseriesAnnotation, } from '../Timeseries/transformers'; -import { TIMESERIES_CONSTANTS } from '../constants'; +import { TIMESERIES_CONSTANTS, TIMEGRAIN_TO_TIMESTAMP } from '../constants'; export default function transformProps( chartProps: EchartsMixedTimeseriesProps, @@ -124,24 +129,35 @@ export default function transformProps( groupbyB, emitFilter, emitFilterB, + xAxis: xAxisOrig, xAxisTitle, yAxisTitle, xAxisTitleMargin, yAxisTitleMargin, yAxisTitlePosition, sliceId, + timeGrainSqla, }: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; const colorScale = CategoricalColorNamespace.getScale(colorScheme as string); + + const xAxisCol = + verboseMap[xAxisOrig] || getColumnLabel(xAxisOrig || DTTM_ALIAS); + const rebasedDataA = rebaseForecastDatum(data1, verboseMap); const rawSeriesA = extractSeries(rebasedDataA, { fillNeighborValue: stack ? 0 : undefined, + xAxis: xAxisCol, }); const rebasedDataB = rebaseForecastDatum(data2, verboseMap); const rawSeriesB = extractSeries(rebasedDataB, { fillNeighborValue: stackB ? 0 : undefined, + xAxis: xAxisCol, }); + const dataTypes = getColtypesMapping(queriesData[0]); + const xAxisDataType = dataTypes?.[xAxisCol]; + const xAxisType = getAxisType(xAxisDataType); const series: SeriesOption[] = []; const formatter = getNumberFormatter(contributionMode ? ',.0%' : yAxisFormat); const formatterSecondary = getNumberFormatter( @@ -255,8 +271,14 @@ export default function transformProps( if (max === undefined) max = 1; } - const tooltipTimeFormatter = getTooltipTimeFormatter(tooltipTimeFormat); - const xAxisFormatter = getXAxisFormatter(xAxisTimeFormat); + const tooltipFormatter = + xAxisDataType === GenericDataType.TEMPORAL + ? getTooltipTimeFormatter(tooltipTimeFormat) + : String; + const xAxisFormatter = + xAxisDataType === GenericDataType.TEMPORAL + ? getXAxisFormatter(xAxisTimeFormat) + : String; const addYAxisTitleOffset = !!(yAxisTitle || yAxisTitleSecondary); const addXAxisTitleOffset = !!xAxisTitle; @@ -298,7 +320,7 @@ export default function transformProps( ...chartPadding, }, xAxis: { - type: 'time', + type: xAxisType, name: xAxisTitle, nameGap: convertInteger(xAxisTitleMargin), nameLocation: 'middle', @@ -306,6 +328,10 @@ export default function transformProps( formatter: xAxisFormatter, rotate: xAxisLabelRotation, }, + minInterval: + xAxisType === 'time' && timeGrainSqla + ? TIMEGRAIN_TO_TIMESTAMP[timeGrainSqla] + : 0, }, yAxis: [ { @@ -350,7 +376,7 @@ export default function transformProps( forecastValue.sort((a, b) => b.data[1] - a.data[1]); } - const rows: Array = [`${tooltipTimeFormatter(xValue)}`]; + const rows: Array = [`${tooltipFormatter(xValue)}`]; const forecastValues = extractForecastValuesFromTooltipParams(forecastValue); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts index 3c2d66175bc21..96c1d6e8e7b56 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts @@ -20,9 +20,9 @@ import { buildQueryContext, DTTM_ALIAS, ensureIsArray, - QueryFormData, normalizeOrderBy, PostProcessingPivot, + QueryFormData, } from '@superset-ui/core'; import { rollingWindowOperator, @@ -94,13 +94,13 @@ export default function buildQuery(formData: QueryFormData) { resampleOperator(formData, baseQueryObject), renameOperator(formData, { ...baseQueryObject, - ...{ is_timeseries }, + is_timeseries, }), contributionOperator(formData, baseQueryObject), flattenOperator(formData, baseQueryObject), // todo: move prophet before flatten prophetOperator(formData, baseQueryObject), - ].filter(op => op), + ].filter(Boolean), }, ]; }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 85fa656f2ed01..87396c0015d7a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -29,7 +29,6 @@ import { isFormulaAnnotationLayer, isIntervalAnnotationLayer, isTimeseriesAnnotationLayer, - TimeGranularity, TimeseriesChartDataResponseResult, } from '@superset-ui/core'; import { EChartsCoreOption, SeriesOption } from 'echarts'; @@ -47,6 +46,7 @@ import { currentSeries, dedupSeries, extractSeries, + getAxisType, getColtypesMapping, getLegendProps, } from '../utils/series'; @@ -70,15 +70,7 @@ import { transformSeries, transformTimeseriesAnnotation, } from './transformers'; -import { TIMESERIES_CONSTANTS } from '../constants'; - -const TimeGrainToTimestamp = { - [TimeGranularity.HOUR]: 3600 * 1000, - [TimeGranularity.DAY]: 3600 * 1000 * 24, - [TimeGranularity.MONTH]: 3600 * 1000 * 24 * 31, - [TimeGranularity.QUARTER]: 3600 * 1000 * 24 * 31 * 3, - [TimeGranularity.YEAR]: 3600 * 1000 * 24 * 31 * 12, -}; +import { TIMESERIES_CONSTANTS, TIMEGRAIN_TO_TIMESTAMP } from '../constants'; export default function transformProps( chartProps: EchartsTimeseriesChartProps, @@ -157,18 +149,7 @@ export default function transformProps( Object.values(rawSeries).map(series => series.name as string), ); const xAxisDataType = dataTypes?.[xAxisCol]; - let xAxisType: 'time' | 'value' | 'category'; - switch (xAxisDataType) { - case GenericDataType.TEMPORAL: - xAxisType = 'time'; - break; - case GenericDataType.NUMERIC: - xAxisType = 'value'; - break; - default: - xAxisType = 'category'; - break; - } + const xAxisType = getAxisType(xAxisDataType); const series: SeriesOption[] = []; const formatter = getNumberFormatter(contributionMode ? ',.0%' : yAxisFormat); @@ -342,7 +323,7 @@ export default function transformProps( }, minInterval: xAxisType === 'time' && timeGrainSqla - ? TimeGrainToTimestamp[timeGrainSqla] + ? TIMEGRAIN_TO_TIMESTAMP[timeGrainSqla] : 0, }; let yAxis: any = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts index b2f3a28a66567..deef2f2e8c6f5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts @@ -17,6 +17,7 @@ * under the License. */ +import { TimeGranularity } from '@superset-ui/core'; import { LabelPositionEnum } from './types'; // eslint-disable-next-line import/prefer-default-export @@ -59,3 +60,11 @@ export enum OpacityEnum { SemiTransparent = 0.3, NonTransparent = 1, } + +export const TIMEGRAIN_TO_TIMESTAMP = { + [TimeGranularity.HOUR]: 3600 * 1000, + [TimeGranularity.DAY]: 3600 * 1000 * 24, + [TimeGranularity.MONTH]: 3600 * 1000 * 24 * 31, + [TimeGranularity.QUARTER]: 3600 * 1000 * 24 * 31 * 3, + [TimeGranularity.YEAR]: 3600 * 1000 * 24 * 31 * 12, +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index 4da3681b7e231..fa8a23138cfd8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -239,3 +239,15 @@ export const currentSeries = { name: '', legend: '', }; + +export function getAxisType( + dataType?: GenericDataType, +): 'time' | 'value' | 'category' { + if (dataType === GenericDataType.TEMPORAL) { + return 'time'; + } + if (dataType === GenericDataType.NUMERIC) { + return 'value'; + } + return 'category'; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts index 72f16482cb771..eb95a4f71df1e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/buildQuery.test.ts @@ -84,8 +84,8 @@ const formDataMixedChartWithAA = { }; test('should compile query object A', () => { - const query_a = buildQuery(formDataMixedChart).queries[0]; - expect(query_a).toEqual({ + const query = buildQuery(formDataMixedChart).queries[0]; + expect(query).toEqual({ time_range: '1980 : 2000', since: undefined, until: undefined, @@ -103,7 +103,7 @@ test('should compile query object A', () => { annotation_layers: [], row_limit: 10, row_offset: undefined, - series_columns: undefined, + series_columns: ['foo'], series_limit: undefined, series_limit_metric: undefined, timeseries_limit: 5, @@ -128,9 +128,6 @@ test('should compile query object A', () => { reset_index: false, }, }, - undefined, - undefined, - undefined, { operation: 'rename', options: { @@ -150,8 +147,8 @@ test('should compile query object A', () => { }); test('should compile query object B', () => { - const query_a = buildQuery(formDataMixedChart).queries[1]; - expect(query_a).toEqual({ + const query = buildQuery(formDataMixedChart).queries[1]; + expect(query).toEqual({ time_range: '1980 : 2000', since: undefined, until: undefined, @@ -169,7 +166,7 @@ test('should compile query object B', () => { annotation_layers: [], row_limit: 100, row_offset: undefined, - series_columns: undefined, + series_columns: [], series_limit: undefined, series_limit_metric: undefined, timeseries_limit: 0, @@ -194,10 +191,6 @@ test('should compile query object B', () => { reset_index: false, }, }, - undefined, - undefined, - undefined, - undefined, { operation: 'flatten', }, @@ -207,14 +200,14 @@ test('should compile query object B', () => { }); test('should compile AA in query A', () => { - const query_a = buildQuery(formDataMixedChartWithAA).queries[0]; + const query = buildQuery(formDataMixedChartWithAA).queries[0]; // time comparison - expect(query_a?.time_offsets).toEqual(['1 years ago']); + expect(query.time_offsets).toEqual(['1 years ago']); // cumsum expect( // prettier-ignore - query_a + query .post_processing ?.find(operator => operator?.operation === 'cum') ?.operation, @@ -223,7 +216,7 @@ test('should compile AA in query A', () => { // resample expect( // prettier-ignore - query_a + query .post_processing ?.find(operator => operator?.operation === 'resample'), ).toEqual({ @@ -237,14 +230,14 @@ test('should compile AA in query A', () => { }); test('should compile AA in query B', () => { - const query_b = buildQuery(formDataMixedChartWithAA).queries[1]; + const query = buildQuery(formDataMixedChartWithAA).queries[1]; // time comparison - expect(query_b?.time_offsets).toEqual(['3 years ago']); + expect(query.time_offsets).toEqual(['3 years ago']); // rolling total expect( // prettier-ignore - query_b + query .post_processing ?.find(operator => operator?.operation === 'rolling'), ).toEqual({ @@ -263,7 +256,7 @@ test('should compile AA in query B', () => { // resample expect( // prettier-ignore - query_b + query .post_processing ?.find(operator => operator?.operation === 'resample'), ).toEqual({ @@ -275,3 +268,99 @@ test('should compile AA in query B', () => { }, }); }); + +test('should compile query objects with x-axis', () => { + const { queries } = buildQuery({ + ...formDataMixedChart, + x_axis: 'my_index', + }); + expect(queries[0]).toEqual({ + time_range: '1980 : 2000', + since: undefined, + until: undefined, + granularity: 'ds', + filters: [], + extras: { + having: '', + having_druid: [], + time_grain_sqla: 'P1W', + where: "(foo in ('a', 'b'))", + }, + applied_time_extras: {}, + columns: ['my_index', 'foo'], + metrics: ['sum(sales)'], + annotation_layers: [], + row_limit: 10, + row_offset: undefined, + series_columns: ['foo'], + series_limit: undefined, + series_limit_metric: undefined, + timeseries_limit: 5, + url_params: {}, + custom_params: {}, + custom_form_data: {}, + is_timeseries: false, + time_offsets: [], + post_processing: [ + { + operation: 'pivot', + options: { + aggregates: { + 'sum(sales)': { + operator: 'mean', + }, + }, + columns: ['foo'], + drop_missing_columns: false, + flatten_columns: false, + index: ['my_index'], + reset_index: false, + }, + }, + { + operation: 'rename', + options: { + columns: { + 'sum(sales)': null, + }, + inplace: true, + level: 0, + }, + }, + { + operation: 'flatten', + }, + ], + orderby: [['count', false]], + }); + + // check the main props on the second query + expect(queries[1]).toEqual( + expect.objectContaining({ + is_timeseries: false, + columns: ['my_index'], + series_columns: [], + metrics: ['count'], + post_processing: [ + { + operation: 'pivot', + options: { + aggregates: { + count: { + operator: 'mean', + }, + }, + columns: [], + drop_missing_columns: false, + flatten_columns: false, + index: ['my_index'], + reset_index: false, + }, + }, + { + operation: 'flatten', + }, + ], + }), + ); +}); diff --git a/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts b/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts index 244114fc40913..c82833f4705b1 100644 --- a/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts +++ b/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts @@ -24,6 +24,7 @@ import { import { ControlPanelConfig, expandControlConfig, + isControlPanelSectionConfig, } from '@superset-ui/chart-controls'; import * as SECTIONS from 'src/explore/controlPanels/sections'; @@ -60,8 +61,7 @@ const getMemoizedSectionsToRender = memoizeOne( : ['granularity_sqla', 'time_grain_sqla']; return [datasourceAndVizType] - .concat(controlPanelSections) - .filter(section => !!section) + .concat(controlPanelSections.filter(isControlPanelSectionConfig)) .map(section => { const { controlSetRows } = section; return { From e2f11d3680a8f8a0fba7746b13551e49cdca7fd6 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Thu, 19 May 2022 20:42:31 +0300 Subject: [PATCH 15/49] fix(presto,trino): use correct literal dttm separator (#20123) * fix(presto,trino): use correct literal dttm separator * remove redundant tests --- superset/db_engine_specs/presto.py | 2 +- superset/db_engine_specs/trino.py | 2 +- .../db_engine_specs/presto_tests.py | 13 ------------- .../db_engine_specs/trino_tests.py | 13 ------------- tests/unit_tests/db_engine_specs/test_presto.py | 6 +++--- tests/unit_tests/db_engine_specs/test_trino.py | 6 +++--- 6 files changed, 8 insertions(+), 34 deletions(-) diff --git a/superset/db_engine_specs/presto.py b/superset/db_engine_specs/presto.py index 8fa4d2794a318..9a72b76081658 100644 --- a/superset/db_engine_specs/presto.py +++ b/superset/db_engine_specs/presto.py @@ -761,7 +761,7 @@ def convert_dttm( utils.TemporalType.TIMESTAMP, utils.TemporalType.TIMESTAMP_WITH_TIME_ZONE, ): - return f"""TIMESTAMP '{dttm.isoformat(timespec="microseconds")}'""" + return f"""TIMESTAMP '{dttm.isoformat(timespec="microseconds", sep=" ")}'""" return None @classmethod diff --git a/superset/db_engine_specs/trino.py b/superset/db_engine_specs/trino.py index f9d06bb9c6a70..9941c57d985b6 100644 --- a/superset/db_engine_specs/trino.py +++ b/superset/db_engine_specs/trino.py @@ -76,7 +76,7 @@ def convert_dttm( utils.TemporalType.TIMESTAMP, utils.TemporalType.TIMESTAMP_WITH_TIME_ZONE, ): - return f"""TIMESTAMP '{dttm.isoformat(timespec="microseconds")}'""" + return f"""TIMESTAMP '{dttm.isoformat(timespec="microseconds", sep=" ")}'""" return None @classmethod diff --git a/tests/integration_tests/db_engine_specs/presto_tests.py b/tests/integration_tests/db_engine_specs/presto_tests.py index acb9922d45632..954f8d660a972 100644 --- a/tests/integration_tests/db_engine_specs/presto_tests.py +++ b/tests/integration_tests/db_engine_specs/presto_tests.py @@ -512,19 +512,6 @@ def test_presto_where_latest_partition(self): query_result = str(result.compile(compile_kwargs={"literal_binds": True})) self.assertEqual("SELECT \nWHERE ds = '01-01-19' AND hour = 1", query_result) - def test_convert_dttm(self): - dttm = self.get_dttm() - - self.assertEqual( - PrestoEngineSpec.convert_dttm("DATE", dttm), - "DATE '2019-01-02'", - ) - - self.assertEqual( - PrestoEngineSpec.convert_dttm("TIMESTAMP", dttm), - "TIMESTAMP '2019-01-02T03:04:05.678900'", - ) - def test_query_cost_formatter(self): raw_cost = [ { diff --git a/tests/integration_tests/db_engine_specs/trino_tests.py b/tests/integration_tests/db_engine_specs/trino_tests.py index 18f00b8988c4d..b2235ae0c726c 100644 --- a/tests/integration_tests/db_engine_specs/trino_tests.py +++ b/tests/integration_tests/db_engine_specs/trino_tests.py @@ -27,19 +27,6 @@ class TestTrinoDbEngineSpec(TestDbEngineSpec): - def test_convert_dttm(self): - dttm = self.get_dttm() - - self.assertEqual( - TrinoEngineSpec.convert_dttm("DATE", dttm), - "DATE '2019-01-02'", - ) - - self.assertEqual( - TrinoEngineSpec.convert_dttm("TIMESTAMP", dttm), - "TIMESTAMP '2019-01-02T03:04:05.678900'", - ) - def test_adjust_database_uri(self): url = URL(drivername="trino", database="hive") TrinoEngineSpec.adjust_database_uri(url, selected_schema="foobar") diff --git a/tests/unit_tests/db_engine_specs/test_presto.py b/tests/unit_tests/db_engine_specs/test_presto.py index 5bb327ff4ebc3..512d03096b0b9 100644 --- a/tests/unit_tests/db_engine_specs/test_presto.py +++ b/tests/unit_tests/db_engine_specs/test_presto.py @@ -30,17 +30,17 @@ ( "TIMESTAMP", datetime(2022, 1, 1, 1, 23, 45, 600000), - "TIMESTAMP '2022-01-01T01:23:45.600000'", + "TIMESTAMP '2022-01-01 01:23:45.600000'", ), ( "TIMESTAMP WITH TIME ZONE", datetime(2022, 1, 1, 1, 23, 45, 600000), - "TIMESTAMP '2022-01-01T01:23:45.600000'", + "TIMESTAMP '2022-01-01 01:23:45.600000'", ), ( "TIMESTAMP WITH TIME ZONE", datetime(2022, 1, 1, 1, 23, 45, 600000, tzinfo=pytz.UTC), - "TIMESTAMP '2022-01-01T01:23:45.600000+00:00'", + "TIMESTAMP '2022-01-01 01:23:45.600000+00:00'", ), ], ) diff --git a/tests/unit_tests/db_engine_specs/test_trino.py b/tests/unit_tests/db_engine_specs/test_trino.py index 1e800f3ad6db6..692fe875da0ef 100644 --- a/tests/unit_tests/db_engine_specs/test_trino.py +++ b/tests/unit_tests/db_engine_specs/test_trino.py @@ -30,17 +30,17 @@ ( "TIMESTAMP", datetime(2022, 1, 1, 1, 23, 45, 600000), - "TIMESTAMP '2022-01-01T01:23:45.600000'", + "TIMESTAMP '2022-01-01 01:23:45.600000'", ), ( "TIMESTAMP WITH TIME ZONE", datetime(2022, 1, 1, 1, 23, 45, 600000), - "TIMESTAMP '2022-01-01T01:23:45.600000'", + "TIMESTAMP '2022-01-01 01:23:45.600000'", ), ( "TIMESTAMP WITH TIME ZONE", datetime(2022, 1, 1, 1, 23, 45, 600000, tzinfo=pytz.UTC), - "TIMESTAMP '2022-01-01T01:23:45.600000+00:00'", + "TIMESTAMP '2022-01-01 01:23:45.600000+00:00'", ), ], ) From b9a98aae79705b4db2dab94f1a5fafcf8b821a8b Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Fri, 20 May 2022 09:40:10 +0100 Subject: [PATCH 16/49] fix: None dataset and schema permissions (#20108) * fix: None dataset and schema permissions * fix pylint * add migration and test * fix migration --- superset/connectors/sqla/models.py | 8 + superset/exceptions.py | 6 + .../e786798587de_delete_none_permissions.py | 145 ++++++++++++++++++ superset/security/manager.py | 20 ++- tests/integration_tests/security_tests.py | 29 ++++ 5 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 superset/migrations/versions/e786798587de_delete_none_permissions.py diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 6dde259f9830c..60eff5e6304ab 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -88,6 +88,7 @@ from superset.db_engine_specs.base import BaseEngineSpec, CTE_ALIAS, TimestampExpression from superset.exceptions import ( AdvancedDataTypeResponseError, + DatasetInvalidPermissionEvaluationException, QueryClauseValidationException, QueryObjectValidationError, ) @@ -785,6 +786,13 @@ def get_schema_perm(self) -> Optional[str]: return security_manager.get_schema_perm(self.database, self.schema) def get_perm(self) -> str: + """ + Return this dataset permission name + :return: dataset permission name + :raises DatasetInvalidPermissionEvaluationException: When database is missing + """ + if self.database is None: + raise DatasetInvalidPermissionEvaluationException() return f"[{self.database}].[{self.table_name}](id:{self.id})" @property diff --git a/superset/exceptions.py b/superset/exceptions.py index 758a026a21176..07bedfa2db568 100644 --- a/superset/exceptions.py +++ b/superset/exceptions.py @@ -216,6 +216,12 @@ class DashboardImportException(SupersetException): pass +class DatasetInvalidPermissionEvaluationException(SupersetException): + """ + When a dataset can't compute its permission name + """ + + class SerializationError(SupersetException): pass diff --git a/superset/migrations/versions/e786798587de_delete_none_permissions.py b/superset/migrations/versions/e786798587de_delete_none_permissions.py new file mode 100644 index 0000000000000..e79c7a4370b7b --- /dev/null +++ b/superset/migrations/versions/e786798587de_delete_none_permissions.py @@ -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. +"""Delete None permissions + +Revision ID: e786798587de +Revises: 6f139c533bea +Create Date: 2022-05-18 16:07:47.648514 + +""" + +# revision identifiers, used by Alembic. +revision = "e786798587de" +down_revision = "6f139c533bea" + +from alembic import op +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + Sequence, + String, + Table, + UniqueConstraint, +) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, Session + +Base = declarative_base() + + +class Permission(Base): + __tablename__ = "ab_permission" + id = Column(Integer, Sequence("ab_permission_id_seq"), primary_key=True) + name = Column(String(100), unique=True, nullable=False) + + def __repr__(self): + return self.name + + +class ViewMenu(Base): + __tablename__ = "ab_view_menu" + id = Column(Integer, Sequence("ab_view_menu_id_seq"), primary_key=True) + name = Column(String(250), unique=True, nullable=False) + + def __repr__(self) -> str: + return self.name + + +assoc_permissionview_role = Table( + "ab_permission_view_role", + Base.metadata, + Column("id", Integer, Sequence("ab_permission_view_role_id_seq"), primary_key=True), + Column("permission_view_id", Integer, ForeignKey("ab_permission_view.id")), + Column("role_id", Integer, ForeignKey("ab_role.id")), + UniqueConstraint("permission_view_id", "role_id"), +) + + +class Role(Base): + __tablename__ = "ab_role" + + id = Column(Integer, Sequence("ab_role_id_seq"), primary_key=True) + name = Column(String(64), unique=True, nullable=False) + permissions = relationship( + "PermissionView", secondary=assoc_permissionview_role, backref="role" + ) + + def __repr__(self) -> str: + return f"{self.name}" + + +class PermissionView(Base): + __tablename__ = "ab_permission_view" + __table_args__ = (UniqueConstraint("permission_id", "view_menu_id"),) + id = Column(Integer, Sequence("ab_permission_view_id_seq"), primary_key=True) + permission_id = Column(Integer, ForeignKey("ab_permission.id")) + permission = relationship("Permission") + view_menu_id = Column(Integer, ForeignKey("ab_view_menu.id")) + view_menu = relationship("ViewMenu") + + def __repr__(self) -> str: + return f"{self.permission.name} on {self.view_menu.name}" + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + session = Session(bind=bind) + + pvms = ( + session.query(PermissionView) + .join(ViewMenu) + .join(Permission) + .filter( + Permission.name.in_(("datasource_access", "schema_access")), + ViewMenu.name.like("[None].%"), + ) + .all() + ) + + roles = ( + session.query(Role) + .outerjoin(Role.permissions) + .join(ViewMenu) + .join(Permission) + .filter( + Permission.name.in_(("datasource_access", "schema_access")), + ViewMenu.name.like("[None].%"), + ) + .all() + ) + + for pvm in pvms: + for role in roles: + if pvm in role.permissions: + print( + f"Going to delete a data access permission [{pvm}] on Role [{role}]" + ) + role.permissions.remove(pvm) + print(f"Going to delete a data access permission [{pvm}]") + session.delete(pvm) + session.delete(pvm.view_menu) + session.commit() + session.close() + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + ... + # ### end Alembic commands ### diff --git a/superset/security/manager.py b/superset/security/manager.py index 44c53329ade68..b231b93b48655 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -64,7 +64,10 @@ from superset.connectors.connector_registry import ConnectorRegistry from superset.constants import RouteMethod from superset.errors import ErrorLevel, SupersetError, SupersetErrorType -from superset.exceptions import SupersetSecurityException +from superset.exceptions import ( + DatasetInvalidPermissionEvaluationException, + SupersetSecurityException, +) from superset.security.guest_token import ( GuestToken, GuestTokenResources, @@ -934,14 +937,19 @@ def set_perm( # pylint: disable=unused-argument :param connection: The DB-API connection :param target: The mapped instance being persisted """ + try: + target_get_perm = target.get_perm() + except DatasetInvalidPermissionEvaluationException: + logger.warning("Dataset has no database refusing to set permission") + return link_table = target.__table__ - if target.perm != target.get_perm(): + if target.perm != target_get_perm: connection.execute( link_table.update() .where(link_table.c.id == target.id) - .values(perm=target.get_perm()) + .values(perm=target_get_perm) ) - target.perm = target.get_perm() + target.perm = target_get_perm if ( hasattr(target, "schema_perm") @@ -956,9 +964,9 @@ def set_perm( # pylint: disable=unused-argument pvm_names = [] if target.__tablename__ in {"dbs", "clusters"}: - pvm_names.append(("database_access", target.get_perm())) + pvm_names.append(("database_access", target_get_perm)) else: - pvm_names.append(("datasource_access", target.get_perm())) + pvm_names.append(("datasource_access", target_get_perm)) if target.schema: pvm_names.append(("schema_access", target.get_schema_perm())) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 1b6d1318db804..c44335552b012 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -272,6 +272,35 @@ def test_set_perm_sqla_table(self): session.delete(stored_table) session.commit() + def test_set_perm_sqla_table_none(self): + session = db.session + table = SqlaTable( + schema="tmp_schema", + table_name="tmp_perm_table", + # Setting database_id instead of database will skip permission creation + database_id=get_example_database().id, + ) + session.add(table) + session.commit() + + stored_table = ( + session.query(SqlaTable).filter_by(table_name="tmp_perm_table").one() + ) + # Assert no permission is created + self.assertIsNone( + security_manager.find_permission_view_menu( + "datasource_access", stored_table.perm + ) + ) + # Assert no bogus permission is created + self.assertIsNone( + security_manager.find_permission_view_menu( + "datasource_access", f"[None].[tmp_perm_table](id:{stored_table.id})" + ) + ) + session.delete(table) + session.commit() + def test_set_perm_database(self): session = db.session database = Database(database_name="tmp_database", sqlalchemy_uri="sqlite://") From d8117f7e377a2c231ea3fb17fb3b4f96408b58fe Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Fri, 20 May 2022 10:08:27 +0100 Subject: [PATCH 17/49] fix: advanced data type API spec and permission name (#20128) * fix: advanced data type API spec and permission name * fix openAPI spec * fix query schema * fix query schema * fix query schema --- superset/advanced_data_type/api.py | 44 ++++++++++---------------- superset/advanced_data_type/schemas.py | 28 ++++++++++++---- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/superset/advanced_data_type/api.py b/superset/advanced_data_type/api.py index 9df46c1322430..0fd3375ca0d30 100644 --- a/superset/advanced_data_type/api.py +++ b/superset/advanced_data_type/api.py @@ -21,7 +21,10 @@ from flask_appbuilder.api import BaseApi, expose, permission_name, protect, rison, safe from flask_babel import lazy_gettext as _ -from superset.advanced_data_type.schemas import advanced_data_type_convert_schema +from superset.advanced_data_type.schemas import ( + advanced_data_type_convert_schema, + AdvancedDataTypeSchema, +) from superset.advanced_data_type.types import AdvancedDataTypeResponse from superset.extensions import event_logger @@ -40,26 +43,28 @@ class AdvancedDataTypeRestApi(BaseApi): allow_browser_login = True include_route_methods = {"get", "get_types"} resource_name = "advanced_data_type" + class_permission_name = "AdvancedDataType" openapi_spec_tag = "Advanced Data Type" apispec_parameter_schemas = { "advanced_data_type_convert_schema": advanced_data_type_convert_schema, } + openapi_spec_component_schemas = (AdvancedDataTypeSchema,) @protect() @safe @expose("/convert", methods=["GET"]) - @permission_name("get") + @permission_name("read") @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", log_to_statsd=False, # pylint: disable-arguments-renamed ) - @rison() + @rison(advanced_data_type_convert_schema) def get(self, **kwargs: Any) -> Response: """Returns a AdvancedDataTypeResponse object populated with the passed in args --- get: - description: >- + summary: >- Returns a AdvancedDataTypeResponse object populated with the passed in args. parameters: - in: query @@ -75,18 +80,7 @@ def get(self, **kwargs: Any) -> Response: content: application/json: schema: - type: object - properties: - status: - type: string - values: - type: array - formatted_value: - type: string - error_message: - type: string - valid_filter_operators: - type: string + $ref: '#/components/schemas/AdvancedDataTypeSchema' 400: $ref: '#/components/responses/400' 401: @@ -96,15 +90,9 @@ def get(self, **kwargs: Any) -> Response: 500: $ref: '#/components/responses/500' """ - items = kwargs["rison"] - advanced_data_type = items.get("type") - if not advanced_data_type: - return self.response( - 400, message=_("Missing advanced data type in request") - ) - values = items["values"] - if not values: - return self.response(400, message=_("Missing values in request")) + item = kwargs["rison"] + advanced_data_type = item["type"] + values = item["values"] addon = ADVANCED_DATA_TYPES.get(advanced_data_type) if not addon: return self.response( @@ -124,7 +112,7 @@ def get(self, **kwargs: Any) -> Response: @protect() @safe @expose("/types", methods=["GET"]) - @permission_name("get") + @permission_name("read") @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", log_to_statsd=False, # pylint: disable-arguments-renamed @@ -147,8 +135,8 @@ def get_types(self) -> Response: properties: result: type: array - 400: - $ref: '#/components/responses/400' + items: + type: string 401: $ref: '#/components/responses/401' 404: diff --git a/superset/advanced_data_type/schemas.py b/superset/advanced_data_type/schemas.py index 133e6ae47f7ab..2175541b31ac1 100644 --- a/superset/advanced_data_type/schemas.py +++ b/superset/advanced_data_type/schemas.py @@ -17,14 +17,30 @@ """ Schemas for advanced data types """ +from marshmallow import fields, Schema advanced_data_type_convert_schema = { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": {"type": "string"}, - "values": {"type": "array"}, + "type": "object", + "properties": { + "type": {"type": "string", "default": "port"}, + "values": { + "type": "array", + "items": {"default": "http"}, + "minItems": 1, }, }, + "required": ["type", "values"], } + + +class AdvancedDataTypeSchema(Schema): + """ + AdvancedDataType response schema + """ + + error_message = fields.String() + values = fields.List(fields.String(description="parsed value (can be any value)")) + display_value = fields.String( + description="The string representation of the parsed values" + ) + valid_filter_operators = fields.List(fields.String()) From 0bcc21bc45ac672d82674a325cc7e94a944e2bc3 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Fri, 20 May 2022 15:39:10 +0300 Subject: [PATCH 18/49] chore(data-table): make formatted dttm the default (#20140) fix test --- .../src/explore/actions/exploreActions.ts | 18 +- .../components/DataTableControl/index.tsx | 73 +++++--- .../DataTableControl/useTableColumns.test.ts | 177 +++++++++--------- .../DataTablesPane/DataTablesPane.test.tsx | 4 +- .../components/DataTablesPane/index.tsx | 14 +- ....ts => useOriginalFormattedTimeColumns.ts} | 4 +- .../src/explore/reducers/exploreReducer.js | 54 +++--- .../src/explore/reducers/getInitialState.ts | 4 +- .../src/utils/localStorageHelpers.ts | 4 +- 9 files changed, 192 insertions(+), 160 deletions(-) rename superset-frontend/src/explore/components/{useTimeFormattedColumns.ts => useOriginalFormattedTimeColumns.ts} (87%) diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts index 61e73e8cc4855..8e73b32a9cd63 100644 --- a/superset-frontend/src/explore/actions/exploreActions.ts +++ b/superset-frontend/src/explore/actions/exploreActions.ts @@ -140,25 +140,27 @@ export function sliceUpdated(slice: Slice) { return { type: SLICE_UPDATED, slice }; } -export const SET_TIME_FORMATTED_COLUMN = 'SET_TIME_FORMATTED_COLUMN'; -export function setTimeFormattedColumn( +export const SET_ORIGINAL_FORMATTED_TIME_COLUMN = + 'SET_ORIGINAL_FORMATTED_TIME_COLUMN'; +export function setOriginalFormattedTimeColumn( datasourceId: string, columnName: string, ) { return { - type: SET_TIME_FORMATTED_COLUMN, + type: SET_ORIGINAL_FORMATTED_TIME_COLUMN, datasourceId, columnName, }; } -export const UNSET_TIME_FORMATTED_COLUMN = 'UNSET_TIME_FORMATTED_COLUMN'; -export function unsetTimeFormattedColumn( +export const UNSET_ORIGINAL_FORMATTED_TIME_COLUMN = + 'UNSET_ORIGINAL_FORMATTED_TIME_COLUMN'; +export function unsetOriginalFormattedTimeColumn( datasourceId: string, columnIndex: number, ) { return { - type: UNSET_TIME_FORMATTED_COLUMN, + type: UNSET_ORIGINAL_FORMATTED_TIME_COLUMN, datasourceId, columnIndex, }; @@ -187,8 +189,8 @@ export const exploreActions = { updateChartTitle, createNewSlice, sliceUpdated, - setTimeFormattedColumn, - unsetTimeFormattedColumn, + setOriginalFormattedTimeColumn, + unsetOriginalFormattedTimeColumn, setForceQuery, }; diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx index 791f5f8ff3898..cc379eda63602 100644 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -35,8 +35,8 @@ import { Input } from 'src/components/Input'; import { BOOL_FALSE_DISPLAY, BOOL_TRUE_DISPLAY, - SLOW_DEBOUNCE, NULL_DISPLAY, + SLOW_DEBOUNCE, } from 'src/constants'; import { Radio } from 'src/components/Radio'; import Icons from 'src/components/Icons'; @@ -46,8 +46,8 @@ import { prepareCopyToClipboardTabularData } from 'src/utils/common'; import CopyToClipboard from 'src/components/CopyToClipboard'; import RowCountLabel from 'src/explore/components/RowCountLabel'; import { - setTimeFormattedColumn, - unsetTimeFormattedColumn, + setOriginalFormattedTimeColumn, + unsetOriginalFormattedTimeColumn, } from 'src/explore/actions/exploreActions'; export const CellNull = styled('span')` @@ -143,8 +143,8 @@ const FormatPicker = ({ }) => ( - {t('Original value')} {t('Formatted date')} + {t('Original value')} ); @@ -166,15 +166,15 @@ const FormatPickerLabel = styled.span` const DataTableTemporalHeaderCell = ({ columnName, datasourceId, - timeFormattedColumnIndex, + originalFormattedTimeColumnIndex, }: { columnName: string; datasourceId?: string; - timeFormattedColumnIndex: number; + originalFormattedTimeColumnIndex: number; }) => { const theme = useTheme(); const dispatch = useDispatch(); - const isColumnTimeFormatted = timeFormattedColumnIndex > -1; + const isTimeColumnOriginalFormatted = originalFormattedTimeColumnIndex > -1; const onChange = useCallback( e => { @@ -183,24 +183,27 @@ const DataTableTemporalHeaderCell = ({ } if ( e.target.value === FormatPickerValue.Original && - isColumnTimeFormatted + !isTimeColumnOriginalFormatted ) { - dispatch( - unsetTimeFormattedColumn(datasourceId, timeFormattedColumnIndex), - ); + dispatch(setOriginalFormattedTimeColumn(datasourceId, columnName)); } else if ( e.target.value === FormatPickerValue.Formatted && - !isColumnTimeFormatted + isTimeColumnOriginalFormatted ) { - dispatch(setTimeFormattedColumn(datasourceId, columnName)); + dispatch( + unsetOriginalFormattedTimeColumn( + datasourceId, + originalFormattedTimeColumnIndex, + ), + ); } }, [ - timeFormattedColumnIndex, + originalFormattedTimeColumnIndex, columnName, datasourceId, dispatch, - isColumnTimeFormatted, + isTimeColumnOriginalFormatted, ], ); const overlayContent = useMemo( @@ -219,14 +222,14 @@ const DataTableTemporalHeaderCell = ({ ) : null, - [datasourceId, isColumnTimeFormatted, onChange], + [datasourceId, isTimeColumnOriginalFormatted, onChange], ); return datasourceId ? ( @@ -285,7 +288,7 @@ export const useTableColumns = ( coltypes?: GenericDataType[], data?: Record[], datasourceId?: string, - timeFormattedColumns: string[] = [], + originalFormattedTimeColumns: string[] = [], moreConfigs?: { [key: string]: Partial }, ) => useMemo( @@ -294,20 +297,25 @@ export const useTableColumns = ( ? colnames .filter((column: string) => Object.keys(data[0]).includes(column)) .map((key, index) => { - const timeFormattedColumnIndex = - coltypes?.[index] === GenericDataType.TEMPORAL - ? timeFormattedColumns.indexOf(key) + const colType = coltypes?.[index]; + const firstValue = data[0][key]; + const originalFormattedTimeColumnIndex = + colType === GenericDataType.TEMPORAL + ? originalFormattedTimeColumns.indexOf(key) : -1; return { id: key, accessor: row => row[key], // When the key is empty, have to give a string of length greater than 0 Header: - coltypes?.[index] === GenericDataType.TEMPORAL ? ( + colType === GenericDataType.TEMPORAL && + typeof firstValue !== 'string' ? ( ) : ( key @@ -322,7 +330,11 @@ export const useTableColumns = ( if (value === null) { return {NULL_DISPLAY}; } - if (timeFormattedColumnIndex > -1) { + if ( + colType === GenericDataType.TEMPORAL && + originalFormattedTimeColumnIndex === -1 && + typeof value === 'number' + ) { return timeFormatter(value); } return String(value); @@ -331,5 +343,12 @@ export const useTableColumns = ( } as Column; }) : [], - [colnames, data, coltypes, datasourceId, moreConfigs, timeFormattedColumns], + [ + colnames, + data, + coltypes, + datasourceId, + moreConfigs, + originalFormattedTimeColumns, + ], ); diff --git a/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts b/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts index bfc4b6d96468d..0a3a73e6f09ef 100644 --- a/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts +++ b/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts @@ -28,31 +28,53 @@ const asciiChars = []; for (let i = 32; i < 127; i += 1) { asciiChars.push(String.fromCharCode(i)); } -const asciiKey = asciiChars.join(''); -const unicodeKey = '你好. 吃了吗?'; +const ASCII_KEY = asciiChars.join(''); +const UNICODE_KEY = '你好. 吃了吗?'; +const NUMTIME_KEY = 'numtime'; +const STRTIME_KEY = 'strtime'; +const NUMTIME_VALUE = 1640995200000; +const NUMTIME_FORMATTED_VALUE = '2022-01-01 00:00:00'; +const STRTIME_VALUE = '2022-01-01'; -const data = [ - { col01: true, col02: false, [asciiKey]: asciiKey, [unicodeKey]: unicodeKey }, - { col01: true, col02: false, [asciiKey]: asciiKey, [unicodeKey]: unicodeKey }, - { col01: true, col02: false, [asciiKey]: asciiKey, [unicodeKey]: unicodeKey }, - { - col01: true, - col02: false, - col03: 'secret', - [asciiKey]: asciiKey, - [unicodeKey]: unicodeKey, - }, +const colnames = [ + 'col01', + 'col02', + ASCII_KEY, + UNICODE_KEY, + NUMTIME_KEY, + STRTIME_KEY, ]; -const all_columns = ['col01', 'col02', 'col03', asciiKey, unicodeKey]; const coltypes = [ GenericDataType.BOOLEAN, GenericDataType.BOOLEAN, GenericDataType.STRING, GenericDataType.STRING, + GenericDataType.TEMPORAL, + GenericDataType.TEMPORAL, ]; +const cellValues = { + col01: true, + col02: false, + [ASCII_KEY]: ASCII_KEY, + [UNICODE_KEY]: UNICODE_KEY, + [NUMTIME_KEY]: NUMTIME_VALUE, + [STRTIME_KEY]: STRTIME_VALUE, +}; + +const data = [cellValues, cellValues, cellValues, cellValues]; + +const expectedDisplayValues = { + col01: BOOL_TRUE_DISPLAY, + col02: BOOL_FALSE_DISPLAY, + [ASCII_KEY]: ASCII_KEY, + [UNICODE_KEY]: UNICODE_KEY, + [NUMTIME_KEY]: NUMTIME_FORMATTED_VALUE, + [STRTIME_KEY]: STRTIME_VALUE, +}; + test('useTableColumns with no options', () => { - const hook = renderHook(() => useTableColumns(all_columns, coltypes, data)); + const hook = renderHook(() => useTableColumns(colnames, coltypes, data)); expect(hook.result.current).toEqual([ { Cell: expect.any(Function), @@ -68,40 +90,59 @@ test('useTableColumns with no options', () => { }, { Cell: expect.any(Function), - Header: asciiKey, + Header: ASCII_KEY, + accessor: expect.any(Function), + id: ASCII_KEY, + }, + { + Cell: expect.any(Function), + Header: UNICODE_KEY, + accessor: expect.any(Function), + id: UNICODE_KEY, + }, + { + Cell: expect.any(Function), + Header: expect.objectContaining({ + type: expect.objectContaining({ + name: 'DataTableTemporalHeaderCell', + }), + props: expect.objectContaining({ + originalFormattedTimeColumnIndex: -1, + }), + }), accessor: expect.any(Function), - id: asciiKey, + id: NUMTIME_KEY, }, { Cell: expect.any(Function), - Header: unicodeKey, + Header: STRTIME_KEY, accessor: expect.any(Function), - id: unicodeKey, + id: STRTIME_KEY, }, ]); hook.result.current.forEach((col: JsonObject) => { - expect(col.accessor(data[0])).toBe(data[0][col.Header]); + expect(col.accessor(data[0])).toBe(data[0][col.id]); }); hook.result.current.forEach((col: JsonObject) => { data.forEach(row => { - expect(col.Cell({ value: row.col01 })).toBe(BOOL_TRUE_DISPLAY); - expect(col.Cell({ value: row.col02 })).toBe(BOOL_FALSE_DISPLAY); - expect(col.Cell({ value: row[asciiKey] })).toBe(asciiKey); - expect(col.Cell({ value: row[unicodeKey] })).toBe(unicodeKey); + expect(col.Cell({ value: row[col.id] })).toBe( + expectedDisplayValues[col.id], + ); }); }); }); -test('use only the first record columns', () => { - const newData = [data[3], data[0]]; +test('useTableColumns with options', () => { const hook = renderHook(() => - useTableColumns(all_columns, coltypes, newData), + useTableColumns(colnames, coltypes, data, undefined, [], { + col01: { Header: 'Header' }, + }), ); expect(hook.result.current).toEqual([ { Cell: expect.any(Function), - Header: 'col01', + Header: 'Header', accessor: expect.any(Function), id: 'col01', }, @@ -113,87 +154,45 @@ test('use only the first record columns', () => { }, { Cell: expect.any(Function), - Header: 'col03', - accessor: expect.any(Function), - id: 'col03', - }, - { - Cell: expect.any(Function), - Header: asciiKey, - accessor: expect.any(Function), - id: asciiKey, - }, - { - Cell: expect.any(Function), - Header: unicodeKey, - accessor: expect.any(Function), - id: unicodeKey, - }, - ]); - - hook.result.current.forEach((col: JsonObject) => { - expect(col.accessor(newData[0])).toBe(newData[0][col.Header]); - }); - - hook.result.current.forEach((col: JsonObject) => { - expect(col.Cell({ value: newData[0].col01 })).toBe(BOOL_TRUE_DISPLAY); - expect(col.Cell({ value: newData[0].col02 })).toBe(BOOL_FALSE_DISPLAY); - expect(col.Cell({ value: newData[0].col03 })).toBe('secret'); - expect(col.Cell({ value: newData[0][asciiKey] })).toBe(asciiKey); - expect(col.Cell({ value: newData[0][unicodeKey] })).toBe(unicodeKey); - }); - - hook.result.current.forEach((col: JsonObject) => { - expect(col.Cell({ value: newData[1].col01 })).toBe(BOOL_TRUE_DISPLAY); - expect(col.Cell({ value: newData[1].col02 })).toBe(BOOL_FALSE_DISPLAY); - expect(col.Cell({ value: newData[1].col03 })).toBe('undefined'); - expect(col.Cell({ value: newData[1][asciiKey] })).toBe(asciiKey); - expect(col.Cell({ value: newData[1][unicodeKey] })).toBe(unicodeKey); - }); -}); - -test('useTableColumns with options', () => { - const hook = renderHook(() => - useTableColumns(all_columns, coltypes, data, undefined, [], { - col01: { id: 'ID' }, - }), - ); - expect(hook.result.current).toEqual([ - { - Cell: expect.any(Function), - Header: 'col01', + Header: ASCII_KEY, accessor: expect.any(Function), - id: 'ID', + id: ASCII_KEY, }, { Cell: expect.any(Function), - Header: 'col02', + Header: UNICODE_KEY, accessor: expect.any(Function), - id: 'col02', + id: UNICODE_KEY, }, { Cell: expect.any(Function), - Header: asciiKey, + Header: expect.objectContaining({ + type: expect.objectContaining({ + name: 'DataTableTemporalHeaderCell', + }), + props: expect.objectContaining({ + originalFormattedTimeColumnIndex: -1, + }), + }), accessor: expect.any(Function), - id: asciiKey, + id: NUMTIME_KEY, }, { Cell: expect.any(Function), - Header: unicodeKey, + Header: STRTIME_KEY, accessor: expect.any(Function), - id: unicodeKey, + id: STRTIME_KEY, }, ]); hook.result.current.forEach((col: JsonObject) => { - expect(col.accessor(data[0])).toBe(data[0][col.Header]); + expect(col.accessor(data[0])).toBe(data[0][col.id]); }); hook.result.current.forEach((col: JsonObject) => { data.forEach(row => { - expect(col.Cell({ value: row.col01 })).toBe(BOOL_TRUE_DISPLAY); - expect(col.Cell({ value: row.col02 })).toBe(BOOL_FALSE_DISPLAY); - expect(col.Cell({ value: row[asciiKey] })).toBe(asciiKey); - expect(col.Cell({ value: row[unicodeKey] })).toBe(unicodeKey); + expect(col.Cell({ value: row[col.id] })).toBe( + expectedDisplayValues[col.id], + ); }); }); }); diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx index b2b3f168c9104..cb95e29fd091c 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx @@ -151,7 +151,7 @@ describe('DataTablesPane', () => { useRedux: true, initialState: { explore: { - timeFormattedColumns: { + originalFormattedTimeColumns: { '34__table': ['__timestamp'], }, }, @@ -203,7 +203,7 @@ describe('DataTablesPane', () => { useRedux: true, initialState: { explore: { - timeFormattedColumns: { + originalFormattedTimeColumns: { '34__table': ['__timestamp'], }, }, diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.tsx b/superset-frontend/src/explore/components/DataTablesPane/index.tsx index fc01483703c70..efa904fd9877c 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/index.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/index.tsx @@ -52,7 +52,7 @@ import { useTableColumns, } from 'src/explore/components/DataTableControl'; import { applyFormattingToTabularData } from 'src/utils/common'; -import { useTimeFormattedColumns } from '../useTimeFormattedColumns'; +import { useOriginalFormattedTimeColumns } from '../useOriginalFormattedTimeColumns'; const RESULT_TYPES = { results: 'results' as const, @@ -147,7 +147,8 @@ const DataTable = ({ errorMessage, type, }: DataTableProps) => { - const timeFormattedColumns = useTimeFormattedColumns(datasource); + const originalFormattedTimeColumns = + useOriginalFormattedTimeColumns(datasource); // this is to preserve the order of the columns, even if there are integer values, // while also only grabbing the first column's keys const columns = useTableColumns( @@ -155,7 +156,7 @@ const DataTable = ({ columnTypes, data, datasource, - timeFormattedColumns, + originalFormattedTimeColumns, ); const filteredData = useFilteredTableData(filterText, data); @@ -210,10 +211,11 @@ const TableControls = ({ columnNames: string[]; isLoading: boolean; }) => { - const timeFormattedColumns = useTimeFormattedColumns(datasourceId); + const originalFormattedTimeColumns = + useOriginalFormattedTimeColumns(datasourceId); const formattedData = useMemo( - () => applyFormattingToTabularData(data, timeFormattedColumns), - [data, timeFormattedColumns], + () => applyFormattingToTabularData(data, originalFormattedTimeColumns), + [data, originalFormattedTimeColumns], ); return ( diff --git a/superset-frontend/src/explore/components/useTimeFormattedColumns.ts b/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts similarity index 87% rename from superset-frontend/src/explore/components/useTimeFormattedColumns.ts rename to superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts index ed72205c7e8a4..b51fef617889d 100644 --- a/superset-frontend/src/explore/components/useTimeFormattedColumns.ts +++ b/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts @@ -19,9 +19,9 @@ import { useSelector } from 'react-redux'; import { ExplorePageState } from '../reducers/getInitialState'; -export const useTimeFormattedColumns = (datasourceId?: string) => +export const useOriginalFormattedTimeColumns = (datasourceId?: string) => useSelector(state => datasourceId - ? state.explore.timeFormattedColumns?.[datasourceId] ?? [] + ? state.explore.originalFormattedTimeColumns?.[datasourceId] ?? [] : [], ); diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js b/superset-frontend/src/explore/reducers/exploreReducer.js index 015eb3e5b59c9..4dfcc9a1781bc 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.js +++ b/superset-frontend/src/explore/reducers/exploreReducer.js @@ -265,41 +265,51 @@ export default function exploreReducer(state = {}, action) { sliceName: action.slice.slice_name ?? state.sliceName, }; }, - [actions.SET_TIME_FORMATTED_COLUMN]() { + [actions.SET_ORIGINAL_FORMATTED_TIME_COLUMN]() { const { datasourceId, columnName } = action; - const newTimeFormattedColumns = { ...state.timeFormattedColumns }; - const newTimeFormattedColumnsForDatasource = ensureIsArray( - newTimeFormattedColumns[datasourceId], + const newOriginalFormattedColumns = { + ...state.originalFormattedTimeColumns, + }; + const newOriginalFormattedColumnsForDatasource = ensureIsArray( + newOriginalFormattedColumns[datasourceId], ).slice(); - newTimeFormattedColumnsForDatasource.push(columnName); - newTimeFormattedColumns[datasourceId] = - newTimeFormattedColumnsForDatasource; + newOriginalFormattedColumnsForDatasource.push(columnName); + newOriginalFormattedColumns[datasourceId] = + newOriginalFormattedColumnsForDatasource; setItem( - LocalStorageKeys.explore__data_table_time_formatted_columns, - newTimeFormattedColumns, + LocalStorageKeys.explore__data_table_original_formatted_time_columns, + newOriginalFormattedColumns, ); - return { ...state, timeFormattedColumns: newTimeFormattedColumns }; + return { + ...state, + originalFormattedTimeColumns: newOriginalFormattedColumns, + }; }, - [actions.UNSET_TIME_FORMATTED_COLUMN]() { + [actions.UNSET_ORIGINAL_FORMATTED_TIME_COLUMN]() { const { datasourceId, columnIndex } = action; - const newTimeFormattedColumns = { ...state.timeFormattedColumns }; - const newTimeFormattedColumnsForDatasource = ensureIsArray( - newTimeFormattedColumns[datasourceId], + const newOriginalFormattedColumns = { + ...state.originalFormattedTimeColumns, + }; + const newOriginalFormattedColumnsForDatasource = ensureIsArray( + newOriginalFormattedColumns[datasourceId], ).slice(); - newTimeFormattedColumnsForDatasource.splice(columnIndex, 1); - newTimeFormattedColumns[datasourceId] = - newTimeFormattedColumnsForDatasource; + newOriginalFormattedColumnsForDatasource.splice(columnIndex, 1); + newOriginalFormattedColumns[datasourceId] = + newOriginalFormattedColumnsForDatasource; - if (newTimeFormattedColumnsForDatasource.length === 0) { - delete newTimeFormattedColumns[datasourceId]; + if (newOriginalFormattedColumnsForDatasource.length === 0) { + delete newOriginalFormattedColumns[datasourceId]; } setItem( - LocalStorageKeys.explore__data_table_time_formatted_columns, - newTimeFormattedColumns, + LocalStorageKeys.explore__data_table_original_formatted_time_columns, + newOriginalFormattedColumns, ); - return { ...state, timeFormattedColumns: newTimeFormattedColumns }; + return { + ...state, + originalFormattedTimeColumns: newOriginalFormattedColumns, + }; }, [actions.SET_FORCE_QUERY]() { return { diff --git a/superset-frontend/src/explore/reducers/getInitialState.ts b/superset-frontend/src/explore/reducers/getInitialState.ts index e82586c5082a6..659e834d2faad 100644 --- a/superset-frontend/src/explore/reducers/getInitialState.ts +++ b/superset-frontend/src/explore/reducers/getInitialState.ts @@ -78,8 +78,8 @@ export default function getInitialState( initialFormData, ) as ControlStateMapping, controlsTransferred: [], - timeFormattedColumns: getItem( - LocalStorageKeys.explore__data_table_time_formatted_columns, + originalFormattedTimeColumns: getItem( + LocalStorageKeys.explore__data_table_original_formatted_time_columns, {}, ), }; diff --git a/superset-frontend/src/utils/localStorageHelpers.ts b/superset-frontend/src/utils/localStorageHelpers.ts index e3a37933418d3..49044a162c6af 100644 --- a/superset-frontend/src/utils/localStorageHelpers.ts +++ b/superset-frontend/src/utils/localStorageHelpers.ts @@ -49,7 +49,7 @@ export enum LocalStorageKeys { * sqllab__is_autocomplete_enabled */ sqllab__is_autocomplete_enabled = 'sqllab__is_autocomplete_enabled', - explore__data_table_time_formatted_columns = 'explore__data_table_time_formatted_columns', + explore__data_table_original_formatted_time_columns = 'explore__data_table_original_formatted_time_columns', } export type LocalStorageValues = { @@ -63,7 +63,7 @@ export type LocalStorageValues = { homepage_collapse_state: string[]; homepage_activity_filter: SetTabType | null; sqllab__is_autocomplete_enabled: boolean; - explore__data_table_time_formatted_columns: Record; + explore__data_table_original_formatted_time_columns: Record; }; export function getItem( From e766f8cb571fda1cef9aa398b146800bdbfaaeb1 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Fri, 20 May 2022 19:07:53 +0300 Subject: [PATCH 19/49] fix(explore): handle null control sections (#20142) * fix(explore): handle null control sections * fix type and add null to test fixture --- .../explore/controlUtils/getControlConfig.ts | 14 +++- superset-frontend/src/explore/fixtures.tsx | 78 ++++++++++--------- .../src/utils/getControlsForVizType.js | 29 +++---- 3 files changed, 67 insertions(+), 54 deletions(-) diff --git a/superset-frontend/src/explore/controlUtils/getControlConfig.ts b/superset-frontend/src/explore/controlUtils/getControlConfig.ts index c0b2363275597..4cd30d26ccbe4 100644 --- a/superset-frontend/src/explore/controlUtils/getControlConfig.ts +++ b/superset-frontend/src/explore/controlUtils/getControlConfig.ts @@ -19,19 +19,22 @@ import memoizeOne from 'memoize-one'; import { getChartControlPanelRegistry } from '@superset-ui/core'; import { + ControlPanelConfig, ControlPanelSectionConfig, expandControlConfig, + isControlPanelSectionConfig, } from '@superset-ui/chart-controls'; /** * Find control item from control panel config. */ export function findControlItem( - controlPanelSections: ControlPanelSectionConfig[], + controlPanelSections: (ControlPanelSectionConfig | null)[], controlKey: string, ) { return ( controlPanelSections + .filter(isControlPanelSectionConfig) .map(section => section.controlSetRows) .flat(2) .find( @@ -46,7 +49,7 @@ export function findControlItem( } const getMemoizedControlConfig = memoizeOne( - (controlKey, controlPanelConfig) => { + (controlKey, controlPanelConfig: ControlPanelConfig) => { const { controlOverrides = {}, controlPanelSections = [] } = controlPanelConfig; const control = expandControlConfig( @@ -62,5 +65,10 @@ export const getControlConfig = function getControlConfig( vizType: string, ) { const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {}; - return getMemoizedControlConfig(controlKey, controlPanelConfig); + return getMemoizedControlConfig( + controlKey, + // TODO: the ChartControlPanelRegistry is incorrectly typed and needs to + // be fixed + controlPanelConfig as ControlPanelConfig, + ); }; diff --git a/superset-frontend/src/explore/fixtures.tsx b/superset-frontend/src/explore/fixtures.tsx index afa615e9bfed3..7ce2626069df2 100644 --- a/superset-frontend/src/explore/fixtures.tsx +++ b/superset-frontend/src/explore/fixtures.tsx @@ -26,47 +26,49 @@ import { ControlPanelSectionConfig, } from '@superset-ui/chart-controls'; -export const controlPanelSectionsChartOptions: ControlPanelSectionConfig[] = [ - { - label: t('Chart Options'), - expanded: true, - controlSetRows: [ - [ - 'color_scheme', - { - name: 'rose_area_proportion', - config: { - type: 'CheckboxControl', - label: t('Use Area Proportions'), - description: t( - 'Check if the Rose Chart should use segment area instead of ' + - 'segment radius for proportioning', - ), - default: false, - renderTrigger: true, +export const controlPanelSectionsChartOptions: (ControlPanelSectionConfig | null)[] = + [ + null, + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + [ + 'color_scheme', + { + name: 'rose_area_proportion', + config: { + type: 'CheckboxControl', + label: t('Use Area Proportions'), + description: t( + 'Check if the Rose Chart should use segment area instead of ' + + 'segment radius for proportioning', + ), + default: false, + renderTrigger: true, + }, }, - }, - ], - [ - { - name: 'stacked_style', - config: { - type: 'SelectControl', - label: t('Stacked Style'), - renderTrigger: true, - choices: [ - ['stack', 'stack'], - ['stream', 'stream'], - ['expand', 'expand'], - ], - default: 'stack', - description: '', + ], + [ + { + name: 'stacked_style', + config: { + type: 'SelectControl', + label: t('Stacked Style'), + renderTrigger: true, + choices: [ + ['stack', 'stack'], + ['stream', 'stream'], + ['expand', 'expand'], + ], + default: 'stack', + description: '', + }, }, - }, + ], ], - ], - }, -]; + }, + ]; export const controlPanelSectionsChartOptionsOnlyColorScheme: ControlPanelSectionConfig[] = [ diff --git a/superset-frontend/src/utils/getControlsForVizType.js b/superset-frontend/src/utils/getControlsForVizType.js index 8034c90f9ea73..ae48b8b0d8c0c 100644 --- a/superset-frontend/src/utils/getControlsForVizType.js +++ b/superset-frontend/src/utils/getControlsForVizType.js @@ -18,26 +18,29 @@ */ import memoize from 'lodash/memoize'; +import { isControlPanelSectionConfig } from '@superset-ui/chart-controls'; import { getChartControlPanelRegistry } from '@superset-ui/core'; import { controls } from '../explore/controls'; const memoizedControls = memoize((vizType, controlPanel) => { const controlsMap = {}; - (controlPanel?.controlPanelSections || []).forEach(section => { - section.controlSetRows.forEach(row => { - row.forEach(control => { - if (!control) return; - if (typeof control === 'string') { - // For now, we have to look in controls.jsx to get the config for some controls. - // Once everything is migrated out, delete this if statement. - controlsMap[control] = controls[control]; - } else if (control.name && control.config) { - // condition needed because there are elements, e.g.
in some control configs (I'm looking at you, FilterBox!) - controlsMap[control.name] = control.config; - } + (controlPanel?.controlPanelSections || []) + .filter(isControlPanelSectionConfig) + .forEach(section => { + section.controlSetRows.forEach(row => { + row.forEach(control => { + if (!control) return; + if (typeof control === 'string') { + // For now, we have to look in controls.jsx to get the config for some controls. + // Once everything is migrated out, delete this if statement. + controlsMap[control] = controls[control]; + } else if (control.name && control.config) { + // condition needed because there are elements, e.g.
in some control configs (I'm looking at you, FilterBox!) + controlsMap[control.name] = control.config; + } + }); }); }); - }); return controlsMap; }); From 56e96950c17ec65ef18cedfb2ed6591796a96cfc Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Fri, 20 May 2022 13:54:42 -0500 Subject: [PATCH 20/49] fix(chart & heatmap): make to fix that y label is rendering out of bounds (#20011) --- .../plugins/legacy-plugin-chart-heatmap/src/Heatmap.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/Heatmap.js b/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/Heatmap.js index dc442039112e7..b0b32aba4a1ac 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/Heatmap.js +++ b/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/Heatmap.js @@ -111,7 +111,7 @@ function Heatmap(element, props) { let showY = true; let showX = true; const pixelsPerCharX = 4.5; // approx, depends on font size - const pixelsPerCharY = 6; // approx, depends on font size + let pixelsPerCharY = 6; // approx, depends on font size const valueFormatter = getNumberFormatter(numberFormat); @@ -121,6 +121,7 @@ function Heatmap(element, props) { let longestY = 1; records.forEach(datum => { + if (typeof datum.y === 'number') pixelsPerCharY = 7; longestX = Math.max( longestX, (datum.x && datum.x.toString().length) || 1, From d7e3ac306f3ddb63a4b9b5b3ca32d39906ab6e14 Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Mon, 23 May 2022 18:24:32 +0800 Subject: [PATCH 21/49] chore: filter undefined operators (#20157) --- .../src/query/buildQueryContext.ts | 25 ++++++++++++------- .../src/MixedTimeseries/buildQuery.ts | 2 +- .../src/Timeseries/buildQuery.ts | 2 +- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts b/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts index 070636f156f89..ad35434cad09d 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts @@ -61,18 +61,25 @@ export default function buildQueryContext( } = typeof options === 'function' ? { buildQuery: options, queryFields: {} } : options || {}; + const queries = buildQuery(buildQueryObject(formData, queryFields), { + extras: {}, + ownState, + hooks: { + setDataMask: () => {}, + setCachedChanges: () => {}, + ...hooks, + }, + }); + queries.forEach(query => { + if (Array.isArray(query.post_processing)) { + // eslint-disable-next-line no-param-reassign + query.post_processing = query.post_processing.filter(Boolean); + } + }); return { datasource: new DatasourceKey(formData.datasource).toObject(), force: formData.force || false, - queries: buildQuery(buildQueryObject(formData, queryFields), { - extras: {}, - ownState, - hooks: { - setDataMask: () => {}, - setCachedChanges: () => {}, - ...hooks, - }, - }), + queries, form_data: formData, result_format: formData.result_format || 'json', result_type: formData.result_type || 'full', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts index ac3a96b2c7376..4bd4df0bcc26e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/buildQuery.ts @@ -86,7 +86,7 @@ export default function buildQuery(formData: QueryFormData) { is_timeseries, }), flattenOperator(fd, queryObject), - ].filter(Boolean), + ], } as QueryObject; return [normalizeOrderBy(tmpQueryObject)]; }), diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts index 96c1d6e8e7b56..085635209ac20 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/buildQuery.ts @@ -100,7 +100,7 @@ export default function buildQuery(formData: QueryFormData) { flattenOperator(formData, baseQueryObject), // todo: move prophet before flatten prophetOperator(formData, baseQueryObject), - ].filter(Boolean), + ], }, ]; }); From b746e6f844d457d9a8c81d64e9154f315a61a29d Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Mon, 23 May 2022 13:18:08 +0200 Subject: [PATCH 22/49] feat(dashboard): Chart title click redirects to Explore (#20111) --- .../src/components/EditableTitle/index.tsx | 22 ++++++++- .../SliceHeader/SliceHeader.test.tsx | 45 ++++++++++++++++++- .../components/SliceHeader/index.tsx | 14 +++++- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/superset-frontend/src/components/EditableTitle/index.tsx b/superset-frontend/src/components/EditableTitle/index.tsx index ddd85875568af..131e9731c0dcf 100644 --- a/superset-frontend/src/components/EditableTitle/index.tsx +++ b/superset-frontend/src/components/EditableTitle/index.tsx @@ -18,7 +18,7 @@ */ import React, { useEffect, useState, useRef } from 'react'; import cx from 'classnames'; -import { styled, t } from '@superset-ui/core'; +import { css, styled, t } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; import CertifiedBadge from '../CertifiedBadge'; @@ -37,6 +37,7 @@ export interface EditableTitleProps { placeholder?: string; certifiedBy?: string; certificationDetails?: string; + onClickTitle?: () => void; } const StyledCertifiedBadge = styled(CertifiedBadge)` @@ -57,6 +58,7 @@ export default function EditableTitle({ placeholder = '', certifiedBy, certificationDetails, + onClickTitle, // rest is related to title tooltip ...rest }: EditableTitleProps) { @@ -216,7 +218,23 @@ export default function EditableTitle({ } if (!canEdit) { // don't actually want an input in this case - titleComponent = {value}; + titleComponent = onClickTitle ? ( + + {value} + + ) : ( + {value} + ); } return ( ({ ), })); -const createProps = () => ({ +const createProps = (overrides: any = {}) => ({ filters: {}, // is in typing but not being used editMode: false, annotationQuery: { param01: 'annotationQuery' } as any, @@ -159,6 +159,7 @@ const createProps = () => ({ formData: { slice_id: 1, datasource: '58__table' }, width: 100, height: 100, + ...overrides, }); test('Should render', () => { @@ -264,6 +265,48 @@ test('Should render title', () => { expect(screen.getByText('Vaccine Candidates per Phase')).toBeInTheDocument(); }); +test('Should render click to edit prompt and run onExploreChart on click', async () => { + const props = createProps(); + render(, { useRedux: true }); + userEvent.hover(screen.getByText('Vaccine Candidates per Phase')); + expect( + await screen.findByText( + 'Click to edit Vaccine Candidates per Phase in a new tab', + ), + ).toBeInTheDocument(); + + userEvent.click(screen.getByText('Vaccine Candidates per Phase')); + expect(props.onExploreChart).toHaveBeenCalled(); +}); + +test('Should not render click to edit prompt and run onExploreChart on click if supersetCanExplore=false', () => { + const props = createProps({ supersetCanExplore: false }); + render(, { useRedux: true }); + userEvent.hover(screen.getByText('Vaccine Candidates per Phase')); + expect( + screen.queryByText( + 'Click to edit Vaccine Candidates per Phase in a new tab', + ), + ).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Vaccine Candidates per Phase')); + expect(props.onExploreChart).not.toHaveBeenCalled(); +}); + +test('Should not render click to edit prompt and run onExploreChart on click if in edit mode', () => { + const props = createProps({ editMode: true }); + render(, { useRedux: true }); + userEvent.hover(screen.getByText('Vaccine Candidates per Phase')); + expect( + screen.queryByText( + 'Click to edit Vaccine Candidates per Phase in a new tab', + ), + ).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Vaccine Candidates per Phase')); + expect(props.onExploreChart).not.toHaveBeenCalled(); +}); + test('Should render "annotationsLoading"', () => { const props = createProps(); render(, { useRedux: true }); diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 3aba641fcbc16..af9d509e2f8af 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -104,9 +104,18 @@ const SliceHeader: FC = ({ [crossFilterValue], ); + const handleClickTitle = + !editMode && supersetCanExplore ? onExploreChart : undefined; + useEffect(() => { const headerElement = headerRef.current; - if ( + if (handleClickTitle) { + setHeaderTooltip( + sliceName + ? t('Click to edit %s in a new tab', sliceName) + : t('Click to edit chart in a new tab'), + ); + } else if ( headerElement && (headerElement.scrollWidth > headerElement.offsetWidth || headerElement.scrollHeight > headerElement.offsetHeight) @@ -115,7 +124,7 @@ const SliceHeader: FC = ({ } else { setHeaderTooltip(null); } - }, [sliceName, width, height]); + }, [sliceName, width, height, handleClickTitle]); return (
@@ -132,6 +141,7 @@ const SliceHeader: FC = ({ emptyText="" onSaveTitle={updateSliceName} showTooltip={false} + onClickTitle={handleClickTitle} /> {!!Object.values(annotationQuery).length && ( From 22b7496d2ea444ca619aa21f9e820bb610cc5648 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Mon, 23 May 2022 14:39:18 -0400 Subject: [PATCH 23/49] fix: string aggregation is incorrect in PivotTableV2 (#19102) * fix: string aggregation is incorrect in PivotTableV2 * cleanup * fix * updates --- .../src/react-pivottable/utilities.js | 95 +++++++++++++------ 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js index e6796a6fe8544..9ae5888819777 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js @@ -177,7 +177,7 @@ const getSort = function (sorters, attr) { return naturalSort; }; -// aggregator templates default to US number formatting but this is overrideable +// aggregator templates default to US number formatting but this is overridable const usFmt = numberFormat(); const usFmtInt = numberFormat({ digitsAfterDecimal: 0 }); const usFmtPct = numberFormat({ @@ -186,6 +186,8 @@ const usFmtPct = numberFormat({ suffix: '%', }); +const fmtNonString = formatter => x => typeof x === 'string' ? x : formatter(x); + const baseAggregatorTemplates = { count(formatter = usFmtInt) { return () => @@ -216,7 +218,7 @@ const baseAggregatorTemplates = { value() { return fn(this.uniq); }, - format: formatter, + format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; }; @@ -229,14 +231,16 @@ const baseAggregatorTemplates = { return { sum: 0, push(record) { - if (!Number.isNaN(parseFloat(record[attr]))) { + if (Number.isNaN(parseFloat(record[attr]))) { + this.sum = record[attr]; + } else { this.sum += parseFloat(record[attr]); } }, value() { return this.sum; }, - format: formatter, + format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; }; @@ -253,20 +257,28 @@ const baseAggregatorTemplates = { attr, ), push(record) { - let x = record[attr]; + const x = record[attr]; if (['min', 'max'].includes(mode)) { - x = parseFloat(x); - if (!Number.isNaN(x)) { - this.val = Math[mode](x, this.val !== null ? this.val : x); + const coercedValue = parseFloat(x); + if (Number.isNaN(coercedValue)) { + this.val = + !this.val || + (mode === 'min' && x < this.val) || + (mode === 'max' && x > this.val) + ? x + : this.val; + } else { + this.val = Math[mode]( + coercedValue, + this.val !== null ? this.val : coercedValue, + ); } - } - if ( + } else if ( mode === 'first' && this.sorter(x, this.val !== null ? this.val : x) <= 0 ) { this.val = x; - } - if ( + } else if ( mode === 'last' && this.sorter(x, this.val !== null ? this.val : x) >= 0 ) { @@ -277,10 +289,10 @@ const baseAggregatorTemplates = { return this.val; }, format(x) { - if (Number.isNaN(x)) { - return x; + if (typeof x === 'number') { + return formatter(x); } - return formatter(x); + return x; }, numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -293,21 +305,40 @@ const baseAggregatorTemplates = { return function () { return { vals: [], + strMap: {}, push(record) { - const x = parseFloat(record[attr]); - if (!Number.isNaN(x)) { + const val = record[attr]; + const x = parseFloat(val); + + if (Number.isNaN(x)) { + this.strMap[val] = (this.strMap[val] || 0) + 1; + } else { this.vals.push(x); } }, value() { - if (this.vals.length === 0) { + if ( + this.vals.length === 0 && + Object.keys(this.strMap).length === 0 + ) { return null; } + + if (Object.keys(this.strMap).length) { + const values = Object.values(this.strMap).sort((a, b) => a - b); + const middle = Math.floor(values.length / 2); + + const keys = Object.keys(this.strMap); + return keys.length % 2 !== 0 + ? keys[middle] + : (keys[middle - 1] + keys[middle]) / 2; + } + this.vals.sort((a, b) => a - b); const i = (this.vals.length - 1) * q; return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0; }, - format: formatter, + format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; }; @@ -321,9 +352,12 @@ const baseAggregatorTemplates = { n: 0.0, m: 0.0, s: 0.0, + strValue: null, push(record) { const x = parseFloat(record[attr]); if (Number.isNaN(x)) { + this.strValue = + typeof record[attr] === 'string' ? record[attr] : this.strValue; return; } this.n += 1.0; @@ -335,6 +369,10 @@ const baseAggregatorTemplates = { this.m = mNew; }, value() { + if (this.strValue) { + return this.strValue; + } + if (mode === 'mean') { if (this.n === 0) { return 0 / 0; @@ -353,7 +391,7 @@ const baseAggregatorTemplates = { throw new Error('unknown mode for runningStat'); } }, - format: formatter, + format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; }; @@ -396,14 +434,17 @@ const baseAggregatorTemplates = { push(record) { this.inner.push(record); }, - format: formatter, + format: fmtNonString(formatter), value() { - return ( - this.inner.value() / - data - .getAggregator(...Array.from(this.selector || [])) - .inner.value() - ); + const acc = data + .getAggregator(...Array.from(this.selector || [])) + .inner.value(); + + if (typeof acc === 'string') { + return acc; + } + + return this.inner.value() / acc; }, numInputs: wrapped(...Array.from(x || []))().numInputs, }; From b96e20a2f4fc2cffa546153ebeca7e39985829ab Mon Sep 17 00:00:00 2001 From: AAfghahi <48933336+AAfghahi@users.noreply.github.com> Date: Mon, 23 May 2022 15:44:10 -0400 Subject: [PATCH 24/49] change button name (#20163) --- .../src/SqlLab/components/ExploreResultsButton/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx index 7c19a4d3b4afe..24d5e8686f71b 100644 --- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx @@ -45,7 +45,7 @@ const ExploreResultsButton = ({ placement="top" label="explore" />{' '} - {t('Explore')} + {t('Create Chart')} ); }; From f8ea7788a90d679ab2e086289ebf6857ca1d2915 Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Mon, 23 May 2022 17:58:15 -0400 Subject: [PATCH 25/49] feat: Add Certified filter to Datasets (#20136) --- .../src/components/ListView/types.ts | 1 + .../views/CRUD/data/dataset/DatasetList.tsx | 13 ++++++++ superset/datasets/api.py | 8 +++-- superset/datasets/filters.py | 18 +++++++++++ tests/integration_tests/datasets/api_tests.py | 31 +++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 53710b84d271d..f8bff90f0ee95 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -113,4 +113,5 @@ export enum FilterOperator { chartIsFav = 'chart_is_favorite', chartIsCertified = 'chart_is_certified', dashboardIsCertified = 'dashboard_is_certified', + datasetIsCertified = 'dataset_is_certified', } diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx index 563a3bce1fc4c..a56a69b346c11 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx @@ -258,6 +258,7 @@ const DatasetList: FunctionComponent = ({ accessor: 'kind_icon', disableSortBy: true, size: 'xs', + id: 'id', }, { Cell: ({ @@ -506,6 +507,18 @@ const DatasetList: FunctionComponent = ({ { label: 'Physical', value: true }, ], }, + { + Header: t('Certified'), + id: 'id', + urlDisplay: 'certified', + input: 'select', + operator: FilterOperator.datasetIsCertified, + unfilteredLabel: t('Any'), + selects: [ + { label: t('Yes'), value: true }, + { label: t('No'), value: false }, + ], + }, { Header: t('Search'), id: 'table_name', diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 268337c090fee..fb01b6ee8c9cc 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -52,7 +52,7 @@ from superset.datasets.commands.refresh import RefreshDatasetCommand from superset.datasets.commands.update import UpdateDatasetCommand from superset.datasets.dao import DatasetDAO -from superset.datasets.filters import DatasetIsNullOrEmptyFilter +from superset.datasets.filters import DatasetCertifiedFilter, DatasetIsNullOrEmptyFilter from superset.datasets.schemas import ( DatasetPostSchema, DatasetPutSchema, @@ -195,7 +195,11 @@ class DatasetRestApi(BaseSupersetModelRestApi): "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "database": "database_name", } - search_filters = {"sql": [DatasetIsNullOrEmptyFilter]} + search_filters = { + "sql": [DatasetIsNullOrEmptyFilter], + "id": [DatasetCertifiedFilter], + } + search_columns = ["id", "database", "owners", "sql", "table_name"] filter_rel_fields = {"database": [["id", DatabaseFilter, lambda: []]]} allowed_rel_fields = {"database", "owners"} allowed_distinct_fields = {"schema"} diff --git a/superset/datasets/filters.py b/superset/datasets/filters.py index 4bbe80fd4e4f1..e72eb281818ae 100644 --- a/superset/datasets/filters.py +++ b/superset/datasets/filters.py @@ -33,3 +33,21 @@ def apply(self, query: Query, value: bool) -> Query: filter_clause = not_(filter_clause) return query.filter(filter_clause) + + +class DatasetCertifiedFilter(BaseFilter): # pylint: disable=too-few-public-methods + name = _("Is certified") + arg_name = "dataset_is_certified" + + def apply(self, query: Query, value: bool) -> Query: + check_value = '%"certification":%' + if value is True: + return query.filter(SqlaTable.extra.ilike(check_value)) + if value is False: + return query.filter( + or_( + SqlaTable.extra.notlike(check_value), + SqlaTable.extra.is_(None), + ) + ) + return query diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 87d0da3cad827..781ae929b743c 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -128,6 +128,7 @@ def create_datasets(self): main_db = get_main_database() for tables_name in self.fixture_tables_names: datasets.append(self.insert_dataset(tables_name, [admin.id], main_db)) + yield datasets # rollback changes @@ -1811,3 +1812,33 @@ def test_import_dataset_invalid_v0_validation(self): } ] } + + @pytest.mark.usefixtures("create_datasets") + def test_get_datasets_is_certified_filter(self): + """ + Dataset API: Test custom dataset_is_certified filter + """ + table_w_certification = SqlaTable( + table_name="foo", + schema=None, + owners=[], + database=get_main_database(), + sql=None, + extra='{"certification": 1}', + ) + db.session.add(table_w_certification) + db.session.commit() + + arguments = { + "filters": [{"col": "id", "opr": "dataset_is_certified", "value": True}] + } + self.login(username="admin") + uri = f"api/v1/dataset/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + + assert rv.status_code == 200 + response = json.loads(rv.data.decode("utf-8")) + assert response.get("count") == 1 + + db.session.delete(table_w_certification) + db.session.commit() From ce01ce9e2f6859b3435e6ffb5425d1c29144442c Mon Sep 17 00:00:00 2001 From: Stephen Liu <750188453@qq.com> Date: Tue, 24 May 2022 13:50:01 +0800 Subject: [PATCH 26/49] fix(css): transparent linear gradient not working in safari (#20086) * fix(css): transparent linear gradient not working in safari * use emotion-rgba instead --- .../nativeFilters/FilterBar/ActionButtons/index.tsx | 6 +++++- .../src/explore/components/ControlPanelsContainer.tsx | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx index 55d4783a6e89c..d94885b1c970e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx @@ -27,6 +27,7 @@ import { import Button from 'src/components/Button'; import { isNullish } from 'src/utils/common'; import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants'; +import { rgba } from 'emotion-rgba'; import { getFilterBarTestId } from '../index'; interface ActionButtonsProps { @@ -53,7 +54,10 @@ const ActionButtonsContainer = styled.div` padding: ${theme.gridUnit * 4}px; padding-top: ${theme.gridUnit * 6}px; - background: linear-gradient(transparent, white 25%); + background: linear-gradient( + ${rgba(theme.colors.grayscale.light5, 0)}, + ${theme.colors.grayscale.light5} ${theme.opacity.mediumLight} + ); pointer-events: none; diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index 98d80275f6977..ef9517e4ddf87 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -58,6 +58,7 @@ import { ExplorePageState } from 'src/explore/reducers/getInitialState'; import { ChartState } from 'src/explore/types'; import { Tooltip } from 'src/components/Tooltip'; +import { rgba } from 'emotion-rgba'; import ControlRow from './ControlRow'; import Control from './Control'; import { ExploreAlert } from './ExploreAlert'; @@ -94,7 +95,7 @@ const actionButtonsContainerStyles = (theme: SupersetTheme) => css` padding: ${theme.gridUnit * 4}px; z-index: 999; background: linear-gradient( - transparent, + ${rgba(theme.colors.grayscale.light5, 0)}, ${theme.colors.grayscale.light5} ${theme.opacity.mediumLight} ); From ce547f4098f54f048d744c5d09200fb694979a48 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Tue, 24 May 2022 23:04:39 +0200 Subject: [PATCH 27/49] chore: Disable flaky assert in reports cypress test (#20174) * chore: Disable flaky assert in reports cypress test * Disable flaky assert in alerts cypress test --- .../cypress/integration/alerts_and_reports/alerts.test.ts | 3 ++- .../cypress/integration/alerts_and_reports/reports.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.ts b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.ts index bc27e831b5afe..0025f4f51383b 100644 --- a/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/alerts.test.ts @@ -39,6 +39,7 @@ describe('alert list view', () => { cy.get('[data-test="sort-header"]').eq(5).contains('Created by'); cy.get('[data-test="sort-header"]').eq(6).contains('Owners'); cy.get('[data-test="sort-header"]').eq(7).contains('Active'); - cy.get('[data-test="sort-header"]').eq(8).contains('Actions'); + // TODO: this assert is flaky, we need to find a way to make it work consistenly + // cy.get('[data-test="sort-header"]').eq(8).contains('Actions'); }); }); diff --git a/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.ts b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.ts index c3007cc1a87d7..a44b389d2e858 100644 --- a/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/alerts_and_reports/reports.test.ts @@ -39,6 +39,7 @@ describe('report list view', () => { cy.get('[data-test="sort-header"]').eq(5).contains('Created by'); cy.get('[data-test="sort-header"]').eq(6).contains('Owners'); cy.get('[data-test="sort-header"]').eq(7).contains('Active'); - cy.get('[data-test="sort-header"]').eq(8).contains('Actions'); + // TODO: this assert is flaky, we need to find a way to make it work consistenly + // cy.get('[data-test="sort-header"]').eq(8).contains('Actions'); }); }); From 7e9b85f76ca8cae38c38e11f857634216b1cd71c Mon Sep 17 00:00:00 2001 From: stevetracvc <70416691+stevetracvc@users.noreply.github.com> Date: Tue, 24 May 2022 20:10:57 -0600 Subject: [PATCH 28/49] feat: add drag and drop column rearrangement for table viz (#19381) --- .../plugin-chart-table/TableStories.tsx | 9 ++++- .../src/DataTable/DataTable.tsx | 35 +++++++++++++++++++ .../src/DataTable/hooks/useSticky.tsx | 3 +- .../src/DataTable/types/react-table.d.ts | 13 ++++++- .../plugin-chart-table/src/TableChart.tsx | 21 +++++++++-- .../plugin-chart-table/src/controlPanel.tsx | 14 ++++++++ .../plugin-chart-table/src/transformProps.ts | 2 ++ .../plugins/plugin-chart-table/src/types.ts | 2 ++ 8 files changed, 93 insertions(+), 6 deletions(-) diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/TableStories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/TableStories.tsx index 129c08f505753..b8ea6d9cb5c85 100644 --- a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/TableStories.tsx +++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-table/TableStories.tsx @@ -66,6 +66,7 @@ function loadData( alignPn = false, showCellBars = true, includeSearch = true, + allowRearrangeColumns = false, }, ): TableChartProps { if (!props.queriesData || !props.queriesData[0]) return props; @@ -86,6 +87,7 @@ function loadData( page_length: pageLength, show_cell_bars: showCellBars, include_search: includeSearch, + allow_rearrange_columns: allowRearrangeColumns, }, height: window.innerHeight - 130, }; @@ -117,8 +119,12 @@ export const BigTable = ({ width, height }) => { const cols = number('Columns', 8, { range: true, min: 1, max: 20 }); const pageLength = number('Page size', 50, { range: true, min: 0, max: 100 }); const includeSearch = boolean('Include search', true); - const alignPn = boolean('Algin PosNeg', false); + const alignPn = boolean('Align PosNeg', false); const showCellBars = boolean('Show Cell Bars', true); + const allowRearrangeColumns = boolean( + 'Allow end user to drag-and-drop column headers to rearrange them.', + false, + ); const chartProps = loadData(birthNames, { pageLength, rows, @@ -126,6 +132,7 @@ export const BigTable = ({ width, height }) => { alignPn, showCellBars, includeSearch, + allowRearrangeColumns, }); return ( extends TableOptions { sticky?: boolean; rowCount: number; wrapperRef?: MutableRefObject; + onColumnOrderChange: () => void; } export interface RenderHTMLCellProps extends HTMLProps { @@ -95,12 +97,14 @@ export default typedMemo(function DataTable({ hooks, serverPagination, wrapperRef: userWrapperRef, + onColumnOrderChange, ...moreUseTableOptions }: DataTableProps): JSX.Element { const tableHooks: PluginHook[] = [ useGlobalFilter, useSortBy, usePagination, + useColumnOrder, doSticky ? useSticky : [], hooks || [], ].flat(); @@ -172,6 +176,8 @@ export default typedMemo(function DataTable({ setGlobalFilter, setPageSize: setPageSize_, wrapStickyTable, + setColumnOrder, + allColumns, state: { pageIndex, pageSize, globalFilter: filterValue, sticky = {} }, } = useTable( { @@ -211,6 +217,33 @@ export default typedMemo(function DataTable({ const shouldRenderFooter = columns.some(x => !!x.Footer); + let columnBeingDragged = -1; + + const onDragStart = (e: React.DragEvent) => { + const el = e.target as HTMLTableCellElement; + columnBeingDragged = allColumns.findIndex( + col => col.id === el.dataset.columnName, + ); + e.dataTransfer.setData('text/plain', `${columnBeingDragged}`); + }; + + const onDrop = (e: React.DragEvent) => { + const el = e.target as HTMLTableCellElement; + const newPosition = allColumns.findIndex( + col => col.id === el.dataset.columnName, + ); + + if (newPosition !== -1) { + const currentCols = allColumns.map(c => c.id); + const colToBeMoved = currentCols.splice(columnBeingDragged, 1); + currentCols.splice(newPosition, 0, colToBeMoved[0]); + setColumnOrder(currentCols); + // toggle value in TableChart to trigger column width recalc + onColumnOrderChange(); + } + e.preventDefault(); + }; + const renderTable = () => ( @@ -223,6 +256,8 @@ export default typedMemo(function DataTable({ column.render('Header', { key: column.id, ...column.getSortByToggleProps(), + onDragStart, + onDrop, }), )} diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx index 9a98fee431817..6fd4d839ce661 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx @@ -350,6 +350,7 @@ function useInstance(instance: TableInstance) { data, page, rows, + allColumns, getTableSize = () => undefined, } = instance; @@ -370,7 +371,7 @@ function useInstance(instance: TableInstance) { useMountedMemo(getTableSize, [getTableSize]) || sticky; // only change of data should trigger re-render // eslint-disable-next-line react-hooks/exhaustive-deps - const table = useMemo(renderer, [page, rows]); + const table = useMemo(renderer, [page, rows, allColumns]); useLayoutEffect(() => { if (!width || !height) { diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/types/react-table.d.ts b/superset-frontend/plugins/plugin-chart-table/src/DataTable/types/react-table.d.ts index 52a18d54e1b0f..c1f49ea396f25 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/types/react-table.d.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/types/react-table.d.ts @@ -36,6 +36,8 @@ import { UseSortByState, UseTableHooks, UseSortByHooks, + UseColumnOrderState, + UseColumnOrderInstanceProps, Renderer, HeaderProps, TableFooterProps, @@ -64,6 +66,7 @@ declare module 'react-table' { UseRowSelectInstanceProps, UseRowStateInstanceProps, UseSortByInstanceProps, + UseColumnOrderInstanceProps, UseStickyInstanceProps {} export interface TableState @@ -73,6 +76,7 @@ declare module 'react-table' { UsePaginationState, UseRowSelectState, UseSortByState, + UseColumnOrderState, UseStickyState {} // Typing from @types/react-table is incomplete @@ -82,12 +86,19 @@ declare module 'react-table' { onClick?: React.MouseEventHandler; } + interface TableRearrangeColumnsProps { + onDragStart: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + } + export interface ColumnInterface extends UseGlobalFiltersColumnOptions, UseSortByColumnOptions { // must define as a new property because it's not possible to override // the existing `Header` renderer option - Header?: Renderer>; + Header?: Renderer< + TableSortByToggleProps & HeaderProps & TableRearrangeColumnsProps + >; Footer?: Renderer>; } diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index 296a7125949a0..f0b125940bb83 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { CSSProperties, useCallback, useMemo } from 'react'; +import React, { CSSProperties, useCallback, useMemo, useState } from 'react'; import { ColumnInstance, ColumnWithLooseAccessor, @@ -192,12 +192,16 @@ export default function TableChart( filters, sticky = true, // whether to use sticky header columnColorFormatters, + allowRearrangeColumns = false, } = props; const timestampFormatter = useCallback( value => getTimeFormatterForGranularity(timeGrain)(value), [timeGrain], ); + // keep track of whether column order changed, so that column widths can too + const [columnOrderToggle, setColumnOrderToggle] = useState(false); + const handleChange = useCallback( (filters: { [x: string]: DataRecordValue[] }) => { if (!emitFilter) { @@ -413,7 +417,7 @@ export default function TableChart( // render `Cell`. This saves some time for large tables. return {text}; }, - Header: ({ column: col, onClick, style }) => ( + Header: ({ column: col, onClick, style, onDragStart, onDrop }) => ( @@ -469,6 +482,7 @@ export default function TableChart( toggleFilter, totals, columnColorFormatters, + columnOrderToggle, ], ); @@ -498,6 +512,7 @@ export default function TableChart( height={height} serverPagination={serverPagination} onServerPaginationChange={handleServerPaginationChange} + onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)} // 9 page items in > 340px works well even for 100+ pages maxPageItemCount={width > 340 ? 9 : 7} noResults={getNoResultsMessage} diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index c121547518e46..bb855bd7ccc56 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -455,6 +455,20 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'allow_rearrange_columns', + config: { + type: 'CheckboxControl', + label: t('Allow columns to be rearranged'), + renderTrigger: true, + default: false, + description: t( + "Allow end user to drag-and-drop column headers to rearrange them. Note their changes won't persist for the next time they open the chart.", + ), + }, + }, + ], [ { name: 'column_config', diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts index 8b701422bb950..5cf4fd1e83c43 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts @@ -220,6 +220,7 @@ const transformProps = ( query_mode: queryMode, show_totals: showTotals, conditional_formatting: conditionalFormatting, + allow_rearrange_columns: allowRearrangeColumns, } = formData; const timeGrain = extractTimegrain(formData); @@ -272,6 +273,7 @@ const transformProps = ( onChangeFilter, columnColorFormatters, timeGrain, + allowRearrangeColumns, }; }; diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts index 7c50f99cb4ae6..f5b83fa8bfd7e 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/types.ts @@ -71,6 +71,7 @@ export type TableChartFormData = QueryFormData & { emit_filter?: boolean; time_grain_sqla?: TimeGranularity; column_config?: Record; + allow_rearrange_columns?: boolean; }; export interface TableChartProps extends ChartProps { @@ -109,6 +110,7 @@ export interface TableChartTransformedProps { emitFilter?: boolean; onChangeFilter?: ChartProps['hooks']['onAddFilter']; columnColorFormatters?: ColorFormatters; + allowRearrangeColumns?: boolean; } export default {}; From b0c6935f0600f111f06ae7ff05f7fa902e9ad252 Mon Sep 17 00:00:00 2001 From: cccs-tom <59839056+cccs-tom@users.noreply.github.com> Date: Wed, 25 May 2022 04:58:15 -0400 Subject: [PATCH 29/49] fix: Allow dataset owners to see their datasets (#20135) --- superset/views/base.py | 9 ++++++++ tests/integration_tests/datasets/api_tests.py | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/superset/views/base.py b/superset/views/base.py index 081951d561aab..1b1c684083ee3 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -620,10 +620,19 @@ def apply(self, query: Query, value: Any) -> Query: return query datasource_perms = security_manager.user_view_menu_names("datasource_access") schema_perms = security_manager.user_view_menu_names("schema_access") + owner_ids_query = ( + db.session.query(models.SqlaTable.id) + .join(models.SqlaTable.owners) + .filter( + security_manager.user_model.id + == security_manager.user_model.get_user_id() + ) + ) return query.filter( or_( self.model.perm.in_(datasource_perms), self.model.schema_perm.in_(schema_perms), + models.SqlaTable.id.in_(owner_ids_query), ) ) diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 781ae929b743c..fa467e0816867 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -214,6 +214,27 @@ def test_get_dataset_list_gamma(self): response = json.loads(rv.data.decode("utf-8")) assert response["result"] == [] + def test_get_dataset_list_gamma_owned(self): + """ + Dataset API: Test get dataset list owned by gamma + """ + main_db = get_main_database() + owned_dataset = self.insert_dataset( + "ab_user", [self.get_user("gamma").id], main_db + ) + + self.login(username="gamma") + uri = "api/v1/dataset/" + rv = self.get_assert_metric(uri, "get_list") + assert rv.status_code == 200 + response = json.loads(rv.data.decode("utf-8")) + + assert response["count"] == 1 + assert response["result"][0]["table_name"] == "ab_user" + + db.session.delete(owned_dataset) + db.session.commit() + def test_get_dataset_related_database_gamma(self): """ Dataset API: Test get dataset related databases gamma From 365acee663f7942ba7d8dfd0e4cf72c4cecb7a2d Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Wed, 25 May 2022 06:04:04 -0400 Subject: [PATCH 30/49] fix: avoid while cycle in computeMaxFontSize for big Number run forever when css rule applied (#20173) --- .../src/dimension/computeMaxFontSize.ts | 16 ++++++++++++++-- .../test/dimension/computeMaxFontSize.test.ts | 9 +++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/dimension/computeMaxFontSize.ts b/superset-frontend/packages/superset-ui-core/src/dimension/computeMaxFontSize.ts index a762d8b1f4460..ebd1f6e5688ba 100644 --- a/superset-frontend/packages/superset-ui-core/src/dimension/computeMaxFontSize.ts +++ b/superset-frontend/packages/superset-ui-core/src/dimension/computeMaxFontSize.ts @@ -27,8 +27,20 @@ function decreaseSizeUntil( ): number { let size = startSize; let dimension = computeDimension(size); + while (!condition(dimension)) { size -= 1; + + // Here if the size goes below zero most likely is because it + // has additional style applied in which case we assume the user + // knows what it's doing and we just let them use that. + // Visually it works, although it could have another + // check in place. + if (size < 0) { + size = startSize; + break; + } + dimension = computeDimension(size); } @@ -66,7 +78,7 @@ export default function computeMaxFontSize( size = decreaseSizeUntil( size, computeDimension, - dim => dim.width <= maxWidth, + dim => dim.width > 0 && dim.width <= maxWidth, ); } @@ -74,7 +86,7 @@ export default function computeMaxFontSize( size = decreaseSizeUntil( size, computeDimension, - dim => dim.height <= maxHeight, + dim => dim.height > 0 && dim.height <= maxHeight, ); } diff --git a/superset-frontend/packages/superset-ui-core/test/dimension/computeMaxFontSize.test.ts b/superset-frontend/packages/superset-ui-core/test/dimension/computeMaxFontSize.test.ts index 99574f4ccf758..a64d819535c6b 100644 --- a/superset-frontend/packages/superset-ui-core/test/dimension/computeMaxFontSize.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/dimension/computeMaxFontSize.test.ts @@ -59,5 +59,14 @@ describe('computeMaxFontSize(input)', () => { }), ).toEqual(25); }); + it('ensure idealFontSize is used if the maximum font size calculation goes below zero', () => { + expect( + computeMaxFontSize({ + maxWidth: 5, + idealFontSize: 34, + text: SAMPLE_TEXT[0], + }), + ).toEqual(34); + }); }); }); From 40abb44ba1376b37414bbedbd05ddca44c4f7450 Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Wed, 25 May 2022 18:18:58 +0800 Subject: [PATCH 31/49] feat: add samples endpoint (#20170) --- superset/constants.py | 1 + superset/datasets/api.py | 64 ++++++++++++++++ superset/datasets/commands/exceptions.py | 4 + superset/datasets/commands/samples.py | 74 +++++++++++++++++++ tests/integration_tests/datasets/api_tests.py | 40 ++++++++++ 5 files changed, 183 insertions(+) create mode 100644 superset/datasets/commands/samples.py diff --git a/superset/constants.py b/superset/constants.py index 98ce7c5d112f3..72fcc3fdb2bce 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -129,6 +129,7 @@ class RouteMethod: # pylint: disable=too-few-public-methods "available": "read", "validate_sql": "read", "get_data": "read", + "samples": "read", } EXTRA_FORM_DATA_APPEND_KEYS = { diff --git a/superset/datasets/api.py b/superset/datasets/api.py index fb01b6ee8c9cc..6a2e64536e22b 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -45,11 +45,13 @@ DatasetInvalidError, DatasetNotFoundError, DatasetRefreshFailedError, + DatasetSamplesFailedError, DatasetUpdateFailedError, ) from superset.datasets.commands.export import ExportDatasetsCommand from superset.datasets.commands.importers.dispatcher import ImportDatasetsCommand from superset.datasets.commands.refresh import RefreshDatasetCommand +from superset.datasets.commands.samples import SamplesDatasetCommand from superset.datasets.commands.update import UpdateDatasetCommand from superset.datasets.dao import DatasetDAO from superset.datasets.filters import DatasetCertifiedFilter, DatasetIsNullOrEmptyFilter @@ -90,6 +92,7 @@ class DatasetRestApi(BaseSupersetModelRestApi): "bulk_delete", "refresh", "related_objects", + "samples", } list_columns = [ "id", @@ -760,3 +763,64 @@ def import_(self) -> Response: ) command.run() return self.response(200, message="OK") + + @expose("//samples") + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.samples", + log_to_statsd=False, + ) + def samples(self, pk: int) -> Response: + """get samples from a Dataset + --- + get: + description: >- + get samples from a Dataset + parameters: + - in: path + schema: + type: integer + name: pk + - in: query + schema: + type: boolean + name: force + responses: + 200: + description: Dataset samples + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/ChartDataResponseResult' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + force = parse_boolean_string(request.args.get("force")) + rv = SamplesDatasetCommand(g.user, pk, force).run() + return self.response(200, result=rv) + except DatasetNotFoundError: + return self.response_404() + except DatasetForbiddenError: + return self.response_403() + except DatasetSamplesFailedError as ex: + logger.error( + "Error get dataset samples %s: %s", + self.__class__.__name__, + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) diff --git a/superset/datasets/commands/exceptions.py b/superset/datasets/commands/exceptions.py index 34c15721abfb6..b743a4355ea06 100644 --- a/superset/datasets/commands/exceptions.py +++ b/superset/datasets/commands/exceptions.py @@ -173,6 +173,10 @@ class DatasetRefreshFailedError(UpdateFailedError): message = _("Dataset could not be updated.") +class DatasetSamplesFailedError(CommandInvalidError): + message = _("Samples for dataset could not be retrieved.") + + class DatasetForbiddenError(ForbiddenError): message = _("Changing this dataset is forbidden") diff --git a/superset/datasets/commands/samples.py b/superset/datasets/commands/samples.py new file mode 100644 index 0000000000000..4be2c6e90f850 --- /dev/null +++ b/superset/datasets/commands/samples.py @@ -0,0 +1,74 @@ +# 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 logging +from typing import Any, Dict, Optional + +from flask_appbuilder.security.sqla.models import User + +from superset.commands.base import BaseCommand +from superset.common.chart_data import ChartDataResultType +from superset.common.query_context_factory import QueryContextFactory +from superset.connectors.sqla.models import SqlaTable +from superset.datasets.commands.exceptions import ( + DatasetForbiddenError, + DatasetNotFoundError, + DatasetSamplesFailedError, +) +from superset.datasets.dao import DatasetDAO +from superset.exceptions import SupersetSecurityException +from superset.views.base import check_ownership + +logger = logging.getLogger(__name__) + + +class SamplesDatasetCommand(BaseCommand): + def __init__(self, user: User, model_id: int, force: bool): + self._actor = user + self._model_id = model_id + self._force = force + self._model: Optional[SqlaTable] = None + + def run(self) -> Dict[str, Any]: + self.validate() + if not self._model: + raise DatasetNotFoundError() + + qc_instance = QueryContextFactory().create( + datasource={ + "type": self._model.type, + "id": self._model.id, + }, + queries=[{}], + result_type=ChartDataResultType.SAMPLES, + force=self._force, + ) + results = qc_instance.get_payload() + try: + return results["queries"][0] + except (IndexError, KeyError) as exc: + raise DatasetSamplesFailedError from exc + + def validate(self) -> None: + # Validate/populate model exists + self._model = DatasetDAO.find_by_id(self._model_id) + if not self._model: + raise DatasetNotFoundError() + # Check ownership + try: + check_ownership(self._model) + except SupersetSecurityException as ex: + raise DatasetForbiddenError() from ex diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index fa467e0816867..b426ddad71523 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -1863,3 +1863,43 @@ def test_get_datasets_is_certified_filter(self): db.session.delete(table_w_certification) db.session.commit() + + @pytest.mark.usefixtures("create_datasets") + def test_get_dataset_samples(self): + """ + Dataset API: Test get dataset samples + """ + dataset = self.get_fixture_datasets()[0] + + self.login(username="admin") + uri = f"api/v1/dataset/{dataset.id}/samples" + + # 1. should cache data + # feeds data + self.client.get(uri) + # get from cache + rv = self.client.get(uri) + rv_data = json.loads(rv.data) + assert rv.status_code == 200 + assert "result" in rv_data + assert rv_data["result"]["cached_dttm"] is not None + + # 2. should through cache + uri2 = f"api/v1/dataset/{dataset.id}/samples?force=true" + # feeds data + self.client.get(uri2) + # force query + rv2 = self.client.get(uri2) + rv_data2 = json.loads(rv2.data) + assert rv_data2["result"]["cached_dttm"] is None + + # 3. data precision + assert "colnames" in rv_data2["result"] + assert "coltypes" in rv_data2["result"] + assert "data" in rv_data2["result"] + + eager_samples = dataset.database.get_df( + f"select * from {dataset.table_name}" + f' limit {self.app.config["SAMPLES_ROW_LIMIT"]}' + ).to_dict(orient="records") + assert eager_samples == rv_data2["result"]["data"] From 3a4176a8d5a862355d62b8a07be20d2a3a124302 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Wed, 25 May 2022 12:56:19 +0200 Subject: [PATCH 32/49] chore: Implement global header in Dashboard (#20146) * Add gloal header * Reimplement report dropdown * Update unit tests * Clean up * Clean up * Remove unused import * Update Cypress * Update Cypress save dashboard test * Fix spacing --- .../dashboard/dashboard.applitools.test.ts | 4 +- .../integration/dashboard/edit_mode.test.js | 8 +- .../dashboard/edit_properties.test.ts | 15 +- .../integration/dashboard/markdown.test.ts | 8 +- .../integration/dashboard/save.test.js | 24 +- .../visualizations/download_chart.test.js | 2 +- .../cypress/support/directories.ts | 5 +- .../src/assets/images/icons/redo.svg | 21 + .../src/assets/images/icons/undo.svg | 21 + .../components/DynamicEditableTitle/index.tsx | 5 + .../src/components/Icons/index.tsx | 2 + .../PageHeaderWithActions/index.tsx | 18 +- .../HeaderReportDropdown/index.tsx | 8 +- .../components/Header/Header.test.tsx | 35 +- .../HeaderActionsDropdown.test.tsx | 81 ++-- .../Header/HeaderActionsDropdown/index.jsx | 218 +++++---- .../src/dashboard/components/Header/index.jsx | 440 ++++++++++-------- .../components/RefreshIntervalModal.test.tsx | 7 +- .../components/menu/ShareMenuItems/index.tsx | 4 +- .../stylesheets/components/header.less | 5 - .../src/dashboard/stylesheets/dashboard.less | 21 - .../components/ExploreViewContainer/index.jsx | 54 +-- 22 files changed, 560 insertions(+), 446 deletions(-) create mode 100644 superset-frontend/src/assets/images/icons/redo.svg create mode 100644 superset-frontend/src/assets/images/icons/undo.svg diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.applitools.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.applitools.test.ts index 7a61087c1a8b3..d492175a5e3a6 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.applitools.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.applitools.test.ts @@ -41,8 +41,8 @@ describe('Dashboard load', () => { }); it('should load the Dashboard in edit mode', () => { - cy.get('[data-test="dashboard-header"]') - .find('[aria-label=edit-alt]') + cy.get('.header-with-actions') + .find('[aria-label="Edit dashboard"]') .click(); // wait for a chart to appear cy.get('[data-test="grid-container"]').find('.box_plot', { diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js index d799dccd3bb6e..7a3b82705cebd 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js @@ -22,8 +22,8 @@ describe('Dashboard edit mode', () => { beforeEach(() => { cy.login(); cy.visit(WORLD_HEALTH_DASHBOARD); - cy.get('[data-test="dashboard-header"]') - .find('[aria-label=edit-alt]') + cy.get('.header-with-actions') + .find('[aria-label="Edit dashboard"]') .click(); }); @@ -94,9 +94,9 @@ describe('Dashboard edit mode', () => { .find('[data-test="discard-changes-button"]') .should('be.visible') .click(); - cy.get('[data-test="dashboard-header"]').within(() => { + cy.get('.header-with-actions').within(() => { cy.get('[data-test="dashboard-edit-actions"]').should('not.be.visible'); - cy.get('[aria-label="edit-alt"]').should('be.visible'); + cy.get('[aria-label="Edit dashboard"]').should('be.visible'); }); }); }); diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_properties.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_properties.test.ts index bf4bd319c1ddd..b3061cdb7d40a 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_properties.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_properties.test.ts @@ -66,9 +66,13 @@ function openAdvancedProperties() { function openDashboardEditProperties() { // open dashboard properties edit modal - cy.get('#save-dash-split-button').trigger('click', { force: true }); + cy.get( + '.header-with-actions .right-button-panel .ant-dropdown-trigger', + ).trigger('click', { + force: true, + }); cy.get('[data-test=header-actions-menu]') - .contains('Edit dashboard properties') + .contains('Edit properties') .click({ force: true }); } @@ -80,7 +84,7 @@ describe('Dashboard edit action', () => { cy.get('.dashboard-grid', { timeout: 50000 }) .should('be.visible') // wait for 50 secs to load dashboard .then(() => { - cy.get('.dashboard-header [aria-label=edit-alt]') + cy.get('.header-with-actions [aria-label="Edit dashboard"]') .should('be.visible') .click(); openDashboardEditProperties(); @@ -106,7 +110,10 @@ describe('Dashboard edit action', () => { cy.get('.ant-modal-body').should('not.exist'); // assert title has been updated - cy.get('.editable-title input').should('have.value', dashboardTitle); + cy.get('[data-test="editable-title-input"]').should( + 'have.value', + dashboardTitle, + ); }); }); describe('the color picker is changed', () => { diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts index 7ce391868809b..2964241b184a2 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts @@ -25,14 +25,12 @@ describe('Dashboard edit markdown', () => { }); it('should add markdown component to dashboard', () => { - cy.get('[data-test="dashboard-header"]') - .find('[aria-label="edit-alt"]') + cy.get('.header-with-actions') + .find('[aria-label="Edit dashboard"]') .click(); // lazy load - need to open dropdown for the scripts to load - cy.get('[data-test="dashboard-header"]') - .find('[aria-label="more-horiz"]') - .click(); + cy.get('.header-with-actions').find('[aria-label="more-horiz"]').click(); cy.get('[data-test="grid-row-background--transparent"]') .first() .as('component-background-first'); diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js b/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js index 8064f81fa14da..b0e9d1141cd30 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js @@ -25,9 +25,11 @@ import { } from './dashboard.helper'; function openDashboardEditProperties() { - cy.get('.dashboard-header [aria-label=edit-alt]').click(); - cy.get('#save-dash-split-button').trigger('click', { force: true }); - cy.get('.dropdown-menu').contains('Edit dashboard properties').click(); + cy.get('.header-with-actions [aria-label="Edit dashboard"]').click(); + cy.get( + '.header-with-actions .right-button-panel .ant-dropdown-trigger', + ).trigger('click', { force: true }); + cy.get('.dropdown-menu').contains('Edit properties').click(); } describe('Dashboard save action', () => { @@ -35,8 +37,8 @@ describe('Dashboard save action', () => { cy.login(); cy.visit(WORLD_HEALTH_DASHBOARD); cy.get('#app').then(data => { - cy.get('[data-test="dashboard-header"]').then(headerElement => { - const dashboardId = headerElement.attr('data-test-id'); + cy.get('.dashboard-header-container').then(headerContainerElement => { + const dashboardId = headerContainerElement.attr('data-test-id'); cy.intercept('POST', `/superset/copy_dash/${dashboardId}/`).as( 'copyRequest', @@ -56,7 +58,7 @@ describe('Dashboard save action', () => { // change to what the title should be it('should save as new dashboard', () => { cy.wait('@copyRequest').then(xhr => { - cy.get('[data-test="editable-title-input"]').then(element => { + cy.get('[data-test="editable-title"]').then(element => { const dashboardTitle = element.attr('title'); expect(dashboardTitle).to.not.equal(`World Bank's Data`); }); @@ -68,7 +70,7 @@ describe('Dashboard save action', () => { WORLD_HEALTH_CHARTS.forEach(waitForChartLoad); // remove box_plot chart from dashboard - cy.get('[aria-label="edit-alt"]').click({ timeout: 5000 }); + cy.get('[aria-label="Edit dashboard"]').click({ timeout: 5000 }); cy.get('[data-test="dashboard-delete-component-button"]') .last() .trigger('mouseenter') @@ -79,15 +81,15 @@ describe('Dashboard save action', () => { .should('not.exist'); cy.intercept('PUT', '/api/v1/dashboard/**').as('putDashboardRequest'); - cy.get('[data-test="dashboard-header"]') + cy.get('.header-with-actions') .find('[data-test="header-save-button"]') .contains('Save') .click(); // go back to view mode cy.wait('@putDashboardRequest'); - cy.get('[data-test="dashboard-header"]') - .find('[aria-label="edit-alt"]') + cy.get('.header-with-actions') + .find('[aria-label="Edit dashboard"]') .click(); // deleted boxplot should still not exist @@ -142,7 +144,7 @@ describe('Dashboard save action', () => { cy.get('.ant-modal-body').should('not.exist'); // save dashboard changes - cy.get('.dashboard-header').contains('Save').click(); + cy.get('.header-with-actions').contains('Save').click(); // assert success flash cy.contains('saved successfully').should('be.visible'); diff --git a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js index 42f9c13123bd8..ce4a871f8e2de 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js +++ b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js @@ -34,7 +34,7 @@ describe('Download Chart > Distribution bar chart', () => { }; cy.visitChartByParams(JSON.stringify(formData)); - cy.get('.right-button-panel .ant-dropdown-trigger').click(); + cy.get('.header-with-actions .ant-dropdown-trigger').click(); cy.get(':nth-child(1) > .ant-dropdown-menu-submenu-title').click(); cy.get( '.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)', diff --git a/superset-frontend/cypress-base/cypress/support/directories.ts b/superset-frontend/cypress-base/cypress/support/directories.ts index 9c783a7220e80..fde9ee0cdeacf 100644 --- a/superset-frontend/cypress-base/cypress/support/directories.ts +++ b/superset-frontend/cypress-base/cypress/support/directories.ts @@ -630,7 +630,8 @@ export const dashboardView = { trashIcon: dataTestLocator('dashboard-delete-component-button'), refreshChart: dataTestLocator('refresh-chart-menu-item'), }, - threeDotsMenuIcon: '#save-dash-split-button', + threeDotsMenuIcon: + '.header-with-actions .right-button-panel .ant-dropdown-trigger', threeDotsMenuDropdown: dataTestLocator('header-actions-menu'), refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'), saveAsMenuOption: dataTestLocator('save-as-menu-item'), @@ -660,7 +661,7 @@ export const dashboardView = { }, sliceThreeDots: '[aria-label="More Options"]', sliceThreeDotsDropdown: '[role="menu"]', - editDashboardButton: '[aria-label=edit-alt]', + editDashboardButton: '[aria-label="Edit dashboard"]', starIcon: dataTestLocator('fave-unfave-icon'), dashboardHeader: dataTestLocator('dashboard-header'), dashboardSectionContainer: dataTestLocator( diff --git a/superset-frontend/src/assets/images/icons/redo.svg b/superset-frontend/src/assets/images/icons/redo.svg new file mode 100644 index 0000000000000..a35cf022525e7 --- /dev/null +++ b/superset-frontend/src/assets/images/icons/redo.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/src/assets/images/icons/undo.svg b/superset-frontend/src/assets/images/icons/undo.svg new file mode 100644 index 0000000000000..b680a68649681 --- /dev/null +++ b/superset-frontend/src/assets/images/icons/undo.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/src/components/DynamicEditableTitle/index.tsx b/superset-frontend/src/components/DynamicEditableTitle/index.tsx index 969aea19b7302..d9e7066330130 100644 --- a/superset-frontend/src/components/DynamicEditableTitle/index.tsx +++ b/superset-frontend/src/components/DynamicEditableTitle/index.tsx @@ -92,6 +92,10 @@ export const DynamicEditableTitle = ({ refreshMode: 'debounce', }); + useEffect(() => { + setCurrentTitle(title); + }, [title]); + useEffect(() => { if (isEditing && contentRef?.current) { contentRef.current.focus(); @@ -202,6 +206,7 @@ export const DynamicEditableTitle = ({ className="dynamic-title" aria-label={label ?? t('Title')} ref={contentRef} + data-test="editable-title" > {currentTitle} diff --git a/superset-frontend/src/components/Icons/index.tsx b/superset-frontend/src/components/Icons/index.tsx index 08b13404a04d2..27efbe4c2e29f 100644 --- a/superset-frontend/src/components/Icons/index.tsx +++ b/superset-frontend/src/components/Icons/index.tsx @@ -155,6 +155,8 @@ const IconFileNames = [ 'tags', 'ballot', 'category', + 'undo', + 'redo', ]; const iconOverrides: Record = {}; diff --git a/superset-frontend/src/components/PageHeaderWithActions/index.tsx b/superset-frontend/src/components/PageHeaderWithActions/index.tsx index 204a82b235d1d..4449d1c6b3472 100644 --- a/superset-frontend/src/components/PageHeaderWithActions/index.tsx +++ b/superset-frontend/src/components/PageHeaderWithActions/index.tsx @@ -50,7 +50,21 @@ const headerStyles = (theme: SupersetTheme) => css` align-items: center; flex-wrap: nowrap; justify-content: space-between; - height: 100%; + background-color: ${theme.colors.grayscale.light5}; + height: ${theme.gridUnit * 16}px; + padding: 0 ${theme.gridUnit * 4}px; + + .editable-title { + overflow: hidden; + + & > input[type='button'], + & > span { + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + white-space: nowrap; + } + } span[role='button'] { display: flex; @@ -113,7 +127,7 @@ export const PageHeaderWithActions = ({ }: PageHeaderWithActionsProps) => { const theme = useTheme(); return ( -
+
{showTitlePanelItems && ( diff --git a/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx b/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx index cd741e5c338ba..7beec04ffd7a3 100644 --- a/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx +++ b/superset-frontend/src/components/ReportModal/HeaderReportDropdown/index.tsx @@ -243,7 +243,11 @@ export default function HeaderReportDropDown({ triggerNode.closest('.action-button') } > - + @@ -253,7 +257,7 @@ export default function HeaderReportDropDown({ role="button" title={t('Schedule email report')} tabIndex={0} - className="action-button" + className="action-button action-schedule-report" onClick={() => setShowModal(true)} > diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/Header/Header.test.tsx index e5851fb2d500b..730596a9f4b2b 100644 --- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx @@ -122,7 +122,7 @@ function setup(props: HeaderProps, initialState = {}) { async function openActionsDropdown() { const btn = screen.getByRole('img', { name: 'more-horiz' }); userEvent.click(btn); - expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect(await screen.findByTestId('header-actions-menu')).toBeInTheDocument(); } test('should render', () => { @@ -134,7 +134,9 @@ test('should render', () => { test('should render the title', () => { const mockedProps = createProps(); setup(mockedProps); - expect(screen.getByText('Dashboard Title')).toBeInTheDocument(); + expect(screen.getByTestId('editable-title')).toHaveTextContent( + 'Dashboard Title', + ); }); test('should render the editable title', () => { @@ -161,21 +163,30 @@ test('should render the "Draft" status', () => { }); test('should publish', () => { - setup(editableProps); + const mockedProps = createProps(); + const canEditProps = { + ...mockedProps, + dashboardInfo: { + ...mockedProps.dashboardInfo, + dash_edit_perm: true, + dash_save_perm: true, + }, + }; + setup(canEditProps); const draft = screen.getByText('Draft'); - expect(editableProps.savePublished).not.toHaveBeenCalled(); + expect(mockedProps.savePublished).toHaveBeenCalledTimes(0); userEvent.click(draft); - expect(editableProps.savePublished).toHaveBeenCalledTimes(1); + expect(mockedProps.savePublished).toHaveBeenCalledTimes(1); }); test('should render the "Undo" action as disabled', () => { setup(editableProps); - expect(screen.getByTitle('Undo').parentElement).toBeDisabled(); + expect(screen.getByTestId('undo-action').parentElement).toBeDisabled(); }); test('should undo', () => { setup(undoProps); - const undo = screen.getByTitle('Undo'); + const undo = screen.getByTestId('undo-action'); expect(undoProps.onUndo).not.toHaveBeenCalled(); userEvent.click(undo); expect(undoProps.onUndo).toHaveBeenCalledTimes(1); @@ -191,12 +202,12 @@ test('should undo with key listener', () => { test('should render the "Redo" action as disabled', () => { setup(editableProps); - expect(screen.getByTitle('Redo').parentElement).toBeDisabled(); + expect(screen.getByTestId('redo-action').parentElement).toBeDisabled(); }); test('should redo', () => { setup(redoProps); - const redo = screen.getByTitle('Redo'); + const redo = screen.getByTestId('redo-action'); expect(redoProps.onRedo).not.toHaveBeenCalled(); userEvent.click(redo); expect(redoProps.onRedo).toHaveBeenCalledTimes(1); @@ -212,7 +223,7 @@ test('should redo with key listener', () => { test('should render the "Discard changes" button', () => { setup(editableProps); - expect(screen.getByText('Discard changes')).toBeInTheDocument(); + expect(screen.getByText('Discard')).toBeInTheDocument(); }); test('should render the "Save" button as disabled', () => { @@ -297,8 +308,8 @@ test('should toggle the edit mode', () => { }, }; setup(canEditProps); - const editDashboard = screen.getByTitle('Edit dashboard'); - expect(screen.queryByTitle('Edit dashboard')).toBeInTheDocument(); + const editDashboard = screen.getByText('Edit dashboard'); + expect(screen.queryByText('Edit dashboard')).toBeInTheDocument(); userEvent.click(editDashboard); expect(mockedProps.logEvent).toHaveBeenCalled(); }); diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx index 57fe7a1333973..eb3c6aeb4e973 100644 --- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx @@ -29,7 +29,7 @@ import HeaderActionsDropdown from '.'; const createProps = () => ({ addSuccessToast: jest.fn(), addDangerToast: jest.fn(), - customCss: '#save-dash-split-button{margin-left: 100px;}', + customCss: '.ant-menu {margin-left: 100px;}', dashboardId: 1, dashboardInfo: { id: 1, @@ -59,7 +59,10 @@ const createProps = () => ({ userCanEdit: false, userCanSave: false, userCanShare: false, + userCanCurate: false, lastModifiedTime: 0, + isDropdownVisible: true, + dataMask: {}, }); const editModeOnProps = { ...createProps(), @@ -67,50 +70,31 @@ const editModeOnProps = { }; function setup(props: HeaderDropdownProps) { - return ( + return render(
-
+
, + { useRedux: true }, ); } fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {}); -async function openDropdown() { - const btn = screen.getByRole('img', { name: 'more-horiz' }); - userEvent.click(btn); - expect(await screen.findByRole('menu')).toBeInTheDocument(); -} - test('should render', () => { const mockedProps = createProps(); - const { container } = render(setup(mockedProps)); + const { container } = setup(mockedProps); expect(container).toBeInTheDocument(); }); test('should render the dropdown button', () => { const mockedProps = createProps(); - render(setup(mockedProps)); + setup(mockedProps); expect(screen.getByRole('button')).toBeInTheDocument(); }); -test('should render the dropdown icon', () => { - const mockedProps = createProps(); - render(setup(mockedProps)); - expect(screen.getByRole('img', { name: 'more-horiz' })).toBeInTheDocument(); -}); - -test('should open the dropdown', async () => { - const mockedProps = createProps(); - render(setup(mockedProps)); - await openDropdown(); - expect(await screen.findByRole('menu')).toBeInTheDocument(); -}); - test('should render the menu items', async () => { const mockedProps = createProps(); - render(setup(mockedProps)); - await openDropdown(); + setup(mockedProps); expect(screen.getAllByRole('menuitem')).toHaveLength(4); expect(screen.getByText('Refresh dashboard')).toBeInTheDocument(); expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument(); @@ -119,13 +103,11 @@ test('should render the menu items', async () => { }); test('should render the menu items in edit mode', async () => { - render(setup(editModeOnProps)); - await openDropdown(); - expect(screen.getAllByRole('menuitem')).toHaveLength(5); - expect(screen.getByText('Refresh dashboard')).toBeInTheDocument(); + setup(editModeOnProps); + expect(screen.getAllByRole('menuitem')).toHaveLength(4); expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument(); expect(screen.getByText('Set filter mapping')).toBeInTheDocument(); - expect(screen.getByText('Edit dashboard properties')).toBeInTheDocument(); + expect(screen.getByText('Edit properties')).toBeInTheDocument(); expect(screen.getByText('Edit CSS')).toBeInTheDocument(); }); @@ -135,10 +117,9 @@ test('should show the share actions', async () => { ...mockedProps, userCanShare: true, }; - render(setup(canShareProps)); - await openDropdown(); - expect(screen.getByText('Copy permalink to clipboard')).toBeInTheDocument(); - expect(screen.getByText('Share permalink by email')).toBeInTheDocument(); + setup(canShareProps); + + expect(screen.getByText('Share')).toBeInTheDocument(); }); test('should render the "Save Modal" when user can save', async () => { @@ -147,15 +128,13 @@ test('should render the "Save Modal" when user can save', async () => { ...mockedProps, userCanSave: true, }; - render(setup(canSaveProps)); - await openDropdown(); + setup(canSaveProps); expect(screen.getByText('Save as')).toBeInTheDocument(); }); test('should NOT render the "Save Modal" menu item when user cannot save', async () => { const mockedProps = createProps(); - render(setup(mockedProps)); - await openDropdown(); + setup(mockedProps); expect(screen.queryByText('Save as')).not.toBeInTheDocument(); }); @@ -165,43 +144,41 @@ test('should render the "Refresh dashboard" menu item as disabled when loading', ...mockedProps, isLoading: true, }; - render(setup(loadingProps)); - await openDropdown(); + setup(loadingProps); expect(screen.getByText('Refresh dashboard')).toHaveClass( - 'ant-dropdown-menu-item-disabled', + 'ant-menu-item-disabled', ); }); test('should NOT render the "Refresh dashboard" menu item as disabled', async () => { const mockedProps = createProps(); - render(setup(mockedProps)); - await openDropdown(); + setup(mockedProps); expect(screen.getByText('Refresh dashboard')).not.toHaveClass( - 'ant-dropdown-menu-item-disabled', + 'ant-menu-item-disabled', ); }); test('should render with custom css', () => { const mockedProps = createProps(); const { customCss } = mockedProps; - render(setup(mockedProps)); + setup(mockedProps); injectCustomCss(customCss); - expect(screen.getByRole('button')).toHaveStyle('margin-left: 100px'); + expect(screen.getByTestId('header-actions-menu')).toHaveStyle( + 'margin-left: 100px', + ); }); test('should refresh the charts', async () => { const mockedProps = createProps(); - render(setup(mockedProps)); - await openDropdown(); + setup(mockedProps); userEvent.click(screen.getByText('Refresh dashboard')); expect(mockedProps.forceRefreshAllCharts).toHaveBeenCalledTimes(1); expect(mockedProps.addSuccessToast).toHaveBeenCalledTimes(1); }); test('should show the properties modal', async () => { - render(setup(editModeOnProps)); - await openDropdown(); - userEvent.click(screen.getByText('Edit dashboard properties')); + setup(editModeOnProps); + userEvent.click(screen.getByText('Edit properties')); expect(editModeOnProps.showPropertiesModal).toHaveBeenCalledTimes(1); }); diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx index ad3dd91ec7ee5..a7860af30f378 100644 --- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx @@ -19,16 +19,15 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { styled, SupersetClient, t } from '@superset-ui/core'; +import { SupersetClient, t } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; -import { NoAnimationDropdown } from 'src/components/Dropdown'; -import Icons from 'src/components/Icons'; import { URL_PARAMS } from 'src/constants'; import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; import CssEditor from 'src/dashboard/components/CssEditor'; import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal'; import SaveModal from 'src/dashboard/components/SaveModal'; +import HeaderReportDropdown from 'src/components/ReportModal/HeaderReportDropdown'; import injectCustomCss from 'src/dashboard/util/injectCustomCss'; import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants'; import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal'; @@ -91,15 +90,9 @@ const MENU_KEYS = { DOWNLOAD_AS_IMAGE: 'download-as-image', TOGGLE_FULLSCREEN: 'toggle-fullscreen', MANAGE_EMBEDDED: 'manage-embedded', + MANAGE_EMAIL_REPORT: 'manage-email-report', }; -const DropdownButton = styled.div` - margin-left: ${({ theme }) => theme.gridUnit * 2.5}px; - span { - color: ${({ theme }) => theme.colors.grayscale.base}; - } -`; - const SCREENSHOT_NODE_SELECTOR = '.dashboard'; class HeaderActionsDropdown extends React.PureComponent { @@ -112,11 +105,13 @@ class HeaderActionsDropdown extends React.PureComponent { this.state = { css: props.customCss, cssTemplates: [], + showReportSubMenu: null, }; this.changeCss = this.changeCss.bind(this); this.changeRefreshInterval = this.changeRefreshInterval.bind(this); this.handleMenuClick = this.handleMenuClick.bind(this); + this.setShowReportSubMenu = this.setShowReportSubMenu.bind(this); } UNSAFE_componentWillMount() { @@ -144,6 +139,12 @@ class HeaderActionsDropdown extends React.PureComponent { } } + setShowReportSubMenu(show) { + this.setState({ + showReportSubMenu: show, + }); + } + changeCss(css) { this.props.onChange(); this.props.updateCss(css); @@ -224,6 +225,9 @@ class HeaderActionsDropdown extends React.PureComponent { addSuccessToast, addDangerToast, filterboxMigrationState, + setIsDropdownVisible, + isDropdownVisible, + ...rest } = this.props; const emailTitle = t('Superset dashboard'); @@ -236,12 +240,47 @@ class HeaderActionsDropdown extends React.PureComponent { hash: window.location.hash, }); - const menu = ( - + return ( + + {!editMode && ( + + {t('Refresh dashboard')} + + )} + {!editMode && ( + + {getUrlParam(URL_PARAMS.standalone) + ? t('Exit fullscreen') + : t('Enter fullscreen')} + + )} + {editMode && ( + + {t('Edit properties')} + + )} + {editMode && ( + + {t('Edit CSS')}} + initialCss={this.state.css} + templates={this.state.cssTemplates} + onChange={this.changeCss} + /> + + )} + {userCanSave && ( )} + {!editMode && ( + + {t('Download as image')} + + )} {userCanShare && ( - + + + + )} + {!editMode && userCanCurate && ( + + {t('Embed dashboard')} + )} - - {t('Refresh dashboard')} - + {!editMode ? ( + this.state.showReportSubMenu ? ( + <> + + + + + + ) : ( + + + + ) + ) : null} + {editMode && + filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.CONVERTED && ( + + + + )} + {t('Set auto-refresh interval')}} /> - - {editMode && - filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.CONVERTED && ( - - - - )} - - {editMode && ( - - {t('Edit dashboard properties')} - - )} - - {editMode && ( - - {t('Edit CSS')}} - initialCss={this.state.css} - templates={this.state.cssTemplates} - onChange={this.changeCss} - /> - - )} - - {!editMode && userCanCurate && ( - - {t('Embed dashboard')} - - )} - - {!editMode && ( - - {t('Download as image')} - - )} - - {!editMode && ( - - {getUrlParam(URL_PARAMS.standalone) - ? t('Exit fullscreen') - : t('Enter fullscreen')} - - )} ); - return ( - - triggerNode.closest('.dashboard-header') - } - > - - - - - ); } } diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index f60a0d85d7ae2..ab85bcda04f55 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -20,8 +20,8 @@ import moment from 'moment'; import React from 'react'; import PropTypes from 'prop-types'; -import { styled, t, getSharedLabelColor } from '@superset-ui/core'; -import ButtonGroup from 'src/components/ButtonGroup'; +import { styled, css, t, getSharedLabelColor } from '@superset-ui/core'; +import { Global } from '@emotion/react'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, @@ -30,11 +30,10 @@ import { } from 'src/logger/LogUtils'; import Icons from 'src/components/Icons'; import Button from 'src/components/Button'; -import EditableTitle from 'src/components/EditableTitle'; -import FaveStar from 'src/components/FaveStar'; +import { AntdButton } from 'src/components/'; +import { Tooltip } from 'src/components/Tooltip'; import { safeStringify } from 'src/utils/safeStringify'; import HeaderActionsDropdown from 'src/dashboard/components/Header/HeaderActionsDropdown'; -import HeaderReportDropdown from 'src/components/ReportModal/HeaderReportDropdown'; import PublishedStatus from 'src/dashboard/components/PublishedStatus'; import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; @@ -51,6 +50,7 @@ import setPeriodicRunner, { import { options as PeriodicRefreshOptions } from 'src/dashboard/components/RefreshIntervalModal'; import findPermission from 'src/dashboard/util/findPermission'; import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants'; +import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import { DashboardEmbedModal } from '../DashboardEmbedControls'; const propTypes = { @@ -107,34 +107,59 @@ const defaultProps = { colorScheme: undefined, }; -// Styled Components -const StyledDashboardHeader = styled.div` - background: ${({ theme }) => theme.colors.grayscale.light5}; +const headerContainerStyle = theme => css` + border-bottom: 1px solid ${theme.colors.grayscale.light2}; +`; + +const editButtonStyle = theme => css` + color: ${theme.colors.primary.dark2}; +`; + +const actionButtonsStyle = theme => css` display: flex; - flex-direction: row; align-items: center; - justify-content: space-between; - padding: 0 ${({ theme }) => theme.gridUnit * 6}px; - border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - .action-button > span { - color: ${({ theme }) => theme.colors.grayscale.base}; + .action-schedule-report { + margin-left: ${theme.gridUnit * 2}px; } - button, - .fave-unfave-icon { - margin-left: ${({ theme }) => theme.gridUnit * 2}px; + + .undoRedo { + margin-right: ${theme.gridUnit * 2}px; } - .button-container { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - .action-button { - font-size: ${({ theme }) => theme.typography.sizes.xl}px; - margin-left: ${({ theme }) => theme.gridUnit * 2.5}px; - } +`; + +const StyledUndoRedoButton = styled(AntdButton)` + padding: 0; + &:hover { + background: transparent; + } +`; + +const undoRedoStyle = theme => css` + color: ${theme.colors.grayscale.light1}; + &:hover { + color: ${theme.colors.grayscale.base}; } `; +const undoRedoEmphasized = theme => css` + color: ${theme.colors.grayscale.base}; +`; + +const undoRedoDisabled = theme => css` + color: ${theme.colors.grayscale.light2}; +`; + +const saveBtnStyle = theme => css` + min-width: ${theme.gridUnit * 17}px; + height: ${theme.gridUnit * 8}px; +`; + +const discardBtnStyle = theme => css` + min-width: ${theme.gridUnit * 22}px; + height: ${theme.gridUnit * 8}px; +`; + class Header extends React.PureComponent { static discardChanges() { const url = new URL(window.location.href); @@ -148,7 +173,9 @@ class Header extends React.PureComponent { this.state = { didNotifyMaxUndoHistoryToast: false, emphasizeUndo: false, + emphasizeRedo: false, showingPropertiesModal: false, + isDropdownVisible: false, }; this.handleChangeText = this.handleChangeText.bind(this); @@ -160,6 +187,7 @@ class Header extends React.PureComponent { this.overwriteDashboard = this.overwriteDashboard.bind(this); this.showPropertiesModal = this.showPropertiesModal.bind(this); this.hidePropertiesModal = this.hidePropertiesModal.bind(this); + this.setIsDropdownVisible = this.setIsDropdownVisible.bind(this); } componentDidMount() { @@ -205,6 +233,12 @@ class Header extends React.PureComponent { } } + setIsDropdownVisible(visible) { + this.setState({ + isDropdownVisible: visible, + }); + } + handleCtrlY() { this.props.onRedo(); this.setState({ emphasizeRedo: true }, () => { @@ -450,180 +484,214 @@ class Header extends React.PureComponent { }; return ( - -
- - - {user?.userId && dashboardInfo?.id && ( - - )} -
- -
- {userCanSaveAs && ( -
- {editMode && ( - <> - - + } + rightPanelAdditionalItems={ +
+ {userCanSaveAs && ( +
+ {editMode && ( +
+
+ + + + + + + + + + +
+ + +
+ )} +
+ )} + {editMode ? ( + + ) : ( +
+ {userCanEdit && ( - - - - + )} +
)}
- )} - {editMode ? ( - - ) : ( - <> - {userCanEdit && ( - - - - )} - - - )} - - {this.state.showingPropertiesModal && ( - + triggerNode.closest('.header-with-actions'), + visible: this.state.isDropdownVisible, + onVisibleChange: this.setIsDropdownVisible, + }} + additionalActionsMenu={ + - )} - - {userCanCurate && ( - - )} - - + {this.state.showingPropertiesModal && ( + + )} + + {userCanCurate && ( + -
- + )} + +
); } } diff --git a/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx index 9f2f68a9d6451..9151275e800de 100644 --- a/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx +++ b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx @@ -68,7 +68,8 @@ describe('RefreshIntervalModal - Enzyme', () => { const createProps = () => ({ addSuccessToast: jest.fn(), addDangerToast: jest.fn(), - customCss: '#save-dash-split-button{margin-left: 100px;}', + customCss: + '.header-with-actions .right-button-panel .ant-dropdown-trigger{margin-left: 100px;}', dashboardId: 1, dashboardInfo: { id: 1, @@ -100,6 +101,7 @@ const createProps = () => ({ userCanSave: false, userCanShare: false, lastModifiedTime: 0, + isDropdownVisible: true, }); const editModeOnProps = { @@ -116,9 +118,6 @@ const setup = (overrides?: any) => ( fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {}); const openRefreshIntervalModal = async () => { - const headerActionsButton = screen.getByRole('img', { name: 'more-horiz' }); - userEvent.click(headerActionsButton); - const autoRefreshOption = screen.getByText('Set auto-refresh interval'); userEvent.click(autoRefreshOption); }; diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index b196100734cc3..92e5665aa01b5 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -87,7 +87,7 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { } return ( - <> +
{copyMenuItemTitle} @@ -98,7 +98,7 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { {emailMenuItemTitle}
- +
); }; diff --git a/superset-frontend/src/dashboard/stylesheets/components/header.less b/superset-frontend/src/dashboard/stylesheets/components/header.less index 7db5924b71265..355385d373fd6 100644 --- a/superset-frontend/src/dashboard/stylesheets/components/header.less +++ b/superset-frontend/src/dashboard/stylesheets/components/header.less @@ -55,11 +55,6 @@ color: @almost-black; } -.dashboard-header .dashboard-component-header { - font-weight: @font-weight-normal; - width: auto; -} - .dashboard--editing /* note: sizes should be a multiple of the 8px grid unit so that rows in the grid align */ diff --git a/superset-frontend/src/dashboard/stylesheets/dashboard.less b/superset-frontend/src/dashboard/stylesheets/dashboard.less index b9b2b0aab92f8..4586913b0ac36 100644 --- a/superset-frontend/src/dashboard/stylesheets/dashboard.less +++ b/superset-frontend/src/dashboard/stylesheets/dashboard.less @@ -150,27 +150,6 @@ body { margin: 0 20px; } -.dashboard-header .dashboard-component-header { - display: flex; - flex-direction: row; - align-items: center; - - .editable-title { - margin-right: 8px; - } - - .favstar { - font-size: @font-size-xl; - position: relative; - margin-left: 8px; - } - - .publish { - position: relative; - margin-left: 8px; - } -} - .slice_container .alert { margin: 10px; } diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 97e30d335b7a6..2a3c45cf9ee8a 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -86,26 +86,6 @@ const ExploreContainer = styled.div` height: 100%; `; -const ExploreHeaderContainer = styled.div` - ${({ theme }) => css` - background-color: ${theme.colors.grayscale.light5}; - height: ${theme.gridUnit * 16}px; - padding: 0 ${theme.gridUnit * 4}px; - - .editable-title { - overflow: hidden; - - & > input[type='button'], - & > span { - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; - white-space: nowrap; - } - } - `} -`; - const ExplorePanelContainer = styled.div` ${({ theme }) => css` background: ${theme.colors.grayscale.light5}; @@ -530,24 +510,22 @@ function ExploreViewContainer(props) { return ( - - - + Date: Wed, 25 May 2022 14:45:16 +0200 Subject: [PATCH 33/49] fix typo in secutiry.mdx (#20185) --- docs/docs/security.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/security.mdx b/docs/docs/security.mdx index 4f2618773163b..0067f196cb1f9 100644 --- a/docs/docs/security.mdx +++ b/docs/docs/security.mdx @@ -49,7 +49,7 @@ to all databases by default, both **Alpha** and **Gamma** users need to be given To allow logged-out users to access some Superset features, you can use the `PUBLIC_ROLE_LIKE` config setting and assign it to another role whose permissions you want passed to this role. -For example, by setting `PUBLIC_ROLE_LIKE = Gamma` in your `superset_config.py` file, you grant +For example, by setting `PUBLIC_ROLE_LIKE = "Gamma"` in your `superset_config.py` file, you grant public role the same set of permissions as for the **Gamma** role. This is useful if one wants to enable anonymous users to view dashboards. Explicit grant on specific datasets is still required, meaning that you need to edit the **Public** role and add the public data sources to the role manually. From 259e03ee12b0c82d801a0ad5765de4456a9646c5 Mon Sep 17 00:00:00 2001 From: Moritz Rathberger <46812235+rathberm@users.noreply.github.com> Date: Wed, 25 May 2022 17:49:56 +0200 Subject: [PATCH 34/49] feat(Helm Chart): Support resource limits and requests for each component (#20052) --- helm/superset/Chart.yaml | 2 +- helm/superset/templates/deployment-beat.yaml | 4 +++ .../superset/templates/deployment-worker.yaml | 4 +++ helm/superset/templates/deployment.yaml | 4 +++ helm/superset/values.schema.json | 9 +++++++ helm/superset/values.yaml | 26 +++++++++++++++++++ 6 files changed, 48 insertions(+), 1 deletion(-) diff --git a/helm/superset/Chart.yaml b/helm/superset/Chart.yaml index 8d93ab473195b..70dd6fd162b89 100644 --- a/helm/superset/Chart.yaml +++ b/helm/superset/Chart.yaml @@ -22,7 +22,7 @@ maintainers: - name: craig-rueda email: craig@craigrueda.com url: https://github.com/craig-rueda -version: 0.6.1 +version: 0.6.2 dependencies: - name: postgresql version: 11.1.22 diff --git a/helm/superset/templates/deployment-beat.yaml b/helm/superset/templates/deployment-beat.yaml index 55223defc6421..d46d47ee3f9c4 100644 --- a/helm/superset/templates/deployment-beat.yaml +++ b/helm/superset/templates/deployment-beat.yaml @@ -98,7 +98,11 @@ spec: {{- tpl (toYaml .) $ | nindent 12 -}} {{- end }} resources: + {{- if .Values.supersetCeleryBeat.resources }} +{{ toYaml .Values.supersetCeleryBeat.resources | indent 12 }} + {{- else }} {{ toYaml .Values.resources | indent 12 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{ toYaml . | indent 8 }} diff --git a/helm/superset/templates/deployment-worker.yaml b/helm/superset/templates/deployment-worker.yaml index 07bacd371b773..54eb5d87517e4 100644 --- a/helm/superset/templates/deployment-worker.yaml +++ b/helm/superset/templates/deployment-worker.yaml @@ -99,7 +99,11 @@ spec: {{- tpl (toYaml .) $ | nindent 12 -}} {{- end }} resources: + {{- if .Values.supersetWorker.resources }} +{{ toYaml .Values.supersetWorker.resources | indent 12 }} + {{- else }} {{ toYaml .Values.resources | indent 12 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{ toYaml . | indent 8 }} diff --git a/helm/superset/templates/deployment.yaml b/helm/superset/templates/deployment.yaml index b760d5454da24..4d3a42e8e20a7 100644 --- a/helm/superset/templates/deployment.yaml +++ b/helm/superset/templates/deployment.yaml @@ -115,7 +115,11 @@ spec: containerPort: {{ .Values.service.port }} protocol: TCP resources: + {{- if .Values.supersetNode.resources }} +{{ toYaml .Values.supersetNode.resources | indent 12 }} + {{- else }} {{ toYaml .Values.resources | indent 12 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{ toYaml . | indent 8 }} diff --git a/helm/superset/values.schema.json b/helm/superset/values.schema.json index 5e419d35a67d7..6c4359a0ff940 100644 --- a/helm/superset/values.schema.json +++ b/helm/superset/values.schema.json @@ -275,6 +275,9 @@ }, "podLabels": { "$ref": "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.23.0/_definitions.json##/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta/properties/labels" + }, + "resources": { + "type": "object" } }, "required": [ @@ -305,6 +308,9 @@ }, "podLabels": { "$ref": "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.23.0/_definitions.json##/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta/properties/labels" + }, + "resources": { + "type": "object" } }, "required": [ @@ -336,6 +342,9 @@ }, "podLabels": { "$ref": "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.23.0/_definitions.json##/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta/properties/labels" + }, + "resources": { + "type": "object" } }, "required": [ diff --git a/helm/superset/values.yaml b/helm/superset/values.yaml index 2adc6bf662944..197ec4b3c6e70 100644 --- a/helm/superset/values.yaml +++ b/helm/superset/values.yaml @@ -203,6 +203,8 @@ resources: {} # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # The limits below will apply to all Superset components. To set individual resource limitations refer to the pod specific values below. + # The pod specific values will overwrite anything that is set here. # limits: # cpu: 100m # memory: 128Mi @@ -253,6 +255,14 @@ supersetNode: podAnnotations: {} ## Labels to be added to supersetNode pods podLabels: {} + # Resource settings for the supersetNode pods - these settings overwrite might existing values from the global resources object defined above. + resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi ## ## Superset worker configuration supersetWorker: @@ -275,6 +285,14 @@ supersetWorker: podAnnotations: {} ## Labels to be added to supersetWorker pods podLabels: {} + # Resource settings for the supersetWorker pods - these settings overwrite might existing values from the global resources object defined above. + resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi ## ## Superset beat configuration (to trigger scheduled jobs like reports) supersetCeleryBeat: @@ -299,6 +317,14 @@ supersetCeleryBeat: podAnnotations: {} ## Labels to be added to supersetCeleryBeat pods podLabels: {} + # Resource settings for the CeleryBeat pods - these settings overwrite might existing values from the global resources object defined above. + resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi ## ## Init job configuration init: From 73443cea2f2c26272148771415ecb0ce5358cc91 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Wed, 25 May 2022 17:42:54 -0300 Subject: [PATCH 35/49] refactor: Removes embedded/index.tsx warnings (#20193) --- superset-frontend/src/embedded/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 52e0aee8d29b5..c28d416a18aef 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -19,7 +19,7 @@ import React, { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter as Router, Route } from 'react-router-dom'; -import { makeApi, t } from '@superset-ui/core'; +import { makeApi, t, logging } from '@superset-ui/core'; import { Switchboard } from '@superset-ui/switchboard'; import { bootstrapData } from 'src/preamble'; import setupClient from 'src/setup/setupClient'; @@ -35,7 +35,7 @@ const debugMode = process.env.WEBPACK_MODE === 'development'; function log(...info: unknown[]) { if (debugMode) { - console.debug(`[superset]`, ...info); + logging.debug(`[superset]`, ...info); } } @@ -69,16 +69,16 @@ const appMountPoint = document.getElementById('app')!; const MESSAGE_TYPE = '__embedded_comms__'; +function showFailureMessage(message: string) { + appMountPoint.innerHTML = message; +} + if (!window.parent || window.parent === window) { showFailureMessage( 'This page is intended to be embedded in an iframe, but it looks like that is not the case.', ); } -function showFailureMessage(message: string) { - appMountPoint.innerHTML = message; -} - // if the page is embedded in an origin that hasn't // been authorized by the curator, we forbid access entirely. // todo: check the referrer on the route serving this page instead @@ -134,7 +134,7 @@ function start() { }, err => { // something is most likely wrong with the guest token - console.error(err); + logging.error(err); showFailureMessage( 'Something went wrong with embedded authentication. Check the dev console for details.', ); From 0501ad25e8437757b0ac611026734f2460796e1b Mon Sep 17 00:00:00 2001 From: Phillip Kelley-Dotson Date: Wed, 25 May 2022 14:23:25 -0700 Subject: [PATCH 36/49] fix: always create parameter json field (#19899) * fix: always create parameter json field * ensure validation for empty catalog * check engine instead of name * put validation in be * fix test * fix test * remove test --- superset/db_engine_specs/gsheets.py | 8 ++++++++ tests/unit_tests/db_engine_specs/test_gsheets.py | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/superset/db_engine_specs/gsheets.py b/superset/db_engine_specs/gsheets.py index ba13389e26898..740c1bc33d367 100644 --- a/superset/db_engine_specs/gsheets.py +++ b/superset/db_engine_specs/gsheets.py @@ -171,6 +171,14 @@ def validate_parameters( if not table_catalog: # Allowing users to submit empty catalogs + errors.append( + SupersetError( + message="Sheet name is required", + error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, + level=ErrorLevel.WARNING, + extra={"catalog": {"idx": 0, "name": True}}, + ), + ) return errors # We need a subject in case domain wide delegation is set, otherwise the diff --git a/tests/unit_tests/db_engine_specs/test_gsheets.py b/tests/unit_tests/db_engine_specs/test_gsheets.py index b050c6fdbf2ab..c2e8346c3c7ac 100644 --- a/tests/unit_tests/db_engine_specs/test_gsheets.py +++ b/tests/unit_tests/db_engine_specs/test_gsheets.py @@ -40,7 +40,14 @@ def test_validate_parameters_simple( "catalog": {}, } errors = GSheetsEngineSpec.validate_parameters(parameters) - assert errors == [] + assert errors == [ + SupersetError( + message="Sheet name is required", + error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, + level=ErrorLevel.WARNING, + extra={"catalog": {"idx": 0, "name": True}}, + ), + ] def test_validate_parameters_catalog( From ac4158e903a0c5c153a84d5cc1bbb11376bb987c Mon Sep 17 00:00:00 2001 From: Nicola Coretti Date: Thu, 26 May 2022 03:01:46 +0200 Subject: [PATCH 37/49] Update dependencies for exasol feature flag (#20127) Updating the sqlalchemy-exasol package will fix #20105 . --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e0a6f7556121b..b76c49a5d39a0 100644 --- a/setup.py +++ b/setup.py @@ -136,7 +136,7 @@ def get_git_sha() -> str: "druid": ["pydruid>=0.6.1,<0.7"], "solr": ["sqlalchemy-solr >= 0.2.0"], "elasticsearch": ["elasticsearch-dbapi>=0.2.0, <0.3.0"], - "exasol": ["sqlalchemy-exasol>=2.1.0, <2.2"], + "exasol": ["sqlalchemy-exasol >= 2.4.0, <3.0"], "excel": ["xlrd>=1.2.0, <1.3"], "firebird": ["sqlalchemy-firebird>=0.7.0, <0.8"], "firebolt": ["firebolt-sqlalchemy>=0.0.1"], From 95b28fc1346939017f8f6d867abeb12c7704d846 Mon Sep 17 00:00:00 2001 From: smileydev <47900232+prosdev0107@users.noreply.github.com> Date: Wed, 25 May 2022 22:18:17 -0500 Subject: [PATCH 38/49] fix(db): make to allow to show/hide the password when only creating (#20186) --- .../DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx index a07c9c9498158..34c21466bec8d 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx @@ -122,7 +122,7 @@ export const passwordField = ({ id="password" name="password" required={required} - type={isEditMode && 'password'} + visibilityToggle={!isEditMode} value={db?.parameters?.password} validationMethods={{ onBlur: getValidation }} errorMessage={validationErrors?.password} From 694f75d37657a554831fe9747e5baef36339ed88 Mon Sep 17 00:00:00 2001 From: Ramunas Balukonis <95075496+ramunas-omnisend@users.noreply.github.com> Date: Thu, 26 May 2022 12:31:32 +0300 Subject: [PATCH 39/49] fix: "Week Staring Monday" time grain for BigQuery (#20091) * Week Staring From Monday added for BQ * lint fix --- superset/db_engine_specs/bigquery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/db_engine_specs/bigquery.py b/superset/db_engine_specs/bigquery.py index daac53d3ab4a4..e33457c79abb1 100644 --- a/superset/db_engine_specs/bigquery.py +++ b/superset/db_engine_specs/bigquery.py @@ -140,6 +140,7 @@ class BigQueryEngineSpec(BaseEngineSpec): "PT1H": "{func}({col}, HOUR)", "P1D": "{func}({col}, DAY)", "P1W": "{func}({col}, WEEK)", + "1969-12-29T00:00:00Z/P1W": "{func}({col}, ISOWEEK)", "P1M": "{func}({col}, MONTH)", "P3M": "{func}({col}, QUARTER)", "P1Y": "{func}({col}, YEAR)", From e9007e3c2c86a9c9380483872e2b5ef17b22e19f Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Thu, 26 May 2022 18:59:54 +0800 Subject: [PATCH 40/49] refactor: decouple DataTablesPane (#20109) --- .../src/components/Chart/chartAction.js | 15 +- .../src/components/Chart/chartActions.test.js | 43 +- .../dashboard/components/Dashboard.test.jsx | 1 + .../DataTablesPane/DataTablesPane.test.tsx | 12 + .../DataTablesPane/DataTablesPane.tsx | 214 +++++++ .../components/DataTableControls.tsx | 75 +++ .../DataTablesPane/components/ResultsPane.tsx | 173 ++++++ .../DataTablesPane/components/SamplesPane.tsx | 143 +++++ .../DataTablesPane/components/index.ts | 21 + .../components/DataTablesPane/index.ts | 20 + .../components/DataTablesPane/index.tsx | 532 ------------------ .../components/DataTablesPane/types.ts | 48 ++ .../ExploreChartHeader.test.tsx | 1 + .../explore/components/ExploreChartPanel.jsx | 5 +- .../components/ExploreChartPanel.test.jsx | 61 +- .../components/ExploreViewContainer/index.jsx | 1 + .../useOriginalFormattedTimeColumns.ts | 2 +- superset/datasets/api.py | 14 +- 18 files changed, 811 insertions(+), 570 deletions(-) create mode 100644 superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx create mode 100644 superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx create mode 100644 superset-frontend/src/explore/components/DataTablesPane/components/ResultsPane.tsx create mode 100644 superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx create mode 100644 superset-frontend/src/explore/components/DataTablesPane/components/index.ts create mode 100644 superset-frontend/src/explore/components/DataTablesPane/index.ts delete mode 100644 superset-frontend/src/explore/components/DataTablesPane/index.tsx create mode 100644 superset-frontend/src/explore/components/DataTablesPane/types.ts diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js index 8f451444f4633..4ccf6a23cda1f 100644 --- a/superset-frontend/src/components/Chart/chartAction.js +++ b/superset-frontend/src/components/Chart/chartAction.js @@ -481,7 +481,6 @@ export function exploreJSON( return Promise.all([ chartDataRequestCaught, dispatch(triggerQuery(false, key)), - dispatch(updateQueryFormData(formData, key)), ...annotationLayers.map(annotation => dispatch( runAnnotationQuery({ @@ -595,3 +594,17 @@ export function refreshChart(chartKey, force, dashboardId) { ); }; } + +export const getDatasetSamples = async (datasetId, force) => { + const endpoint = `/api/v1/dataset/${datasetId}/samples?force=${force}`; + try { + const response = await SupersetClient.get({ endpoint }); + return response.json.result; + } catch (err) { + const clientError = await getClientErrorObject(err); + throw new Error( + clientError.message || clientError.error || t('Sorry, an error occurred'), + { cause: err }, + ); + } +}; diff --git a/superset-frontend/src/components/Chart/chartActions.test.js b/superset-frontend/src/components/Chart/chartActions.test.js index 7c7af00a4b2e4..08840fd6db6ae 100644 --- a/superset-frontend/src/components/Chart/chartActions.test.js +++ b/superset-frontend/src/components/Chart/chartActions.test.js @@ -105,8 +105,8 @@ describe('chart actions', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + // chart update, trigger query, success + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.args[0][0].type).toBe(actions.CHART_UPDATE_STARTED); }); @@ -116,43 +116,32 @@ describe('chart actions', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.args[1][0].type).toBe(actions.TRIGGER_QUERY); }); }); - it('should dispatch UPDATE_QUERY_FORM_DATA action with the query', () => { - const actionThunk = actions.postChartFormData({}); - return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); - expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(dispatch.args[2][0].type).toBe(actions.UPDATE_QUERY_FORM_DATA); - }); - }); - it('should dispatch logEvent async action', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + // chart update, trigger query, success + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(typeof dispatch.args[3][0]).toBe('function'); - dispatch.args[3][0](dispatch); - expect(dispatch.callCount).toBe(6); - expect(dispatch.args[5][0].type).toBe(LOG_EVENT); + dispatch.args[2][0](dispatch); + expect(dispatch.callCount).toBe(5); + expect(dispatch.args[4][0].type).toBe(LOG_EVENT); }); }); it('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => { const actionThunk = actions.postChartFormData({}); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, success - expect(dispatch.callCount).toBe(5); + // chart update, trigger query, success + expect(dispatch.callCount).toBe(4); expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED); + expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED); }); }); @@ -168,8 +157,8 @@ describe('chart actions', () => { return actionThunk(dispatch).then(() => { // chart update, trigger query, update form data, fail expect(fetchMock.calls(MOCK_URL)).toHaveLength(1); - expect(dispatch.callCount).toBe(5); - expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_FAILED); + expect(dispatch.callCount).toBe(4); + expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_FAILED); setupDefaultFetchMock(); }); }); @@ -185,9 +174,9 @@ describe('chart actions', () => { const actionThunk = actions.postChartFormData({}, false, timeoutInSec); return actionThunk(dispatch).then(() => { - // chart update, trigger query, update form data, fail - expect(dispatch.callCount).toBe(5); - const updateFailedAction = dispatch.args[4][0]; + // chart update, trigger query, fail + expect(dispatch.callCount).toBe(4); + const updateFailedAction = dispatch.args[3][0]; expect(updateFailedAction.type).toBe(actions.CHART_UPDATE_FAILED); expect(updateFailedAction.queriesResponse[0].error).toBe('misc error'); diff --git a/superset-frontend/src/dashboard/components/Dashboard.test.jsx b/superset-frontend/src/dashboard/components/Dashboard.test.jsx index a881d0cdadf93..c76e1e3518219 100644 --- a/superset-frontend/src/dashboard/components/Dashboard.test.jsx +++ b/superset-frontend/src/dashboard/components/Dashboard.test.jsx @@ -48,6 +48,7 @@ describe('Dashboard', () => { removeSliceFromDashboard() {}, triggerQuery() {}, logEvent() {}, + updateQueryFormData() {}, }, initMessages: [], dashboardState, diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx index cb95e29fd091c..16445a2e3ed02 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx @@ -26,6 +26,8 @@ import { screen, waitForElementToBeRemoved, } from 'spec/helpers/testing-library'; +import { DatasourceType } from '@superset-ui/core'; +import { exploreActions } from 'src/explore/actions/exploreActions'; import { DataTablesPane } from '.'; const createProps = () => ({ @@ -62,6 +64,16 @@ const createProps = () => ({ colnames: [], }, ], + datasource: { + id: 0, + name: '', + type: DatasourceType.Table, + columns: [], + metrics: [], + columnFormats: {}, + verboseMap: {}, + }, + actions: exploreActions, }); describe('DataTablesPane', () => { diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx new file mode 100644 index 0000000000000..77258f7e92f9b --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx @@ -0,0 +1,214 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { + useCallback, + useEffect, + useMemo, + useState, + MouseEvent, +} from 'react'; +import { styled, t, useTheme } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; +import Tabs from 'src/components/Tabs'; +import { + getItem, + setItem, + LocalStorageKeys, +} from 'src/utils/localStorageHelpers'; +import { ResultsPane, SamplesPane, TableControlsWrapper } from './components'; +import { DataTablesPaneProps } from './types'; + +enum ResultTypes { + Results = 'results', + Samples = 'samples', +} + +const SouthPane = styled.div` + ${({ theme }) => ` + position: relative; + background-color: ${theme.colors.grayscale.light5}; + z-index: 5; + overflow: hidden; + + .ant-tabs { + height: 100%; + } + + .ant-tabs-content-holder { + height: 100%; + } + + .ant-tabs-content { + height: 100%; + } + + .ant-tabs-tabpane { + display: flex; + flex-direction: column; + height: 100%; + + .table-condensed { + height: 100%; + overflow: auto; + margin-bottom: ${theme.gridUnit * 4}px; + + .table { + margin-bottom: ${theme.gridUnit * 2}px; + } + } + + .pagination-container > ul[role='navigation'] { + margin-top: 0; + } + } + `} +`; + +export const DataTablesPane = ({ + queryFormData, + datasource, + queryForce, + onCollapseChange, + ownState, + errorMessage, + actions, +}: DataTablesPaneProps) => { + const theme = useTheme(); + const [activeTabKey, setActiveTabKey] = useState(ResultTypes.Results); + const [isRequest, setIsRequest] = useState>({ + results: getItem(LocalStorageKeys.is_datapanel_open, false), + samples: false, + }); + const [panelOpen, setPanelOpen] = useState( + getItem(LocalStorageKeys.is_datapanel_open, false), + ); + + useEffect(() => { + setItem(LocalStorageKeys.is_datapanel_open, panelOpen); + }, [panelOpen]); + + useEffect(() => { + if (!panelOpen) { + setIsRequest({ + results: false, + samples: false, + }); + } + + if (panelOpen && activeTabKey === ResultTypes.Results) { + setIsRequest({ + results: true, + samples: false, + }); + } + + if (panelOpen && activeTabKey === ResultTypes.Samples) { + setIsRequest({ + results: false, + samples: true, + }); + } + }, [panelOpen, activeTabKey]); + + const handleCollapseChange = useCallback( + (isOpen: boolean) => { + onCollapseChange(isOpen); + setPanelOpen(isOpen); + }, + [onCollapseChange], + ); + + const handleTabClick = useCallback( + (tabKey: string, e: MouseEvent) => { + if (!panelOpen) { + handleCollapseChange(true); + } else if (tabKey === activeTabKey) { + e.preventDefault(); + handleCollapseChange(false); + } + setActiveTabKey(tabKey); + }, + [activeTabKey, handleCollapseChange, panelOpen], + ); + + const CollapseButton = useMemo(() => { + const caretIcon = panelOpen ? ( + + ) : ( + + ); + return ( + + {panelOpen ? ( + handleCollapseChange(false)} + > + {caretIcon} + + ) : ( + handleCollapseChange(true)} + > + {caretIcon} + + )} + + ); + }, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]); + + return ( + + + + + + + + + + + ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx new file mode 100644 index 0000000000000..738a20c3b95ec --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx @@ -0,0 +1,75 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useMemo } from 'react'; +import { css, styled } from '@superset-ui/core'; +import { + CopyToClipboardButton, + FilterInput, + RowCount, +} from 'src/explore/components/DataTableControl'; +import { applyFormattingToTabularData } from 'src/utils/common'; +import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns'; + +export const TableControlsWrapper = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: ${theme.gridUnit * 2}px; + + span { + flex-shrink: 0; + } + `} +`; + +export const TableControls = ({ + data, + datasourceId, + onInputChange, + columnNames, + isLoading, +}: { + data: Record[]; + datasourceId?: string; + onInputChange: (input: string) => void; + columnNames: string[]; + isLoading: boolean; +}) => { + const originalFormattedTimeColumns = + useOriginalFormattedTimeColumns(datasourceId); + const formattedData = useMemo( + () => applyFormattingToTabularData(data, originalFormattedTimeColumns), + [data, originalFormattedTimeColumns], + ); + return ( + + +
+ + +
+
+ ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPane.tsx new file mode 100644 index 0000000000000..b334a980b52b1 --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPane.tsx @@ -0,0 +1,173 @@ +/** + * 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 { ensureIsArray, GenericDataType, styled, t } from '@superset-ui/core'; +import Loading from 'src/components/Loading'; +import { EmptyStateMedium } from 'src/components/EmptyState'; +import TableView, { EmptyWrapperType } from 'src/components/TableView'; +import { + useFilteredTableData, + useTableColumns, +} from 'src/explore/components/DataTableControl'; +import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns'; +import { getChartDataRequest } from 'src/components/Chart/chartAction'; +import { getClientErrorObject } from 'src/utils/getClientErrorObject'; +import { TableControls } from './DataTableControls'; +import { ResultsPaneProps } from '../types'; + +const Error = styled.pre` + margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`}; +`; + +const cache = new WeakSet(); + +export const ResultsPane = ({ + isRequest, + queryFormData, + queryForce, + ownState, + errorMessage, + actions, + dataSize = 50, +}: ResultsPaneProps) => { + const [filterText, setFilterText] = useState(''); + const [data, setData] = useState[][]>([]); + const [colnames, setColnames] = useState([]); + const [coltypes, setColtypes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [responseError, setResponseError] = useState(''); + + useEffect(() => { + // it's an invalid formData when gets a errorMessage + if (errorMessage) return; + if (isRequest && !cache.has(queryFormData)) { + setIsLoading(true); + getChartDataRequest({ + formData: queryFormData, + force: queryForce, + resultFormat: 'json', + resultType: 'results', + ownState, + }) + .then(({ json }) => { + const { colnames, coltypes } = json.result[0]; + // Only displaying the first query is currently supported + if (json.result.length > 1) { + // todo: move these code to the backend, shouldn't loop by row in FE + const data: any[] = []; + json.result.forEach((item: { data: any[] }) => { + item.data.forEach((row, i) => { + if (data[i] !== undefined) { + data[i] = { ...data[i], ...row }; + } else { + data[i] = row; + } + }); + }); + setData(data); + setColnames(colnames); + setColtypes(coltypes); + } else { + setData(ensureIsArray(json.result[0].data)); + setColnames(colnames); + setColtypes(coltypes); + } + setResponseError(''); + cache.add(queryFormData); + if (queryForce && actions) { + actions.setForceQuery(false); + } + }) + .catch(response => { + getClientErrorObject(response).then(({ error, message }) => { + setResponseError(error || message || t('Sorry, an error occurred')); + }); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [queryFormData, isRequest]); + + const originalFormattedTimeColumns = useOriginalFormattedTimeColumns( + queryFormData.datasource, + ); + // this is to preserve the order of the columns, even if there are integer values, + // while also only grabbing the first column's keys + const columns = useTableColumns( + colnames, + coltypes, + data, + queryFormData.datasource, + originalFormattedTimeColumns, + ); + const filteredData = useFilteredTableData(filterText, data); + + if (errorMessage) { + const title = t('Run a query to display results'); + return ; + } + + if (isLoading) { + return ; + } + + if (responseError) { + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + {responseError} + + ); + } + + if (data.length === 0) { + const title = t('No results were returned for this query'); + return ; + } + + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + + + ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx new file mode 100644 index 0000000000000..de3ef919f6b6c --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx @@ -0,0 +1,143 @@ +/** + * 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, useMemo } from 'react'; +import { GenericDataType, styled, t } from '@superset-ui/core'; +import Loading from 'src/components/Loading'; +import { EmptyStateMedium } from 'src/components/EmptyState'; +import TableView, { EmptyWrapperType } from 'src/components/TableView'; +import { + useFilteredTableData, + useTableColumns, +} from 'src/explore/components/DataTableControl'; +import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns'; +import { getDatasetSamples } from 'src/components/Chart/chartAction'; +import { TableControls } from './DataTableControls'; +import { SamplesPaneProps } from '../types'; + +const Error = styled.pre` + margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`}; +`; + +const cache = new WeakSet(); + +export const SamplesPane = ({ + isRequest, + datasource, + queryForce, + actions, + dataSize = 50, +}: SamplesPaneProps) => { + const [filterText, setFilterText] = useState(''); + const [data, setData] = useState[][]>([]); + const [colnames, setColnames] = useState([]); + const [coltypes, setColtypes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [responseError, setResponseError] = useState(''); + const datasourceId = useMemo( + () => `${datasource.id}__${datasource.type}`, + [datasource], + ); + + useEffect(() => { + if (isRequest && queryForce) { + cache.delete(datasource); + } + + if (isRequest && !cache.has(datasource)) { + setIsLoading(true); + getDatasetSamples(datasource.id, queryForce) + .then(response => { + setData(response.data); + setColnames(response.colnames); + setColtypes(response.coltypes); + setResponseError(''); + cache.add(datasource); + if (queryForce && actions) { + actions.setForceQuery(false); + } + }) + .catch(error => { + setResponseError(`${error.name}: ${error.message}`); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [datasource, isRequest, queryForce]); + + const originalFormattedTimeColumns = + useOriginalFormattedTimeColumns(datasourceId); + // this is to preserve the order of the columns, even if there are integer values, + // while also only grabbing the first column's keys + const columns = useTableColumns( + colnames, + coltypes, + data, + datasourceId, + originalFormattedTimeColumns, + ); + const filteredData = useFilteredTableData(filterText, data); + + if (isLoading) { + return ; + } + + if (responseError) { + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + {responseError} + + ); + } + + if (data.length === 0) { + const title = t('No samples were returned for this dataset'); + return ; + } + + return ( + <> + setFilterText(input)} + isLoading={isLoading} + /> + + + ); +}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/index.ts b/superset-frontend/src/explore/components/DataTablesPane/components/index.ts new file mode 100644 index 0000000000000..41623cb572083 --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/components/index.ts @@ -0,0 +1,21 @@ +/** + * 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 { ResultsPane } from './ResultsPane'; +export { SamplesPane } from './SamplesPane'; +export { TableControls, TableControlsWrapper } from './DataTableControls'; diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.ts b/superset-frontend/src/explore/components/DataTablesPane/index.ts new file mode 100644 index 0000000000000..603cf71e6ff5d --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/index.ts @@ -0,0 +1,20 @@ +/** + * 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 { DataTablesPane } from './DataTablesPane'; +export * from './components'; diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.tsx b/superset-frontend/src/explore/components/DataTablesPane/index.tsx deleted file mode 100644 index efa904fd9877c..0000000000000 --- a/superset-frontend/src/explore/components/DataTablesPane/index.tsx +++ /dev/null @@ -1,532 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import React, { - useCallback, - useEffect, - useMemo, - useState, - MouseEvent, -} from 'react'; -import { - css, - ensureIsArray, - GenericDataType, - JsonObject, - styled, - t, - useTheme, -} from '@superset-ui/core'; -import Icons from 'src/components/Icons'; -import Tabs from 'src/components/Tabs'; -import Loading from 'src/components/Loading'; -import { EmptyStateMedium } from 'src/components/EmptyState'; -import TableView, { EmptyWrapperType } from 'src/components/TableView'; -import { getChartDataRequest } from 'src/components/Chart/chartAction'; -import { getClientErrorObject } from 'src/utils/getClientErrorObject'; -import { - getItem, - setItem, - LocalStorageKeys, -} from 'src/utils/localStorageHelpers'; -import { - CopyToClipboardButton, - FilterInput, - RowCount, - useFilteredTableData, - useTableColumns, -} from 'src/explore/components/DataTableControl'; -import { applyFormattingToTabularData } from 'src/utils/common'; -import { useOriginalFormattedTimeColumns } from '../useOriginalFormattedTimeColumns'; - -const RESULT_TYPES = { - results: 'results' as const, - samples: 'samples' as const, -}; - -const getDefaultDataTablesState = (value: any) => ({ - [RESULT_TYPES.results]: value, - [RESULT_TYPES.samples]: value, -}); - -const DATA_TABLE_PAGE_SIZE = 50; - -const TableControlsWrapper = styled.div` - ${({ theme }) => ` - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: ${theme.gridUnit * 2}px; - - span { - flex-shrink: 0; - } - `} -`; - -const SouthPane = styled.div` - ${({ theme }) => ` - position: relative; - background-color: ${theme.colors.grayscale.light5}; - z-index: 5; - overflow: hidden; - - .ant-tabs { - height: 100%; - } - - .ant-tabs-content-holder { - height: 100%; - } - - .ant-tabs-content { - height: 100%; - } - - .ant-tabs-tabpane { - display: flex; - flex-direction: column; - height: 100%; - - .table-condensed { - height: 100%; - overflow: auto; - margin-bottom: ${theme.gridUnit * 4}px; - - .table { - margin-bottom: ${theme.gridUnit * 2}px; - } - } - - .pagination-container > ul[role='navigation'] { - margin-top: 0; - } - } - `} -`; - -const Error = styled.pre` - margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`}; -`; - -interface DataTableProps { - columnNames: string[]; - columnTypes: GenericDataType[] | undefined; - datasource: string | undefined; - filterText: string; - data: object[] | undefined; - isLoading: boolean; - error: string | undefined; - errorMessage: React.ReactElement | undefined; - type: 'results' | 'samples'; -} - -const DataTable = ({ - columnNames, - columnTypes, - datasource, - filterText, - data, - isLoading, - error, - errorMessage, - type, -}: DataTableProps) => { - const originalFormattedTimeColumns = - useOriginalFormattedTimeColumns(datasource); - // this is to preserve the order of the columns, even if there are integer values, - // while also only grabbing the first column's keys - const columns = useTableColumns( - columnNames, - columnTypes, - data, - datasource, - originalFormattedTimeColumns, - ); - const filteredData = useFilteredTableData(filterText, data); - - if (isLoading) { - return ; - } - if (error) { - return {error}; - } - if (data) { - if (data.length === 0) { - const title = - type === 'samples' - ? t('No samples were returned for this query') - : t('No results were returned for this query'); - return ; - } - return ( - - ); - } - if (errorMessage) { - const title = - type === 'samples' - ? t('Run a query to display samples') - : t('Run a query to display results'); - return ; - } - return null; -}; - -const TableControls = ({ - data, - datasourceId, - onInputChange, - columnNames, - isLoading, -}: { - data: Record[]; - datasourceId?: string; - onInputChange: (input: string) => void; - columnNames: string[]; - isLoading: boolean; -}) => { - const originalFormattedTimeColumns = - useOriginalFormattedTimeColumns(datasourceId); - const formattedData = useMemo( - () => applyFormattingToTabularData(data, originalFormattedTimeColumns), - [data, originalFormattedTimeColumns], - ); - return ( - - -
- - -
-
- ); -}; - -export const DataTablesPane = ({ - queryFormData, - queryForce, - onCollapseChange, - chartStatus, - ownState, - errorMessage, - queriesResponse, -}: { - queryFormData: Record; - queryForce: boolean; - chartStatus: string; - ownState?: JsonObject; - onCollapseChange: (isOpen: boolean) => void; - errorMessage?: JSX.Element; - queriesResponse: Record; -}) => { - const theme = useTheme(); - const [data, setData] = useState(getDefaultDataTablesState(undefined)); - const [isLoading, setIsLoading] = useState(getDefaultDataTablesState(true)); - const [columnNames, setColumnNames] = useState(getDefaultDataTablesState([])); - const [columnTypes, setColumnTypes] = useState(getDefaultDataTablesState([])); - const [error, setError] = useState(getDefaultDataTablesState('')); - const [filterText, setFilterText] = useState(getDefaultDataTablesState('')); - const [activeTabKey, setActiveTabKey] = useState( - RESULT_TYPES.results, - ); - const [isRequestPending, setIsRequestPending] = useState( - getDefaultDataTablesState(false), - ); - const [panelOpen, setPanelOpen] = useState( - getItem(LocalStorageKeys.is_datapanel_open, false), - ); - - const getData = useCallback( - (resultType: 'samples' | 'results') => { - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [resultType]: true, - })); - return getChartDataRequest({ - formData: queryFormData, - force: queryForce, - resultFormat: 'json', - resultType, - ownState, - }) - .then(({ json }) => { - // Only displaying the first query is currently supported - if (json.result.length > 1) { - const data: any[] = []; - json.result.forEach((item: { data: any[] }) => { - item.data.forEach((row, i) => { - if (data[i] !== undefined) { - data[i] = { ...data[i], ...row }; - } else { - data[i] = row; - } - }); - }); - setData(prevData => ({ - ...prevData, - [resultType]: data, - })); - } else { - setData(prevData => ({ - ...prevData, - [resultType]: json.result[0].data, - })); - } - - const colNames = ensureIsArray(json.result[0].colnames); - - setColumnNames(prevColumnNames => ({ - ...prevColumnNames, - [resultType]: colNames, - })); - setColumnTypes(prevColumnTypes => ({ - ...prevColumnTypes, - [resultType]: json.result[0].coltypes || [], - })); - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [resultType]: false, - })); - setError(prevError => ({ - ...prevError, - [resultType]: undefined, - })); - }) - .catch(response => { - getClientErrorObject(response).then(({ error, message }) => { - setError(prevError => ({ - ...prevError, - [resultType]: error || message || t('Sorry, an error occurred'), - })); - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [resultType]: false, - })); - }); - }); - }, - [queryFormData, columnNames], - ); - useEffect(() => { - setItem(LocalStorageKeys.is_datapanel_open, panelOpen); - }, [panelOpen]); - - useEffect(() => { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: true, - })); - }, [queryFormData]); - - useEffect(() => { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.samples]: true, - })); - }, [queryFormData?.datasource]); - - useEffect(() => { - if (queriesResponse && chartStatus === 'success') { - const { colnames } = queriesResponse[0]; - setColumnNames(prevColumnNames => ({ - ...prevColumnNames, - [RESULT_TYPES.results]: colnames ?? [], - })); - } - }, [queriesResponse, chartStatus]); - - useEffect(() => { - if (panelOpen && isRequestPending[RESULT_TYPES.results]) { - if (errorMessage) { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: false, - })); - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [RESULT_TYPES.results]: false, - })); - return; - } - if (chartStatus === 'loading') { - setIsLoading(prevIsLoading => ({ - ...prevIsLoading, - [RESULT_TYPES.results]: true, - })); - } else { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: false, - })); - getData(RESULT_TYPES.results); - } - } - if ( - panelOpen && - isRequestPending[RESULT_TYPES.samples] && - activeTabKey === RESULT_TYPES.samples - ) { - setIsRequestPending(prevState => ({ - ...prevState, - [RESULT_TYPES.samples]: false, - })); - getData(RESULT_TYPES.samples); - } - }, [ - panelOpen, - isRequestPending, - getData, - activeTabKey, - chartStatus, - errorMessage, - ]); - - const handleCollapseChange = useCallback( - (isOpen: boolean) => { - onCollapseChange(isOpen); - setPanelOpen(isOpen); - }, - [onCollapseChange], - ); - - const handleTabClick = useCallback( - (tabKey: string, e: MouseEvent) => { - if (!panelOpen) { - handleCollapseChange(true); - } else if (tabKey === activeTabKey) { - e.preventDefault(); - handleCollapseChange(false); - } - setActiveTabKey(tabKey); - }, - [activeTabKey, handleCollapseChange, panelOpen], - ); - - const CollapseButton = useMemo(() => { - const caretIcon = panelOpen ? ( - - ) : ( - - ); - return ( - - {panelOpen ? ( - handleCollapseChange(false)} - > - {caretIcon} - - ) : ( - handleCollapseChange(true)} - > - {caretIcon} - - )} - - ); - }, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]); - - return ( - - - - - setFilterText(prevState => ({ - ...prevState, - [RESULT_TYPES.results]: input, - })) - } - isLoading={isLoading[RESULT_TYPES.results]} - /> - - - - - setFilterText(prevState => ({ - ...prevState, - [RESULT_TYPES.samples]: input, - })) - } - isLoading={isLoading[RESULT_TYPES.samples]} - /> - - - - - ); -}; diff --git a/superset-frontend/src/explore/components/DataTablesPane/types.ts b/superset-frontend/src/explore/components/DataTablesPane/types.ts new file mode 100644 index 0000000000000..aa71326aa4b71 --- /dev/null +++ b/superset-frontend/src/explore/components/DataTablesPane/types.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 { Datasource, JsonObject, QueryFormData } from '@superset-ui/core'; +import { ExploreActions } from 'src/explore/actions/exploreActions'; + +export interface DataTablesPaneProps { + queryFormData: QueryFormData; + datasource: Datasource; + queryForce: boolean; + ownState?: JsonObject; + onCollapseChange: (isOpen: boolean) => void; + errorMessage?: JSX.Element; + actions: ExploreActions; +} + +export interface ResultsPaneProps { + isRequest: boolean; + queryFormData: QueryFormData; + queryForce: boolean; + ownState?: JsonObject; + errorMessage?: React.ReactElement; + actions?: ExploreActions; + dataSize?: number; +} + +export interface SamplesPaneProps { + isRequest: boolean; + datasource: Datasource; + queryForce: boolean; + actions?: ExploreActions; + dataSize?: number; +} diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index 540a444ab0473..b76796bfde97c 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -97,6 +97,7 @@ const createProps = () => ({ fetchFaveStar: jest.fn(), saveFaveStar: jest.fn(), redirectSQLLab: jest.fn(), + updateQueryFormData: jest.fn(), }, user: { userId: 1, diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.jsx index 49ed20848d687..ff8a46b3c5149 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.jsx @@ -198,6 +198,7 @@ const ExploreChartPanel = ({ undefined, ownState, ); + actions.updateQueryFormData(formData, chart.id); }, [actions, chart.id, formData, ownState, timeout]); const onCollapseChange = useCallback(isOpen => { @@ -388,11 +389,11 @@ const ExploreChartPanel = ({ )} diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx index a779773052e69..557b2149e01e1 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.test.jsx @@ -17,12 +17,12 @@ * under the License. */ import React from 'react'; +import userEvent from '@testing-library/user-event'; import { render, screen } from 'spec/helpers/testing-library'; import ChartContainer from 'src/explore/components/ExploreChartPanel'; const createProps = (overrides = {}) => ({ sliceName: 'Trend Line', - vizType: 'line', height: '500px', actions: {}, can_overwrite: false, @@ -30,9 +30,29 @@ const createProps = (overrides = {}) => ({ containerId: 'foo', width: '500px', isStarred: false, - chartIsStale: false, - chart: {}, - form_data: {}, + vizType: 'histogram', + chart: { + id: 1, + latestQueryFormData: { + viz_type: 'histogram', + datasource: '49__table', + slice_id: 318, + url_params: {}, + granularity_sqla: 'time_start', + time_range: 'No filter', + all_columns_x: ['age'], + adhoc_filters: [], + row_limit: 10000, + groupby: null, + color_scheme: 'supersetColors', + label_colors: {}, + link_length: '25', + x_axis_label: 'age', + y_axis_label: 'count', + }, + chartStatus: 'rendered', + queriesResponse: [{ is_cached: true }], + }, ...overrides, }); @@ -83,4 +103,37 @@ describe('ChartContainer', () => { screen.getByText('Required control values have been removed'), ).toBeVisible(); }); + + it('should render cached button and call expected actions', () => { + const setForceQuery = jest.fn(); + const postChartFormData = jest.fn(); + const updateQueryFormData = jest.fn(); + const props = createProps({ + actions: { + setForceQuery, + postChartFormData, + updateQueryFormData, + }, + }); + render(, { useRedux: true }); + + const cached = screen.queryByText('Cached'); + expect(cached).toBeInTheDocument(); + + userEvent.click(cached); + expect(setForceQuery).toHaveBeenCalledTimes(1); + expect(postChartFormData).toHaveBeenCalledTimes(1); + expect(updateQueryFormData).toHaveBeenCalledTimes(1); + }); + + it('should hide cached button', () => { + const props = createProps({ + chart: { + chartStatus: 'rendered', + queriesResponse: [{ is_cached: false }], + }, + }); + render(, { useRedux: true }); + expect(screen.queryByText('Cached')).not.toBeInTheDocument(); + }); }); diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 2a3c45cf9ee8a..fc5703a2adaa2 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -267,6 +267,7 @@ function ExploreViewContainer(props) { const onQuery = useCallback(() => { props.actions.setForceQuery(false); props.actions.triggerQuery(true, props.chart.id); + props.actions.updateQueryFormData(props.form_data, props.chart.id); addHistory(); setLastQueriedControls(props.controls); }, [props.controls, addHistory, props.actions, props.chart.id]); diff --git a/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts b/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts index b51fef617889d..140cd3ba6223d 100644 --- a/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts +++ b/superset-frontend/src/explore/components/useOriginalFormattedTimeColumns.ts @@ -22,6 +22,6 @@ import { ExplorePageState } from '../reducers/getInitialState'; export const useOriginalFormattedTimeColumns = (datasourceId?: string) => useSelector(state => datasourceId - ? state.explore.originalFormattedTimeColumns?.[datasourceId] ?? [] + ? state?.explore?.originalFormattedTimeColumns?.[datasourceId] ?? [] : [], ); diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 6a2e64536e22b..313634766c001 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -21,8 +21,9 @@ from typing import Any from zipfile import is_zipfile, ZipFile +import simplejson import yaml -from flask import g, request, Response, send_file +from flask import g, make_response, request, Response, send_file from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext @@ -62,7 +63,7 @@ get_delete_ids_schema, get_export_ids_schema, ) -from superset.utils.core import parse_boolean_string +from superset.utils.core import json_int_dttm_ser, parse_boolean_string from superset.views.base import DatasourceFilter, generate_download_headers from superset.views.base_api import ( BaseSupersetModelRestApi, @@ -811,7 +812,14 @@ def samples(self, pk: int) -> Response: try: force = parse_boolean_string(request.args.get("force")) rv = SamplesDatasetCommand(g.user, pk, force).run() - return self.response(200, result=rv) + response_data = simplejson.dumps( + {"result": rv}, + default=json_int_dttm_ser, + ignore_nan=True, + ) + resp = make_response(response_data, 200) + resp.headers["Content-Type"] = "application/json; charset=utf-8" + return resp except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: From 64c4226817b04ff598be29b52d8e2c4a679ef70a Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Thu, 26 May 2022 14:45:20 +0300 Subject: [PATCH 41/49] fix(temporary-cache): when user is anonymous (#20181) * fix(temporary-cache): fail on anonymous user * make exceptions generic * fix test * remove redundant bool return * fix unit tests --- .../filter_state/commands/create.py | 17 ++++--- .../filter_state/commands/delete.py | 22 ++++---- .../dashboards/filter_state/commands/get.py | 4 +- .../filter_state/commands/update.py | 45 ++++++++-------- .../dashboards/filter_state/commands/utils.py | 35 +++++++++++++ superset/explore/form_data/api.py | 45 +++++----------- superset/explore/form_data/commands/create.py | 6 +-- superset/explore/form_data/commands/delete.py | 5 +- superset/explore/form_data/commands/get.py | 2 +- superset/explore/form_data/commands/state.py | 2 +- superset/explore/form_data/commands/update.py | 10 ++-- superset/explore/form_data/commands/utils.py | 42 +++++++++++++++ superset/explore/utils.py | 8 ++- superset/key_value/utils.py | 7 ++- superset/temporary_cache/api.py | 51 ++++--------------- superset/temporary_cache/commands/entry.py | 4 +- .../temporary_cache/commands/exceptions.py | 4 ++ tests/unit_tests/explore/utils_test.py | 8 +-- 18 files changed, 175 insertions(+), 142 deletions(-) create mode 100644 superset/dashboards/filter_state/commands/utils.py create mode 100644 superset/explore/form_data/commands/utils.py diff --git a/superset/dashboards/filter_state/commands/create.py b/superset/dashboards/filter_state/commands/create.py index 137623027a1fc..18dff8928fe83 100644 --- a/superset/dashboards/filter_state/commands/create.py +++ b/superset/dashboards/filter_state/commands/create.py @@ -14,11 +14,13 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import cast + from flask import session -from superset.dashboards.dao import DashboardDAO +from superset.dashboards.filter_state.commands.utils import check_access from superset.extensions import cache_manager -from superset.key_value.utils import random_key +from superset.key_value.utils import get_owner, random_key from superset.temporary_cache.commands.create import CreateTemporaryCacheCommand from superset.temporary_cache.commands.entry import Entry from superset.temporary_cache.commands.parameters import CommandParameters @@ -34,10 +36,9 @@ def create(self, cmd_params: CommandParameters) -> str: key = cache_manager.filter_state_cache.get(contextual_key) if not key or not tab_id: key = random_key() - value = cmd_params.value - dashboard = DashboardDAO.get_by_id_or_slug(str(resource_id)) - if dashboard and value: - entry: Entry = {"owner": actor.get_user_id(), "value": value} - cache_manager.filter_state_cache.set(cache_key(resource_id, key), entry) - cache_manager.filter_state_cache.set(contextual_key, key) + value = cast(str, cmd_params.value) # schema ensures that value is not optional + check_access(resource_id) + entry: Entry = {"owner": get_owner(actor), "value": value} + cache_manager.filter_state_cache.set(cache_key(resource_id, key), entry) + cache_manager.filter_state_cache.set(contextual_key, key) return key diff --git a/superset/dashboards/filter_state/commands/delete.py b/superset/dashboards/filter_state/commands/delete.py index 155c63f1084c6..3ddc08fc51900 100644 --- a/superset/dashboards/filter_state/commands/delete.py +++ b/superset/dashboards/filter_state/commands/delete.py @@ -16,8 +16,9 @@ # under the License. from flask import session -from superset.dashboards.dao import DashboardDAO +from superset.dashboards.filter_state.commands.utils import check_access from superset.extensions import cache_manager +from superset.key_value.utils import get_owner from superset.temporary_cache.commands.delete import DeleteTemporaryCacheCommand from superset.temporary_cache.commands.entry import Entry from superset.temporary_cache.commands.exceptions import TemporaryCacheAccessDeniedError @@ -30,14 +31,13 @@ def delete(self, cmd_params: CommandParameters) -> bool: resource_id = cmd_params.resource_id actor = cmd_params.actor key = cache_key(resource_id, cmd_params.key) - dashboard = DashboardDAO.get_by_id_or_slug(str(resource_id)) - if dashboard: - entry: Entry = cache_manager.filter_state_cache.get(key) - if entry: - if entry["owner"] != actor.get_user_id(): - raise TemporaryCacheAccessDeniedError() - tab_id = cmd_params.tab_id - contextual_key = cache_key(session.get("_id"), tab_id, resource_id) - cache_manager.filter_state_cache.delete(contextual_key) - return cache_manager.filter_state_cache.delete(key) + check_access(resource_id) + entry: Entry = cache_manager.filter_state_cache.get(key) + if entry: + if entry["owner"] != get_owner(actor): + raise TemporaryCacheAccessDeniedError() + tab_id = cmd_params.tab_id + contextual_key = cache_key(session.get("_id"), tab_id, resource_id) + cache_manager.filter_state_cache.delete(contextual_key) + return cache_manager.filter_state_cache.delete(key) return False diff --git a/superset/dashboards/filter_state/commands/get.py b/superset/dashboards/filter_state/commands/get.py index 9cdd5bcddcb48..ca7ffa9879a9f 100644 --- a/superset/dashboards/filter_state/commands/get.py +++ b/superset/dashboards/filter_state/commands/get.py @@ -18,7 +18,7 @@ from flask import current_app as app -from superset.dashboards.dao import DashboardDAO +from superset.dashboards.filter_state.commands.utils import check_access from superset.extensions import cache_manager from superset.temporary_cache.commands.get import GetTemporaryCacheCommand from superset.temporary_cache.commands.parameters import CommandParameters @@ -34,7 +34,7 @@ def __init__(self, cmd_params: CommandParameters) -> None: def get(self, cmd_params: CommandParameters) -> Optional[str]: resource_id = cmd_params.resource_id key = cache_key(resource_id, cmd_params.key) - DashboardDAO.get_by_id_or_slug(str(resource_id)) + check_access(resource_id) entry = cache_manager.filter_state_cache.get(key) or {} if entry and self._refresh_timeout: cache_manager.filter_state_cache.set(key, entry) diff --git a/superset/dashboards/filter_state/commands/update.py b/superset/dashboards/filter_state/commands/update.py index d27277f9afb97..7f150aae6bae3 100644 --- a/superset/dashboards/filter_state/commands/update.py +++ b/superset/dashboards/filter_state/commands/update.py @@ -14,13 +14,13 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import Optional +from typing import cast, Optional from flask import session -from superset.dashboards.dao import DashboardDAO +from superset.dashboards.filter_state.commands.utils import check_access from superset.extensions import cache_manager -from superset.key_value.utils import random_key +from superset.key_value.utils import get_owner, random_key from superset.temporary_cache.commands.entry import Entry from superset.temporary_cache.commands.exceptions import TemporaryCacheAccessDeniedError from superset.temporary_cache.commands.parameters import CommandParameters @@ -33,28 +33,23 @@ def update(self, cmd_params: CommandParameters) -> Optional[str]: resource_id = cmd_params.resource_id actor = cmd_params.actor key = cmd_params.key - value = cmd_params.value - dashboard = DashboardDAO.get_by_id_or_slug(str(resource_id)) - if dashboard and value: - entry: Entry = cache_manager.filter_state_cache.get( - cache_key(resource_id, key) - ) - if entry: - user_id = actor.get_user_id() - if entry["owner"] != user_id: - raise TemporaryCacheAccessDeniedError() + value = cast(str, cmd_params.value) # schema ensures that value is not optional + check_access(resource_id) + entry: Entry = cache_manager.filter_state_cache.get(cache_key(resource_id, key)) + owner = get_owner(actor) + if entry: + if entry["owner"] != owner: + raise TemporaryCacheAccessDeniedError() - # Generate a new key if tab_id changes or equals 0 - contextual_key = cache_key( - session.get("_id"), cmd_params.tab_id, resource_id - ) - key = cache_manager.filter_state_cache.get(contextual_key) - if not key or not cmd_params.tab_id: - key = random_key() - cache_manager.filter_state_cache.set(contextual_key, key) + # Generate a new key if tab_id changes or equals 0 + contextual_key = cache_key( + session.get("_id"), cmd_params.tab_id, resource_id + ) + key = cache_manager.filter_state_cache.get(contextual_key) + if not key or not cmd_params.tab_id: + key = random_key() + cache_manager.filter_state_cache.set(contextual_key, key) - new_entry: Entry = {"owner": actor.get_user_id(), "value": value} - cache_manager.filter_state_cache.set( - cache_key(resource_id, key), new_entry - ) + new_entry: Entry = {"owner": owner, "value": value} + cache_manager.filter_state_cache.set(cache_key(resource_id, key), new_entry) return key diff --git a/superset/dashboards/filter_state/commands/utils.py b/superset/dashboards/filter_state/commands/utils.py new file mode 100644 index 0000000000000..35f940f4343e1 --- /dev/null +++ b/superset/dashboards/filter_state/commands/utils.py @@ -0,0 +1,35 @@ +# 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. + +from superset.dashboards.commands.exceptions import ( + DashboardAccessDeniedError, + DashboardNotFoundError, +) +from superset.dashboards.dao import DashboardDAO +from superset.temporary_cache.commands.exceptions import ( + TemporaryCacheAccessDeniedError, + TemporaryCacheResourceNotFoundError, +) + + +def check_access(resource_id: int) -> None: + try: + DashboardDAO.get_by_id_or_slug(str(resource_id)) + except DashboardNotFoundError as ex: + raise TemporaryCacheResourceNotFoundError from ex + except DashboardAccessDeniedError as ex: + raise TemporaryCacheAccessDeniedError from ex diff --git a/superset/explore/form_data/api.py b/superset/explore/form_data/api.py index dc6ee7ea94cc3..ea2b38658bb67 100644 --- a/superset/explore/form_data/api.py +++ b/superset/explore/form_data/api.py @@ -21,15 +21,7 @@ from flask_appbuilder.api import BaseApi, expose, protect, safe from marshmallow import ValidationError -from superset.charts.commands.exceptions import ( - ChartAccessDeniedError, - ChartNotFoundError, -) from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod -from superset.datasets.commands.exceptions import ( - DatasetAccessDeniedError, - DatasetNotFoundError, -) from superset.explore.form_data.commands.create import CreateFormDataCommand from superset.explore.form_data.commands.delete import DeleteFormDataCommand from superset.explore.form_data.commands.get import GetFormDataCommand @@ -37,7 +29,10 @@ from superset.explore.form_data.commands.update import UpdateFormDataCommand from superset.explore.form_data.schemas import FormDataPostSchema, FormDataPutSchema from superset.extensions import event_logger -from superset.temporary_cache.commands.exceptions import TemporaryCacheAccessDeniedError +from superset.temporary_cache.commands.exceptions import ( + TemporaryCacheAccessDeniedError, + TemporaryCacheResourceNotFoundError, +) from superset.views.base_api import requires_json logger = logging.getLogger(__name__) @@ -118,13 +113,9 @@ def post(self) -> Response: return self.response(201, key=key) except ValidationError as ex: return self.response(400, message=ex.messages) - except ( - ChartAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) @expose("/form_data/", methods=["PUT"]) @@ -195,13 +186,9 @@ def put(self, key: str) -> Response: return self.response(200, key=result) except ValidationError as ex: return self.response(400, message=ex.messages) - except ( - ChartAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) @expose("/form_data/", methods=["GET"]) @@ -250,13 +237,9 @@ def get(self, key: str) -> Response: if not form_data: return self.response_404() return self.response(200, form_data=form_data) - except ( - ChartAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) @expose("/form_data/", methods=["DELETE"]) @@ -306,11 +289,7 @@ def delete(self, key: str) -> Response: if not result: return self.response_404() return self.response(200, message="Deleted successfully") - except ( - ChartAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) diff --git a/superset/explore/form_data/commands/create.py b/superset/explore/form_data/commands/create.py index 7b1f866c505df..84ad0f0078500 100644 --- a/superset/explore/form_data/commands/create.py +++ b/superset/explore/form_data/commands/create.py @@ -22,9 +22,9 @@ from superset.commands.base import BaseCommand from superset.explore.form_data.commands.parameters import CommandParameters from superset.explore.form_data.commands.state import TemporaryExploreState -from superset.explore.utils import check_access +from superset.explore.form_data.commands.utils import check_access from superset.extensions import cache_manager -from superset.key_value.utils import random_key +from superset.key_value.utils import get_owner, random_key from superset.temporary_cache.commands.exceptions import TemporaryCacheCreateFailedError from superset.temporary_cache.utils import cache_key from superset.utils.schema import validate_json @@ -51,7 +51,7 @@ def run(self) -> str: key = random_key() if form_data: state: TemporaryExploreState = { - "owner": actor.get_user_id(), + "owner": get_owner(actor), "dataset_id": dataset_id, "chart_id": chart_id, "form_data": form_data, diff --git a/superset/explore/form_data/commands/delete.py b/superset/explore/form_data/commands/delete.py index ec537313d2ba0..69d5a79b8f01b 100644 --- a/superset/explore/form_data/commands/delete.py +++ b/superset/explore/form_data/commands/delete.py @@ -23,8 +23,9 @@ from superset.commands.base import BaseCommand from superset.explore.form_data.commands.parameters import CommandParameters from superset.explore.form_data.commands.state import TemporaryExploreState -from superset.explore.utils import check_access +from superset.explore.form_data.commands.utils import check_access from superset.extensions import cache_manager +from superset.key_value.utils import get_owner from superset.temporary_cache.commands.exceptions import ( TemporaryCacheAccessDeniedError, TemporaryCacheDeleteFailedError, @@ -49,7 +50,7 @@ def run(self) -> bool: dataset_id = state["dataset_id"] chart_id = state["chart_id"] check_access(dataset_id, chart_id, actor) - if state["owner"] != actor.get_user_id(): + if state["owner"] != get_owner(actor): raise TemporaryCacheAccessDeniedError() tab_id = self._cmd_params.tab_id contextual_key = cache_key( diff --git a/superset/explore/form_data/commands/get.py b/superset/explore/form_data/commands/get.py index 5b582008218cc..809672f32f982 100644 --- a/superset/explore/form_data/commands/get.py +++ b/superset/explore/form_data/commands/get.py @@ -24,7 +24,7 @@ from superset.commands.base import BaseCommand from superset.explore.form_data.commands.parameters import CommandParameters from superset.explore.form_data.commands.state import TemporaryExploreState -from superset.explore.utils import check_access +from superset.explore.form_data.commands.utils import check_access from superset.extensions import cache_manager from superset.temporary_cache.commands.exceptions import TemporaryCacheGetFailedError diff --git a/superset/explore/form_data/commands/state.py b/superset/explore/form_data/commands/state.py index 2aba14a8cb28f..c8061e81f5a7e 100644 --- a/superset/explore/form_data/commands/state.py +++ b/superset/explore/form_data/commands/state.py @@ -20,7 +20,7 @@ class TemporaryExploreState(TypedDict): - owner: int + owner: Optional[int] dataset_id: int chart_id: Optional[int] form_data: str diff --git a/superset/explore/form_data/commands/update.py b/superset/explore/form_data/commands/update.py index 596c5f6e27ef2..76dfee1dadef8 100644 --- a/superset/explore/form_data/commands/update.py +++ b/superset/explore/form_data/commands/update.py @@ -24,9 +24,9 @@ from superset.commands.base import BaseCommand from superset.explore.form_data.commands.parameters import CommandParameters from superset.explore.form_data.commands.state import TemporaryExploreState -from superset.explore.utils import check_access +from superset.explore.form_data.commands.utils import check_access from superset.extensions import cache_manager -from superset.key_value.utils import random_key +from superset.key_value.utils import get_owner, random_key from superset.temporary_cache.commands.exceptions import ( TemporaryCacheAccessDeniedError, TemporaryCacheUpdateFailedError, @@ -56,9 +56,9 @@ def run(self) -> Optional[str]: state: TemporaryExploreState = cache_manager.explore_form_data_cache.get( key ) + owner = get_owner(actor) if state and form_data: - user_id = actor.get_user_id() - if state["owner"] != user_id: + if state["owner"] != owner: raise TemporaryCacheAccessDeniedError() # Generate a new key if tab_id changes or equals 0 @@ -72,7 +72,7 @@ def run(self) -> Optional[str]: cache_manager.explore_form_data_cache.set(contextual_key, key) new_state: TemporaryExploreState = { - "owner": actor.get_user_id(), + "owner": owner, "dataset_id": dataset_id, "chart_id": chart_id, "form_data": form_data, diff --git a/superset/explore/form_data/commands/utils.py b/superset/explore/form_data/commands/utils.py new file mode 100644 index 0000000000000..5d09657fbec4f --- /dev/null +++ b/superset/explore/form_data/commands/utils.py @@ -0,0 +1,42 @@ +# 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. +from typing import Optional + +from flask_appbuilder.security.sqla.models import User + +from superset.charts.commands.exceptions import ( + ChartAccessDeniedError, + ChartNotFoundError, +) +from superset.datasets.commands.exceptions import ( + DatasetAccessDeniedError, + DatasetNotFoundError, +) +from superset.explore.utils import check_access as explore_check_access +from superset.temporary_cache.commands.exceptions import ( + TemporaryCacheAccessDeniedError, + TemporaryCacheResourceNotFoundError, +) + + +def check_access(dataset_id: int, chart_id: Optional[int], actor: User) -> None: + try: + explore_check_access(dataset_id, chart_id, actor) + except (ChartNotFoundError, DatasetNotFoundError) as ex: + raise TemporaryCacheResourceNotFoundError from ex + except (ChartAccessDeniedError, DatasetAccessDeniedError) as ex: + raise TemporaryCacheAccessDeniedError from ex diff --git a/superset/explore/utils.py b/superset/explore/utils.py index 3eeb1bab9964a..7ab29de2f70ec 100644 --- a/superset/explore/utils.py +++ b/superset/explore/utils.py @@ -44,12 +44,10 @@ def check_dataset_access(dataset_id: int) -> Optional[bool]: raise DatasetNotFoundError() -def check_access( - dataset_id: int, chart_id: Optional[int], actor: User -) -> Optional[bool]: +def check_access(dataset_id: int, chart_id: Optional[int], actor: User) -> None: check_dataset_access(dataset_id) if not chart_id: - return True + return chart = ChartDAO.find_by_id(chart_id) if chart: can_access_chart = ( @@ -58,6 +56,6 @@ def check_access( or security_manager.can_access("can_read", "Chart") ) if can_access_chart: - return True + return raise ChartAccessDeniedError() raise ChartNotFoundError() diff --git a/superset/key_value/utils.py b/superset/key_value/utils.py index b2e8e729b0466..db27e505fbd6c 100644 --- a/superset/key_value/utils.py +++ b/superset/key_value/utils.py @@ -18,10 +18,11 @@ from hashlib import md5 from secrets import token_urlsafe -from typing import Union +from typing import Optional, Union from uuid import UUID import hashids +from flask_appbuilder.security.sqla.models import User from flask_babel import gettext as _ from superset.key_value.exceptions import KeyValueParseKeyError @@ -63,3 +64,7 @@ def get_uuid_namespace(seed: str) -> UUID: md5_obj = md5() md5_obj.update(seed.encode("utf-8")) return UUID(md5_obj.hexdigest()) + + +def get_owner(user: User) -> Optional[int]: + return user.get_user_id() if not user.is_anonymous else None diff --git a/superset/temporary_cache/api.py b/superset/temporary_cache/api.py index e91a2886691f4..bdbdda302e694 100644 --- a/superset/temporary_cache/api.py +++ b/superset/temporary_cache/api.py @@ -24,20 +24,11 @@ from flask_appbuilder.api import BaseApi from marshmallow import ValidationError -from superset.charts.commands.exceptions import ( - ChartAccessDeniedError, - ChartNotFoundError, -) from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod -from superset.dashboards.commands.exceptions import ( - DashboardAccessDeniedError, - DashboardNotFoundError, -) -from superset.datasets.commands.exceptions import ( - DatasetAccessDeniedError, - DatasetNotFoundError, +from superset.temporary_cache.commands.exceptions import ( + TemporaryCacheAccessDeniedError, + TemporaryCacheResourceNotFoundError, ) -from superset.temporary_cache.commands.exceptions import TemporaryCacheAccessDeniedError from superset.temporary_cache.commands.parameters import CommandParameters from superset.temporary_cache.schemas import ( TemporaryCachePostSchema, @@ -86,14 +77,9 @@ def post(self, pk: int) -> Response: return self.response(201, key=key) except ValidationError as ex: return self.response(400, message=ex.messages) - except ( - ChartAccessDeniedError, - DashboardAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DashboardNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) @requires_json @@ -112,14 +98,9 @@ def put(self, pk: int, key: str) -> Response: return self.response(200, key=key) except ValidationError as ex: return self.response(400, message=ex.messages) - except ( - ChartAccessDeniedError, - DashboardAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DashboardNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) def get(self, pk: int, key: str) -> Response: @@ -129,14 +110,9 @@ def get(self, pk: int, key: str) -> Response: if not value: return self.response_404() return self.response(200, value=value) - except ( - ChartAccessDeniedError, - DashboardAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DashboardNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) def delete(self, pk: int, key: str) -> Response: @@ -146,14 +122,9 @@ def delete(self, pk: int, key: str) -> Response: if not result: return self.response_404() return self.response(200, message="Deleted successfully") - except ( - ChartAccessDeniedError, - DashboardAccessDeniedError, - DatasetAccessDeniedError, - TemporaryCacheAccessDeniedError, - ) as ex: + except TemporaryCacheAccessDeniedError as ex: return self.response(403, message=str(ex)) - except (ChartNotFoundError, DashboardNotFoundError, DatasetNotFoundError) as ex: + except TemporaryCacheResourceNotFoundError as ex: return self.response(404, message=str(ex)) @abstractmethod diff --git a/superset/temporary_cache/commands/entry.py b/superset/temporary_cache/commands/entry.py index 0e9ad0a735069..90aa8e1bebf48 100644 --- a/superset/temporary_cache/commands/entry.py +++ b/superset/temporary_cache/commands/entry.py @@ -14,9 +14,11 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Optional + from typing_extensions import TypedDict class Entry(TypedDict): - owner: int + owner: Optional[int] value: str diff --git a/superset/temporary_cache/commands/exceptions.py b/superset/temporary_cache/commands/exceptions.py index 0f8c44cb18fd9..8652a732f7ea0 100644 --- a/superset/temporary_cache/commands/exceptions.py +++ b/superset/temporary_cache/commands/exceptions.py @@ -43,3 +43,7 @@ class TemporaryCacheUpdateFailedError(UpdateFailedError): class TemporaryCacheAccessDeniedError(ForbiddenError): message = _("You don't have permission to modify the value.") + + +class TemporaryCacheResourceNotFoundError(ForbiddenError): + message = _("Resource was not found.") diff --git a/tests/unit_tests/explore/utils_test.py b/tests/unit_tests/explore/utils_test.py index 3d12f5e911ee9..11e2906ed88db 100644 --- a/tests/unit_tests/explore/utils_test.py +++ b/tests/unit_tests/explore/utils_test.py @@ -75,7 +75,7 @@ def test_unsaved_chart_authorized_dataset( mocker.patch(dataset_find_by_id, return_value=SqlaTable()) mocker.patch(can_access_datasource, return_value=True) - assert check_access(dataset_id=1, chart_id=0, actor=User()) == True + check_access(dataset_id=1, chart_id=0, actor=User()) def test_saved_chart_unknown_chart_id( @@ -112,7 +112,7 @@ def test_saved_chart_is_admin(mocker: MockFixture, app_context: AppContext) -> N mocker.patch(can_access_datasource, return_value=True) mocker.patch(is_user_admin, return_value=True) mocker.patch(chart_find_by_id, return_value=Slice()) - assert check_access(dataset_id=1, chart_id=1, actor=User()) is True + check_access(dataset_id=1, chart_id=1, actor=User()) def test_saved_chart_is_owner(mocker: MockFixture, app_context: AppContext) -> None: @@ -125,7 +125,7 @@ def test_saved_chart_is_owner(mocker: MockFixture, app_context: AppContext) -> N mocker.patch(is_user_admin, return_value=False) mocker.patch(is_owner, return_value=True) mocker.patch(chart_find_by_id, return_value=Slice()) - assert check_access(dataset_id=1, chart_id=1, actor=User()) == True + check_access(dataset_id=1, chart_id=1, actor=User()) def test_saved_chart_has_access(mocker: MockFixture, app_context: AppContext) -> None: @@ -139,7 +139,7 @@ def test_saved_chart_has_access(mocker: MockFixture, app_context: AppContext) -> mocker.patch(is_owner, return_value=False) mocker.patch(can_access, return_value=True) mocker.patch(chart_find_by_id, return_value=Slice()) - assert check_access(dataset_id=1, chart_id=1, actor=User()) == True + check_access(dataset_id=1, chart_id=1, actor=User()) def test_saved_chart_no_access(mocker: MockFixture, app_context: AppContext) -> None: From e140b7aa87c06068890ee02379252bcb3cbefe95 Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Thu, 26 May 2022 22:27:12 +0800 Subject: [PATCH 42/49] fix: unable to set destroyOnClose on ModalTrigger (#20201) --- superset-frontend/src/components/Modal/Modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/components/Modal/Modal.tsx b/superset-frontend/src/components/Modal/Modal.tsx index 389982cc22021..8ef38a7e8dcde 100644 --- a/superset-frontend/src/components/Modal/Modal.tsx +++ b/superset-frontend/src/components/Modal/Modal.tsx @@ -325,7 +325,7 @@ const CustomModal = ({ mask={shouldShowMask} draggable={draggable} resizable={resizable} - destroyOnClose={destroyOnClose || resizable || draggable} + destroyOnClose={destroyOnClose} {...rest} > {children} From e56bcd36bdd7da388533bcf6d24deef1811cb76d Mon Sep 17 00:00:00 2001 From: Dan Roscigno Date: Thu, 26 May 2022 10:37:03 -0400 Subject: [PATCH 43/49] Update clickhouse.mdx (#20195) ClickHouse is camel case. --- docs/docs/databases/clickhouse.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/databases/clickhouse.mdx b/docs/docs/databases/clickhouse.mdx index 1ece9186f1fbd..e717b60bf0b24 100644 --- a/docs/docs/databases/clickhouse.mdx +++ b/docs/docs/databases/clickhouse.mdx @@ -1,13 +1,13 @@ --- -title: Clickhouse +title: ClickHouse hide_title: true sidebar_position: 15 version: 1 --- -## Clickhouse +## ClickHouse -To use Clickhouse with Superset, you will need to add the following Python libraries: +To use ClickHouse with Superset, you will need to add the following Python libraries: ``` clickhouse-driver==0.2.0 @@ -21,7 +21,7 @@ clickhouse-driver>=0.2.0 clickhouse-sqlalchemy>=0.1.6 ``` -The recommended connector library for Clickhouse is +The recommended connector library for ClickHouse is [sqlalchemy-clickhouse](https://github.com/cloudflare/sqlalchemy-clickhouse). The expected connection string is formatted as follows: From 77ccec50cc0b97057b074126e57697c6fd00c2c0 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 26 May 2022 15:43:05 +0100 Subject: [PATCH 44/49] feat: add statsd metrics for notifications (#20158) * feat: add statsd metrics for notifications * fix import * fix lint * add decorator arg for custom prefix * add tests --- superset/reports/notifications/email.py | 2 + superset/reports/notifications/slack.py | 2 + superset/utils/decorators.py | 23 +++++++- .../reports/commands_tests.py | 53 +++++++++++-------- .../utils/decorators_tests.py | 20 ++++++- 5 files changed, 75 insertions(+), 25 deletions(-) diff --git a/superset/reports/notifications/email.py b/superset/reports/notifications/email.py index 20afeae437b00..3991f24b9264d 100644 --- a/superset/reports/notifications/email.py +++ b/superset/reports/notifications/email.py @@ -30,6 +30,7 @@ from superset.reports.notifications.base import BaseNotification from superset.reports.notifications.exceptions import NotificationError from superset.utils.core import send_email_smtp +from superset.utils.decorators import statsd_gauge from superset.utils.urls import modify_url_query logger = logging.getLogger(__name__) @@ -149,6 +150,7 @@ def _get_subject(self) -> str: def _get_to(self) -> str: return json.loads(self._recipient.recipient_config_json)["target"] + @statsd_gauge("reports.email.send") def send(self) -> None: subject = self._get_subject() content = self._get_content() diff --git a/superset/reports/notifications/slack.py b/superset/reports/notifications/slack.py index b833cbd53ddf3..2a198d66453c2 100644 --- a/superset/reports/notifications/slack.py +++ b/superset/reports/notifications/slack.py @@ -29,6 +29,7 @@ from superset.models.reports import ReportRecipientType from superset.reports.notifications.base import BaseNotification from superset.reports.notifications.exceptions import NotificationError +from superset.utils.decorators import statsd_gauge from superset.utils.urls import modify_url_query logger = logging.getLogger(__name__) @@ -147,6 +148,7 @@ def _get_inline_files(self) -> Sequence[Union[str, IOBase, bytes]]: return [] @backoff.on_exception(backoff.expo, SlackApiError, factor=10, base=2, max_tries=5) + @statsd_gauge("reports.slack.send") def send(self) -> None: files = self._get_inline_files() title = self._content.name diff --git a/superset/utils/decorators.py b/superset/utils/decorators.py index ab4ee308787c8..f14335f2cada5 100644 --- a/superset/utils/decorators.py +++ b/superset/utils/decorators.py @@ -19,7 +19,7 @@ import time from contextlib import contextmanager from functools import wraps -from typing import Any, Callable, Dict, Iterator, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Iterator, Optional, TYPE_CHECKING, Union from flask import current_app, Response @@ -32,6 +32,27 @@ from superset.stats_logger import BaseStatsLogger +def statsd_gauge(metric_prefix: Optional[str] = None) -> Callable[..., Any]: + def decorate(f: Callable[..., Any]) -> Callable[..., Any]: + """ + Handle sending statsd gauge metric from any method or function + """ + + def wrapped(*args: Any, **kwargs: Any) -> Any: + metric_prefix_ = metric_prefix or f.__name__ + try: + result = f(*args, **kwargs) + current_app.config["STATS_LOGGER"].gauge(f"{metric_prefix_}.ok", 1) + return result + except Exception as ex: + current_app.config["STATS_LOGGER"].gauge(f"{metric_prefix_}.error", 1) + raise ex + + return wrapped + + return decorate + + @contextmanager def stats_timing(stats_key: str, stats_logger: BaseStatsLogger) -> Iterator[float]: """Provide a transactional scope around a series of operations.""" diff --git a/tests/integration_tests/reports/commands_tests.py b/tests/integration_tests/reports/commands_tests.py index 7629bdd5b4583..dd23d291fd69a 100644 --- a/tests/integration_tests/reports/commands_tests.py +++ b/tests/integration_tests/reports/commands_tests.py @@ -22,6 +22,7 @@ from uuid import uuid4 import pytest +from flask import current_app from flask_sqlalchemy import BaseQuery from freezegun import freeze_time from sqlalchemy.sql import func @@ -1026,20 +1027,23 @@ def test_email_dashboard_report_schedule( screenshot_mock.return_value = SCREENSHOT_FILE with freeze_time("2020-01-01T00:00:00Z"): - AsyncExecuteReportScheduleCommand( - TEST_ID, create_report_email_dashboard.id, datetime.utcnow() - ).run() + with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock: - notification_targets = get_target_from_report_schedule( - create_report_email_dashboard - ) - # Assert the email smtp address - assert email_mock.call_args[0][0] == notification_targets[0] - # Assert the email inline screenshot - smtp_images = email_mock.call_args[1]["images"] - assert smtp_images[list(smtp_images.keys())[0]] == SCREENSHOT_FILE - # Assert logs are correct - assert_log(ReportState.SUCCESS) + AsyncExecuteReportScheduleCommand( + TEST_ID, create_report_email_dashboard.id, datetime.utcnow() + ).run() + + notification_targets = get_target_from_report_schedule( + create_report_email_dashboard + ) + # Assert the email smtp address + assert email_mock.call_args[0][0] == notification_targets[0] + # Assert the email inline screenshot + smtp_images = email_mock.call_args[1]["images"] + assert smtp_images[list(smtp_images.keys())[0]] == SCREENSHOT_FILE + # Assert logs are correct + assert_log(ReportState.SUCCESS) + statsd_mock.assert_called_once_with("reports.email.send.ok", 1) @pytest.mark.usefixtures( @@ -1094,19 +1098,22 @@ def test_slack_chart_report_schedule( screenshot_mock.return_value = SCREENSHOT_FILE with freeze_time("2020-01-01T00:00:00Z"): - AsyncExecuteReportScheduleCommand( - TEST_ID, create_report_slack_chart.id, datetime.utcnow() - ).run() + with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock: - notification_targets = get_target_from_report_schedule( - create_report_slack_chart - ) + AsyncExecuteReportScheduleCommand( + TEST_ID, create_report_slack_chart.id, datetime.utcnow() + ).run() - assert file_upload_mock.call_args[1]["channels"] == notification_targets[0] - assert file_upload_mock.call_args[1]["file"] == SCREENSHOT_FILE + notification_targets = get_target_from_report_schedule( + create_report_slack_chart + ) - # Assert logs are correct - assert_log(ReportState.SUCCESS) + assert file_upload_mock.call_args[1]["channels"] == notification_targets[0] + assert file_upload_mock.call_args[1]["file"] == SCREENSHOT_FILE + + # Assert logs are correct + assert_log(ReportState.SUCCESS) + statsd_mock.assert_called_once_with("reports.slack.send.ok", 1) @pytest.mark.usefixtures( diff --git a/tests/integration_tests/utils/decorators_tests.py b/tests/integration_tests/utils/decorators_tests.py index 98faa8a2ba6a4..d0ab6f98434b3 100644 --- a/tests/integration_tests/utils/decorators_tests.py +++ b/tests/integration_tests/utils/decorators_tests.py @@ -14,7 +14,10 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from unittest.mock import call, Mock +from unittest.mock import call, Mock, patch + +import pytest +from flask import current_app from superset.utils import decorators from tests.integration_tests.base_tests import SupersetTestCase @@ -41,3 +44,18 @@ def myfunc(arg1: int, arg2: int, kwarg1: str = "abc", kwarg2: int = 2): result = myfunc(1, 0, kwarg1="haha", kwarg2=2) mock.assert_has_calls([call(1, "abc"), call(1, "haha")]) self.assertEqual(result, 3) + + def test_statsd_gauge(self): + @decorators.statsd_gauge("custom.prefix") + def my_func(fail: bool, *args, **kwargs): + if fail: + raise ValueError("Error") + return "OK" + + with patch.object(current_app.config["STATS_LOGGER"], "gauge") as mock: + my_func(False, 1, 2) + mock.assert_called_once_with("custom.prefix.ok", 1) + + with pytest.raises(ValueError): + my_func(True, 1, 2) + mock.assert_called_once_with("custom.prefix.error", 1) From 7a2eb8b602bf1701aa5641f23577d45adef4e37d Mon Sep 17 00:00:00 2001 From: "Hugh A. Miles II" Date: Thu, 26 May 2022 11:26:11 -0400 Subject: [PATCH 45/49] add columns for bootstrap_data (#20134) --- superset/views/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/superset/views/core.py b/superset/views/core.py index 15ff3b1620e92..12b04dd706c4d 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -889,8 +889,11 @@ def explore( except (SupersetException, SQLAlchemyError): datasource_data = dummy_datasource_data + columns: List[Dict[str, Any]] = [] if datasource: datasource_data["owners"] = datasource.owners_data + if isinstance(datasource, Query): + columns = datasource.extra.get("columns", []) bootstrap_data = { "can_add": slice_add_perm, @@ -905,6 +908,7 @@ def explore( "user": bootstrap_user_data(g.user, include_perms=True), "forced_height": request.args.get("height"), "common": common_bootstrap_payload(), + "columns": columns, } if slc: title = slc.slice_name From 75e0fc25ebd84be486c37ad334f8df94030f5bec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 May 2022 09:01:33 +0300 Subject: [PATCH 46/49] chore(deps): bump swagger-ui-react from 4.1.2 to 4.1.3 in /docs (#20205) Bumps [swagger-ui-react](https://github.com/swagger-api/swagger-ui) from 4.1.2 to 4.1.3. - [Release notes](https://github.com/swagger-api/swagger-ui/releases) - [Commits](https://github.com/swagger-api/swagger-ui/compare/v4.1.2...v4.1.3) --- updated-dependencies: - dependency-name: swagger-ui-react dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- docs/yarn.lock | 31 +++++-------------------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/docs/package.json b/docs/package.json index 420aba6394438..0d0953de1b40b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -38,7 +38,7 @@ "react-dom": "^17.0.1", "react-github-btn": "^1.2.0", "stream": "^0.0.2", - "swagger-ui-react": "^4.1.2", + "swagger-ui-react": "^4.1.3", "url-loader": "^4.1.1" }, "devDependencies": { diff --git a/docs/yarn.lock b/docs/yarn.lock index b87498c11780e..2c788f210c475 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2183,23 +2183,7 @@ "@babel/helper-validator-option" "^7.16.7" "@babel/plugin-transform-typescript" "^7.16.7" -"@babel/runtime-corejs3@^7.11.2", "@babel/runtime-corejs3@^7.16.3": - version "7.16.3" - resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.16.3.tgz" - integrity sha512-IAdDC7T0+wEB4y2gbIL0uOXEYpiZEeuFUTVbdGq+UwCcF35T/tS8KrmMomEwEc5wBbyfH3PJVpTSUqrhPDXFcQ== - dependencies: - core-js-pure "^3.19.0" - regenerator-runtime "^0.13.4" - -"@babel/runtime-corejs3@^7.17.2": - version "7.17.8" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.17.8.tgz#d7dd49fb812f29c61c59126da3792d8740d4e284" - integrity sha512-ZbYSUvoSF6dXZmMl/CYTMOvzIFnbGfv4W3SEHYgMvNsFTeLaF2gkGAF4K2ddmtSK4Emej+0aYcnSC6N5dPCXUQ== - dependencies: - core-js-pure "^3.20.2" - regenerator-runtime "^0.13.4" - -"@babel/runtime-corejs3@^7.17.8": +"@babel/runtime-corejs3@^7.11.2", "@babel/runtime-corejs3@^7.16.3", "@babel/runtime-corejs3@^7.17.2", "@babel/runtime-corejs3@^7.17.8": version "7.17.9" resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz#3d02d0161f0fbf3ada8e88159375af97690f4055" integrity sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw== @@ -4960,11 +4944,6 @@ core-js-compat@^3.20.0, core-js-compat@^3.20.2: browserslist "^4.19.1" semver "7.0.0" -core-js-pure@^3.19.0: - version "3.19.1" - resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.19.1.tgz" - integrity sha512-Q0Knr8Es84vtv62ei6/6jXH/7izKmOrtrxH9WJTHLCMAVeU+8TF8z8Nr08CsH4Ot0oJKzBzJJL9SJBYIv7WlfQ== - core-js-pure@^3.20.2: version "3.21.1" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.21.1.tgz#8c4d1e78839f5f46208de7230cebfb72bc3bdb51" @@ -10568,10 +10547,10 @@ swagger-client@^3.17.0: traverse "~0.6.6" url "~0.11.0" -swagger-ui-react@^4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-4.1.2.tgz" - integrity sha512-HWsvfDviykATBpUh1Q4lvIBsnMFNaHBIw3nr7zAUUxMLzvlX6cbq4jATtM1v7MWiu9zUiE/Z/LmCc3YufTeTnw== +swagger-ui-react@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-4.1.3.tgz#a722ecbe54ef237fa9080447a7c708c4c72d846a" + integrity sha512-o1AoXUTNH40cxWus0QOeWQ8x9tSIEmrLBrOgAOHDnvWJ1qyjT8PjgHjPbUVjMbja18coyuaAAeUdyLKvLGmlDA== dependencies: "@babel/runtime-corejs3" "^7.16.3" "@braintree/sanitize-url" "^5.0.2" From 653cf773f7c3337a6a20072e22137db3f7e4e2af Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Fri, 27 May 2022 02:42:01 -0400 Subject: [PATCH 47/49] fix(sql lab): SQL Lab Compile Query Delay (#20206) --- .../src/SqlLab/actions/sqlLab.js | 6 ++- .../src/SqlLab/actions/sqlLab.test.js | 6 +-- .../QueryHistory/QueryHistory.test.tsx | 2 +- .../SqlLab/components/QueryHistory/index.tsx | 2 +- .../SqlLab/components/QuerySearch/index.tsx | 2 +- .../SqlLab/components/QueryTable/index.tsx | 8 ++-- .../components/SouthPane/SouthPane.test.jsx | 2 +- .../src/SqlLab/components/SouthPane/index.tsx | 2 +- .../src/SqlLab/components/SqlEditor/index.jsx | 40 +++++++++---------- 9 files changed, 35 insertions(+), 35 deletions(-) diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index b950d0e37737f..be968b2ec99cc 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -917,9 +917,13 @@ export function updateSavedQuery(query) { } export function queryEditorSetSql(queryEditor, sql) { + return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql }; +} + +export function queryEditorSetAndSaveSql(queryEditor, sql) { return function (dispatch) { // saved query and set tab state use this action - dispatch({ type: QUERY_EDITOR_SET_SQL, queryEditor, sql }); + dispatch(queryEditorSetSql(queryEditor, sql)); if (isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)) { return SupersetClient.put({ endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.test.js b/superset-frontend/src/SqlLab/actions/sqlLab.test.js index f2d56caee4d33..440df74bf937f 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.test.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.test.js @@ -635,7 +635,7 @@ describe('async actions', () => { }); }); - describe('queryEditorSetSql', () => { + describe('queryEditorSetAndSaveSql', () => { const sql = 'SELECT * '; const expectedActions = [ { @@ -651,7 +651,7 @@ describe('async actions', () => { const store = mockStore({}); return store - .dispatch(actions.queryEditorSetSql(queryEditor, sql)) + .dispatch(actions.queryEditorSetAndSaveSql(queryEditor, sql)) .then(() => { expect(store.getActions()).toEqual(expectedActions); expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); @@ -668,7 +668,7 @@ describe('async actions', () => { const store = mockStore({}); - store.dispatch(actions.queryEditorSetSql(queryEditor, sql)); + store.dispatch(actions.queryEditorSetAndSaveSql(queryEditor, sql)); expect(store.getActions()).toEqual(expectedActions); expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx index e63de3fdca869..8d25fca910124 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx @@ -24,7 +24,7 @@ const NOOP = () => {}; const mockedProps = { queries: [], actions: { - queryEditorSetSql: NOOP, + queryEditorSetAndSaveSql: NOOP, cloneQueryToNewTab: NOOP, fetchQueryResults: NOOP, clearQueryResults: NOOP, diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx index 6820e19d49deb..c41ace1ead01b 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx @@ -25,7 +25,7 @@ import QueryTable from 'src/SqlLab/components/QueryTable'; interface QueryHistoryProps { queries: Query[]; actions: { - queryEditorSetSql: Function; + queryEditorSetAndSaveSql: Function; cloneQueryToNewTab: Function; fetchQueryResults: Function; clearQueryResults: Function; diff --git a/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx b/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx index 762f35e89880e..be13dc8e592e3 100644 --- a/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx +++ b/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx @@ -37,7 +37,7 @@ interface QuerySearchProps { actions: { addDangerToast: (msg: string) => void; setDatabases: (data: Record) => Record; - queryEditorSetSql: Function; + queryEditorSetAndSaveSql: Function; cloneQueryToNewTab: Function; fetchQueryResults: Function; clearQueryResults: Function; diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx index a50779d6eb9c1..5e42d213810e0 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx @@ -46,7 +46,7 @@ interface QueryTableQuery interface QueryTableProps { columns?: string[]; actions: { - queryEditorSetSql: Function; + queryEditorSetAndSaveSql: Function; cloneQueryToNewTab: Function; fetchQueryResults: Function; clearQueryResults: Function; @@ -94,7 +94,7 @@ const QueryTable = ({ const user = useSelector(state => state.sqlLab.user); const { - queryEditorSetSql, + queryEditorSetAndSaveSql, cloneQueryToNewTab, fetchQueryResults, clearQueryResults, @@ -103,7 +103,7 @@ const QueryTable = ({ const data = useMemo(() => { const restoreSql = (query: Query) => { - queryEditorSetSql({ id: query.sqlEditorId }, query.sql); + queryEditorSetAndSaveSql({ id: query.sqlEditorId }, query.sql); }; const openQueryInNewTab = (query: Query) => { @@ -314,7 +314,7 @@ const QueryTable = ({ clearQueryResults, cloneQueryToNewTab, fetchQueryResults, - queryEditorSetSql, + queryEditorSetAndSaveSql, removeQuery, ]); diff --git a/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.jsx b/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.jsx index 1786a6cf313a6..6dfca33dbc06b 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.jsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.jsx @@ -80,7 +80,7 @@ const mockedEmptyProps = { latestQueryId: '', dataPreviewQueries: [], actions: { - queryEditorSetSql: NOOP, + queryEditorSetAndSaveSql: NOOP, cloneQueryToNewTab: NOOP, fetchQueryResults: NOOP, clearQueryResults: NOOP, diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index 767b608f3b7d2..94db283edf76f 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -46,7 +46,7 @@ interface SouthPanePropTypes { latestQueryId?: string; dataPreviewQueries: any[]; actions: { - queryEditorSetSql: Function; + queryEditorSetAndSaveSql: Function; cloneQueryToNewTab: Function; fetchQueryResults: Function; clearQueryResults: Function; diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx index 06cb87e4f0340..a01ff33a969d1 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx @@ -45,6 +45,7 @@ import { queryEditorSetAutorun, queryEditorSetQueryLimit, queryEditorSetSql, + queryEditorSetAndSaveSql, queryEditorSetTemplateParams, runQuery, saveQuery, @@ -177,7 +178,6 @@ class SqlEditor extends React.PureComponent { ctas: '', northPercent: props.queryEditor.northPercent || INITIAL_NORTH_PERCENT, southPercent: props.queryEditor.southPercent || INITIAL_SOUTH_PERCENT, - sql: props.queryEditor.sql, autocompleteEnabled: getItem( LocalStorageKeys.sqllab__is_autocomplete_enabled, true, @@ -198,12 +198,13 @@ class SqlEditor extends React.PureComponent { this.stopQuery = this.stopQuery.bind(this); this.saveQuery = this.saveQuery.bind(this); this.onSqlChanged = this.onSqlChanged.bind(this); - this.setQueryEditorSql = this.setQueryEditorSql.bind(this); - this.setQueryEditorSqlWithDebounce = debounce( - this.setQueryEditorSql.bind(this), + this.setQueryEditorAndSaveSql = this.setQueryEditorAndSaveSql.bind(this); + this.setQueryEditorAndSaveSqlWithDebounce = debounce( + this.setQueryEditorAndSaveSql.bind(this), SET_QUERY_EDITOR_SQL_DEBOUNCE_MS, ); this.queryPane = this.queryPane.bind(this); + this.getHotkeyConfig = this.getHotkeyConfig.bind(this); this.renderQueryLimit = this.renderQueryLimit.bind(this); this.getAceEditorAndSouthPaneHeights = this.getAceEditorAndSouthPaneHeights.bind(this); @@ -250,12 +251,6 @@ class SqlEditor extends React.PureComponent { }); } - componentDidUpdate() { - if (this.props.queryEditor.sql !== this.state.sql) { - this.onSqlChanged(this.props.queryEditor.sql); - } - } - componentWillUnmount() { window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('beforeunload', this.onBeforeUnload); @@ -290,12 +285,12 @@ class SqlEditor extends React.PureComponent { } onSqlChanged(sql) { - this.setState({ sql }); - this.setQueryEditorSqlWithDebounce(sql); + this.props.queryEditorSetSql(this.props.queryEditor, sql); + this.setQueryEditorAndSaveSqlWithDebounce(sql); // Request server-side validation of the query text if (this.canValidateQuery()) { // NB. requestValidation is debounced - this.requestValidation(); + this.requestValidation(sql); } } @@ -330,7 +325,7 @@ class SqlEditor extends React.PureComponent { key: 'ctrl+r', descr: t('Run query'), func: () => { - if (this.state.sql.trim() !== '') { + if (this.props.queryEditor.sql.trim() !== '') { this.runQuery(); } }, @@ -340,7 +335,7 @@ class SqlEditor extends React.PureComponent { key: 'ctrl+enter', descr: t('Run query'), func: () => { - if (this.state.sql.trim() !== '') { + if (this.props.queryEditor.sql.trim() !== '') { this.runQuery(); } }, @@ -383,8 +378,8 @@ class SqlEditor extends React.PureComponent { this.setState({ showEmptyState: bool }); } - setQueryEditorSql(sql) { - this.props.queryEditorSetSql(this.props.queryEditor, sql); + setQueryEditorAndSaveSql(sql) { + this.props.queryEditorSetAndSaveSql(this.props.queryEditor, sql); } setQueryLimit(queryLimit) { @@ -396,7 +391,7 @@ class SqlEditor extends React.PureComponent { const qe = this.props.queryEditor; const query = { dbId: qe.dbId, - sql: qe.selectedText ? qe.selectedText : this.state.sql, + sql: qe.selectedText ? qe.selectedText : this.props.queryEditor.sql, sqlEditorId: qe.id, schema: qe.schema, templateParams: qe.templateParams, @@ -429,12 +424,12 @@ class SqlEditor extends React.PureComponent { }; } - requestValidation() { + requestValidation(sql) { if (this.props.database) { const qe = this.props.queryEditor; const query = { dbId: qe.dbId, - sql: this.state.sql, + sql, sqlEditorId: qe.id, schema: qe.schema, templateParams: qe.templateParams, @@ -466,7 +461,7 @@ class SqlEditor extends React.PureComponent { const qe = this.props.queryEditor; const query = { dbId: qe.dbId, - sql: qe.selectedText ? qe.selectedText : this.state.sql, + sql: qe.selectedText ? qe.selectedText : qe.sql, sqlEditorId: qe.id, tab: qe.title, schema: qe.schema, @@ -682,7 +677,7 @@ class SqlEditor extends React.PureComponent { runQuery={this.runQuery} selectedText={qe.selectedText} stopQuery={this.stopQuery} - sql={this.state.sql} + sql={this.props.queryEditor.sql} overlayCreateAsMenu={showMenu ? runMenuBtn : null} /> @@ -854,6 +849,7 @@ function mapDispatchToProps(dispatch) { queryEditorSetAutorun, queryEditorSetQueryLimit, queryEditorSetSql, + queryEditorSetAndSaveSql, queryEditorSetTemplateParams, runQuery, saveQuery, From 0d2e42229e4664c2f7a870dcdacea800f1fb674f Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Fri, 27 May 2022 16:09:12 +0300 Subject: [PATCH 48/49] docs: update release instructions (#20210) --- RELEASING/README.md | 180 +++++++++++++++++++++++++++++++------ RELEASING/changelog.py | 6 ++ RELEASING/requirements.txt | 19 ++++ 3 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 RELEASING/requirements.txt diff --git a/RELEASING/README.md b/RELEASING/README.md index 46913d55ecad8..e48728d1b820d 100644 --- a/RELEASING/README.md +++ b/RELEASING/README.md @@ -67,6 +67,33 @@ need to be done at every release. svn update ``` +To minimize the risk of mixing up your local development environment, it's recommended to work on the +release in a different directory than where the devenv is located. In this example, we'll clone +the repo directly from the main `apache/superset` repo to a new directory `superset-release`: + +```bash +cd +git clone git@github.com:apache/superset.git superset-release +cd superset-release +``` + +We recommend setting up a virtual environment to isolate the python dependencies from your main +setup: + +```bash +virtualenv venv +source venv/bin/activate +``` + + +In addition, we recommend using the [`cherrytree`](https://pypi.org/project/cherrytree/) tool for +automating cherry picking, as it will help speed up the release process. To install `cherrytree` +and other dependencies that are required for the release process, run the following commands: + +```bash +pip install -r RELEASING/requirements.txt +``` + ## Setting up the release environment (do every time) As the vote process takes a minimum of 72h, sometimes stretching over several weeks @@ -78,35 +105,39 @@ the wrong files/using wrong names. There's a script to help you set correctly al necessary environment variables. Change your current directory to `superset/RELEASING` and execute the `set_release_env.sh` script with the relevant parameters: -Usage (BASH): + +Usage (MacOS/ZSH): ```bash -. set_release_env.sh +cd RELEASING +source set_release_env.sh ``` -Usage (ZSH): +Usage (BASH): ```bash -source set_release_env.sh +. set_release_env.sh ``` Example: ```bash -source set_release_env.sh 0.38.0rc1 myid@apache.org +source set_release_env.sh 1.5.1rc1 myid@apache.org ``` -The script will output the exported variables. Here's example for 0.38.0rc1: +The script will output the exported variables. Here's example for 1.5.1rc1: ``` +------------------------------- Set Release env variables -SUPERSET_VERSION=0.38.0 +SUPERSET_VERSION=1.5.1 SUPERSET_RC=1 -SUPERSET_GITHUB_BRANCH=0.38 -SUPERSET_PGP_FULLNAME=myid@apache.org -SUPERSET_VERSION_RC=0.38.0rc1 -SUPERSET_RELEASE=apache-superset-0.38.0 -SUPERSET_RELEASE_RC=apache-superset-0.38.0rc1 -SUPERSET_RELEASE_TARBALL=apache-superset-0.38.0-source.tar.gz -SUPERSET_RELEASE_RC_TARBALL=apache-superset-0.38.0rc1-source.tar.gz -SUPERSET_TMP_ASF_SITE_PATH=/tmp/superset-site-0.38.0 +SUPERSET_GITHUB_BRANCH=1.5 +SUPERSET_PGP_FULLNAME=villebro@apache.org +SUPERSET_VERSION_RC=1.5.1rc1 +SUPERSET_RELEASE=apache-superset-1.5.1 +SUPERSET_RELEASE_RC=apache-superset-1.5.1rc1 +SUPERSET_RELEASE_TARBALL=apache-superset-1.5.1-source.tar.gz +SUPERSET_RELEASE_RC_TARBALL=apache-superset-1.5.1rc1-source.tar.gz +SUPERSET_TMP_ASF_SITE_PATH=/tmp/incubator-superset-site-1.5.1 +------------------------------- ``` ## Crafting a source release @@ -116,23 +147,101 @@ a branch named with the release MAJOR.MINOR version (on this example 0.37). This new branch will hold all PATCH and release candidates that belong to the MAJOR.MINOR version. +### Creating an initial minor release (e.g. 1.5.0) + The MAJOR.MINOR branch is normally a "cut" from a specific point in time from the master branch. -Then (if needed) apply all cherries that will make the PATCH. +When creating the initial minor release (e.g. 1.5.0), create a new branch: +```bash +git checkout master +git pull +git checkout -b ${SUPERSET_GITHUB_BRANCH} +git push origin $SUPERSET_GITHUB_BRANCH +``` + +Note that this initializes a new "release cut", and is NOT needed when creating a patch release +(e.g. 1.5.1). + +### Creating a patch release (e.g. 1.5.1) + +When getting ready to bake a patch release, simply checkout the relevant branch: ```bash -git checkout -b $SUPERSET_GITHUB_BRANCH -git push upstream $SUPERSET_GITHUB_BRANCH +git checkout master +git pull +git checkout ${SUPERSET_GITHUB_BRANCH} ``` +### Cherry picking + +It is customary to label PRs that have been introduced after the cut with the label +`v.`. For example, for any PRs that should be included in the 1.5 branch, the +label `v1.5` should be added. + +To see how well the labelled PRs would apply to the current branch, run the following command: + +```bash +cherrytree bake -r apache/superset -m master -l v${SUPERSET_GITHUB_BRANCH} ${SUPERSET_GITHUB_BRANCH} +``` + +This requires the presence of an environment variable `GITHUB_TOKEN`. Alternatively, +you can pass the token directly via the `--access-token` parameter (`-at` for short). + +#### Happy path: no conflicts + +This will show how many cherries will apply cleanly. If there are no conflicts, you can simply apply all cherries +by adding the `--no-dry-run` flag (`-nd` for short): + +```bash +cherrytree bake -r apache/superset -m master -l v${SUPERSET_GITHUB_BRANCH} -nd ${SUPERSET_GITHUB_BRANCH} +``` + + +#### Resolving conflicts + +If there are conflicts, you can issue the following command to apply all cherries up until the conflict automatically, and then +break by adding the `-error-mode break` flag (`-e break` for short): + +```bash +cherrytree bake -r apache/superset -m master -l v${SUPERSET_GITHUB_BRANCH} -nd -e break ${SUPERSET_GITHUB_BRANCH} +``` + +After applying the cleanly merged cherries, `cherrytree` will specify the SHA of the conflicted cherry. To resolve the conflict, +simply issue the following command: + +```bash +git cherry-pick +``` + +Then fix all conflicts, followed by + +```bash +git add -u # add all changes +git cherry-pick --continue +``` + +After this, rerun all the above steps until all cherries have been picked, finally pushing all new commits to the release branch +on the main repo: + +```bash +git push +``` + +### Updating changelog + Next, update the `CHANGELOG.md` with all the changes that are included in the release. -Make sure the branch has been pushed to `upstream` to ensure the changelog generator +Make sure the branch has been pushed to `origin` to ensure the changelog generator can pick up changes since the previous release. -Change log script requires a github token and will try to use your env var GITHUB_TOKEN. -you can also pass the token using the parameter `--access_token`. +Similar to `cherrytree`, the change log script requires a github token, either as an env var +(`GITHUB_TOKEN`) or as the parameter `--access_token`. + +#### Initial release (e.g. 1.5.0) + +When generating the changelog for an initial minor relese, you should compare with +the previous release (in the example, the previous release branch is `1.4`, so remember to +update it accordingly): -Example: ```bash -python changelog.py --previous_version 0.37 --current_version 0.38 changelog +python changelog.py --previous_version 1.4 --current_version ${SUPERSET_GITHUB_BRANCH} changelog ``` You can get a list of pull requests with labels started with blocking, risk, hold, revert and security by using the parameter `--risk`. @@ -141,16 +250,29 @@ Example: python changelog.py --previous_version 0.37 --current_version 0.38 changelog --access_token {GITHUB_TOKEN} --risk ``` -The script will checkout both branches and compare all the PR's, copy the output and paste it on the `CHANGELOG.md` +The script will checkout both branches, compare all the PRs, and output the lines that are needed to be added to the +`CHANGELOG.md` file in the root of the repo. Remember to also make sure to update the branch id (with the above command +`1.5` needs to be changed to `1.5.0`) Then, in `UPDATING.md`, a file that contains a list of notifications around deprecations and upgrading-related topics, make sure to move the content now under the `Next Version` section under a new section for the new release. -Finally bump the version number on `superset-frontend/package.json` (replace with whichever version is being released excluding the RC version): +#### Patch release (e.g. 1.5.1) + +To compare the forthcoming patch release with the latest release from the same branch, set +`--previous_version` as the tag of the previous release (in this example `1.5.0`; remember to update accordingly) + +```bash +python changelog.py --previous_version 1.5.0 --current_version ${SUPERSET_GITHUB_BRANCH} changelog +``` + +### Set version number -```json +Finally, bump the version number on `superset-frontend/package.json` (replace with whichever version is being released excluding the RC version): + +``` "version": "0.38.0" ``` @@ -162,7 +284,7 @@ git add ... git commit ... # push new tag git tag ${SUPERSET_VERSION_RC} -git push upstream ${SUPERSET_VERSION_RC} +git push origin ${SUPERSET_VERSION_RC} ``` ## Preparing the release candidate @@ -180,7 +302,7 @@ the tag and create a signed source tarball from it: Note that `make_tarball.sh`: -- By default assumes you have already executed an SVN checkout to `$HOME/svn/superset_dev`. +- By default, the script assumes you have already executed an SVN checkout to `$HOME/svn/superset_dev`. This can be overridden by setting `SUPERSET_SVN_DEV_PATH` environment var to a different svn dev directory - Will refuse to craft a new release candidate if a release already exists on your local svn dev directory - Will check `package.json` version number and fails if it's not correctly set @@ -289,7 +411,7 @@ git branch # Create the release tag git tag -f ${SUPERSET_VERSION} # push the tag to the remote -git push upstream ${SUPERSET_VERSION} +git push origin ${SUPERSET_VERSION} ``` ### Update CHANGELOG and UPDATING on superset diff --git a/RELEASING/changelog.py b/RELEASING/changelog.py index 5d4f346c8edfb..0729853ba57e9 100644 --- a/RELEASING/changelog.py +++ b/RELEASING/changelog.py @@ -164,6 +164,12 @@ def _is_risk_pull_request(self, labels: List[Any]) -> bool: return False def _get_changelog_version_head(self) -> str: + if not len(self._logs): + print( + f"No changes found between revisions. " + f"Make sure your branch is up to date." + ) + sys.exit(1) return f"### {self._version} ({self._logs[0].time})" def _parse_change_log( diff --git a/RELEASING/requirements.txt b/RELEASING/requirements.txt new file mode 100644 index 0000000000000..bd3586e04c0d3 --- /dev/null +++ b/RELEASING/requirements.txt @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +cherrytree +jinja2 From c8fe518a7b55fe48545228dca6cf4f7c400f04e6 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 27 May 2022 10:51:03 -0600 Subject: [PATCH 49/49] fix(cosmetic): Limiting modal height (#20147) * more changes that didn't make it into the last commit somehow. * Allow modals to be short, but reach a max height and scroll * now with template literals --- superset-frontend/src/components/Modal/Modal.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/superset-frontend/src/components/Modal/Modal.tsx b/superset-frontend/src/components/Modal/Modal.tsx index 8ef38a7e8dcde..c6d5b3ee0aac6 100644 --- a/superset-frontend/src/components/Modal/Modal.tsx +++ b/superset-frontend/src/components/Modal/Modal.tsx @@ -89,9 +89,20 @@ export const StyledModal = styled(BaseModal)` max-width: ${maxWidth ?? '900px'}; padding-left: ${theme.gridUnit * 3}px; padding-right: ${theme.gridUnit * 3}px; + padding-bottom: 0; + top: 0; `} + .ant-modal-content { + display: flex; + flex-direction: column; + max-height: ${({ theme }) => `calc(100vh - ${theme.gridUnit * 8}px)`}; + margin-bottom: ${({ theme }) => theme.gridUnit * 4}px; + margin-top: ${({ theme }) => theme.gridUnit * 4}px; + } + .ant-modal-header { + flex: 0 0 auto; background-color: ${({ theme }) => theme.colors.grayscale.light4}; border-radius: ${({ theme }) => theme.borderRadius}px ${({ theme }) => theme.borderRadius}px 0 0; @@ -119,11 +130,13 @@ export const StyledModal = styled(BaseModal)` } .ant-modal-body { + flex: 0 1 auto; padding: ${({ theme }) => theme.gridUnit * 4}px; overflow: auto; ${({ resizable, height }) => !resizable && height && `height: ${height};`} } .ant-modal-footer { + flex: 0 0 1; border-top: ${({ theme }) => theme.gridUnit / 4}px solid ${({ theme }) => theme.colors.grayscale.light2}; padding: ${({ theme }) => theme.gridUnit * 4}px;
( ...style, }} onClick={onClick} + data-column-name={col.id} + {...(allowRearrangeColumns && { + draggable: 'true', + onDragStart, + onDragOver: e => e.preventDefault(), + onDragEnter: e => e.preventDefault(), + onDrop, + })} > {/* can't use `columnWidth &&` because it may also be zero */} {config.columnWidth ? ( @@ -434,12 +446,13 @@ export default function TableChart( /> ) : null}
- {label} + {label}