diff --git a/.pylintrc b/.pylintrc index e3715334d1458..0de47845e4219 100644 --- a/.pylintrc +++ b/.pylintrc @@ -86,6 +86,9 @@ disable= missing-docstring, too-many-lines, duplicate-code, + unspecified-encoding, + # re-enable once this no longer raises false positives + too-many-instance-attributes [REPORTS] diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 51e2700c579ee..994fec44b4a89 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -106,7 +106,7 @@ This statement thanks the following, on which it draws for content and inspirati # Slack Community Guidelines -If you decide to join the [Community Slack](https://join.slack.com/t/apache-superset/shared_invite/zt-l5f5e0av-fyYu8tlfdqbMdz_sPLwUqQ), please adhere to the following rules: +If you decide to join the [Community Slack](https://join.slack.com/t/apache-superset/shared_invite/zt-uxbh5g36-AISUtHbzOXcu0BIj7kgUaw), please adhere to the following rules: **1. Treat everyone in the community with respect.** diff --git a/Makefile b/Makefile index ef0d3edbec13f..2d32588e3bf3e 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,9 @@ venv: test -d venv || ${PYTHON} -m venv venv # setup a python3 virtualenv . venv/bin/activate +make activate: + source venv/bin/activate + pre-commit: # setup pre commit dependencies pip3 install -r requirements/integration.txt diff --git a/README.md b/README.md index a2992d1a341fa..443c48c6aabab 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ under the License. [![PyPI version](https://badge.fury.io/py/apache-superset.svg)](https://badge.fury.io/py/apache-superset) [![Coverage Status](https://codecov.io/github/apache/superset/coverage.svg?branch=master)](https://codecov.io/github/apache/superset) [![PyPI](https://img.shields.io/pypi/pyversions/apache-superset.svg?maxAge=2592000)](https://pypi.python.org/pypi/apache-superset) -[![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://join.slack.com/t/apache-superset/shared_invite/zt-l5f5e0av-fyYu8tlfdqbMdz_sPLwUqQ) +[![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](https://join.slack.com/t/apache-superset/shared_invite/zt-uxbh5g36-AISUtHbzOXcu0BIj7kgUaw) [![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.apache.org) [![Dependencies Status](https://david-dm.org/apache/superset/status.svg?path=superset-frontend)](https://david-dm.org/apache/superset?path=superset-frontend) @@ -136,7 +136,7 @@ Want to add support for your datastore or data engine? Read more [here](https:// ## Get Involved - Ask and answer questions on [StackOverflow](https://stackoverflow.com/questions/tagged/apache-superset) using the **apache-superset** tag -- [Join our community's Slack](https://join.slack.com/t/apache-superset/shared_invite/zt-l5f5e0av-fyYu8tlfdqbMdz_sPLwUqQ) +- [Join our community's Slack](https://join.slack.com/t/apache-superset/shared_invite/zt-uxbh5g36-AISUtHbzOXcu0BIj7kgUaw) and please read our [Slack Community Guidelines](https://github.com/apache/superset/blob/master/CODE_OF_CONDUCT.md#slack-community-guidelines) - [Join our dev@superset.apache.org Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org) diff --git a/docs/src/pages/community.tsx b/docs/src/pages/community.tsx index 01cab3495ac6b..b9317b1d9bbb9 100644 --- a/docs/src/pages/community.tsx +++ b/docs/src/pages/community.tsx @@ -26,7 +26,7 @@ import { pmc } from '../resources/data'; const links = [ [ - 'https://join.slack.com/t/apache-superset/shared_invite/zt-l5f5e0av-fyYu8tlfdqbMdz_sPLwUqQ', + 'https://join.slack.com/t/apache-superset/shared_invite/zt-uxbh5g36-AISUtHbzOXcu0BIj7kgUaw', 'Slack', 'interact with other Superset users and community members', ], diff --git a/docs/src/pages/docs/Connecting to Databases/index.mdx b/docs/src/pages/docs/Connecting to Databases/index.mdx index 4460d5e4b1dfa..0704dc7956940 100644 --- a/docs/src/pages/docs/Connecting to Databases/index.mdx +++ b/docs/src/pages/docs/Connecting to Databases/index.mdx @@ -69,5 +69,5 @@ exists, please file an issue on the supporting it. [StackOverflow](https://stackoverflow.com/questions/tagged/apache-superset+superset) and the -[Superset community Slack](https://join.slack.com/t/apache-superset/shared_invite/zt-l5f5e0av-fyYu8tlfdqbMdz_sPLwUqQ) +[Superset community Slack](https://join.slack.com/t/apache-superset/shared_invite/zt-uxbh5g36-AISUtHbzOXcu0BIj7kgUaw) are great places to get help with connecting to databases in Superset. diff --git a/docs/src/pages/docs/contributing-page.mdx b/docs/src/pages/docs/contributing-page.mdx index dd7f479cd65f2..dc8f53f5f1c07 100644 --- a/docs/src/pages/docs/contributing-page.mdx +++ b/docs/src/pages/docs/contributing-page.mdx @@ -11,7 +11,7 @@ The core contributors (or committers) to Superset communicate primarily in the f which you can join): - [Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org) -- [Apache Superset Slack community](https://join.slack.com/t/apache-superset/shared_invite/zt-l5f5e0av-fyYu8tlfdqbMdz_sPLwUqQ) +- [Apache Superset Slack community](https://join.slack.com/t/apache-superset/shared_invite/zt-uxbh5g36-AISUtHbzOXcu0BIj7kgUaw) - [Github issues and PR's](https://github.com/apache/superset/issues) If you're interested in contributing, we recommend reading the Community Contribution Guide diff --git a/docs/src/pages/docs/installation/configuring.mdx b/docs/src/pages/docs/installation/configuring.mdx index 707aaeef7209e..d7437f270a3b0 100644 --- a/docs/src/pages/docs/installation/configuring.mdx +++ b/docs/src/pages/docs/installation/configuring.mdx @@ -237,7 +237,7 @@ FEATURE_FLAGS = { } ``` -A current list of feature flags can be found in `RESOURCES/FEATURE_FLAGS.md` +A current list of feature flags can be found in [RESOURCES/FEATURE_FLAGS.md](https://github.com/apache/superset/blob/master/RESOURCES/FEATURE_FLAGS.md). ### SIP 15 diff --git a/requirements/testing.in b/requirements/testing.in index 3355d71fa4335..016ae845b93b4 100644 --- a/requirements/testing.in +++ b/requirements/testing.in @@ -28,7 +28,7 @@ openpyxl parameterized pyfakefs pyhive[presto]>=0.6.3 -pylint==2.6.0 +pylint==2.10.2 pytest pytest-cov statsd diff --git a/requirements/testing.txt b/requirements/testing.txt index ccbd88989e6a3..68d96c9b9a53f 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,4 +1,4 @@ -# SHA1:5bfcfb5d0ab31dd532ce58caa2aab91d6807b123 +# SHA1:59e47200215ca4695f09e03a773e1a6f310f78da # # This file is autogenerated by pip-compile-multi # To update, run: @@ -11,7 +11,7 @@ # via -r requirements/base.in appnope==0.1.2 # via ipython -astroid==2.6.6 +astroid==2.7.2 # via pylint backcall==0.2.0 # via ipython @@ -71,7 +71,7 @@ pyhive[hive,presto]==0.6.4 # via # -r requirements/development.in # -r requirements/testing.in -pylint==2.9.6 +pylint==2.10.2 # via -r requirements/testing.in pytest==6.2.4 # via diff --git a/setup.py b/setup.py index 19a08951df0bd..a999161c20057 100644 --- a/setup.py +++ b/setup.py @@ -133,7 +133,7 @@ def get_git_sha() -> str: "exasol": ["sqlalchemy-exasol>=2.1.0, <2.2"], "excel": ["xlrd>=1.2.0, <1.3"], "firebird": ["sqlalchemy-firebird>=0.7.0, <0.8"], - "gsheets": ["shillelagh[gsheetsapi]>=0.7.1, <0.8"], + "gsheets": ["shillelagh[gsheetsapi]>=1.0.3, <2"], "hana": ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"], "hive": ["pyhive[hive]>=0.6.1", "tableschema", "thrift>=0.11.0, <1.0.0"], "impala": ["impyla>0.16.2, <0.17"], @@ -148,6 +148,9 @@ def get_git_sha() -> str: "prophet": ["prophet>=1.0.1, <1.1", "pystan<3.0"], "redshift": ["sqlalchemy-redshift>=0.8.1, < 0.9"], "rockset": ["rockset>=0.7.68, <0.8"], + "shillelagh": [ + "shillelagh[datasetteapi,gsheetsapi,socrata,weatherapi]>=1.0.3, <2" + ], "snowflake": ["snowflake-sqlalchemy>=1.2.3, <1.3"], "teradata": ["sqlalchemy-teradata==0.9.0.dev0"], "thumbnails": ["Pillow>=7.0.0, <8.0.0"], diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index 191f7a5e1df1a..2d9529f69dd15 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -19,8 +19,8 @@ module.exports = { testRegex: '(\\/spec|\\/src)\\/.*(_spec|\\.test)\\.(j|t)sx?$', moduleNameMapper: { - '\\.(css|less)$': '/spec/__mocks__/styleMock.js', - '\\.(gif|ttf|eot|png|jpg)$': '/spec/__mocks__/fileMock.js', + '\\.(css|less|geojson)$': '/spec/__mocks__/mockExportObject.js', + '\\.(gif|ttf|eot|png|jpg)$': '/spec/__mocks__/mockExportString.js', '\\.svg$': '/spec/__mocks__/svgrMock.tsx', '^src/(.*)$': '/src/$1', '^spec/(.*)$': '/spec/$1', diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 1ef93bce0e862..7b2cb12f6a767 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -19,7 +19,7 @@ "@superset-ui/core": "^0.17.81", "@superset-ui/legacy-plugin-chart-calendar": "^0.17.85", "@superset-ui/legacy-plugin-chart-chord": "^0.17.85", - "@superset-ui/legacy-plugin-chart-country-map": "^0.17.85", + "@superset-ui/legacy-plugin-chart-country-map": "^0.17.87", "@superset-ui/legacy-plugin-chart-event-flow": "^0.17.85", "@superset-ui/legacy-plugin-chart-force-directed": "^0.17.85", "@superset-ui/legacy-plugin-chart-heatmap": "^0.17.85", @@ -39,7 +39,7 @@ "@superset-ui/legacy-preset-chart-big-number": "^0.17.86", "@superset-ui/legacy-preset-chart-deckgl": "^0.4.11", "@superset-ui/legacy-preset-chart-nvd3": "^0.17.85", - "@superset-ui/plugin-chart-echarts": "^0.17.85", + "@superset-ui/plugin-chart-echarts": "^0.17.87", "@superset-ui/plugin-chart-pivot-table": "^0.17.85", "@superset-ui/plugin-chart-table": "^0.17.85", "@superset-ui/plugin-chart-word-cloud": "^0.17.85", @@ -12136,9 +12136,9 @@ } }, "node_modules/@superset-ui/legacy-plugin-chart-country-map": { - "version": "0.17.85", - "resolved": "https://registry.npmjs.org/@superset-ui/legacy-plugin-chart-country-map/-/legacy-plugin-chart-country-map-0.17.85.tgz", - "integrity": "sha512-g8OAwInPI9r/k/0ip9pXvQR7L6PInQhN8KfMb4j5VgC4+eUl04BQDpsclVoYOz5KqthkxPvGhdlOH0zHybv3Vg==", + "version": "0.17.87", + "resolved": "https://registry.npmjs.org/@superset-ui/legacy-plugin-chart-country-map/-/legacy-plugin-chart-country-map-0.17.87.tgz", + "integrity": "sha512-IAXDWeuYPREMtXSiwBvlgw/JpqG3iele4cg4Mf9mKHJjKXXTBJV5SkSF5/raGH029/iUKChCGl4jZJgpnD5hMw==", "dependencies": { "@superset-ui/chart-controls": "0.17.85", "@superset-ui/core": "0.17.81", @@ -12685,9 +12685,9 @@ } }, "node_modules/@superset-ui/plugin-chart-echarts": { - "version": "0.17.85", - "resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-echarts/-/plugin-chart-echarts-0.17.85.tgz", - "integrity": "sha512-dAqRzVVm7Shn2fgKoj2puX0jefe2r14dOQThJ8CG/hRlpmXsKmLnSywwLvQVxq1oUhh+uQYsemdYBCsrQAuxcg==", + "version": "0.17.87", + "resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-echarts/-/plugin-chart-echarts-0.17.87.tgz", + "integrity": "sha512-IAmISCJzZk7GkxT/sHOrvdGClgIak9/QcwmAum+BPl9VDJTHCRJZGwXigEVLqUTq7cHfGLGhNb7GI/dGztSuIQ==", "dependencies": { "@superset-ui/chart-controls": "0.17.85", "@superset-ui/core": "0.17.81", @@ -61617,9 +61617,9 @@ } }, "@superset-ui/legacy-plugin-chart-country-map": { - "version": "0.17.85", - "resolved": "https://registry.npmjs.org/@superset-ui/legacy-plugin-chart-country-map/-/legacy-plugin-chart-country-map-0.17.85.tgz", - "integrity": "sha512-g8OAwInPI9r/k/0ip9pXvQR7L6PInQhN8KfMb4j5VgC4+eUl04BQDpsclVoYOz5KqthkxPvGhdlOH0zHybv3Vg==", + "version": "0.17.87", + "resolved": "https://registry.npmjs.org/@superset-ui/legacy-plugin-chart-country-map/-/legacy-plugin-chart-country-map-0.17.87.tgz", + "integrity": "sha512-IAXDWeuYPREMtXSiwBvlgw/JpqG3iele4cg4Mf9mKHJjKXXTBJV5SkSF5/raGH029/iUKChCGl4jZJgpnD5hMw==", "requires": { "@superset-ui/chart-controls": "0.17.85", "@superset-ui/core": "0.17.81", @@ -62116,9 +62116,9 @@ } }, "@superset-ui/plugin-chart-echarts": { - "version": "0.17.85", - "resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-echarts/-/plugin-chart-echarts-0.17.85.tgz", - "integrity": "sha512-dAqRzVVm7Shn2fgKoj2puX0jefe2r14dOQThJ8CG/hRlpmXsKmLnSywwLvQVxq1oUhh+uQYsemdYBCsrQAuxcg==", + "version": "0.17.87", + "resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-echarts/-/plugin-chart-echarts-0.17.87.tgz", + "integrity": "sha512-IAmISCJzZk7GkxT/sHOrvdGClgIak9/QcwmAum+BPl9VDJTHCRJZGwXigEVLqUTq7cHfGLGhNb7GI/dGztSuIQ==", "requires": { "@superset-ui/chart-controls": "0.17.85", "@superset-ui/core": "0.17.81", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 20e1afc52bb4d..70a80b0df2a70 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -71,7 +71,7 @@ "@superset-ui/core": "^0.17.81", "@superset-ui/legacy-plugin-chart-calendar": "^0.17.85", "@superset-ui/legacy-plugin-chart-chord": "^0.17.85", - "@superset-ui/legacy-plugin-chart-country-map": "^0.17.85", + "@superset-ui/legacy-plugin-chart-country-map": "^0.17.87", "@superset-ui/legacy-plugin-chart-event-flow": "^0.17.85", "@superset-ui/legacy-plugin-chart-force-directed": "^0.17.85", "@superset-ui/legacy-plugin-chart-heatmap": "^0.17.85", @@ -91,7 +91,7 @@ "@superset-ui/legacy-preset-chart-big-number": "^0.17.86", "@superset-ui/legacy-preset-chart-deckgl": "^0.4.11", "@superset-ui/legacy-preset-chart-nvd3": "^0.17.85", - "@superset-ui/plugin-chart-echarts": "^0.17.85", + "@superset-ui/plugin-chart-echarts": "^0.17.87", "@superset-ui/plugin-chart-pivot-table": "^0.17.85", "@superset-ui/plugin-chart-table": "^0.17.85", "@superset-ui/plugin-chart-word-cloud": "^0.17.85", @@ -147,6 +147,7 @@ "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.13.0", + "react-draggable": "^4.4.3", "react-gravatar": "^2.6.1", "react-hot-loader": "^4.12.20", "react-js-cron": "^1.2.0", diff --git a/superset-frontend/spec/__mocks__/styleMock.js b/superset-frontend/spec/__mocks__/mockExportObject.js similarity index 100% rename from superset-frontend/spec/__mocks__/styleMock.js rename to superset-frontend/spec/__mocks__/mockExportObject.js diff --git a/superset-frontend/spec/__mocks__/fileMock.js b/superset-frontend/spec/__mocks__/mockExportString.js similarity index 100% rename from superset-frontend/spec/__mocks__/fileMock.js rename to superset-frontend/spec/__mocks__/mockExportString.js diff --git a/superset-frontend/spec/helpers/setup.ts b/superset-frontend/spec/helpers/setup.ts index 28abd96bc5c25..c2c991f95621d 100644 --- a/superset-frontend/spec/helpers/setup.ts +++ b/superset-frontend/spec/helpers/setup.ts @@ -23,3 +23,5 @@ import { configure as configureTestingLibrary } from '@testing-library/react'; configureTestingLibrary({ testIdAttribute: 'data-test', }); + +document.body.innerHTML = '
'; diff --git a/superset-frontend/spec/javascripts/explore/components/SaveModal_spec.jsx b/superset-frontend/spec/javascripts/explore/components/SaveModal_spec.jsx index 258c725bbccbe..b9d87eea6e8d8 100644 --- a/superset-frontend/spec/javascripts/explore/components/SaveModal_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/SaveModal_spec.jsx @@ -42,14 +42,16 @@ describe('SaveModal', () => { dashboards: [], }, explore: { - can_overwrite: true, - user_id: '1', datasource: {}, slice: { slice_id: 1, slice_name: 'title', + owners: [1], }, alert: null, + user: { + userId: 1, + }, }, }; const store = mockStore(initialState); @@ -104,7 +106,7 @@ describe('SaveModal', () => { it('disable overwrite option for non-owner', () => { const wrapperForNonOwner = getWrapper(); - wrapperForNonOwner.setProps({ can_overwrite: false }); + wrapperForNonOwner.setProps({ userId: 2 }); const overwriteRadio = wrapperForNonOwner.find('#overwrite-radio'); expect(overwriteRadio).toHaveLength(1); expect(overwriteRadio.prop('disabled')).toBe(true); diff --git a/superset-frontend/src/SqlLab/components/ResultSet.tsx b/superset-frontend/src/SqlLab/components/ResultSet.tsx index b5a6e4faaa0a5..4f1c788b1e16c 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet.tsx @@ -448,7 +448,7 @@ export default class ResultSet extends React.PureComponent< if (this.props.cache && this.props.query.cached) { ({ data } = this.state); } - + const { columns } = this.props.query.results; // Added compute logic to stop user from being able to Save & Explore const { saveDatasetRadioBtnState, @@ -508,7 +508,7 @@ export default class ResultSet extends React.PureComponent< )} diff --git a/superset-frontend/src/components/Button/index.tsx b/superset-frontend/src/components/Button/index.tsx index 62e1214d3dffc..5199d26f4f8a2 100644 --- a/superset-frontend/src/components/Button/index.tsx +++ b/superset-frontend/src/components/Button/index.tsx @@ -63,6 +63,7 @@ export interface ButtonProps { htmlType?: 'button' | 'submit' | 'reset'; cta?: boolean; loading?: boolean | { delay?: number | undefined } | undefined; + showMarginRight?: boolean; } export default function Button(props: ButtonProps) { @@ -76,6 +77,7 @@ export default function Button(props: ButtonProps) { cta, children, href, + showMarginRight = true, ...restProps } = props; @@ -154,8 +156,8 @@ export default function Button(props: ButtonProps) { } else { renderedChildren = Children.toArray(children); } - - const firstChildMargin = renderedChildren.length > 1 ? theme.gridUnit * 2 : 0; + const firstChildMargin = + showMarginRight && renderedChildren.length > 1 ? theme.gridUnit * 2 : 0; const button = ( ( // Removes mask animation. Fixed in 4.6.0. // https://github.com/ant-design/ant-design/issues/27192 @@ -100,9 +123,8 @@ export const StyledModal = styled(BaseModal)` .ant-modal-body { padding: ${({ theme }) => theme.gridUnit * 4}px; overflow: auto; - ${({ height }) => height && `height: ${height};`} + ${({ resizable, height }) => !resizable && height && `height: ${height};`} } - .ant-modal-footer { border-top: ${({ theme }) => theme.gridUnit / 4}px solid ${({ theme }) => theme.colors.grayscale.light2}; @@ -128,6 +150,44 @@ export const StyledModal = styled(BaseModal)` &.no-content-padding .ant-modal-body { padding: 0; } + + ${({ draggable, theme }) => + draggable && + ` + .ant-modal-header { + padding: 0; + .draggable-trigger { + cursor: move; + padding: ${theme.gridUnit * 4}px; + width: 100%; + } + } + `}; + + ${({ resizable, hideFooter }) => + resizable && + ` + .resizable { + pointer-events: all; + + .resizable-wrapper { + height: 100%; + } + + .ant-modal-content { + height: 100%; + + .ant-modal-body { + /* 100% - header height - footer height */ + height: ${ + hideFooter + ? `calc(100% - ${MODAL_HEADER_HEIGHT}px);` + : `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px);` + } + } + } + } + `} `; const CustomModal = ({ @@ -147,8 +207,33 @@ const CustomModal = ({ footer, hideFooter, wrapProps, + draggable = false, + resizable = false, + resizableConfig = { + maxHeight: RESIZABLE_MAX_HEIGHT, + maxWidth: RESIZABLE_MAX_WIDTH, + minHeight: hideFooter + ? RESIZABLE_MIN_HEIGHT + : RESIZABLE_MIN_HEIGHT + MODAL_FOOTER_HEIGHT, + minWidth: RESIZABLE_MIN_WIDTH, + enable: { + bottom: true, + bottomLeft: false, + bottomRight: true, + left: false, + top: false, + topLeft: false, + topRight: false, + right: true, + }, + }, + draggableConfig, + destroyOnClose, ...rest }: ModalProps) => { + const draggableRef = useRef(null); + const [bounds, setBounds] = useState(); + const [dragDisabled, setDragDisabled] = useState(true); const modalFooter = isNil(footer) ? [ @@ -225,7 +228,7 @@ class SaveModal extends React.Component { this.changeAction('overwrite')} data-test="save-overwrite-radio" @@ -289,8 +292,7 @@ function mapStateToProps({ return { datasource: explore.datasource, slice: explore.slice, - can_overwrite: explore.can_overwrite, - userId: explore.user_id, + userId: explore.user?.userId, dashboards: saveModal.dashboards, alert: saveModal.saveModalAlert, }; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx index 93d00a70592db..37240d57caada 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx @@ -18,13 +18,17 @@ */ import React from 'react'; import { render, screen } from 'spec/helpers/testing-library'; -import { LabelProps } from 'src/explore/components/controls/DndColumnSelectControl/types'; -import { DndColumnSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnSelect'; +import { + DndColumnSelect, + DndColumnSelectProps, +} from 'src/explore/components/controls/DndColumnSelectControl/DndColumnSelect'; -const defaultProps: LabelProps = { +const defaultProps: DndColumnSelectProps = { + type: 'DndColumnSelect', name: 'Filter', onChange: jest.fn(), options: { string: { column_name: 'Column A' } }, + actions: { setControlValue: jest.fn() }, }; test('renders with default props', () => { diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx index 69cc6442e26a4..d3e447ca99a35 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx @@ -20,7 +20,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import { FeatureFlag, isFeatureEnabled, tn } from '@superset-ui/core'; import { ColumnMeta } from '@superset-ui/chart-controls'; import { isEmpty } from 'lodash'; -import { LabelProps } from 'src/explore/components/controls/DndColumnSelectControl/types'; import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel'; import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper'; import { OptionSelector } from 'src/explore/components/controls/DndColumnSelectControl/utils'; @@ -28,8 +27,13 @@ import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/t import { DndItemType } from 'src/explore/components/DndItemType'; import { useComponentDidUpdate } from 'src/common/hooks/useComponentDidUpdate'; import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger'; +import { DndControlProps } from './types'; -export const DndColumnSelect = (props: LabelProps) => { +export type DndColumnSelectProps = DndControlProps & { + options: Record; +}; + +export function DndColumnSelect(props: DndColumnSelectProps) { const { value, options, @@ -68,6 +72,7 @@ export const DndColumnSelect = (props: LabelProps) => { ) { onChange(optionSelectorValues); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(value), JSON.stringify(optionSelector.getValues())]); // useComponentDidUpdate to avoid running this for the first render, to avoid @@ -203,7 +208,7 @@ export const DndColumnSelect = (props: LabelProps) => { return (
- + {
); -}; +} diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx index bddb80a9aa210..40962c7c60b67 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx @@ -23,17 +23,24 @@ import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetr import AdhocFilter, { EXPRESSION_TYPES, } from 'src/explore/components/controls/FilterControl/AdhocFilter'; -import { DndFilterSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect'; +import { + DndFilterSelect, + DndFilterSelectProps, +} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect'; +import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants'; +import { DEFAULT_FORM_DATA } from '@superset-ui/plugin-chart-echarts/lib/Timeseries/types'; -const defaultProps = { +const defaultProps: DndFilterSelectProps = { + type: 'DndFilterSelect', name: 'Filter', value: [], columns: [], - datasource: {}, - formData: {}, + datasource: PLACEHOLDER_DATASOURCE, + formData: null, savedMetrics: [], + selectedMetrics: [], onChange: jest.fn(), - options: { string: { column_name: 'Column' } }, + actions: { setControlValue: jest.fn() }, }; test('renders with default props', () => { @@ -53,9 +60,15 @@ test('renders with value', () => { }); test('renders options with saved metric', () => { - render(, { - useDnd: true, - }); + render( + , + { + useDnd: true, + }, + ); expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument(); }); @@ -84,8 +97,14 @@ test('renders options with adhoc metric', () => { expression: 'AVG(birth_names.num)', metric_name: 'avg__num', }); - render(, { - useDnd: true, - }); + render( + , + { + useDnd: true, + }, + ); expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx index a9e327bfd6bfd..f9a142b665d6b 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx @@ -22,6 +22,8 @@ import { isFeatureEnabled, logging, Metric, + QueryFormData, + QueryFormMetric, SupersetClient, t, } from '@superset-ui/core'; @@ -30,11 +32,8 @@ import { OPERATOR_ENUM_TO_OPERATOR_TYPE, Operators, } from 'src/explore/constants'; -import { OptionSortType } from 'src/explore/types'; -import { - DndFilterSelectProps, - OptionValueType, -} from 'src/explore/components/controls/DndColumnSelectControl/types'; +import { Datasource, OptionSortType } from 'src/explore/types'; +import { OptionValueType } from 'src/explore/components/controls/DndColumnSelectControl/types'; import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger'; import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper'; import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel'; @@ -48,6 +47,7 @@ import { DndItemValue, } from 'src/explore/components/DatasourcePanel/types'; import { DndItemType } from 'src/explore/components/DndItemType'; +import { ControlComponentProps } from 'src/explore/components/Control'; const DND_ACCEPTED_TYPES = [ DndItemType.Column, @@ -59,8 +59,16 @@ const DND_ACCEPTED_TYPES = [ const isDictionaryForAdhocFilter = (value: OptionValueType) => !(value instanceof AdhocFilter) && value?.expressionType; +export interface DndFilterSelectProps + extends ControlComponentProps { + columns: ColumnMeta[]; + savedMetrics: Metric[]; + selectedMetrics: QueryFormMetric[]; + datasource: Datasource; +} + export const DndFilterSelect = (props: DndFilterSelectProps) => { - const { datasource, onChange } = props; + const { datasource, onChange = () => {}, name: controlName } = props; const propsValues = Array.from(props.value ?? []); const [values, setValues] = useState( @@ -74,7 +82,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { const optionsForSelect = ( columns: ColumnMeta[], - formData: Record, + formData: QueryFormData | null | undefined, ) => { const options: OptionSortType[] = [ ...columns, @@ -369,7 +377,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { setDroppedItem(item.value); togglePopover(true); }, - [togglePopover], + [controlName, togglePopover], ); const ghostButtonText = isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) @@ -378,7 +386,7 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => { return ( <> - + { const columnNames = new Set( [...(columns || []), ...(savedMetrics || [])] @@ -104,6 +108,12 @@ const columnsContainAllMetrics = ( ); }; +export type DndMetricSelectProps = DndControlProps & { + savedMetrics: savedMetricType[]; + columns: ColumnMeta[]; + datasourceType?: DatasourceType; +}; + export const DndMetricSelect = (props: any) => { const { onChange, multi, columns, savedMetrics } = props; @@ -130,7 +140,7 @@ export const DndMetricSelect = (props: any) => { [multi, onChange], ); - const [value, setValue] = useState<(AdhocMetric | Metric | string)[]>( + const [value, setValue] = useState( coerceAdhocMetrics(props.value), ); const [droppedItem, setDroppedItem] = useState( @@ -176,7 +186,9 @@ export const DndMetricSelect = (props: any) => { const onNewMetric = useCallback( (newMetric: Metric) => { - const newValue = props.multi ? [...value, newMetric] : [newMetric]; + const newValue = props.multi + ? [...value, newMetric.metric_name] + : [newMetric.metric_name]; setValue(newValue); handleChange(newValue); }, @@ -191,7 +203,7 @@ export const DndMetricSelect = (props: any) => { const newValue = value.map(value => { if ( // compare saved metrics - value === (oldMetric as Metric).metric_name || + ('metric_name' in oldMetric && value === oldMetric.metric_name) || // compare adhoc metrics typeof (value as AdhocMetric).optionName !== 'undefined' ? (value as AdhocMetric).optionName === @@ -254,7 +266,7 @@ export const DndMetricSelect = (props: any) => { ); const valueRenderer = useCallback( - (option: Metric | AdhocMetric | string, index: number) => ( + (option: ValueType, index: number) => ( { return (
- + false, valuesRenderer: () => , - onChange: jest.fn(), - options: { string: { column_name: 'Column' } }, }; test('renders with default props', async () => { diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx index d3bf95d09002a..019d9d3b99d05 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { useDrop } from 'react-dnd'; import { t, useTheme } from '@superset-ui/core'; import ControlHeader from 'src/explore/components/ControlHeader'; @@ -25,18 +25,35 @@ import { DndLabelsContainer, HeaderContainer, } from 'src/explore/components/controls/OptionControls'; -import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types'; +import { + DatasourcePanelDndItem, + DndItemValue, +} from 'src/explore/components/DatasourcePanel/types'; import Icons from 'src/components/Icons'; -import { DndColumnSelectProps } from './types'; +import { DndItemType } from '../../DndItemType'; + +export type DndSelectLabelProps = { + name: string; + accept: DndItemType | DndItemType[]; + ghostButtonText?: string; + onDrop: (item: DatasourcePanelDndItem) => void; + canDrop: (item: DatasourcePanelDndItem) => boolean; + canDropValue?: (value: DndItemValue) => boolean; + onDropValue?: (value: DndItemValue) => void; + valuesRenderer: () => ReactNode; + displayGhostButton?: boolean; + onClickGhostButton?: () => void; +}; -export default function DndSelectLabel({ +export default function DndSelectLabel({ displayGhostButton = true, + accept, ...props -}: DndColumnSelectProps) { +}: DndSelectLabelProps) { const theme = useTheme(); const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({ - accept: props.accept, + accept, drop: (item: DatasourcePanelDndItem) => { props.onDrop(item); diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts index b77e9d2633417..9156d48e82d2a 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts @@ -17,13 +17,9 @@ * under the License. */ import { ReactNode } from 'react'; -import { Metric } from '@superset-ui/core'; +import { JsonValue } from '@superset-ui/core'; +import { ControlComponentProps } from 'src/explore/components/Control'; import { ColumnMeta } from '@superset-ui/chart-controls'; -import { - DatasourcePanelDndItem, - DndItemValue, -} from '../../DatasourcePanel/types'; -import { DndItemType } from '../../DndItemType'; export interface OptionProps { children?: ReactNode; @@ -42,40 +38,16 @@ export interface OptionItemInterface { dragIndex: number; } -export interface LabelProps { - name: string; - value?: T; - onChange: (value?: T) => void; - options: { string: ColumnMeta }; +/** + * Shared control props for all DnD control. + */ +export type DndControlProps< + ValueType extends JsonValue +> = ControlComponentProps & { multi?: boolean; canDelete?: boolean; ghostButtonText?: string; - label?: string; -} - -export interface DndColumnSelectProps< - T = string[] | string, - O = string[] | string -> extends LabelProps { - onDrop: (item: DatasourcePanelDndItem) => void; - canDrop: (item: DatasourcePanelDndItem) => boolean; - canDropValue?: (value: DndItemValue) => boolean; - onDropValue?: (value: DndItemValue) => void; - valuesRenderer: () => ReactNode; - accept: DndItemType | DndItemType[]; - ghostButtonText?: string; - displayGhostButton?: boolean; - onClickGhostButton?: () => void; -} + onChange: (value: ValueType | ValueType[] | null | undefined) => void; +}; export type OptionValueType = Record; -export interface DndFilterSelectProps { - name: string; - value: OptionValueType[]; - columns: ColumnMeta[]; - datasource: Record; - formData: Record; - savedMetrics: Metric[]; - onChange: (filters: OptionValueType[]) => void; - options: { string: ColumnMeta }; -} diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/utils/optionSelector.ts b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/utils/optionSelector.ts index 532aa33fc6328..6000d5703954e 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/utils/optionSelector.ts +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/utils/optionSelector.ts @@ -22,25 +22,25 @@ import { ensureIsArray } from '@superset-ui/core'; export class OptionSelector { values: ColumnMeta[]; - options: { string: ColumnMeta }; + options: Record; multi: boolean; constructor( - options: { string: ColumnMeta }, + options: Record, multi: boolean, - initialValues?: string[] | string, + initialValues?: string[] | string | null, ) { this.options = options; this.multi = multi; this.values = ensureIsArray(initialValues) .map(value => { - if (value in options) { + if (value && value in options) { return options[value]; } return null; }) - .filter(Boolean); + .filter(Boolean) as ColumnMeta[]; } add(value: string) { diff --git a/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx b/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx index 88391bf995c0a..38b89c289a370 100644 --- a/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx +++ b/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx @@ -50,6 +50,14 @@ type Result = { language: string; }; +const StyledSyntaxContainer = styled.div` + height: 100%; +`; + +const StyledSyntaxHighlighter = styled(SyntaxHighlighter)` + height: calc(100% - 26px); // 100% - clipboard height +`; + const ViewQueryModal: React.FC = props => { const [result, setResult] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -93,7 +101,7 @@ const ViewQueryModal: React.FC = props => { <> {result.map(item => item.query ? ( -
+ = props => { } /> - {item.query} - -
+ + ) : null, )} diff --git a/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts b/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts index c61612d9c7e39..797b5def3d397 100644 --- a/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts +++ b/superset-frontend/src/explore/exploreUtils/getChartDataUri.test.ts @@ -31,7 +31,7 @@ test('Get ChartUri when allowDomainSharding:false', () => { duplicateQueryParameters: false, escapeQuerySpace: true, fragment: null, - hostname: undefined, + hostname: 'localhost', password: null, path: '/path', port: '', diff --git a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts index efa4b6ed8e1cc..7457b462b0314 100644 --- a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts +++ b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts @@ -33,13 +33,13 @@ const createParams = () => ({ test('Get ExploreUrl with default params', () => { const params = createParams(); - expect(getExploreUrl(params)).toBe('http:///superset/explore/'); + expect(getExploreUrl(params)).toBe('http://localhost/superset/explore/'); }); test('Get ExploreUrl with endpointType:full', () => { const params = createParams(); expect(getExploreUrl({ ...params, endpointType: 'full' })).toBe( - 'http:///superset/explore_json/', + 'http://localhost/superset/explore_json/', ); }); @@ -47,5 +47,5 @@ test('Get ExploreUrl with endpointType:full and method:GET', () => { const params = createParams(); expect( getExploreUrl({ ...params, endpointType: 'full', method: 'GET' }), - ).toBe('http:///superset/explore_json/'); + ).toBe('http://localhost/superset/explore_json/'); }); diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js b/superset-frontend/src/explore/reducers/exploreReducer.js index d094b494cedc8..4c771971a0abf 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.js +++ b/superset-frontend/src/explore/reducers/exploreReducer.js @@ -219,6 +219,7 @@ export default function exploreReducer(state = {}, action) { slice: { ...state.slice, ...action.slice, + owners: action.slice.owners ?? null, }, sliceName: action.slice.slice_name ?? state.sliceName, }; diff --git a/superset-frontend/src/explore/reducers/getInitialState.ts b/superset-frontend/src/explore/reducers/getInitialState.ts index dea0ff7af4b94..28a270d2f906f 100644 --- a/superset-frontend/src/explore/reducers/getInitialState.ts +++ b/superset-frontend/src/explore/reducers/getInitialState.ts @@ -22,7 +22,10 @@ import { ControlStateMapping, DatasourceMeta, } from '@superset-ui/chart-controls'; -import { CommonBootstrapData } from 'src/types/bootstrapTypes'; +import { + CommonBootstrapData, + UserWithPermissionsAndRoles, +} from 'src/types/bootstrapTypes'; import getToastsFromPyFlashMessages from 'src/messageToasts/utils/getToastsFromPyFlashMessages'; import { ChartState, Slice } from 'src/explore/types'; @@ -37,15 +40,15 @@ export interface ExlorePageBootstrapData extends JsonObject { can_add: boolean; can_download: boolean; can_overwrite: boolean; + common: CommonBootstrapData; datasource: DatasourceMeta; - form_data: QueryFormData; datasource_id: number; datasource_type: DatasourceType; + forced_height: string | null; + form_data: QueryFormData; slice: Slice | null; standalone: boolean; - user_id: number; - forced_height: string | null; - common: CommonBootstrapData; + user: UserWithPermissionsAndRoles; } export default function getInitialState( diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index 32ad4ebe7045e..fe6436ab86d0e 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -22,7 +22,8 @@ import { AnnotationData, AdhocMetric, } from '@superset-ui/core'; -import { ColumnMeta } from '@superset-ui/chart-controls'; +import { ColumnMeta, DatasourceMeta } from '@superset-ui/chart-controls'; +import { DatabaseObject } from 'src/views/CRUD/types'; export { Slice, Chart } from 'src/types/Chart'; @@ -54,3 +55,10 @@ export interface ChartState { export type OptionSortType = Partial< ColumnMeta & AdhocMetric & { saved_metric_name: string } >; + +export type Datasource = DatasourceMeta & { + database?: DatabaseObject; + datasource?: string; + schema?: string; + is_sqllab_view?: boolean; +}; diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 4865501e1a8ef..718193706e0d6 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -20,6 +20,7 @@ import { AppSection, DataMask, + DataRecordValue, ensureIsArray, ExtraFormData, GenericDataType, @@ -36,7 +37,11 @@ import { useImmerReducer } from 'use-immer'; import { FormItemProps } from 'antd/lib/form'; import { PluginFilterSelectProps, SelectValue } from './types'; import { StyledFormItem, FilterPluginStyle, StatusMessage } from '../common'; -import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils'; +import { + formatFilterValue, + getDataRecordFormatter, + getSelectExtraFormData, +} from '../../utils'; type DataMaskAction = | { type: 'ownState'; ownState: JsonObject } @@ -119,7 +124,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { filterState: { ...filterState, label: values?.length - ? `${(values || []).join(', ')}${suffix}` + ? `${(values || []).map(formatFilterValue).join(', ')}${suffix}` : undefined, value: appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem @@ -249,12 +254,12 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { } const options = useMemo(() => { - const options: { label: string; value: string | number }[] = []; + const options: { label: string; value: DataRecordValue }[] = []; data.forEach(row => { const [value] = groupby.map(col => row[col]); options.push({ label: labelFormatter(value, datatype), - value: typeof value === 'number' ? value : String(value), + value, }); }); return options; @@ -286,6 +291,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { loading={isRefreshing} maxTagCount={5} invertSelection={inverseSelection} + // @ts-ignore options={options} /> diff --git a/superset-frontend/src/filters/utils.ts b/superset-frontend/src/filters/utils.ts index ecd7268adfe1b..464dc41c35d60 100644 --- a/superset-frontend/src/filters/utils.ts +++ b/superset-frontend/src/filters/utils.ts @@ -28,7 +28,7 @@ import { FALSE_STRING, NULL_STRING, TRUE_STRING } from 'src/utils/common'; export const getSelectExtraFormData = ( col: string, - value?: null | (string | number)[], + value?: null | (string | number | boolean | null)[], emptyFilter = false, inverseSelection = false, ): ExtraFormData => { @@ -46,6 +46,7 @@ export const getSelectExtraFormData = ( { col, op: inverseSelection ? ('NOT IN' as const) : ('IN' as const), + // @ts-ignore val: value, }, ]; @@ -116,3 +117,18 @@ export function getDataRecordFormatter({ return String(value); }; } + +export function formatFilterValue( + value: string | number | boolean | null, +): string { + if (value === null) { + return NULL_STRING; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number') { + return String(value); + } + return value ? TRUE_STRING : FALSE_STRING; +} diff --git a/superset-frontend/src/types/Database.ts b/superset-frontend/src/types/Database.ts new file mode 100644 index 0000000000000..02c9347215d4d --- /dev/null +++ b/superset-frontend/src/types/Database.ts @@ -0,0 +1,29 @@ +/** + * 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 default interface Database { + id: number; + allow_run_async: boolean; + database_name: string; + encrypted_extra: string; + extra: string; + impersonate_user: boolean; + server_cert: string; + sqlalchemy_uri: string; +} diff --git a/superset-frontend/src/utils/common.js b/superset-frontend/src/utils/common.js index 974268c58e32c..567ec3d968f49 100644 --- a/superset-frontend/src/utils/common.js +++ b/superset-frontend/src/utils/common.js @@ -87,10 +87,21 @@ export function optionFromValue(opt) { return { value: optionValue(opt), label: optionLabel(opt) }; } -export function prepareCopyToClipboardTabularData(data) { +export function prepareCopyToClipboardTabularData(data, columns) { let result = ''; for (let i = 0; i < data.length; i += 1) { - result += `${Object.values(data[i]).join('\t')}\n`; + const row = {}; + for (let j = 0; j < columns.length; j += 1) { + // JavaScript does not mantain the order of a mixed set of keys (i.e integers and strings) + // the below function orders the keys based on the column names. + const key = columns[j].name || columns[j]; + if (data[i][key]) { + row[j] = data[i][key]; + } else { + row[j] = data[i][parseFloat(key)]; + } + } + result += `${Object.values(row).join('\t')}\n`; } return result; } diff --git a/superset-frontend/src/utils/common.test.jsx b/superset-frontend/src/utils/common.test.jsx index b47f423f2ffd2..5542648eb8714 100644 --- a/superset-frontend/src/utils/common.test.jsx +++ b/superset-frontend/src/utils/common.test.jsx @@ -46,15 +46,18 @@ describe('utils/common', () => { describe('prepareCopyToClipboardTabularData', () => { it('converts empty array', () => { const array = []; - expect(prepareCopyToClipboardTabularData(array)).toEqual(''); + const column = []; + expect(prepareCopyToClipboardTabularData(array, column)).toEqual(''); }); it('converts non empty array', () => { const array = [ { column1: 'lorem', column2: 'ipsum' }, { column1: 'dolor', column2: 'sit', column3: 'amet' }, ]; - expect(prepareCopyToClipboardTabularData(array)).toEqual( - 'lorem\tipsum\ndolor\tsit\tamet\n', + const column = ['column1', 'column2', 'column3']; + console.log(prepareCopyToClipboardTabularData(array, column)); + expect(prepareCopyToClipboardTabularData(array, column)).toEqual( + 'lorem\tipsum\t\ndolor\tsit\tamet\n', ); }); }); diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx index 8575a526a3754..7d5e60772d4c1 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx @@ -24,7 +24,7 @@ import fetchMock from 'fetch-mock'; import { act } from 'react-dom/test-utils'; import AlertReportModal from 'src/views/CRUD/alert/AlertReportModal'; import Modal from 'src/components/Modal'; -import { AsyncSelect, NativeGraySelect as Select } from 'src/components/Select'; +import { Select } from 'src/components'; import { Switch } from 'src/components/Switch'; import { Radio } from 'src/components/Radio'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; @@ -161,11 +161,15 @@ describe('AlertReportModal', () => { }; const editWrapper = await mountAndWait(props); - expect(editWrapper.find(AsyncSelect).at(1).props().value).toEqual({ + expect( + editWrapper.find('[aria-label="Database"]').at(0).props().value, + ).toEqual({ value: 1, label: 'test database', }); - expect(editWrapper.find(AsyncSelect).at(2).props().value).toEqual({ + expect( + editWrapper.find('[aria-label="Chart"]').at(0).props().value, + ).toEqual({ value: 1, label: 'test chart', }); @@ -176,21 +180,9 @@ describe('AlertReportModal', () => { expect(wrapper.find('input[name="name"]')).toExist(); }); - it('renders three async select elements when in report mode', () => { - expect(wrapper.find(AsyncSelect)).toExist(); - expect(wrapper.find(AsyncSelect)).toHaveLength(3); - }); - - it('renders four async select elements when in alert mode', async () => { - const props = { - ...mockedProps, - isReport: false, - }; - - const addWrapper = await mountAndWait(props); - - expect(addWrapper.find(AsyncSelect)).toExist(); - expect(addWrapper.find(AsyncSelect)).toHaveLength(4); + it('renders five select elements when in report mode', () => { + expect(wrapper.find(Select)).toExist(); + expect(wrapper.find(Select)).toHaveLength(5); }); it('renders Switch element', () => { @@ -226,12 +218,12 @@ describe('AlertReportModal', () => { expect(input.props().value).toEqual('SELECT NaN'); }); - it('renders two select element when in report mode', () => { + it('renders five select element when in report mode', () => { expect(wrapper.find(Select)).toExist(); - expect(wrapper.find(Select)).toHaveLength(2); + expect(wrapper.find(Select)).toHaveLength(5); }); - it('renders three select elements when in alert mode', async () => { + it('renders seven select elements when in alert mode', async () => { const props = { ...mockedProps, isReport: false, @@ -240,7 +232,7 @@ describe('AlertReportModal', () => { const addWrapper = await mountAndWait(props); expect(addWrapper.find(Select)).toExist(); - expect(addWrapper.find(Select)).toHaveLength(3); + expect(addWrapper.find(Select)).toHaveLength(7); }); it('renders value input element when in alert mode', async () => { @@ -334,9 +326,10 @@ describe('AlertReportModal', () => { }); await waitForComponentToPaint(wrapper); + // use default config: only show Email as notification option. expect( wrapper.find('[data-test="notification-add"]').props().status, - ).toEqual('disabled'); + ).toEqual('hidden'); act(() => { wrapper .find('[data-test="select-delivery-method"]') diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx index c39783ebbeaa3..56e1eba7fbd96 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import React, { FunctionComponent, useState, useEffect } from 'react'; +import React, { + FunctionComponent, + useState, + useEffect, + useMemo, + useCallback, +} from 'react'; import { styled, t, @@ -32,15 +38,14 @@ import { Switch } from 'src/components/Switch'; import Modal from 'src/components/Modal'; import TimezoneSelector from 'src/components/TimezoneSelector'; import { Radio } from 'src/components/Radio'; -import { AsyncSelect, NativeGraySelect as Select } from 'src/components/Select'; +import { Select } from 'src/components'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import Owner from 'src/types/Owner'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; -import { AlertReportCronScheduler } from './components/AlertReportCronScheduler'; -import { NotificationMethod } from './components/NotificationMethod'; - +import { useCommonConf } from 'src/views/CRUD/data/database/state'; import { + NotificationMethodOption, AlertObject, ChartObject, DashboardObject, @@ -48,9 +53,10 @@ import { MetaObject, Operator, Recipient, -} from './types'; +} from 'src/views/CRUD/alert/types'; +import { AlertReportCronScheduler } from './components/AlertReportCronScheduler'; +import { NotificationMethod } from './components/NotificationMethod'; -const SELECT_PAGE_SIZE = 2000; // temporary fix for paginated query const TIMEOUT_MIN = 1; const TEXT_BASED_VISUALIZATION_TYPES = [ 'pivot_table', @@ -73,7 +79,7 @@ interface AlertReportModalProps { show: boolean; } -const NOTIFICATION_METHODS: NotificationMethod[] = ['Email', 'Slack']; +const DEFAULT_NOTIFICATION_METHODS: NotificationMethodOption[] = ['Email']; const DEFAULT_NOTIFICATION_FORMAT = 'PNG'; const CONDITIONS = [ { @@ -215,10 +221,6 @@ const StyledSectionContainer = styled.div` } } } - - .hide-dropdown { - display: none; - } `; const StyledSectionTitle = styled.div` @@ -248,7 +250,7 @@ const StyledSwitchContainer = styled.div` `; export const StyledInputContainer = styled.div` - flex: 1 1 auto; + flex: 1; margin: ${({ theme }) => theme.gridUnit * 2}px; margin-top: 0; @@ -284,9 +286,7 @@ export const StyledInputContainer = styled.div` } input, - textarea, - .Select, - .ant-select { + textarea { flex: 1 1 auto; } @@ -300,36 +300,24 @@ export const StyledInputContainer = styled.div` } input::placeholder, - textarea::placeholder, - .Select__placeholder { + textarea::placeholder { color: ${({ theme }) => theme.colors.grayscale.light1}; } textarea, input[type='text'], - input[type='number'], - .Select__control, - .ant-select-single .ant-select-selector { - padding: ${({ theme }) => theme.gridUnit * 1.5}px + input[type='number'] { + padding: ${({ theme }) => theme.gridUnit}px ${({ theme }) => theme.gridUnit * 2}px; border-style: none; border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; border-radius: ${({ theme }) => theme.gridUnit}px; - .ant-select-selection-placeholder, - .ant-select-selection-item { - line-height: 24px; - } - &[name='description'] { flex: 1 1 auto; } } - .Select__control { - padding: 2px 0; - } - .input-label { margin-left: 10px; } @@ -394,12 +382,10 @@ const NotificationMethodAdd: FunctionComponent = ({ ); }; -type NotificationMethod = 'Email' | 'Slack'; - type NotificationSetting = { - method?: NotificationMethod; + method?: NotificationMethodOption; recipients: string; - options: NotificationMethod[]; + options: NotificationMethodOption[]; }; const AlertReportModal: FunctionComponent = ({ @@ -410,6 +396,10 @@ const AlertReportModal: FunctionComponent = ({ alert = null, isReport = false, }) => { + const conf = useCommonConf(); + const allowedNotificationMethods: NotificationMethodOption[] = + conf?.ALERT_REPORTS_NOTIFICATION_METHODS || DEFAULT_NOTIFICATION_METHODS; + const [disableSave, setDisableSave] = useState(true); const [ currentAlert, @@ -448,12 +438,14 @@ const AlertReportModal: FunctionComponent = ({ settings.push({ recipients: '', - options: NOTIFICATION_METHODS, // TODO: Need better logic for this + options: allowedNotificationMethods, }); setNotificationSettings(settings); setNotificationAddState( - settings.length === NOTIFICATION_METHODS.length ? 'hidden' : 'disabled', + settings.length === allowedNotificationMethods.length + ? 'hidden' + : 'disabled', ); }; @@ -581,94 +573,100 @@ const AlertReportModal: FunctionComponent = ({ }; // Fetch data to populate form dropdowns - const loadOwnerOptions = (input = '') => { - const query = rison.encode({ filter: input, page_size: SELECT_PAGE_SIZE }); - return SupersetClient.get({ - endpoint: `/api/v1/report/related/owners?q=${query}`, - }).then( - response => - response.json.result.map((item: any) => ({ - value: item.value, - label: item.text, - })), - badResponse => [], - ); - }; - - const loadSourceOptions = (input = '') => { - const query = rison.encode({ filter: input, page_size: SELECT_PAGE_SIZE }); - return SupersetClient.get({ - endpoint: `/api/v1/report/related/database?q=${query}`, - }).then( - response => { - const list = response.json.result.map((item: any) => ({ - value: item.value, - label: item.text, - })); - - setSourceOptions(list); - - // Find source if current alert has one set - if ( - currentAlert && - currentAlert.database && - !currentAlert.database.label - ) { - updateAlertState('database', getSourceData()); - } + const loadOwnerOptions = useMemo( + () => (input = '', page: number, pageSize: number) => { + const query = rison.encode({ filter: input, page, page_size: pageSize }); + return SupersetClient.get({ + endpoint: `/api/v1/report/related/owners?q=${query}`, + }).then(response => ({ + data: response.json.result.map( + (item: { value: number; text: string }) => ({ + value: item.value, + label: item.text, + }), + ), + totalCount: response.json.count, + })); + }, + [], + ); - return list; - }, - badResponse => [], - ); - }; + const getSourceData = useCallback( + (db?: MetaObject) => { + const database = db || currentAlert?.database; - const getSourceData = (db?: MetaObject) => { - const database = db || currentAlert?.database; + if (!database || database.label) { + return null; + } - if (!database || database.label) { - return null; - } + let result; - let result; + // Cycle through source options to find the selected option + sourceOptions.forEach(source => { + if (source.value === database.value || source.value === database.id) { + result = source; + } + }); - // Cycle through source options to find the selected option - sourceOptions.forEach(source => { - if (source.value === database.value || source.value === database.id) { - result = source; - } - }); + return result; + }, + [currentAlert?.database, sourceOptions], + ); - return result; + // Updating alert/report state + const updateAlertState = (name: string, value: any) => { + setCurrentAlert(currentAlertData => ({ + ...currentAlertData, + [name]: value, + })); }; - const loadDashboardOptions = (input = '') => { - const query = rison.encode({ filter: input, page_size: SELECT_PAGE_SIZE }); - return SupersetClient.get({ - endpoint: `/api/v1/report/related/dashboard?q=${query}`, - }).then( - response => { - const list = response.json.result.map((item: any) => ({ - value: item.value, - label: item.text, - })); + const loadSourceOptions = useMemo( + () => (input = '', page: number, pageSize: number) => { + const query = rison.encode({ filter: input, page, page_size: pageSize }); + return SupersetClient.get({ + endpoint: `/api/v1/report/related/database?q=${query}`, + }).then(response => { + const list = response.json.result.map( + (item: { value: number; text: string }) => ({ + value: item.value, + label: item.text, + }), + ); + setSourceOptions(list); + return { data: list, totalCount: response.json.count }; + }); + }, + [], + ); + const databaseLabel = + currentAlert && currentAlert.database && !currentAlert.database.label; + useEffect(() => { + // Find source if current alert has one set + if (databaseLabel) { + updateAlertState('database', getSourceData()); + } + }, [databaseLabel, getSourceData]); + + const loadDashboardOptions = useMemo( + () => (input = '', page: number, pageSize: number) => { + const query = rison.encode({ filter: input, page, page_size: pageSize }); + return SupersetClient.get({ + endpoint: `/api/v1/report/related/dashboard?q=${query}`, + }).then(response => { + const list = response.json.result.map( + (item: { value: number; text: string }) => ({ + value: item.value, + label: item.text, + }), + ); setDashboardOptions(list); - - // Find source if current alert has one set - if ( - currentAlert && - currentAlert.dashboard && - !currentAlert.dashboard.label - ) { - updateAlertState('dashboard', getDashboardData()); - } - - return list; - }, - badResponse => [], - ); - }; + return { data: list, totalCount: response.json.count }; + }); + }, + [], + ); const getDashboardData = (db?: MetaObject) => { const dashboard = db || currentAlert?.dashboard; @@ -689,62 +687,62 @@ const AlertReportModal: FunctionComponent = ({ return result; }; - const loadChartOptions = (input = '') => { - const query = rison.encode({ filter: input, page_size: SELECT_PAGE_SIZE }); - return SupersetClient.get({ - endpoint: `/api/v1/report/related/chart?q=${query}`, - }).then( - response => { - const list = response.json.result.map((item: any) => ({ - value: item.value, - label: item.text, - })); + const getChartData = useCallback( + (chartData?: MetaObject) => { + const chart = chartData || currentAlert?.chart; - setChartOptions(list); + if (!chart || chart.label) { + return null; + } - // Find source if current alert has one set - if (currentAlert && currentAlert.chart && !currentAlert.chart.label) { - updateAlertState('chart', getChartData()); - } + let result; - return list; - }, - badResponse => [], - ); - }; + // Cycle through chart options to find the selected option + chartOptions.forEach(slice => { + if (slice.value === chart.value || slice.value === chart.id) { + result = slice; + } + }); - const getChartData = (chartData?: MetaObject) => { - const chart = chartData || currentAlert?.chart; + return result; + }, + [chartOptions, currentAlert?.chart], + ); - if (!chart || chart.label) { - return null; + const noChartLabel = + currentAlert && currentAlert.chart && !currentAlert.chart.label; + useEffect(() => { + // Find source if current alert has one set + if (noChartLabel) { + updateAlertState('chart', getChartData()); } + }, [getChartData, noChartLabel]); + + const loadChartOptions = useMemo( + () => (input = '', page: number, pageSize: number) => { + const query = rison.encode({ filter: input, page, page_size: pageSize }); + return SupersetClient.get({ + endpoint: `/api/v1/report/related/chart?q=${query}`, + }).then(response => { + const list = response.json.result.map( + (item: { value: number; text: string }) => ({ + value: item.value, + label: item.text, + }), + ); - let result; - - // Cycle through chart options to find the selected option - chartOptions.forEach(slice => { - if (slice.value === chart.value || slice.value === chart.id) { - result = slice; - } - }); - - return result; - }; + setChartOptions(list); + return { data: list, totalCount: response.json.count }; + }); + }, + [], + ); const getChartVisualizationType = (chart: SelectValue) => SupersetClient.get({ endpoint: `/api/v1/chart/${chart.value}`, }).then(response => setChartVizType(response.json.result.viz_type)); - // Updating alert/report state - const updateAlertState = (name: string, value: any) => { - setCurrentAlert(currentAlertData => ({ - ...currentAlertData, - [name]: value, - })); - }; - // Handle input/textarea updates const onTextChange = ( event: React.ChangeEvent, @@ -775,11 +773,11 @@ const AlertReportModal: FunctionComponent = ({ updateAlertState('sql', value || ''); }; - const onOwnersChange = (value: Array) => { + const onOwnersChange = (value: Array) => { updateAlertState('owners', value || []); }; - const onSourceChange = (value: Array) => { + const onSourceChange = (value: Array) => { updateAlertState('database', value || []); }; @@ -832,8 +830,8 @@ const AlertReportModal: FunctionComponent = ({ const onContentTypeChange = (event: any) => { const { target } = event; - - setContentType(target.value); + // Gives time to close the select before changing the type + setTimeout(() => setContentType(target.value), 200); }; const onFormatChange = (event: any) => { @@ -917,16 +915,18 @@ const AlertReportModal: FunctionComponent = ({ ? JSON.parse(setting.recipient_config_json) : {}; return { - method: setting.type as NotificationMethod, + method: setting.type, // @ts-ignore: Type not assignable recipients: config.target || setting.recipient_config_json, - options: NOTIFICATION_METHODS as NotificationMethod[], // Need better logic for this + options: allowedNotificationMethods, }; }); setNotificationSettings(settings); setNotificationAddState( - settings.length === NOTIFICATION_METHODS.length ? 'hidden' : 'active', + settings.length === allowedNotificationMethods.length + ? 'hidden' + : 'active', ); setContentType(resource.chart ? 'chart' : 'dashboard'); setReportFormat( @@ -1004,19 +1004,6 @@ const AlertReportModal: FunctionComponent = ({ setIsHidden(false); } - // Dropdown options - const conditionOptions = CONDITIONS.map(condition => ( - - {condition.label} - - )); - - const retentionOptions = RETENTION_OPTIONS.map(option => ( - - {option.label} - - )); - return ( = ({ *
-
@@ -1107,19 +1099,19 @@ const AlertReportModal: FunctionComponent = ({ *
-
@@ -1148,21 +1140,14 @@ const AlertReportModal: FunctionComponent = ({
+ options={CONDITIONS} + />
@@ -1224,21 +1209,12 @@ const AlertReportModal: FunctionComponent = ({
+ value={currentAlert?.log_retention || DEFAULT_RETENTION} + options={RETENTION_OPTIONS} + />
@@ -1284,44 +1260,38 @@ const AlertReportModal: FunctionComponent = ({ {t('Dashboard')} {t('Chart')} - - {formatOptionEnabled && ( @@ -1346,6 +1316,7 @@ const AlertReportModal: FunctionComponent = ({ diff --git a/superset-frontend/src/views/CRUD/alert/components/NotificationMethod.tsx b/superset-frontend/src/views/CRUD/alert/components/NotificationMethod.tsx index 0e4b1b7e09bed..12b59a7afd2d5 100644 --- a/superset-frontend/src/views/CRUD/alert/components/NotificationMethod.tsx +++ b/superset-frontend/src/views/CRUD/alert/components/NotificationMethod.tsx @@ -18,8 +18,9 @@ */ import React, { FunctionComponent, useState } from 'react'; import { styled, t, useTheme } from '@superset-ui/core'; -import { NativeGraySelect as Select } from 'src/components/Select'; +import { Select } from 'src/components'; import Icons from 'src/components/Icons'; +import { NotificationMethodOption } from 'src/views/CRUD/alert/types'; import { StyledInputContainer } from '../AlertReportModal'; const StyledNotificationMethod = styled.div` @@ -49,12 +50,10 @@ const StyledNotificationMethod = styled.div` } `; -type NotificationMethod = 'Email' | 'Slack'; - type NotificationSetting = { - method?: NotificationMethod; + method?: NotificationMethodOption; recipients: string; - options: NotificationMethod[]; + options: NotificationMethodOption[]; }; interface NotificationMethodProps { @@ -80,7 +79,7 @@ export const NotificationMethod: FunctionComponent = ({ return null; } - const onMethodChange = (method: NotificationMethod) => { + const onMethodChange = (method: NotificationMethodOption) => { // Since we're swapping the method, reset the recipients setRecipientValue(''); if (onUpdate) { @@ -116,26 +115,24 @@ export const NotificationMethod: FunctionComponent = ({ setRecipientValue(recipients); } - const methodOptions = (options || []).map((method: NotificationMethod) => ( - - {t(method)} - - )); - return (
+ />
{method !== undefined && !!onRemove ? ( diff --git a/superset-frontend/src/views/CRUD/alert/types.ts b/superset-frontend/src/views/CRUD/alert/types.ts index f67a6042dfe83..3f939a03bcfa1 100644 --- a/superset-frontend/src/views/CRUD/alert/types.ts +++ b/superset-frontend/src/views/CRUD/alert/types.ts @@ -40,11 +40,13 @@ export type DatabaseObject = { id: number; }; +export type NotificationMethodOption = 'Email' | 'Slack'; + export type Recipient = { recipient_config_json: { target: string; }; - type: string; + type: NotificationMethodOption; }; export type MetaObject = { diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx index e4aff8192cca3..76e3809d69811 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx @@ -57,6 +57,7 @@ import Chart from 'src/types/Chart'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils'; +import setupPlugins from 'src/setup/setupPlugins'; import ChartCard from './ChartCard'; const PAGE_SIZE = 25; @@ -73,6 +74,7 @@ const CONFIRM_OVERWRITE_MESSAGE = t( 'sure you want to overwrite?', ); +setupPlugins(); const registry = getChartMetadataRegistry(); const createFetchDatasets = (handleError: (err: Response) => void) => async ( diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index 9c2bd841c093a..efb15440451e3 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -46,6 +46,7 @@ import FaveStar from 'src/components/FaveStar'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; import { Tooltip } from 'src/components/Tooltip'; import ImportModelsModal from 'src/components/ImportModal/index'; +import OmniContainer from 'src/components/OmniContainer'; import Dashboard from 'src/dashboard/containers/Dashboard'; import DashboardCard from './DashboardCard'; @@ -642,6 +643,9 @@ function DashboardList(props: DashboardListProps) { passwordFields={passwordFields} setPasswordFields={setPasswordFields} /> + + + {preparingExport && } ); diff --git a/superset-frontend/src/views/CRUD/data/database/state.ts b/superset-frontend/src/views/CRUD/data/database/state.ts index 777fdc87b508f..9a16371236e25 100644 --- a/superset-frontend/src/views/CRUD/data/database/state.ts +++ b/superset-frontend/src/views/CRUD/data/database/state.ts @@ -21,5 +21,5 @@ import { useSelector } from 'react-redux'; import { ViewState } from 'src/views/types'; export function useCommonConf() { - return useSelector((state: ViewState) => state.common.conf); + return useSelector((state: ViewState) => state?.common?.conf); } diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index d08ca46a692ea..35cfc55161a60 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -17,6 +17,7 @@ * under the License. */ import { User } from 'src/types/bootstrapTypes'; +import Database from 'src/types/Database'; import Owner from 'src/types/Owner'; export type FavoriteStatus = { @@ -137,12 +138,5 @@ export type ImportResourceName = | 'dataset' | 'saved_query'; -export type DatabaseObject = { - allow_run_async?: boolean; - database_name?: string; - encrypted_extra?: string; - extra?: string; - impersonate_user?: boolean; - server_cert?: string; - sqlalchemy_uri: string; -}; +export type DatabaseObject = Partial & + Pick; diff --git a/superset-frontend/src/views/types.ts b/superset-frontend/src/views/types.ts index 26e0f099c70c0..c0ffead6a5d86 100644 --- a/superset-frontend/src/views/types.ts +++ b/superset-frontend/src/views/types.ts @@ -16,11 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import { NotificationMethodOption } from './CRUD/alert/types'; + export interface ViewState { common: { conf: { SQLALCHEMY_DOCS_URL: string; SQLALCHEMY_DISPLAY_TEXT: string; + ALERT_REPORTS_NOTIFICATION_METHODS: NotificationMethodOption[]; }; }; messageToast: Array; diff --git a/superset/annotation_layers/annotations/api.py b/superset/annotation_layers/annotations/api.py index 2e2f81fe27560..49172a2bc2eb3 100644 --- a/superset/annotation_layers/annotations/api.py +++ b/superset/annotation_layers/annotations/api.py @@ -381,7 +381,7 @@ def put( # pylint: disable=arguments-differ try: new_model = UpdateAnnotationCommand(g.user, annotation_id, item).run() return self.response(200, id=new_model.id, result=item) - except (AnnotationNotFoundError, AnnotationLayerNotFoundError) as ex: + except (AnnotationNotFoundError, AnnotationLayerNotFoundError): return self.response_404() except AnnotationInvalidError as ex: return self.response_422(message=ex.normalized_messages()) @@ -438,7 +438,7 @@ def delete( # pylint: disable=arguments-differ try: DeleteAnnotationCommand(g.user, annotation_id).run() return self.response(200, message="OK") - except AnnotationNotFoundError as ex: + except AnnotationNotFoundError: return self.response_404() except AnnotationDeleteFailedError as ex: logger.error( diff --git a/superset/annotation_layers/annotations/commands/create.py b/superset/annotation_layers/annotations/commands/create.py index cbcb09a4c5ce9..d745df121f7c7 100644 --- a/superset/annotation_layers/annotations/commands/create.py +++ b/superset/annotation_layers/annotations/commands/create.py @@ -52,7 +52,7 @@ def run(self) -> Model: return annotation def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] layer_id: Optional[int] = self._properties.get("layer") start_dttm: Optional[datetime] = self._properties.get("start_dttm") end_dttm: Optional[datetime] = self._properties.get("end_dttm") diff --git a/superset/annotation_layers/annotations/commands/update.py b/superset/annotation_layers/annotations/commands/update.py index eb544344f1eaa..ccf11b6536582 100644 --- a/superset/annotation_layers/annotations/commands/update.py +++ b/superset/annotation_layers/annotations/commands/update.py @@ -56,7 +56,7 @@ def run(self) -> Model: return annotation def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] layer_id: Optional[int] = self._properties.get("layer") short_descr: str = self._properties.get("short_descr", "") diff --git a/superset/annotation_layers/api.py b/superset/annotation_layers/api.py index f43b385544222..bba34e2fa8d79 100644 --- a/superset/annotation_layers/api.py +++ b/superset/annotation_layers/api.py @@ -149,7 +149,7 @@ def delete(self, pk: int) -> Response: try: DeleteAnnotationLayerCommand(g.user, pk).run() return self.response(200, message="OK") - except AnnotationLayerNotFoundError as ex: + except AnnotationLayerNotFoundError: return self.response_404() except AnnotationLayerDeleteIntegrityError as ex: return self.response_422(message=str(ex)) @@ -288,7 +288,7 @@ def put(self, pk: int) -> Response: try: new_model = UpdateAnnotationLayerCommand(g.user, pk, item).run() return self.response(200, id=new_model.id, result=item) - except (AnnotationLayerNotFoundError) as ex: + except AnnotationLayerNotFoundError: return self.response_404() except AnnotationLayerInvalidError as ex: return self.response_422(message=ex.normalized_messages()) diff --git a/superset/annotation_layers/commands/create.py b/superset/annotation_layers/commands/create.py index 5e26bc49d4ae1..ee42ce7557404 100644 --- a/superset/annotation_layers/commands/create.py +++ b/superset/annotation_layers/commands/create.py @@ -48,7 +48,7 @@ def run(self) -> Model: return annotation_layer def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] name = self._properties.get("name", "") diff --git a/superset/annotation_layers/commands/update.py b/superset/annotation_layers/commands/update.py index 7e8f6d5bd7bb8..d2f48abb2444e 100644 --- a/superset/annotation_layers/commands/update.py +++ b/superset/annotation_layers/commands/update.py @@ -52,7 +52,7 @@ def run(self) -> Model: return annotation_layer def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] name = self._properties.get("name", "") self._model = AnnotationLayerDAO.find_by_id(self._model_id) diff --git a/superset/charts/commands/create.py b/superset/charts/commands/create.py index 36dca581a784c..34a25aea2d4a6 100644 --- a/superset/charts/commands/create.py +++ b/superset/charts/commands/create.py @@ -53,7 +53,7 @@ def run(self) -> Model: return chart def validate(self) -> None: - exceptions = list() + exceptions = [] datasource_type = self._properties["datasource_type"] datasource_id = self._properties["datasource_id"] dashboard_ids = self._properties.get("dashboards", []) diff --git a/superset/charts/commands/update.py b/superset/charts/commands/update.py index b09845512c0c4..0355e6e5ffe52 100644 --- a/superset/charts/commands/update.py +++ b/superset/charts/commands/update.py @@ -68,7 +68,7 @@ def run(self) -> Model: return chart def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] dashboard_ids = self._properties.get("dashboards") owner_ids: Optional[List[int]] = self._properties.get("owners") @@ -84,13 +84,17 @@ def validate(self) -> None: if not self._model: raise ChartNotFoundError() - # Check ownership; when only updating query context we ignore + # Check and update ownership; when only updating query context we ignore # ownership so the update can be performed by report workers if not is_query_context_update(self._properties): try: check_ownership(self._model) + owners = self.populate_owners(self._actor, owner_ids) + self._properties["owners"] = owners except SupersetSecurityException as ex: raise ChartForbiddenError() from ex + except ValidationError as ex: + exceptions.append(ex) # Validate/Populate datasource if datasource_id is not None: @@ -107,12 +111,6 @@ def validate(self) -> None: exceptions.append(DashboardsNotFoundValidationError()) self._properties["dashboards"] = dashboards - # Validate/Populate owner - try: - owners = self.populate_owners(self._actor, owner_ids) - self._properties["owners"] = owners - except ValidationError as ex: - exceptions.append(ex) if exceptions: exception = ChartInvalidError() exception.add_list(exceptions) diff --git a/superset/commands/utils.py b/superset/commands/utils.py index c656ce304036e..070039874eb17 100644 --- a/superset/commands/utils.py +++ b/superset/commands/utils.py @@ -41,7 +41,7 @@ def populate_owners( :returns: Final list of owners """ owner_ids = owner_ids or [] - owners = list() + owners = [] if not owner_ids and default_to_user: return [user] if user.id not in owner_ids and "admin" not in [ diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index c5c46628382e0..33bacc6be5b66 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -471,9 +471,7 @@ def data(self) -> Dict[str, Any]: ) -class SqlaTable( # pylint: disable=too-many-instance-attributes,too-many-public-methods - Model, BaseDatasource -): +class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-methods """An ORM object for SqlAlchemy table references""" type = "table" diff --git a/superset/dashboards/commands/create.py b/superset/dashboards/commands/create.py index 91f7e90bac56b..1e796bc318e49 100644 --- a/superset/dashboards/commands/create.py +++ b/superset/dashboards/commands/create.py @@ -50,7 +50,7 @@ def run(self) -> Model: return dashboard def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] owner_ids: Optional[List[int]] = self._properties.get("owners") role_ids: Optional[List[int]] = self._properties.get("roles") slug: str = self._properties.get("slug", "") diff --git a/superset/databases/commands/create.py b/superset/databases/commands/create.py index 6024b64469ae2..ffcb018c0678c 100644 --- a/superset/databases/commands/create.py +++ b/superset/databases/commands/create.py @@ -77,7 +77,7 @@ def run(self) -> Model: return database def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] sqlalchemy_uri: Optional[str] = self._properties.get("sqlalchemy_uri") database_name: Optional[str] = self._properties.get("database_name") if not sqlalchemy_uri: diff --git a/superset/databases/commands/update.py b/superset/databases/commands/update.py index db1741c32bb5b..69b6c30e71c64 100644 --- a/superset/databases/commands/update.py +++ b/superset/databases/commands/update.py @@ -69,7 +69,7 @@ def run(self) -> Model: return database def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] # Validate/populate model exists self._model = DatabaseDAO.find_by_id(self._model_id) if not self._model: diff --git a/superset/datasets/commands/create.py b/superset/datasets/commands/create.py index 0bf403ebfbcdb..4a89b1a818394 100644 --- a/superset/datasets/commands/create.py +++ b/superset/datasets/commands/create.py @@ -66,7 +66,7 @@ def run(self) -> Model: return dataset def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] database_id = self._properties["database"] table_name = self._properties["table_name"] schema = self._properties.get("schema", None) diff --git a/superset/datasets/commands/update.py b/superset/datasets/commands/update.py index c9bf8500c9297..cb1016c7a053a 100644 --- a/superset/datasets/commands/update.py +++ b/superset/datasets/commands/update.py @@ -76,7 +76,7 @@ def run(self) -> Model: raise DatasetUpdateFailedError() def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] owner_ids: Optional[List[int]] = self._properties.get("owners") # Validate/populate model exists self._model = DatasetDAO.find_by_id(self._model_id) diff --git a/superset/datasets/dao.py b/superset/datasets/dao.py index db7bbe04f5def..8aee37bbe18da 100644 --- a/superset/datasets/dao.py +++ b/superset/datasets/dao.py @@ -219,7 +219,7 @@ def update_metrics( - If there are extra metrics on the metadata db that are not defined on the List then we delete. """ - new_metrics = list() + new_metrics = [] for metric in property_metrics: metric_id = metric.get("id") if metric.get("id"): diff --git a/superset/db_engine_specs/clickhouse.py b/superset/db_engine_specs/clickhouse.py index a425a719ba364..60a3584baf46b 100644 --- a/superset/db_engine_specs/clickhouse.py +++ b/superset/db_engine_specs/clickhouse.py @@ -14,15 +14,23 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import logging from datetime import datetime -from typing import Dict, Optional, Type +from typing import Dict, List, Optional, Type, TYPE_CHECKING from urllib3.exceptions import NewConnectionError from superset.db_engine_specs.base import BaseEngineSpec from superset.db_engine_specs.exceptions import SupersetDBAPIDatabaseError +from superset.extensions import cache_manager from superset.utils import core as utils +if TYPE_CHECKING: + # prevent circular imports + from superset.models.core import Database + +logger = logging.getLogger(__name__) + class ClickHouseEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method """Dialect for ClickHouse analytical DB.""" @@ -48,6 +56,8 @@ class ClickHouseEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method "P1Y": "toStartOfYear(toDateTime({col}))", } + _show_functions_column = "name" + @classmethod def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]: return {NewConnectionError: SupersetDBAPIDatabaseError} @@ -69,3 +79,42 @@ def convert_dttm(cls, target_type: str, dttm: datetime) -> Optional[str]: if tt == utils.TemporalType.DATETIME: return f"""toDateTime('{dttm.isoformat(sep=" ", timespec="seconds")}')""" return None + + @classmethod + @cache_manager.cache.memoize() + def get_function_names(cls, database: "Database") -> List[str]: + """ + Get a list of function names that are able to be called on the database. + Used for SQL Lab autocomplete. + + :param database: The database to get functions for + :return: A list of function names usable in the database + """ + system_functions_sql = "SELECT name FROM system.functions" + try: + df = database.get_df(system_functions_sql) + if cls._show_functions_column in df: + return df[cls._show_functions_column].tolist() + columns = df.columns.values.tolist() + logger.error( + "Payload from `%s` has the incorrect format. " + "Expected column `%s`, found: %s.", + system_functions_sql, + cls._show_functions_column, + ", ".join(columns), + exc_info=True, + ) + # if the results have a single column, use that + if len(columns) == 1: + return df[columns[0]].tolist() + except Exception as ex: # pylint: disable=broad-except + logger.error( + "Query `%s` fire error %s. ", + system_functions_sql, + str(ex), + exc_info=True, + ) + return [] + + # otherwise, return no function names to prevent errors + return [] diff --git a/superset/db_engine_specs/shillelagh.py b/superset/db_engine_specs/shillelagh.py new file mode 100644 index 0000000000000..c6e6f618c7251 --- /dev/null +++ b/superset/db_engine_specs/shillelagh.py @@ -0,0 +1,26 @@ +# 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.db_engine_specs.sqlite import SqliteEngineSpec + + +class ShillelaghEngineSpec(SqliteEngineSpec): + """Engine for shillelagh""" + + engine = "shillelagh" + engine_name = "Shillelagh" + allows_joins = True + allows_subqueries = True diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index 8f0fd87204ef8..04dfd7939ffa0 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -132,9 +132,7 @@ def copy_dashboard( ) -class Dashboard( # pylint: disable=too-many-instance-attributes - Model, AuditMixinNullable, ImportExportMixin -): +class Dashboard(Model, AuditMixinNullable, ImportExportMixin): """The dashboard object!""" __tablename__ = "dashboards" diff --git a/superset/models/slice.py b/superset/models/slice.py index 28473d53d14ec..b4c7f6604f9a0 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -201,9 +201,7 @@ def data(self) -> Dict[str, Any]: "form_data": self.form_data, "query_context": self.query_context, "modified": self.modified(), - "owners": [ - f"{owner.first_name} {owner.last_name}" for owner in self.owners - ], + "owners": [owner.id for owner in self.owners], "slice_id": self.id, "slice_name": self.slice_name, "slice_url": self.slice_url, diff --git a/superset/reports/commands/create.py b/superset/reports/commands/create.py index 23ed27e89c440..b260fb9b5fd54 100644 --- a/superset/reports/commands/create.py +++ b/superset/reports/commands/create.py @@ -55,7 +55,7 @@ def run(self) -> Model: return report_schedule def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] owner_ids: Optional[List[int]] = self._properties.get("owners") name = self._properties.get("name", "") report_type = self._properties.get("type") diff --git a/superset/reports/commands/update.py b/superset/reports/commands/update.py index c1dd4167908f3..a38ca983e85ea 100644 --- a/superset/reports/commands/update.py +++ b/superset/reports/commands/update.py @@ -59,7 +59,7 @@ def run(self) -> Model: return report_schedule def validate(self) -> None: - exceptions: List[ValidationError] = list() + exceptions: List[ValidationError] = [] owner_ids: Optional[List[int]] = self._properties.get("owners") report_type = self._properties.get("type", ReportScheduleType.ALERT) diff --git a/superset/tasks/async_queries.py b/superset/tasks/async_queries.py index d2df6dd617537..19fbef297af70 100644 --- a/superset/tasks/async_queries.py +++ b/superset/tasks/async_queries.py @@ -68,7 +68,7 @@ def load_chart_data_into_cache( raise exc except Exception as exc: # TODO: QueryContext should support SIP-40 style errors - error = exc.message if hasattr(exc, "message") else str(exc) # type: ignore # pylint: disable=no-member + error = exc.message if hasattr(exc, "message") else str(exc) # type: ignore errors = [{"message": error}] async_query_manager.update_job( job_metadata, async_query_manager.STATUS_ERROR, errors=errors @@ -122,11 +122,9 @@ def load_explore_json_into_cache( # pylint: disable=too-many-locals raise ex except Exception as exc: if isinstance(exc, SupersetVizException): - errors = exc.errors # pylint: disable=no-member + errors = exc.errors else: - error = ( - exc.message if hasattr(exc, "message") else str(exc) # type: ignore # pylint: disable=no-member - ) + error = exc.message if hasattr(exc, "message") else str(exc) # type: ignore errors = [error] async_query_manager.update_job( diff --git a/superset/utils/core.py b/superset/utils/core.py index a549a753c56a0..646b04c0227bc 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -457,7 +457,7 @@ def cast_to_num(value: Optional[Union[float, int, str]]) -> Optional[Union[float return None -def cast_to_boolean(value: Any) -> bool: +def cast_to_boolean(value: Any) -> Optional[bool]: """Casts a value to an int/float >>> cast_to_boolean(1) @@ -473,12 +473,13 @@ def cast_to_boolean(value: Any) -> bool: >>> cast_to_boolean('False') False >>> cast_to_boolean(None) - False :param value: value to be converted to boolean representation :returns: value cast to `bool`. when value is 'true' or value that are not 0 - converte into True + converted into True. Return `None` if value is `None` """ + if value is None: + return None if isinstance(value, (int, float)): return value != 0 if isinstance(value, str): diff --git a/superset/utils/dict_import_export.py b/superset/utils/dict_import_export.py index 256f1b5655a0d..1924d5964294f 100644 --- a/superset/utils/dict_import_export.py +++ b/superset/utils/dict_import_export.py @@ -36,7 +36,7 @@ def export_schema_to_dict(back_references: bool) -> Dict[str, Any]: clusters = [ DruidCluster.export_schema(recursive=True, include_parent_ref=back_references) ] - data = dict() + data = {} if databases: data[DATABASES_KEY] = databases if clusters: @@ -69,7 +69,7 @@ def export_to_dict( for cluster in cls ] logger.info("Exported %d %s", len(clusters), DRUID_CLUSTERS_KEY) - data = dict() + data = {} if databases: data[DATABASES_KEY] = databases if clusters: diff --git a/superset/utils/sqllab_execution_context.py b/superset/utils/sqllab_execution_context.py new file mode 100644 index 0000000000000..2aeb520c30c6f --- /dev/null +++ b/superset/utils/sqllab_execution_context.py @@ -0,0 +1,111 @@ +# 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 __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any, cast, Dict, Optional + +from flask import g + +from superset import app, is_feature_enabled +from superset.sql_parse import CtasMethod +from superset.utils import core as utils + +QueryStatus = utils.QueryStatus +logger = logging.getLogger(__name__) + +SqlResults = Dict[str, Any] + + +@dataclass +class SqlJsonExecutionContext: # pylint: disable=too-many-instance-attributes + database_id: int + schema: str + sql: str + template_params: Dict[str, Any] + async_flag: bool + limit: int + status: str + select_as_cta: bool + ctas_method: CtasMethod + tmp_table_name: str + client_id: str + client_id_or_short_id: str + sql_editor_id: str + tab_name: str + user_id: Optional[int] + expand_data: bool + + def __init__(self, query_params: Dict[str, Any]): + self._init_from_query_params(query_params) + self.user_id = self._get_user_id() + self.client_id_or_short_id = cast(str, self.client_id or utils.shortid()[:10]) + + def _init_from_query_params(self, query_params: Dict[str, Any]) -> None: + self.database_id = cast(int, query_params.get("database_id")) + self.schema = cast(str, query_params.get("schema")) + self.sql = cast(str, query_params.get("sql")) + self.template_params = self._get_template_params(query_params) + self.async_flag = cast(bool, query_params.get("runAsync")) + self.limit = self._get_limit_param(query_params) + self.status = cast(str, query_params.get("status")) + self.select_as_cta = cast(bool, query_params.get("select_as_cta")) + self.ctas_method = cast( + CtasMethod, query_params.get("ctas_method", CtasMethod.TABLE) + ) + self.tmp_table_name = cast(str, query_params.get("tmp_table_name")) + self.client_id = cast(str, query_params.get("client_id")) + self.sql_editor_id = cast(str, query_params.get("sql_editor_id")) + self.tab_name = cast(str, query_params.get("tab")) + self.expand_data: bool = cast( + bool, + is_feature_enabled("PRESTO_EXPAND_DATA") + and query_params.get("expand_data"), + ) + + @staticmethod + def _get_template_params(query_params: Dict[str, Any]) -> Dict[str, Any]: + try: + template_params = json.loads(query_params.get("templateParams") or "{}") + except json.JSONDecodeError: + logger.warning( + "Invalid template parameter %s" " specified. Defaulting to empty dict", + str(query_params.get("templateParams")), + ) + template_params = {} + return template_params + + @staticmethod + def _get_limit_param(query_params: Dict[str, Any]) -> int: + limit: int = query_params.get("queryLimit") or app.config["SQL_MAX_ROW"] + if limit < 0: + logger.warning( + "Invalid limit of %i specified. Defaulting to max limit.", limit + ) + limit = 0 + return limit + + def _get_user_id(self) -> Optional[int]: # pylint: disable=R0201 + try: + return g.user.get_id() if g.user else None + except RuntimeError: + return None + + def is_run_asynchronous(self) -> bool: + return self.async_flag diff --git a/superset/views/base.py b/superset/views/base.py index 3d4f988259657..61bd312fb6019 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -70,6 +70,7 @@ SupersetSecurityException, ) from superset.models.helpers import ImportExportMixin +from superset.models.reports import ReportRecipientType from superset.translations.utils import get_language_pack from superset.typing import FlaskResponse from superset.utils import core as utils @@ -346,9 +347,21 @@ def common_bootstrap_payload() -> Dict[str, Any]: messages = get_flashed_messages(with_categories=True) locale = str(get_locale()) + # should not expose API TOKEN to frontend + frontend_config = {k: conf.get(k) for k in FRONTEND_CONF_KEYS} + if conf.get("SLACK_API_TOKEN"): + frontend_config["ALERT_REPORTS_NOTIFICATION_METHODS"] = [ + ReportRecipientType.EMAIL, + ReportRecipientType.SLACK, + ] + else: + frontend_config["ALERT_REPORTS_NOTIFICATION_METHODS"] = [ + ReportRecipientType.EMAIL, + ] + bootstrap_data = { "flash_messages": messages, - "conf": {k: conf.get(k) for k in FRONTEND_CONF_KEYS}, + "conf": frontend_config, "locale": locale, "language_pack": get_language_pack(locale), "feature_flags": get_feature_flags(), diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 7fb63ebb2ca0d..e80134605f54b 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -487,6 +487,12 @@ def related(self, column_name: str, **kwargs: Any) -> FlaskResponse: # handle pagination page, page_size = self._handle_page_args(args) + + ids = args.get("include_ids") + if page and ids: + # pagination with forced ids is not supported + return self.response_422() + try: datamodel = self.datamodel.get_related_interface(column_name) except KeyError: @@ -501,7 +507,7 @@ def related(self, column_name: str, **kwargs: Any) -> FlaskResponse: # handle filters filters = self._get_related_filter(datamodel, column_name, args.get("filter")) # Make the query - _, rows = datamodel.query( + total_rows, rows = datamodel.query( filters, order_column, order_direction, page=page, page_size=page_size ) @@ -509,10 +515,11 @@ def related(self, column_name: str, **kwargs: Any) -> FlaskResponse: result = self._get_result_from_rows(datamodel, rows, column_name) # If ids are specified make sure we fetch and include them on the response - ids = args.get("include_ids") - self._add_extra_ids_to_result(datamodel, column_name, ids, result) + if ids: + self._add_extra_ids_to_result(datamodel, column_name, ids, result) + total_rows = len(result) - return self.response(200, count=len(result), result=result) + return self.response(200, count=total_rows, result=result) @expose("/distinct/", methods=["GET"]) @protect() diff --git a/superset/views/base_schemas.py b/superset/views/base_schemas.py index de39e71027624..b7ffc72223db6 100644 --- a/superset/views/base_schemas.py +++ b/superset/views/base_schemas.py @@ -112,7 +112,7 @@ def pre_load(self, data: Dict[Any, Any]) -> None: @staticmethod def set_owners(instance: Model, owners: List[int]) -> None: - owner_objs = list() + owner_objs = [] if g.user.get_id() not in owners: owners.append(g.user.get_id()) for owner_id in owners: diff --git a/superset/views/core.py b/superset/views/core.py index 836bee4916b5c..29109f13c3b53 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -14,7 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# pylint: disable=comparison-with-callable, line-too-long, too-many-branches +# pylint: disable=comparison-with-callable, line-too-long import dataclasses import logging import re @@ -100,7 +100,7 @@ from superset.models.user_attributes import UserAttribute from superset.queries.dao import QueryDAO from superset.security.analytics_db_safety import check_sqlalchemy_uri -from superset.sql_parse import CtasMethod, ParsedQuery, Table +from superset.sql_parse import ParsedQuery, Table from superset.sql_validators import get_validator_by_name from superset.tasks.async_queries import load_explore_json_into_cache from superset.typing import FlaskResponse @@ -110,6 +110,7 @@ from superset.utils.core import ReservedUrlParameters from superset.utils.dates import now_as_float from superset.utils.decorators import check_dashboard_access +from superset.utils.sqllab_execution_context import SqlJsonExecutionContext from superset.views.base import ( api, BaseSupersetView, @@ -122,7 +123,6 @@ get_error_msg, get_user_roles, handle_api_exception, - is_user_admin, json_error_response, json_errors_response, json_success, @@ -716,7 +716,6 @@ def import_dashboards(self) -> FlaskResponse: def explore( # pylint: disable=too-many-locals self, datasource_type: Optional[str] = None, datasource_id: Optional[int] = None ) -> FlaskResponse: - user_id = g.user.get_id() if g.user else None form_data, slc = get_form_data(use_slice_data=True) query_context = request.form.get("query_context") # Flash the SIP-15 message if the slice is owned by the current user and has not @@ -788,9 +787,7 @@ def explore( # pylint: disable=too-many-locals # slc perms slice_add_perm = security_manager.can_access("can_write", "Chart") - slice_overwrite_perm = ( - is_owner(slc, g.user) or is_user_admin() if slc else False - ) + slice_overwrite_perm = is_owner(slc, g.user) if slc else False slice_download_perm = security_manager.can_access("can_csv", "Superset") form_data["datasource"] = str(datasource_id) + "__" + cast(str, datasource_type) @@ -845,14 +842,12 @@ def explore( # pylint: disable=too-many-locals bootstrap_data = { "can_add": slice_add_perm, "can_download": slice_download_perm, - "can_overwrite": slice_overwrite_perm, "datasource": datasource_data, "form_data": form_data, "datasource_id": datasource_id, "datasource_type": datasource_type, "slice": slc.data if slc else None, "standalone": standalone_mode, - "user_id": user_id, "user": bootstrap_user_data(g.user, include_perms=True), "forced_height": request.args.get("height"), "common": common_bootstrap_payload(), @@ -1022,7 +1017,6 @@ def save_or_overwrite_slice( response = { "can_add": slice_add_perm, "can_download": slice_download_perm, - "can_overwrite": is_owner(slc, g.user), "form_data": slc.form_data, "slice": slc.data, "dashboard_url": dash.url if dash else None, @@ -2580,42 +2574,16 @@ def sql_json(self) -> FlaskResponse: log_params = { "user_agent": cast(Optional[str], request.headers.get("USER_AGENT")) } - return self.sql_json_exec(request.json, log_params) + execution_context = SqlJsonExecutionContext(request.json) + return self.sql_json_exec(execution_context, request.json, log_params) def sql_json_exec( # pylint: disable=too-many-statements,too-many-locals - self, query_params: Dict[str, Any], log_params: Optional[Dict[str, Any]] = None + self, + execution_context: SqlJsonExecutionContext, + query_params: Dict[str, Any], + log_params: Optional[Dict[str, Any]] = None, ) -> FlaskResponse: """Runs arbitrary sql and returns data as json""" - # Collect Values - database_id: int = cast(int, query_params.get("database_id")) - schema: str = cast(str, query_params.get("schema")) - sql: str = cast(str, query_params.get("sql")) - try: - template_params = json.loads(query_params.get("templateParams") or "{}") - except json.JSONDecodeError: - logger.warning( - "Invalid template parameter %s" " specified. Defaulting to empty dict", - str(query_params.get("templateParams")), - ) - template_params = {} - limit: int = query_params.get("queryLimit") or app.config["SQL_MAX_ROW"] - async_flag: bool = cast(bool, query_params.get("runAsync")) - if limit < 0: - logger.warning( - "Invalid limit of %i specified. Defaulting to max limit.", limit - ) - limit = 0 - select_as_cta: bool = cast(bool, query_params.get("select_as_cta")) - ctas_method: CtasMethod = cast( - CtasMethod, query_params.get("ctas_method", CtasMethod.TABLE) - ) - tmp_table_name: str = cast(str, query_params.get("tmp_table_name")) - client_id: str = cast(str, query_params.get("client_id")) - client_id_or_short_id: str = cast(str, client_id or utils.shortid()[:10]) - sql_editor_id: str = cast(str, query_params.get("sql_editor_id")) - tab_name: str = cast(str, query_params.get("tab")) - status: str = QueryStatus.PENDING if async_flag else QueryStatus.RUNNING - user_id: int = g.user.get_id() if g.user else None session = db.session() @@ -2623,7 +2591,9 @@ def sql_json_exec( # pylint: disable=too-many-statements,too-many-locals query = ( session.query(Query) .filter_by( - client_id=client_id, user_id=user_id, sql_editor_id=sql_editor_id + client_id=execution_context.client_id, + user_id=execution_context.user_id, + sql_editor_id=execution_context.sql_editor_id, ) .one_or_none() ) @@ -2638,7 +2608,7 @@ def sql_json_exec( # pylint: disable=too-many-statements,too-many-locals ) return json_success(payload) - mydb = session.query(Database).get(database_id) + mydb = session.query(Database).get(execution_context.database_id) if not mydb: raise SupersetGenericErrorException( __( @@ -2651,27 +2621,29 @@ def sql_json_exec( # pylint: disable=too-many-statements,too-many-locals # TODO(bkyryliuk): consider parsing, splitting tmp_schema_name from # tmp_table_name if user enters # . - tmp_schema_name: Optional[str] = schema - if select_as_cta and mydb.force_ctas_schema: + tmp_schema_name: Optional[str] = execution_context.schema + if execution_context.select_as_cta and mydb.force_ctas_schema: tmp_schema_name = mydb.force_ctas_schema - elif select_as_cta: - tmp_schema_name = get_cta_schema_name(mydb, g.user, schema, sql) + elif execution_context.select_as_cta: + tmp_schema_name = get_cta_schema_name( + mydb, g.user, execution_context.schema, execution_context.sql + ) # Save current query query = Query( - database_id=database_id, - sql=sql, - schema=schema, - select_as_cta=select_as_cta, - ctas_method=ctas_method, + database_id=execution_context.database_id, + sql=execution_context.sql, + schema=execution_context.schema, + select_as_cta=execution_context.select_as_cta, + ctas_method=execution_context.ctas_method, start_time=now_as_float(), - tab_name=tab_name, - status=status, - sql_editor_id=sql_editor_id, - tmp_table_name=tmp_table_name, + tab_name=execution_context.tab_name, + status=execution_context.status, + sql_editor_id=execution_context.sql_editor_id, + tmp_table_name=execution_context.tmp_table_name, tmp_schema_name=tmp_schema_name, - user_id=user_id, - client_id=client_id_or_short_id, + user_id=execution_context.user_id, + client_id=execution_context.client_id_or_short_id, ) try: session.add(query) @@ -2706,7 +2678,7 @@ def sql_json_exec( # pylint: disable=too-many-statements,too-many-locals database=query.database, query=query ) rendered_query = template_processor.process_template( - query.sql, **template_params + query.sql, **execution_context.template_params ) except TemplateError as ex: query.status = QueryStatus.FAILED @@ -2737,15 +2709,18 @@ def sql_json_exec( # pylint: disable=too-many-statements,too-many-locals error=SupersetErrorType.MISSING_TEMPLATE_PARAMS_ERROR, extra={ "undefined_parameters": list(undefined_parameters), - "template_parameters": template_params, + "template_parameters": execution_context.template_params, }, ) # Limit is not applied to the CTA queries if SQLLAB_CTAS_NO_LIMIT flag is set # to True. - if not (config.get("SQLLAB_CTAS_NO_LIMIT") and select_as_cta): + if not (config.get("SQLLAB_CTAS_NO_LIMIT") and execution_context.select_as_cta): # set LIMIT after template processing - limits = [mydb.db_engine_spec.get_limit_from_sql(rendered_query), limit] + limits = [ + mydb.db_engine_spec.get_limit_from_sql(rendered_query), + execution_context.limit, + ] if limits[0] is None or limits[0] > limits[1]: query.limiting_factor = LimitingFactor.DROPDOWN elif limits[1] > limits[0]: @@ -2763,7 +2738,7 @@ def sql_json_exec( # pylint: disable=too-many-statements,too-many-locals ) # Async request. - if async_flag: + if execution_context.is_run_asynchronous(): return self._sql_json_async( session, rendered_query, query, expand_data, log_params ) diff --git a/superset/viz.py b/superset/viz.py index 57eb1a8e2af4c..357b6c8f9bc5d 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -450,7 +450,8 @@ def get_payload(self, query_obj: Optional[QueryObjectDict] = None) -> VizPayload payload = self.get_df_payload(query_obj) - df = payload.get("df") + # if payload does not have a df, we are raising an error here. + df = cast(Optional[pd.DataFrame], payload["df"]) if self.status != utils.QueryStatus.FAILED: payload["data"] = self.get_data(df) @@ -482,7 +483,8 @@ def get_payload(self, query_obj: Optional[QueryObjectDict] = None) -> VizPayload for col in filter_columns if col not in columns and col not in filter_values_columns ] + rejected_time_columns - + if df is not None: + payload["colnames"] = list(df.columns) return payload def get_df_payload( diff --git a/tests/integration_tests/base_api_tests.py b/tests/integration_tests/base_api_tests.py index e6e795f4d6b27..a76346149ef66 100644 --- a/tests/integration_tests/base_api_tests.py +++ b/tests/integration_tests/base_api_tests.py @@ -202,6 +202,40 @@ def test_get_related_owners(self): for expected_user in expected_users: assert expected_user in response_users + def test_get_related_owners_paginated(self): + """ + API: Test get related owners with pagination + """ + self.login(username="admin") + page_size = 1 + argument = {"page_size": page_size} + uri = f"api/v1/{self.resource_name}/related/owners?q={prison.dumps(argument)}" + rv = self.client.get(uri) + assert rv.status_code == 200 + response = json.loads(rv.data.decode("utf-8")) + users = db.session.query(security_manager.user_model).all() + + # the count should correspond with the total number of users + assert response["count"] == len(users) + + # the length of the result should be at most equal to the page size + assert len(response["result"]) == min(page_size, len(users)) + + # make sure all received users are included in the full set of users + all_users = [str(user) for user in users] + for received_user in [result["text"] for result in response["result"]]: + assert received_user in all_users + + def test_get_ids_related_owners_paginated(self): + """ + API: Test get related owners with pagination returns 422 + """ + self.login(username="admin") + argument = {"page": 1, "page_size": 1, "include_ids": [2]} + uri = f"api/v1/{self.resource_name}/related/owners?q={prison.dumps(argument)}" + rv = self.client.get(uri) + assert rv.status_code == 422 + def test_get_filter_related_owners(self): """ API: Test get filter related owners diff --git a/tests/integration_tests/charts/commands_tests.py b/tests/integration_tests/charts/commands_tests.py index 238a54e29c7f4..7e718f8ebd07e 100644 --- a/tests/integration_tests/charts/commands_tests.py +++ b/tests/integration_tests/charts/commands_tests.py @@ -321,7 +321,7 @@ def test_update_v1_response(self, mock_sm_g, mock_g): json_obj = { "description": "test for update", "cache_timeout": None, - "owners": [1], + "owners": [actor.id], } command = UpdateChartCommand(actor, model_id, json_obj) last_saved_before = db.session.query(Slice).get(pk).last_saved_at @@ -329,3 +329,31 @@ def test_update_v1_response(self, mock_sm_g, mock_g): chart = db.session.query(Slice).get(pk) assert chart.last_saved_at != last_saved_before assert chart.last_saved_by == actor + + @patch("superset.views.base.g") + @patch("superset.security.manager.g") + @pytest.mark.usefixtures("load_energy_table_with_slice") + def test_query_context_update_command(self, mock_sm_g, mock_g): + """" + Test that a user can generate the chart query context + payloadwithout affecting owners + """ + chart = db.session.query(Slice).all()[0] + pk = chart.id + admin = security_manager.find_user(username="admin") + chart.owners = [admin] + db.session.commit() + + actor = security_manager.find_user(username="alpha") + mock_g.user = mock_sm_g.user = actor + query_context = json.dumps({"foo": "bar"}) + json_obj = { + "query_context_generation": True, + "query_context": query_context, + } + command = UpdateChartCommand(actor, pk, json_obj) + command.run() + chart = db.session.query(Slice).get(pk) + assert chart.query_context == query_context + assert len(chart.owners) == 1 + assert chart.owners[0] == admin