diff --git a/UPDATING.md b/UPDATING.md index 9216650bafb19..c5c9d62065add 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -24,8 +24,9 @@ assists people when migrating to a new version. ## Next +- [31976](https://github.com/apache/superset/pull/31976) Removed the `DISABLE_LEGACY_DATASOURCE_EDITOR` feature flag. The previous value of the feature flag was `True` and now the feature is permanently removed. - [31959](https://github.com/apache/superset/pull/32000) Removes CSV_UPLOAD_MAX_SIZE config, use your web server to control file upload size. -- [31959](https://github.com/apache/superset/pull/31959) Removes the following endpoints from data uploads: /api/v1/database//_upload and /api/v1/database/_metadata, in favour of new one (Details on the PR). And simplifies permissions. +- [31959](https://github.com/apache/superset/pull/31959) Removes the following endpoints from data uploads: `/api/v1/database//_upload` and `/api/v1/database/_metadata`, in favour of new one (Details on the PR). And simplifies permissions. - [31844](https://github.com/apache/superset/pull/31844) The `ALERT_REPORTS_EXECUTE_AS` and `THUMBNAILS_EXECUTE_AS` config parameters have been renamed to `ALERT_REPORTS_EXECUTORS` and `THUMBNAILS_EXECUTORS` respectively. A new config flag `CACHE_WARMUP_EXECUTORS` has also been introduced to be able to control which user is used to execute cache warmup tasks. Finally, the config flag `THUMBNAILS_SELENIUM_USER` has been removed. To use a fixed executor for async tasks, use the new `FixedExecutor` class. See the config and docs for more info on setting up different executor profiles. - [31894](https://github.com/apache/superset/pull/31894) Domain sharding is deprecated in favor of HTTP2. The `SUPERSET_WEBSERVER_DOMAINS` configuration will be removed in the next major version (6.0) - [31794](https://github.com/apache/superset/pull/31794) Removed the previously deprecated `DASHBOARD_CROSS_FILTERS` feature flag @@ -46,7 +47,6 @@ assists people when migrating to a new version. - [30284](https://github.com/apache/superset/pull/30284) Deprecated GLOBAL_ASYNC_QUERIES_REDIS_CONFIG in favor of the new GLOBAL_ASYNC_QUERIES_CACHE_BACKEND configuration. To leverage Redis Sentinel, set CACHE_TYPE to RedisSentinelCache, or use RedisCache for standalone Redis - [31961](https://github.com/apache/superset/pull/31961) Upgraded React from version 16.13.1 to 17.0.2. If you are using custom frontend extensions or plugins, you may need to update them to be compatible with React 17. - ### Potential Downtime ## 4.1.0 diff --git a/docs/docs/installation/architecture.mdx b/docs/docs/installation/architecture.mdx index 85b42398b8978..e94f7258c3d46 100644 --- a/docs/docs/installation/architecture.mdx +++ b/docs/docs/installation/architecture.mdx @@ -45,7 +45,7 @@ This is the core application. Superset operates like this: This is where chart and dashboard definitions, user information, logs, etc. are stored. Superset is tested to work with PostgreSQL and MySQL databases as the metadata database (not be confused with a data source like your data warehouse, which could be a much greater variety of options like Snowflake, Redshift, etc.). -Some installation methods like our Quickstart and PyPI come configured by default to use a SQLite on-disk database. And in a Docker Compose installation, the data would be stored in a PostgresQL container volume. Neither of these cases are recommended for production instances of Superset. +Some installation methods like our Quickstart and PyPI come configured by default to use a SQLite on-disk database. And in a Docker Compose installation, the data would be stored in a PostgreSQL container volume. Neither of these cases are recommended for production instances of Superset. For production, a properly-configured, managed, standalone database is recommended. No matter what database you use, you should plan to back it up regularly. diff --git a/docs/src/resources/data.js b/docs/src/resources/data.js index baeed74eb9543..7bd418778ee83 100644 --- a/docs/src/resources/data.js +++ b/docs/src/resources/data.js @@ -137,4 +137,9 @@ export const Databases = [ href: 'https://www.denodo.com/', imgName: 'denodo.png', }, + { + title: 'TDengine', + href: 'https://www.tdengine.com/', + imgName: 'tdengine.png', + }, ]; diff --git a/docs/static/img/databases/tdengine.png b/docs/static/img/databases/tdengine.png new file mode 100644 index 0000000000000..cc9d1852e00be Binary files /dev/null and b/docs/static/img/databases/tdengine.png differ diff --git a/docs/static/img/tdengine.png b/docs/static/img/tdengine.png deleted file mode 100644 index 9d008807815e5..0000000000000 Binary files a/docs/static/img/tdengine.png and /dev/null differ diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.controls.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.controls.test.ts index 4a65d68cf5883..0e5c7331314aa 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.controls.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.controls.test.ts @@ -47,12 +47,12 @@ describe.skip('Dashboard top-level controls', () => { // Solution: pause the network before clicking, assert, then unpause network. cy.get('[data-test="refresh-chart-menu-item"]').should( 'have.class', - 'ant-dropdown-menu-item-disabled', + 'antd5-dropdown-menu-item-disabled', ); waitForChartLoad(mapSpec); cy.get('[data-test="refresh-chart-menu-item"]').should( 'not.have.class', - 'ant-dropdown-menu-item-disabled', + 'antd5-dropdown-menu-item-disabled', ); }); }); @@ -65,7 +65,7 @@ describe.skip('Dashboard top-level controls', () => { cy.get('[aria-label="more-horiz"]').click(); cy.get('[data-test="refresh-dashboard-menu-item"]').should( 'not.have.class', - 'ant-dropdown-menu-item-disabled', + 'antd5-dropdown-menu-item-disabled', ); cy.get('[data-test="refresh-dashboard-menu-item"]').click({ @@ -73,7 +73,7 @@ describe.skip('Dashboard top-level controls', () => { }); cy.get('[data-test="refresh-dashboard-menu-item"]').should( 'have.class', - 'ant-dropdown-menu-item-disabled', + 'antd5-dropdown-menu-item-disabled', ); // wait all charts force refreshed. @@ -94,7 +94,7 @@ describe.skip('Dashboard top-level controls', () => { cy.get('[aria-label="more-horiz"]').click(); cy.get('[data-test="refresh-dashboard-menu-item"]').and( 'not.have.class', - 'ant-dropdown-menu-item-disabled', + 'antd5-dropdown-menu-item-disabled', ); }); }); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts index e471d1da8caa2..2586fbd438148 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/drillby.test.ts @@ -54,15 +54,14 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => { interceptV1ChartData(); } - cy.get('.ant-dropdown:not(.ant-dropdown-hidden)') - .first() + cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)') .should('be.visible') .find("[role='menu'] [role='menuitem']") .contains(/^Drill by$/) .trigger('mouseover', { force: true }); cy.get( - '.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]', + '.antd5-dropdown-menu-submenu:not(.antd5-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]', ) .should('be.visible') .find('[role="menuitem"]') diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts index 4ebd64dd6e501..d2eb482a9faff 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/drilltodetail.test.ts @@ -61,15 +61,14 @@ function drillToDetail(targetMenuItem: string) { const drillToDetailBy = (targetDrill: string) => { interceptSamples(); - cy.get('.ant-dropdown:not(.ant-dropdown-hidden)') - .first() + cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)') .should('be.visible') .find("[role='menu'] [role='menuitem']") .contains(/^Drill to detail by$/) .trigger('mouseover', { force: true }); cy.get( - '.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]', + '.antd5-dropdown-menu-submenu:not(.antd5-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]', ) .should('be.visible') .find('[role="menuitem"]') diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts index 996327725c69e..b89e9f8a4b3f4 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts @@ -57,16 +57,16 @@ function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') { .trigger('mouseover'); if (orientation === 'vertical') { - cy.get('.antd5-menu-item-selected') + cy.get('.antd5-dropdown-menu-item-selected') .contains('Horizontal (Top)') .should('exist'); - cy.get('.antd5-menu-item').contains('Vertical (Left)').click(); + cy.get('.antd5-dropdown-menu-item').contains('Vertical (Left)').click(); cy.getBySel('dashboard-filters-panel').should('exist'); } else { - cy.get('.antd5-menu-item-selected') + cy.get('.antd5-dropdown-menu-item-selected') .contains('Vertical (Left)') .should('exist'); - cy.get('.antd5-menu-item').contains('Horizontal (Top)').click(); + cy.get('.antd5-dropdown-menu-item').contains('Horizontal (Top)').click(); cy.getBySel('loading-indicator').should('exist'); cy.getBySel('filter-bar').should('exist'); cy.getBySel('dashboard-filters-panel').should('not.exist'); diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js index c7b81a3e12d34..abe5a0c51d769 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js @@ -31,35 +31,35 @@ const SAMPLE_DASHBOARDS_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; function openDashboardsAddedTo() { cy.getBySel('actions-trigger').click(); - cy.get('.ant-dropdown-menu-submenu-title') + cy.get('.antd5-dropdown-menu-submenu-title') .contains('On dashboards') .trigger('mouseover', { force: true }); } function closeDashboardsAddedTo() { - cy.get('.ant-dropdown-menu-submenu-title') + cy.get('.antd5-dropdown-menu-submenu-title') .contains('On dashboards') .trigger('mouseout', { force: true }); cy.getBySel('actions-trigger').click(); } function verifyDashboardsSubmenuItem(dashboardName) { - cy.get('.ant-dropdown-menu-submenu-popup').contains(dashboardName); + cy.get('.antd5-dropdown-menu-submenu-popup').contains(dashboardName); closeDashboardsAddedTo(); } function verifyDashboardSearch() { openDashboardsAddedTo(); - cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover'); - cy.get('.ant-dropdown-menu-submenu-popup') + cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover'); + cy.get('.antd5-dropdown-menu-submenu-popup') .find('input[placeholder="Search"]') .type('1'); - cy.get('.ant-dropdown-menu-submenu-popup').contains('1 - Sample dashboard'); - cy.get('.ant-dropdown-menu-submenu-popup') + cy.get('.antd5-dropdown-menu-submenu-popup').contains('1 - Sample dashboard'); + cy.get('.antd5-dropdown-menu-submenu-popup') .find('input[placeholder="Search"]') .type('Blahblah'); - cy.get('.ant-dropdown-menu-submenu-popup').contains('No results found'); - cy.get('.ant-dropdown-menu-submenu-popup') + cy.get('.antd5-dropdown-menu-submenu-popup').contains('No results found'); + cy.get('.antd5-dropdown-menu-submenu-popup') .find('[aria-label="close-circle"]') .click(); closeDashboardsAddedTo(); @@ -68,8 +68,8 @@ function verifyDashboardSearch() { function verifyDashboardLink() { interceptDashboardGet(); openDashboardsAddedTo(); - cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover'); - cy.get('.ant-dropdown-menu-submenu-popup a') + cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover'); + cy.get('.antd5-dropdown-menu-submenu-popup a') .first() .invoke('removeAttr', 'target') .click(); diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts index b68d828ba86bd..c792a310ef857 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts @@ -51,8 +51,8 @@ describe('Datasource control', () => { ) .first() .focus(); - cy.focused().clear(); - cy.focused().type(`${newMetricName}{enter}`); + cy.focused().clear({ force: true }); + cy.focused().type(`${newMetricName}{enter}`, { force: true }); cy.get('[data-test="datasource-modal-save"]').click(); cy.get('.antd5-modal-confirm-btns button').contains('OK').click(); diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js index ce7e522774019..76653dada9c80 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/download_chart.test.js @@ -36,10 +36,10 @@ describe('Download Chart > Bar chart', () => { }; cy.visitChartByParams(formData); - cy.get('.header-with-actions .ant-dropdown-trigger').click(); - cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click(); + cy.get('.header-with-actions .antd5-dropdown-trigger').click(); + cy.get(':nth-child(3) > .antd5-dropdown-menu-submenu-title').click(); cy.get( - '.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)', + '.antd5-dropdown-menu-submenu > .antd5-dropdown-menu li:nth-child(3)', ).click(); cy.verifyDownload('.jpg', { contains: true, diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts index 0deeabde8dc1b..ef97eb1c19f7d 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts @@ -80,9 +80,9 @@ describe('SqlLab query tabs', () => { // configure some editor settings cy.get(editorInput).type('some random query string', { force: true }); cy.get(queryLimitSelector).parent().click({ force: true }); - cy.get('.ant-dropdown-menu') + cy.get('.antd5-dropdown-menu') .last() - .find('.ant-dropdown-menu-item') + .find('.antd5-dropdown-menu-item') .first() .click({ force: true }); diff --git a/superset-frontend/cypress-base/cypress/support/directories.ts b/superset-frontend/cypress-base/cypress/support/directories.ts index ff5e29b49e598..286f865444a85 100644 --- a/superset-frontend/cypress-base/cypress/support/directories.ts +++ b/superset-frontend/cypress-base/cypress/support/directories.ts @@ -158,10 +158,10 @@ export const sqlLabView = { runButton: '.css-d3dxop', }, rowsLimit: { - dropdown: '.ant-dropdown-menu', - limitButton: '.ant-dropdown-menu-item', + dropdown: '.antd5-dropdown-menu', + limitButton: '.antd5-dropdown-menu-item', limitButtonText: '.css-151uxnz', - limitTextWithValue: '[class="ant-dropdown-trigger"]', + limitTextWithValue: '[class="antd5-dropdown-trigger"]', }, renderedTableHeader: '.ReactVirtualized__Table__headerRow', renderedTableRow: '.ReactVirtualized__Table__row', @@ -633,7 +633,7 @@ export const dashboardView = { refreshChart: dataTestLocator('refresh-chart-menu-item'), }, threeDotsMenuIcon: - '.header-with-actions .right-button-panel .ant-dropdown-trigger', + '.header-with-actions .right-button-panel .antd5-dropdown-trigger', threeDotsMenuDropdown: dataTestLocator('header-actions-menu'), refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'), saveAsMenuOption: dataTestLocator('save-as-menu-item'), diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index 07553d889e23a..13d07aa1989dd 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -75,4 +75,5 @@ module.exports = { }, ], ], + testTimeout: 10000, }; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index a53a45de35878..62ac94468857a 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -84,6 +84,12 @@ export interface Dataset { filter_select?: boolean; filter_select_enabled?: boolean; column_names?: string[]; + catalog?: string; + schema?: string; + table_name?: string; + database?: Record; + normalize_columns?: boolean; + always_filter_main_dttm?: boolean; } export interface ControlPanelState { diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts index 227ca6e71d562..229852373a872 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts @@ -17,7 +17,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Maybe, QueryFormMetric } from '../../types'; +import { Currency, Maybe, QueryFormMetric } from '../../types'; import { Column } from './Column'; export type Aggregate = @@ -65,7 +65,7 @@ export interface Metric { certification_details?: Maybe; certified_by?: Maybe; d3format?: Maybe; - currency?: Maybe; + currency?: Maybe; description?: Maybe; is_certified?: boolean; verbose_name?: string; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts index 8c9ee5621c525..8b0bf35525859 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts @@ -173,7 +173,7 @@ describe('BigNumberWithTrendline', () => { label: 'value', metric_name: 'value', d3format: '.2f', - currency: `{symbol: 'USD', symbolPosition: 'prefix' }`, + currency: { symbol: 'USD', symbolPosition: 'prefix' }, }, ], }, diff --git a/superset-frontend/src/GlobalStyles.tsx b/superset-frontend/src/GlobalStyles.tsx index af69b59cde960..e80d32e7ec487 100644 --- a/superset-frontend/src/GlobalStyles.tsx +++ b/superset-frontend/src/GlobalStyles.tsx @@ -43,13 +43,13 @@ export const GlobalStyles = () => ( // Ant Design is applying inline z-index styles causing troubles // TODO: Remove z-indexes when Ant Design is fully upgraded to v5 // Prefer vanilla Ant Design z-indexes that should work out of the box - .antd5-dropdown, .ant-dropdown, .ant-select-dropdown, .antd5-modal-wrap, .antd5-modal-mask, .antd5-picker-dropdown, + .ant-popover, .antd5-popover { z-index: ${theme.zIndex.max} !important; } @@ -107,13 +107,6 @@ export const GlobalStyles = () => ( margin-right: 0; } } - .ant-dropdown-menu-sub .antd5-menu.antd5-menu-vertical { - box-shadow: none; - } - .ant-dropdown-menu-submenu-title, - .ant-dropdown-menu-item { - line-height: 1.5em !important; - } `} /> ); diff --git a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx index df35351e5c231..ce9cb2b06d281 100644 --- a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx @@ -17,12 +17,13 @@ * under the License. */ import { useDispatch } from 'react-redux'; -import { styled, useTheme, t } from '@superset-ui/core'; -import { AntdDropdown } from 'src/components'; +import { useTheme, t } from '@superset-ui/core'; +import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import Icons from 'src/components/Icons'; import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; +import Button from 'src/components/Button'; export interface QueryLimitSelectProps { queryEditorId: string; @@ -34,28 +35,6 @@ export function convertToNumWithSpaces(num: number) { return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 '); } -const LimitSelectStyled = styled.span` - ${({ theme }) => ` - .ant-dropdown-trigger { - align-items: center; - color: ${theme.colors.grayscale.dark2}; - display: flex; - font-size: 12px; - margin-right: ${theme.gridUnit * 2}px; - text-decoration: none; - border: 0; - background: transparent; - span { - display: inline-block; - margin-right: ${theme.gridUnit * 2}px; - &:last-of-type: { - margin-right: ${theme.gridUnit * 4}px; - } - } - } - `} -`; - function renderQueryLimit( maxRow: number, setQueryLimit: (limit: number) => void, @@ -94,20 +73,18 @@ const QueryLimitSelect = ({ dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit)); return ( - - - - - + renderQueryLimit(maxRow, setQueryLimit)} + trigger={['click']} + > + + ); }; diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx index 2acd7665bfb15..6cb17bc2b4ff3 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx @@ -16,12 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { FC } from 'react'; -import { t, useTheme, styled } from '@superset-ui/core'; +import { t, useTheme } from '@superset-ui/core'; import Icons from 'src/components/Icons'; import { DropdownButton } from 'src/components/DropdownButton'; import Button from 'src/components/Button'; -import { DropdownButtonProps } from 'antd/lib/dropdown'; interface SaveDatasetActionButtonProps { setShowSave: (arg0: boolean) => void; @@ -34,34 +32,14 @@ const SaveDatasetActionButton = ({ }: SaveDatasetActionButtonProps) => { const theme = useTheme(); - const StyledDropdownButton = styled( - DropdownButton as FC, - )` - &.ant-dropdown-button button.ant-btn.ant-btn-default { - font-weight: ${theme.gridUnit * 150}; - background-color: ${theme.colors.primary.light4}; - color: ${theme.colors.primary.dark1}; - &:nth-of-type(2) { - &:before, - &:hover:before { - border-left: 2px solid ${theme.colors.primary.dark2}; - } - } - } - span[name='caret-down'] { - margin-left: ${theme.gridUnit * 1}px; - color: ${theme.colors.primary.dark2}; - } - `; - return !overlayMenu ? ( ) : ( - setShowSave(true)} - overlay={overlayMenu} + dropdownRender={() => overlayMenu} icon={ {t('Save')} - + ); }; diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 527c22752d012..957de4fbc7916 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -56,7 +56,8 @@ import Mousetrap from 'mousetrap'; import Button from 'src/components/Button'; import Timer from 'src/components/Timer'; import ResizableSidebar from 'src/components/ResizableSidebar'; -import { AntdDropdown, Skeleton } from 'src/components'; +import { Dropdown } from 'src/components/Dropdown'; +import { Skeleton } from 'src/components'; import { Switch } from 'src/components/Switch'; import { Input } from 'src/components/Input'; import { Menu } from 'src/components/Menu'; @@ -868,9 +869,12 @@ const SqlEditor: FC = ({ - + renderDropdown()} + trigger={['click']} + > - + )} diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx index d8f4c8e4ed072..76c8b74b19d89 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx @@ -20,7 +20,7 @@ import { useMemo, FC } from 'react'; import { bindActionCreators } from 'redux'; import { useSelector, useDispatch, shallowEqual } from 'react-redux'; -import { Dropdown } from 'src/components/Dropdown'; +import { MenuDotsDropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import { styled, t, QueryState } from '@superset-ui/core'; import { @@ -88,10 +88,10 @@ const SqlEditorTabHeader: FC = ({ queryEditor }) => { return ( - + = ({ dbId, catalog, schema, tableName }) => { <Icons.Table iconSize="l" /> {tableName} - <AntdDropdown - overlay={ + <Dropdown + dropdownRender={() => ( <Menu onClick={({ key }) => { if (key === 'refresh-table') { @@ -324,7 +321,7 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => { }} items={dropdownMenu} /> - } + )} trigger={['click']} > <Icons.DownSquareOutlined @@ -332,7 +329,7 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => { style={{ marginTop: 2, marginLeft: 4 }} aria-label={t('Table actions')} /> - </AntdDropdown> + </Dropdown> {isMetadataRefreshing ? ( diff --git a/superset-frontend/src/components/Button/index.tsx b/superset-frontend/src/components/Button/index.tsx index c38fbc15cab73..aa447e13bcf9a 100644 --- a/superset-frontend/src/components/Button/index.tsx +++ b/superset-frontend/src/components/Button/index.tsx @@ -67,7 +67,7 @@ const decideType = (buttonStyle: ButtonStyle) => { success: 'primary', secondary: 'default', default: 'default', - tertiary: 'dashed', + tertiary: 'default', dashed: 'dashed', link: 'link', }; diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index d619dae4897a1..efea8cc23f8cd 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -30,6 +30,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { Behavior, BinaryQueryObjectFilterClause, + Column, ContextMenuFilters, ensureIsArray, FeatureFlag, @@ -42,8 +43,11 @@ import { import { RootState } from 'src/dashboard/types'; import { Menu } from 'src/components/Menu'; import { usePermissions } from 'src/hooks/usePermissions'; -import { AntdDropdown as Dropdown } from 'src/components/index'; +import { Dropdown } from 'src/components/Dropdown'; import { updateDataMask } from 'src/dataMask/actions'; +import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal'; +import { useVerboseMap } from 'src/hooks/apiResources/datasets'; +import { Dataset } from 'src/components/Chart/types'; import { DrillDetailMenuItems } from '../DrillDetail'; import { getMenuAdjustedY } from '../utils'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; @@ -114,8 +118,22 @@ const ChartContextMenu = ( }>({ clientX: 0, clientY: 0 }); const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); + const [drillByColumn, setDrillByColumn] = useState(); + const [showDrillByModal, setShowDrillByModal] = useState(false); + const [dataset, setDataset] = useState(); + const verboseMap = useVerboseMap(dataset); - const menuItems = []; + const handleDrillBy = useCallback((column: Column, dataset: Dataset) => { + setDrillByColumn(column); + setDataset(dataset); // Save dataset when drilling + setShowDrillByModal(true); + }, []); + + const handleCloseDrillByModal = useCallback(() => { + setShowDrillByModal(false); + }, []); + + const menuItems: React.JSX.Element[] = []; const showDrillToDetail = isFeatureEnabled(FeatureFlag.DrillToDetail) && @@ -249,9 +267,9 @@ const ChartContextMenu = ( formData={formData} contextMenuY={clientY} submenuIndex={submenuIndex} - canDownload={canDownload} open={openKeys.includes('drill-by-submenu')} key="drill-by-submenu" + onDrillBy={handleDrillBy} {...(additionalConfig?.drillBy || {})} />, ); @@ -286,7 +304,7 @@ const ChartContextMenu = ( return ReactDOM.createPortal( <> ( {t('No actions')} )} - } + )} trigger={['click']} - onVisibleChange={value => { + onOpenChange={value => { setVisible(value); if (!value) { setOpenKeys([]); } }} - visible={visible} + open={visible} > )} + {showDrillByModal && drillByColumn && dataset && filters?.drillBy && ( + + )} , document.body, ); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx index c874da68540a4..c494ce52aa9a9 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx @@ -74,7 +74,6 @@ const renderMenu = ({ diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx index 47666db7802f0..b054ff02d6255 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx @@ -53,10 +53,8 @@ import { cachedSupersetGet, supersetGetCache, } from 'src/utils/cachedSupersetGet'; -import { useVerboseMap } from 'src/hooks/apiResources/datasets'; import { InputRef } from 'antd-v5'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; -import DrillByModal from './DrillByModal'; import { getSubmenuYOffset } from '../utils'; import { MenuItemWithTruncation } from '../MenuItemWithTruncation'; import { Dataset } from '../types'; @@ -74,8 +72,8 @@ export interface DrillByMenuItemsProps { onClick?: (event: MouseEvent) => void; openNewModal?: boolean; excludedColumns?: Column[]; - canDownload: boolean; open: boolean; + onDrillBy?: (column: Column, dataset: Dataset) => void; } const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options'); @@ -106,8 +104,8 @@ export const DrillByMenuItems = ({ onClick = () => {}, excludedColumns, openNewModal = true, - canDownload, open, + onDrillBy, ...rest }: DrillByMenuItemsProps) => { const theme = useTheme(); @@ -117,25 +115,20 @@ export const DrillByMenuItems = ({ const [debouncedSearchInput, setDebouncedSearchInput] = useState(''); const [dataset, setDataset] = useState(); const [columns, setColumns] = useState([]); - const [showModal, setShowModal] = useState(false); - const [currentColumn, setCurrentColumn] = useState(); const ref = useRef(null); const showSearch = loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD; + const handleSelection = useCallback( (event, column) => { onClick(event); onSelection(column, drillByConfig); - setCurrentColumn(column); - if (openNewModal) { - setShowModal(true); + if (openNewModal && onDrillBy && dataset) { + onDrillBy(column, dataset); } }, - [drillByConfig, onClick, onSelection, openNewModal], + [drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset], ); - const closeModal = useCallback(() => { - setShowModal(false); - }, []); useEffect(() => { if (open) { @@ -156,7 +149,6 @@ export const DrillByMenuItems = ({ ?.behaviors.find(behavior => behavior === Behavior.DrillBy), [formData.viz_type], ); - const verboseMap = useVerboseMap(dataset); useEffect(() => { async function loadOptions() { @@ -275,11 +267,11 @@ export const DrillByMenuItems = ({ const column = columns[index]; return ( handleSelection(e, column)} style={style} + {...rest} > {column.verbose_name || column.column_name} @@ -289,6 +281,7 @@ export const DrillByMenuItems = ({ return ( <> - {showModal && ( - - )} ); }; diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx index e20bc0290f777..aae82d8309c91 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx @@ -60,8 +60,15 @@ const DISABLED_REASONS = { ), }; -const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => ( - +const DisabledMenuItem = ({ + children, + menuKey, + ...rest +}: { + children: ReactNode; + menuKey: string; +}) => ( +
+ {DRILL_TO_DETAIL} ) : ( - + {DRILL_TO_DETAIL} ); const drillToDetailByMenuItem = drillByDisabled ? ( - + {DRILL_TO_DETAIL_BY} ) : (
{filters.map((filter, i) => ( {`${DRILL_TO_DETAIL_BY} `} @@ -224,7 +226,6 @@ const DrillDetailMenuItems = ({ ))} {filters.length > 1 && ( diff --git a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx index 9e8802f128421..cf77427cc7791 100644 --- a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx +++ b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx @@ -28,12 +28,15 @@ export type MenuItemWithTruncationProps = { children: ReactNode; onClick?: MenuItemProps['onClick']; style?: CSSProperties; + menuKey?: string; }; export const MenuItemWithTruncation = ({ tooltipText, children, - ...props + onClick, + style, + menuKey, }: MenuItemWithTruncationProps) => { const [itemRef, itemIsTruncated] = useCSSTextTruncation(); @@ -43,7 +46,9 @@ export const MenuItemWithTruncation = ({ display: flex; line-height: 1.5em; `} - {...props} + eventKey={menuKey} + onClick={onClick} + style={style} >
( ); @@ -694,116 +702,27 @@ class DatasourceEditor extends PureComponent { }); } - updateColumns(cols) { - // cols: Array<{column_name: string; is_dttm: boolean; type: string;}> - const { databaseColumns } = this.state; - const databaseColumnNames = cols.map(col => col.column_name); - const currentCols = databaseColumns.reduce( - (agg, col) => ({ - ...agg, - [col.column_name]: col, - }), - {}, - ); - const finalColumns = []; - const results = { - added: [], - modified: [], - removed: databaseColumns - .map(col => col.column_name) - .filter(col => !databaseColumnNames.includes(col)), - }; - cols.forEach(col => { - const currentCol = currentCols[col.column_name]; - if (!currentCol) { - // new column - finalColumns.push({ - id: nanoid(), - column_name: col.column_name, - type: col.type, - groupby: true, - filterable: true, - is_dttm: col.is_dttm, - }); - results.added.push(col.column_name); - } else if ( - currentCol.type !== col.type || - (!currentCol.is_dttm && col.is_dttm) - ) { - // modified column - finalColumns.push({ - ...currentCol, - type: col.type, - is_dttm: currentCol.is_dttm || col.is_dttm, - }); - results.modified.push(col.column_name); - } else { - // unchanged - finalColumns.push(currentCol); - } - }); - if ( - results.added.length || - results.modified.length || - results.removed.length - ) { - this.setColumns({ databaseColumns: finalColumns }); - } - return results; - } - - syncMetadata() { + async syncMetadata() { const { datasource } = this.state; - const params = { - datasource_type: datasource.type || datasource.datasource_type, - database_name: - datasource.database.database_name || datasource.database.name, - catalog_name: datasource.catalog, - schema_name: datasource.schema, - table_name: datasource.table_name, - normalize_columns: datasource.normalize_columns, - always_filter_main_dttm: datasource.always_filter_main_dttm, - }; - Object.entries(params).forEach(([key, value]) => { - // rison can't encode the undefined value - if (value === undefined) { - params[key] = null; - } - }); - const endpoint = `/datasource/external_metadata_by_name/?q=${rison.encode_uri( - params, - )}`; this.setState({ metadataLoading: true }); - - SupersetClient.get({ endpoint }) - .then(({ json }) => { - const results = this.updateColumns(json); - if (results.modified.length) { - this.props.addSuccessToast( - t('Modified columns: %s', results.modified.join(', ')), - ); - } - if (results.removed.length) { - this.props.addSuccessToast( - t('Removed columns: %s', results.removed.join(', ')), - ); - } - if (results.added.length) { - this.props.addSuccessToast( - t('New columns added: %s', results.added.join(', ')), - ); - } - this.props.addSuccessToast(t('Metadata has been synced')); - this.setState({ metadataLoading: false }); - }) - .catch(response => - getClientErrorObject(response).then(({ error, statusText }) => { - this.props.addDangerToast( - error || statusText || t('An error has occurred'), - ); - this.setState({ metadataLoading: false }); - }), + try { + const newCols = await fetchSyncedColumns(datasource); + const columnChanges = updateColumns( + datasource.columns, + newCols, + this.props.addSuccessToast, ); + this.setColumns({ databaseColumns: columnChanges.finalColumns }); + this.props.addSuccessToast(t('Metadata has been synced')); + this.setState({ metadataLoading: false }); + } catch (error) { + const { error: clientError, statusText } = + await getClientErrorObject(error); + this.props.addDangerToast( + clientError || statusText || t('An error has occurred'), + ); + this.setState({ metadataLoading: false }); + } } findDuplicates(arr, accessor) { @@ -1146,6 +1065,7 @@ class DatasourceEditor extends PureComponent { maxLines={Infinity} readOnly={!this.state.isEditMode} resize="both" + tooltipOptions={sqlTooltipOptions} /> } /> diff --git a/superset-frontend/src/components/Datasource/DatasourceModal.tsx b/superset-frontend/src/components/Datasource/DatasourceModal.tsx index 78483771d340c..33cd820677e4f 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal.tsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal.tsx @@ -17,11 +17,11 @@ * under the License. */ import { FunctionComponent, useState, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import Alert from 'src/components/Alert'; import Button from 'src/components/Button'; import { isDefined, - Metric, styled, SupersetClient, getClientErrorObject, @@ -33,7 +33,16 @@ import Modal from 'src/components/Modal'; import AsyncEsmComponent from 'src/components/AsyncEsmComponent'; import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace'; import withToasts from 'src/components/MessageToasts/withToasts'; -import { useSelector } from 'react-redux'; +import { + startMetaDataLoading, + stopMetaDataLoading, + syncDatasourceMetadata, +} from 'src/explore/actions/exploreActions'; +import { + fetchSyncedColumns, + updateColumns, +} from 'src/components/Datasource/utils'; +import { DatasetObject } from '../../features/datasets/types'; const DatasourceEditor = AsyncEsmComponent(() => import('./DatasourceEditor')); @@ -60,14 +69,17 @@ const StyledDatasourceModal = styled(Modal)` interface DatasourceModalProps { addSuccessToast: (msg: string) => void; - datasource: any; + addDangerToast: (msg: string) => void; + datasource: DatasetObject; onChange: () => {}; onDatasourceSave: (datasource: object, errors?: Array) => {}; onHide: () => {}; show: boolean; } -function buildExtraJsonObject(item: Record) { +function buildExtraJsonObject( + item: DatasetObject['metrics'][0] | DatasetObject['columns'][0], +) { const certification = item?.certified_by || item?.certification_details ? { @@ -83,18 +95,14 @@ function buildExtraJsonObject(item: Record) { const DatasourceModal: FunctionComponent = ({ addSuccessToast, + addDangerToast, datasource, onDatasourceSave, onHide, show, }) => { - const [currentDatasource, setCurrentDatasource] = useState({ - ...datasource, - metrics: datasource?.metrics?.map((metric: Metric) => ({ - ...metric, - currency: JSON.parse(metric.currency || 'null'), - })), - }); + const dispatch = useDispatch(); + const [currentDatasource, setCurrentDatasource] = useState(datasource); const currencies = useSelector< { common: { @@ -108,130 +116,145 @@ const DatasourceModal: FunctionComponent = ({ const [isEditing, setIsEditing] = useState(false); const dialog = useRef(null); const [modal, contextHolder] = Modal.useModal(); - - const onConfirmSave = () => { + const buildPayload = (datasource: Record) => ({ + table_name: datasource.table_name, + database_id: datasource.database?.id, + sql: datasource.sql, + filter_select_enabled: datasource.filter_select_enabled, + fetch_values_predicate: datasource.fetch_values_predicate, + schema: + datasource.tableSelector?.schema || + datasource.databaseSelector?.schema || + datasource.schema, + description: datasource.description, + main_dttm_col: datasource.main_dttm_col, + normalize_columns: datasource.normalize_columns, + always_filter_main_dttm: datasource.always_filter_main_dttm, + offset: datasource.offset, + default_endpoint: datasource.default_endpoint, + cache_timeout: + datasource.cache_timeout === '' ? null : datasource.cache_timeout, + is_sqllab_view: datasource.is_sqllab_view, + template_params: datasource.template_params, + extra: datasource.extra, + is_managed_externally: datasource.is_managed_externally, + external_url: datasource.external_url, + metrics: datasource?.metrics?.map((metric: DatasetObject['metrics'][0]) => { + const metricBody: any = { + expression: metric.expression, + description: metric.description, + metric_name: metric.metric_name, + metric_type: metric.metric_type, + d3format: metric.d3format || null, + currency: !isDefined(metric.currency) + ? null + : JSON.stringify(metric.currency), + verbose_name: metric.verbose_name, + warning_text: metric.warning_text, + uuid: metric.uuid, + extra: buildExtraJsonObject(metric), + }; + if (!Number.isNaN(Number(metric.id))) { + metricBody.id = metric.id; + } + return metricBody; + }), + columns: datasource?.columns?.map( + (column: DatasetObject['columns'][0]) => ({ + id: typeof column.id === 'number' ? column.id : undefined, + column_name: column.column_name, + type: column.type, + advanced_data_type: column.advanced_data_type, + verbose_name: column.verbose_name, + description: column.description, + expression: column.expression, + filterable: column.filterable, + groupby: column.groupby, + is_active: column.is_active, + is_dttm: column.is_dttm, + python_date_format: column.python_date_format || null, + uuid: column.uuid, + extra: buildExtraJsonObject(column), + }), + ), + owners: datasource.owners.map( + (o: Record) => o.value || o.id, + ), + }); + const onConfirmSave = async () => { // Pull out extra fields into the extra object - const schema = - currentDatasource.tableSelector?.schema || - currentDatasource.databaseSelector?.schema || - currentDatasource.schema; - setIsSaving(true); - SupersetClient.put({ - endpoint: `/api/v1/dataset/${currentDatasource.id}`, - jsonPayload: { - table_name: currentDatasource.table_name, - database_id: currentDatasource.database?.id, - sql: currentDatasource.sql, - filter_select_enabled: currentDatasource.filter_select_enabled, - fetch_values_predicate: currentDatasource.fetch_values_predicate, - schema, - description: currentDatasource.description, - main_dttm_col: currentDatasource.main_dttm_col, - normalize_columns: currentDatasource.normalize_columns, - always_filter_main_dttm: currentDatasource.always_filter_main_dttm, - offset: currentDatasource.offset, - default_endpoint: currentDatasource.default_endpoint, - cache_timeout: - currentDatasource.cache_timeout === '' - ? null - : currentDatasource.cache_timeout, - is_sqllab_view: currentDatasource.is_sqllab_view, - template_params: currentDatasource.template_params, - extra: currentDatasource.extra, - is_managed_externally: currentDatasource.is_managed_externally, - external_url: currentDatasource.external_url, - metrics: currentDatasource?.metrics?.map( - (metric: Record) => { - const metricBody: any = { - expression: metric.expression, - description: metric.description, - metric_name: metric.metric_name, - metric_type: metric.metric_type, - d3format: metric.d3format || null, - currency: !isDefined(metric.currency) - ? null - : JSON.stringify(metric.currency), - verbose_name: metric.verbose_name, - warning_text: metric.warning_text, - uuid: metric.uuid, - extra: buildExtraJsonObject(metric), - }; - if (!Number.isNaN(Number(metric.id))) { - metricBody.id = metric.id; - } - return metricBody; - }, - ), - columns: currentDatasource?.columns?.map( - (column: Record) => ({ - id: typeof column.id === 'number' ? column.id : undefined, - column_name: column.column_name, - type: column.type, - advanced_data_type: column.advanced_data_type, - verbose_name: column.verbose_name, - description: column.description, - expression: column.expression, - filterable: column.filterable, - groupby: column.groupby, - is_active: column.is_active, - is_dttm: column.is_dttm, - python_date_format: column.python_date_format || null, - uuid: column.uuid, - extra: buildExtraJsonObject(column), - }), - ), - owners: currentDatasource.owners.map( - (o: Record) => o.value || o.id, - ), - }, - }) - .then(() => { - addSuccessToast(t('The dataset has been saved')); - return SupersetClient.get({ - endpoint: `/api/v1/dataset/${currentDatasource?.id}`, - }); - }) - .then(({ json }) => { - // eslint-disable-next-line no-param-reassign - json.result.type = 'table'; - onDatasourceSave({ - ...json.result, - owners: currentDatasource.owners, - }); - onHide(); - }) - .catch(response => { - setIsSaving(false); - getClientErrorObject(response).then(error => { - let errorResponse: SupersetError | undefined; - let errorText: string | undefined; - // sip-40 error response - if (error?.errors?.length) { - errorResponse = error.errors[0]; - } else if (typeof error.error === 'string') { - // backward compatible with old error messages - errorText = error.error; - } - modal.error({ - title: t('Error saving dataset'), - okButtonProps: { danger: true, className: 'btn-danger' }, - content: ( - - ), - }); + try { + await SupersetClient.put({ + endpoint: `/api/v1/dataset/${currentDatasource.id}`, + jsonPayload: buildPayload(currentDatasource), + }); + if (datasource.sql !== currentDatasource.sql) { + // if sql has changed, save a second time with synced columns + dispatch(startMetaDataLoading()); + try { + const columnJson = await fetchSyncedColumns(currentDatasource); + const columnChanges = updateColumns( + currentDatasource.columns, + columnJson, + addSuccessToast, + ); + currentDatasource.columns = columnChanges.finalColumns; + dispatch(syncDatasourceMetadata(currentDatasource)); + dispatch(stopMetaDataLoading()); + addSuccessToast(t('Metadata has been synced')); + } catch (error) { + dispatch(stopMetaDataLoading()); + addDangerToast( + t('An error has occurred while syncing virtual dataset columns'), + ); + } + await SupersetClient.put({ + endpoint: `/api/v1/dataset/${currentDatasource.id}`, + jsonPayload: buildPayload(currentDatasource), }); + } + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/dataset/${currentDatasource?.id}`, + }); + addSuccessToast(t('The dataset has been saved')); + // eslint-disable-next-line no-param-reassign + json.result.type = 'table'; + onDatasourceSave({ + ...json.result, + owners: currentDatasource.owners, + }); + onHide(); + } catch (response) { + setIsSaving(false); + const error = await getClientErrorObject(response); + let errorResponse: SupersetError | undefined; + let errorText: string | undefined; + // sip-40 error response + if (error?.errors?.length) { + errorResponse = error.errors[0]; + } else if (typeof error.error === 'string') { + // backward compatible with old error messages + errorText = error.error; + } + modal.error({ + title: t('Error saving dataset'), + okButtonProps: { danger: true, className: 'btn-danger' }, + content: ( + + ), }); + } }; - const onDatasourceChange = (data: Record, err: Array) => { + const onDatasourceChange = (data: DatasetObject, err: Array) => { setCurrentDatasource({ ...data, - metrics: data?.metrics.map((metric: Record) => ({ + metrics: data?.metrics.map((metric: DatasetObject['metrics'][0]) => ({ ...metric, is_certified: metric?.certified_by || metric?.certification_details, })), diff --git a/superset-frontend/src/components/Datasource/utils.js b/superset-frontend/src/components/Datasource/utils.js index ccdb1b414a092..001a1a30b7926 100644 --- a/superset-frontend/src/components/Datasource/utils.js +++ b/superset-frontend/src/components/Datasource/utils.js @@ -17,6 +17,9 @@ * under the License. */ import { Children, cloneElement } from 'react'; +import { nanoid } from 'nanoid'; +import { SupersetClient, tn } from '@superset-ui/core'; +import rison from 'rison'; export function recurseReactClone(children, type, propExtender) { /** @@ -40,3 +43,102 @@ export function recurseReactClone(children, type, propExtender) { return newChild; }); } + +export function updateColumns(prevCols, newCols, addSuccessToast) { + // cols: Array<{column_name: string; is_dttm: boolean; type: string;}> + const databaseColumnNames = newCols.map(col => col.column_name); + const currentCols = prevCols.reduce((agg, col) => { + // eslint-disable-next-line no-param-reassign + agg[col.column_name] = col; + return agg; + }, {}); + const columnChanges = { + added: [], + modified: [], + removed: prevCols + .map(col => col.column_name) + .filter(col => !databaseColumnNames.includes(col)), + finalColumns: [], + }; + newCols.forEach(col => { + const currentCol = currentCols[col.column_name]; + if (!currentCol) { + // new column + columnChanges.finalColumns.push({ + id: nanoid(), + column_name: col.column_name, + type: col.type, + groupby: true, + filterable: true, + is_dttm: col.is_dttm, + }); + columnChanges.added.push(col.column_name); + } else if ( + currentCol.type !== col.type || + currentCol.is_dttm !== col.is_dttm + ) { + // modified column + columnChanges.finalColumns.push({ + ...currentCol, + type: col.type, + is_dttm: currentCol.is_dttm || col.is_dttm, + }); + columnChanges.modified.push(col.column_name); + } else { + // unchanged + columnChanges.finalColumns.push(currentCol); + } + }); + if (columnChanges.modified.length) { + addSuccessToast( + tn( + 'Modified 1 column in the virtual dataset', + 'Modified %s columns in the virtual dataset', + columnChanges.modified.length, + ), + ); + } + if (columnChanges.removed.length) { + addSuccessToast( + tn( + 'Removed 1 column from the virtual dataset', + 'Removed %s columns from the virtual dataset', + columnChanges.removed.length, + ), + ); + } + if (columnChanges.added.length) { + addSuccessToast( + tn( + 'Added 1 new column to the virtual dataset', + 'Added %s new columns to the virtual dataset', + columnChanges.added.length, + ), + ); + } + return columnChanges; +} + +export async function fetchSyncedColumns(datasource) { + const params = { + datasource_type: datasource.type, + database_name: + datasource.database?.database_name || datasource.database?.name, + catalog_name: datasource.catalog, + schema_name: datasource.schema, + table_name: datasource.table_name, + normalize_columns: datasource.normalize_columns, + always_filter_main_dttm: datasource.always_filter_main_dttm, + }; + Object.entries(params).forEach(([key, value]) => { + // rison can't encode the undefined value + if (value === undefined) { + params[key] = null; + } + }); + const endpoint = `/datasource/external_metadata_by_name/?q=${rison.encode_uri( + params, + )}`; + const { json } = await SupersetClient.get({ endpoint }); + return json; +} diff --git a/superset-frontend/src/components/Dropdown/Dropdown.stories.tsx b/superset-frontend/src/components/Dropdown/Dropdown.stories.tsx index 43d281505a67d..e30a69a38af98 100644 --- a/superset-frontend/src/components/Dropdown/Dropdown.stories.tsx +++ b/superset-frontend/src/components/Dropdown/Dropdown.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ import { Menu } from 'src/components/Menu'; -import { Dropdown, DropdownProps } from '.'; +import { MenuDotsDropdown, MenuDotsDropdownProps } from '.'; export default { title: 'Dropdown', @@ -50,8 +50,8 @@ const customOverlay = ( export const InteractiveDropdown = ({ overlayType, ...rest -}: DropdownProps & { overlayType: string }) => ( - ( + diff --git a/superset-frontend/src/components/Dropdown/index.tsx b/superset-frontend/src/components/Dropdown/index.tsx index ef81bd42ccd07..1fdca2e75d2c8 100644 --- a/superset-frontend/src/components/Dropdown/index.tsx +++ b/superset-frontend/src/components/Dropdown/index.tsx @@ -24,13 +24,10 @@ import { cloneElement, } from 'react'; -import { AntdDropdown } from 'src/components'; -// TODO: @geido - Remove these after dropdown is fully migrated to Antd v5 import { - Dropdown as Antd5Dropdown, - DropDownProps as Antd5DropdownProps, + Dropdown as AntdDropdown, + DropdownProps as AntdDropdownProps, } from 'antd-v5'; -import { DropDownProps } from 'antd/lib/dropdown'; import { styled } from '@superset-ui/core'; import Icons from 'src/components/Icons'; @@ -83,7 +80,8 @@ export enum IconOrientation { Vertical = 'vertical', Horizontal = 'horizontal', } -export interface DropdownProps extends DropDownProps { + +export interface MenuDotsDropdownProps extends AntdDropdownProps { overlay: ReactElement; iconOrientation?: IconOrientation; } @@ -100,19 +98,19 @@ const RenderIcon = ( return component; }; -export const Dropdown = ({ +export const MenuDotsDropdown = ({ overlay, iconOrientation = IconOrientation.Vertical, ...rest -}: DropdownProps) => ( - +}: MenuDotsDropdownProps) => ( + overlay} {...rest}> {RenderIcon(iconOrientation)} ); -export interface NoAnimationDropdownProps extends Antd5DropdownProps { +export interface NoAnimationDropdownProps extends AntdDropdownProps { children: ReactNode; onBlur?: (e: FocusEvent) => void; onKeyDown?: (e: KeyboardEvent) => void; @@ -126,8 +124,13 @@ export const NoAnimationDropdown = (props: NoAnimationDropdownProps) => { }); return ( - + {childrenWithProps} - + ); }; + +export type DropdownProps = AntdDropdownProps; +export const Dropdown = (props: DropdownProps) => ( + +); diff --git a/superset-frontend/src/components/DropdownButton/index.tsx b/superset-frontend/src/components/DropdownButton/index.tsx index 32a7739e3c308..84ffd5fe28bbb 100644 --- a/superset-frontend/src/components/DropdownButton/index.tsx +++ b/superset-frontend/src/components/DropdownButton/index.tsx @@ -16,90 +16,39 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactNode, ReactElement } from 'react'; +import { type ComponentProps } from 'react'; -import { AntdDropdown, AntdTooltip } from 'src/components'; -import { styled } from '@superset-ui/core'; +import { Dropdown } from 'antd-v5'; +import { Tooltip, TooltipPlacement } from 'src/components/Tooltip'; import { kebabCase } from 'lodash'; -const StyledDropdownButton = styled.div` - .ant-btn-group { - button.ant-btn { - background-color: ${({ theme }) => theme.colors.primary.dark1}; - border-color: transparent; - color: ${({ theme }) => theme.colors.grayscale.light5}; - font-size: 12px; - line-height: 13px; - outline: none; - &:first-of-type { - border-radius: ${({ theme }) => - `${theme.gridUnit}px 0 0 ${theme.gridUnit}px`}; - margin: 0; - } - - &:disabled { - background-color: ${({ theme }) => theme.colors.grayscale.light2}; - color: ${({ theme }) => theme.colors.grayscale.base}; - } - &:nth-of-type(2) { - margin: 0; - border-radius: ${({ theme }) => - `0 ${theme.gridUnit}px ${theme.gridUnit}px 0`}; - width: ${({ theme }) => theme.gridUnit * 9}px; - &:before, - &:hover:before { - border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light5}; - content: ''; - display: block; - height: ${({ theme }) => theme.gridUnit * 8}px; - margin: 0; - position: absolute; - width: ${({ theme }) => theme.gridUnit * 0.25}px; - } - - &:disabled:before { - border-left: 1px solid ${({ theme }) => theme.colors.grayscale.base}; - } - } - } - } -`; - -export interface DropdownButtonProps { - overlay: ReactElement; +export type DropdownButtonProps = ComponentProps & { tooltip?: string; - placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; - buttonsRender?: ((buttons: ReactNode[]) => ReactNode[]) | undefined; -} + tooltipPlacement?: TooltipPlacement; +}; export const DropdownButton = ({ - overlay, + dropdownRender, tooltip, - placement, + tooltipPlacement, + children, ...rest }: DropdownButtonProps) => { - const buildButton = ( - props: { - buttonsRender?: DropdownButtonProps['buttonsRender']; - } = {}, - ) => ( - - - + const button = ( + + {children} + ); if (tooltip) { - return buildButton({ - buttonsRender: ([leftButton, rightButton]) => [ - - {leftButton} - , - rightButton, - ], - }); + return ( + + {button} + + ); } - return buildButton(); + return button; }; diff --git a/superset-frontend/src/components/DropdownSelectableIcon/DropdownSelectableIcon.stories.tsx b/superset-frontend/src/components/DropdownSelectableIcon/DropdownSelectableIcon.stories.tsx deleted file mode 100644 index cc12a8ba5bcb3..0000000000000 --- a/superset-frontend/src/components/DropdownSelectableIcon/DropdownSelectableIcon.stories.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import Icons from 'src/components/Icons'; -import DropdownSelectableIcon, { DropDownSelectableProps } from '.'; - -export default { - title: 'DropdownSelectableIcon', - component: DropdownSelectableIcon, -}; - -export const Component = (props: DropDownSelectableProps) => ( - } - /> -); - -Component.args = { - info: 'Info go here', - selectedKeys: ['vertical'], - menuItems: [ - { - key: 'vertical', - label: 'Vertical', - }, - { - key: 'horizontal', - label: 'Horizontal', - }, - ], -}; - -Component.argTypes = { - onSelect: { - action: 'onSelect', - table: { - disable: true, - }, - }, -}; diff --git a/superset-frontend/src/components/DropdownSelectableIcon/DropdownSelectableIcon.test.tsx b/superset-frontend/src/components/DropdownSelectableIcon/DropdownSelectableIcon.test.tsx deleted file mode 100644 index cfff77d5eda27..0000000000000 --- a/superset-frontend/src/components/DropdownSelectableIcon/DropdownSelectableIcon.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { render, screen, waitFor } from 'spec/helpers/testing-library'; -import Icons from 'src/components/Icons'; -import userEvent from '@testing-library/user-event'; -import DropdownSelectableIcon, { DropDownSelectableProps } from '.'; - -const mockedProps = { - menuItems: [ - { - key: 'vertical', - label: 'vertical', - }, - { - key: 'horizontal', - label: 'horizontal', - }, - ], - selectedKeys: [], - icon: , -}; - -const asyncRender = (props: DropDownSelectableProps) => - waitFor(() => render()); - -const openMenu = () => { - userEvent.click(screen.getByRole('img', { name: 'gear' })); -}; - -test('should render', async () => { - const { container } = await asyncRender(mockedProps); - expect(container).toBeInTheDocument(); -}); - -test('should render the icon', async () => { - await asyncRender(mockedProps); - expect(screen.getByRole('img', { name: 'gear' })).toBeInTheDocument(); -}); - -test('should not render the info', async () => { - await asyncRender(mockedProps); - openMenu(); - expect( - screen.queryByTestId('dropdown-selectable-info'), - ).not.toBeInTheDocument(); -}); - -test('should render the info', async () => { - const infoProps = { - ...mockedProps, - info: 'Test', - }; - await asyncRender(infoProps); - openMenu(); - expect(screen.getByTestId('dropdown-selectable-info')).toBeInTheDocument(); - expect(screen.getByText('Test')).toBeInTheDocument(); -}); - -test('should render the menu items', async () => { - await asyncRender(mockedProps); - openMenu(); - expect(screen.getAllByRole('menuitem')).toHaveLength(2); - expect(screen.getByText('vertical')).toBeInTheDocument(); - expect(screen.getByText('horizontal')).toBeInTheDocument(); -}); - -test('should not render any selected menu item', async () => { - await asyncRender(mockedProps); - openMenu(); - expect(screen.getAllByRole('menuitem')).toHaveLength(2); - expect(screen.queryByRole('img', { name: 'check' })).not.toBeInTheDocument(); -}); - -test('should render the selected menu items', async () => { - const selectedProps = { - ...mockedProps, - selectedKeys: ['vertical'], - }; - await asyncRender(selectedProps); - openMenu(); - expect(screen.getByRole('img', { name: 'check' })).toBeInTheDocument(); -}); diff --git a/superset-frontend/src/components/DropdownSelectableIcon/index.tsx b/superset-frontend/src/components/DropdownSelectableIcon/index.tsx deleted file mode 100644 index 8d791929d3719..0000000000000 --- a/superset-frontend/src/components/DropdownSelectableIcon/index.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { addAlpha, styled, useTheme } from '@superset-ui/core'; -import { FC, RefObject, useMemo, ReactNode, useState } from 'react'; -import Icons from 'src/components/Icons'; -import { DropdownButton } from 'src/components/DropdownButton'; -import { DropdownButtonProps } from 'antd/lib/dropdown'; -import { Menu, MenuProps } from 'src/components/Menu'; - -const { SubMenu } = Menu; - -type SubMenuItemProps = { key: string; label: string | ReactNode }; - -export interface DropDownSelectableProps extends Pick { - ref?: RefObject; - icon: ReactNode; - info?: string; - menuItems: { - key: string; - label: string | ReactNode; - children?: SubMenuItemProps[]; - divider?: boolean; - }[]; - selectedKeys?: string[]; -} - -const StyledDropdownButton = styled(DropdownButton as FC)` - button.ant-btn:first-of-type { - display: none; - } - > button.ant-btn:nth-of-type(2) { - display: inline-flex; - background-color: transparent !important; - height: unset; - padding: 0; - border: none; - width: auto !important; - - .anticon { - line-height: 0; - } - &:after { - box-shadow: none !important; - } - } -`; - -const StyledMenu = styled(Menu)` - ${({ theme }) => ` - box-shadow: - 0 3px 6px -4px ${addAlpha(theme.colors.grayscale.dark2, 0.12)}, - 0 6px 16px 0 - ${addAlpha(theme.colors.grayscale.dark2, 0.08)}, - 0 9px 28px 8px - ${addAlpha(theme.colors.grayscale.dark2, 0.05)}; - .info { - font-size: ${theme.typography.sizes.s}px; - color: ${theme.colors.grayscale.base}; - padding: ${theme.gridUnit}px ${theme.gridUnit * 3}px ${ - theme.gridUnit - }px ${theme.gridUnit * 3}px; - } - .ant-dropdown-menu-item-selected { - color: ${theme.colors.grayscale.dark1}; - background-color: ${theme.colors.primary.light5}; - } - `} -`; - -const StyleMenuItem = styled(Menu.Item)<{ divider?: boolean }>` - display: flex; - justify-content: space-between; - > span { - width: 100%; - } - border-bottom: ${({ divider, theme }) => - divider ? `1px solid ${theme.colors.grayscale.light3};` : 'none;'}; -`; - -const StyleSubmenuItem = styled.div` - display: flex; - justify-content: space-between; - width: 100%; - > div { - flex-grow: 1; - } -`; - -export default (props: DropDownSelectableProps) => { - const theme = useTheme(); - const [visible, setVisible] = useState(false); - const { icon, info, menuItems, selectedKeys, onSelect } = props; - - const handleVisibleChange = setVisible; - - const handleMenuSelect: MenuProps['onSelect'] = info => { - if (onSelect) { - onSelect(info); - } - setVisible(false); - }; - const menuItem = useMemo( - () => (label: string | ReactNode, key: string, divider?: boolean) => ( - - - {label} - {selectedKeys?.includes(key) && ( - - )} - - - ), - [selectedKeys, theme.colors.primary.base], - ); - - const overlayMenu = useMemo( - () => ( - <> - {info && ( -
- {info} -
- )} - - {menuItems.map(m => - m.children?.length ? ( - - {m.children.map(s => menuItem(s.label, s.key))} - - ) : ( - menuItem(m.label, m.key, m.divider) - ), - )} - - - ), - [selectedKeys, onSelect, info, menuItems, menuItem, handleMenuSelect], - ); - - return ( - - ); -}; diff --git a/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx index 38fc7cbe94f76..61d2c70a31cde 100644 --- a/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx +++ b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ import { action } from '@storybook/addon-actions'; -import { AntdDropdown } from 'src/components'; +import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import Icons from 'src/components/Icons'; import FaveStar from 'src/components/FaveStar'; @@ -70,8 +70,8 @@ export const SupersetListViewCard = ({ saveFaveStar={action('saveFaveStar')} isStarred={isStarred} /> - ( Delete @@ -80,10 +80,10 @@ export const SupersetListViewCard = ({ Edit - } + )} > - +
} /> diff --git a/superset-frontend/src/components/Menu/index.tsx b/superset-frontend/src/components/Menu/index.tsx index ff2c1c0b6ec2e..5d0f6e0dcccb5 100644 --- a/superset-frontend/src/components/Menu/index.tsx +++ b/superset-frontend/src/components/Menu/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { addAlpha, styled } from '@superset-ui/core'; +import { styled } from '@superset-ui/core'; import { ReactElement } from 'react'; import { Menu as AntdMenu } from 'antd-v5'; import { MenuProps as AntdMenuProps } from 'antd-v5/es/menu'; @@ -73,23 +73,11 @@ const StyledMenuItem = styled(AntdMenu.Item)` } `; -// TODO: @geido - Move this to theme after fully migrating dropdown to Antd5 const StyledMenu = styled(AntdMenu)` - ${({ theme }) => ` - &.antd5-menu-horizontal { - background-color: inherit; - border-bottom: 1px solid transparent; - } - &.antd5-menu-vertical, - &.ant-dropdown-menu { - box-shadow: - 0 3px 6px -4px ${addAlpha(theme.colors.grayscale.dark2, 0.12)}, - 0 6px 16px 0 - ${addAlpha(theme.colors.grayscale.dark2, 0.08)}, - 0 9px 28px 8px - ${addAlpha(theme.colors.grayscale.dark2, 0.05)}; - } - `} + &.antd5-menu-horizontal { + background-color: inherit; + border-bottom: 1px solid transparent; + } `; const StyledNav = styled(AntdMenu)` @@ -145,11 +133,6 @@ const StyledSubMenu = styled(AntdMenu.SubMenu)` transition: all ${({ theme }) => theme.transitionTiming}s; } } - - .ant-dropdown-menu-submenu-arrow:before, - .ant-dropdown-menu-submenu-arrow:after { - content: none !important; - } `; export type MenuMode = AntdMenuProps['mode']; diff --git a/superset-frontend/src/components/PageHeaderWithActions/index.tsx b/superset-frontend/src/components/PageHeaderWithActions/index.tsx index b84e67b31f939..68c33d78c7e8c 100644 --- a/superset-frontend/src/components/PageHeaderWithActions/index.tsx +++ b/superset-frontend/src/components/PageHeaderWithActions/index.tsx @@ -18,7 +18,7 @@ */ import { ReactNode, ReactElement } from 'react'; import { css, SupersetTheme, t, useTheme } from '@superset-ui/core'; -import { AntdDropdown, AntdDropdownProps } from 'src/components'; +import { Dropdown, DropdownProps } from 'src/components/Dropdown'; import { TooltipPlacement } from 'src/components/Tooltip'; import { DynamicEditableTitle, @@ -116,7 +116,7 @@ export type PageHeaderWithActionsProps = { titlePanelAdditionalItems: ReactNode; rightPanelAdditionalItems: ReactNode; additionalActionsMenu: ReactElement; - menuDropdownProps: Omit; + menuDropdownProps: Omit; tooltipProps?: { text?: string; placement?: TooltipPlacement; @@ -155,9 +155,9 @@ export const PageHeaderWithActions = ({ {rightPanelAdditionalItems}
{showMenuDropdown && ( - additionalActionsMenu} {...menuDropdownProps} > - + )}
diff --git a/superset-frontend/src/components/PopoverDropdown/index.tsx b/superset-frontend/src/components/PopoverDropdown/index.tsx index 9ad507d5f72a2..41812ea13f5de 100644 --- a/superset-frontend/src/components/PopoverDropdown/index.tsx +++ b/superset-frontend/src/components/PopoverDropdown/index.tsx @@ -19,7 +19,7 @@ import { Key } from 'react'; import cx from 'classnames'; import { styled, useTheme } from '@superset-ui/core'; -import { AntdDropdown } from 'src/components'; +import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import Icons from 'src/components/Icons'; @@ -89,10 +89,10 @@ const PopoverDropdown = (props: PopoverDropdownProps) => { const theme = useTheme(); const selected = options.find(opt => opt.value === value); return ( - ( onChange(key)}> {options.map(option => ( { ))} - } + )} >
{selected && renderButton(selected)} @@ -115,7 +115,7 @@ const PopoverDropdown = (props: PopoverDropdownProps) => { css={{ marginTop: theme.gridUnit * 0.5 }} />
-
+ ); }; diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx index 1c3127504f1fc..5b404ccbdd721 100644 --- a/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx @@ -18,7 +18,7 @@ */ import { useState, useEffect } from 'react'; import { styled } from '@superset-ui/core'; -import { Dropdown, IconOrientation } from 'src/components/Dropdown'; +import { MenuDotsDropdown, IconOrientation } from 'src/components/Dropdown'; import { Menu, MenuProps } from 'src/components/Menu'; /** @@ -126,7 +126,7 @@ export function ActionCell(props: ActionCellProps) { setVisible(flag); }; return ( - ( - <> - - + ); diff --git a/superset-frontend/src/components/index.ts b/superset-frontend/src/components/index.ts index cb949ef5c36a1..9d9ad65ebe26a 100644 --- a/superset-frontend/src/components/index.ts +++ b/superset-frontend/src/components/index.ts @@ -56,7 +56,6 @@ export { Card as AntdCard, Checkbox as AntdCheckbox, Collapse as AntdCollapse, - Dropdown as AntdDropdown, Form as AntdForm, Input as AntdInput, Select as AntdSelect, @@ -67,5 +66,4 @@ export { // Exported types export type { FormInstance } from 'antd/lib/form'; -export type { DropDownProps as AntdDropdownProps } from 'antd/lib/dropdown'; export type { RadioChangeEvent } from 'antd/lib/radio'; diff --git a/superset-frontend/src/dashboard/components/CssEditor/index.tsx b/superset-frontend/src/dashboard/components/CssEditor/index.tsx index e2748c8b0b1d7..2b9b990704808 100644 --- a/superset-frontend/src/dashboard/components/CssEditor/index.tsx +++ b/superset-frontend/src/dashboard/components/CssEditor/index.tsx @@ -17,8 +17,8 @@ * under the License. */ import { Key, ReactNode, PureComponent } from 'react'; +import { Dropdown } from 'src/components/Dropdown'; import rison from 'rison'; -import { AntdDropdown } from 'src/components'; import { Menu } from 'src/components/Menu'; import Button from 'src/components/Button'; import { t, styled, SupersetClient } from '@superset-ui/core'; @@ -115,9 +115,9 @@ class CssEditor extends PureComponent {
); return ( - + menu} placement="bottomRight"> - +
); } return null; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx index 1a0b73165ada2..877d9203a00ba 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx @@ -53,9 +53,6 @@ jest.mock('src/components/Select/Select', () => () => ( jest.mock('src/components/Select/AsyncSelect', () => () => (
)); -jest.mock('src/dashboard/components/Header/HeaderActionsDropdown', () => () => ( -
-)); jest.mock('src/components/PageHeaderWithActions', () => ({ PageHeaderWithActions: () => (
diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx deleted file mode 100644 index 090f12a2bd079..0000000000000 --- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/HeaderActionsDropdown.test.tsx +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import { render, screen } from 'spec/helpers/testing-library'; -import userEvent from '@testing-library/user-event'; -import fetchMock from 'fetch-mock'; -import { HeaderDropdownProps } from 'src/dashboard/components/Header/types'; -import injectCustomCss from 'src/dashboard/util/injectCustomCss'; -import { HeaderActionsDropdown } from '.'; - -const createProps = (): HeaderDropdownProps => ({ - addSuccessToast: jest.fn(), - addDangerToast: jest.fn(), - customCss: '.ant-menu {margin-left: 100px;}', - dashboardId: 1, - dashboardInfo: { - id: 1, - dash_edit_perm: true, - dash_save_perm: true, - userId: '1', - metadata: {}, - common: { - conf: { - DASHBOARD_AUTO_REFRESH_INTERVALS: [ - [0, "Don't refresh"], - [10, '10 seconds'], - ], - }, - }, - }, - dashboardTitle: 'Title', - editMode: false, - expandedSlices: {}, - forceRefreshAllCharts: jest.fn(), - hasUnsavedChanges: false, - isLoading: false, - layout: {}, - onChange: jest.fn(), - onSave: jest.fn(), - refreshFrequency: 200, - setRefreshFrequency: jest.fn(), - shouldPersistRefreshFrequency: false, - showPropertiesModal: jest.fn(), - startPeriodicRender: jest.fn(), - updateCss: jest.fn(), - userCanEdit: false, - userCanSave: false, - userCanShare: false, - userCanCurate: false, - lastModifiedTime: 0, - isDropdownVisible: true, - setIsDropdownVisible: jest.fn(), - directPathToChild: [], - manageEmbedded: jest.fn(), - dataMask: {}, - logEvent: jest.fn(), - refreshLimit: 0, - refreshWarning: '', -}); - -const editModeOnProps = { - ...createProps(), - editMode: true, -}; - -const editModeOnWithFilterScopesProps = { - ...editModeOnProps, - dashboardInfo: { - ...editModeOnProps.dashboardInfo, - metadata: { - filter_scopes: { - '1': { scopes: ['ROOT_ID'], immune: [] }, - }, - }, - }, -}; - -const guestUserProps = { - ...createProps(), - dashboardInfo: { - ...createProps().dashboardInfo, - userId: undefined, - }, -}; - -function setup(props: HeaderDropdownProps) { - return render( -
- -
, - { useRedux: true }, - ); -} - -fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {}); - -test('should render', () => { - const mockedProps = createProps(); - const { container } = setup(mockedProps); - expect(container).toBeInTheDocument(); -}); - -test('should render the Download dropdown button when not in edit mode', () => { - const mockedProps = createProps(); - setup(mockedProps); - expect( - screen.getByRole('menuitem', { name: 'Download' }), - ).toBeInTheDocument(); -}); - -test('should render the menu items', async () => { - const mockedProps = createProps(); - setup(mockedProps); - expect(screen.getAllByRole('menuitem')).toHaveLength(4); - expect(screen.getByText('Refresh dashboard')).toBeInTheDocument(); - expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument(); - expect(screen.getByText('Enter fullscreen')).toBeInTheDocument(); - expect(screen.getByText('Download')).toBeInTheDocument(); -}); - -test('should render the menu items in edit mode', async () => { - setup(editModeOnProps); - expect(screen.getAllByRole('menuitem')).toHaveLength(4); - expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument(); - expect(screen.getByText('Edit properties')).toBeInTheDocument(); - expect(screen.getByText('Edit CSS')).toBeInTheDocument(); - expect(screen.getByText('Download')).toBeInTheDocument(); -}); - -test('should render the menu items in Embedded mode', async () => { - setup(guestUserProps); - expect(screen.getAllByRole('menuitem')).toHaveLength(3); - expect(screen.getByText('Refresh dashboard')).toBeInTheDocument(); - expect(screen.getByText('Download')).toBeInTheDocument(); - expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument(); -}); - -test('should not render filter mapping in edit mode if explicit filter scopes undefined', async () => { - setup(editModeOnProps); - expect(screen.queryByText('Set filter mapping')).not.toBeInTheDocument(); -}); - -test('should render filter mapping in edit mode if explicit filter scopes defined', async () => { - setup(editModeOnWithFilterScopesProps); - expect(screen.getByText('Set filter mapping')).toBeInTheDocument(); -}); - -test('should show the share actions', async () => { - const mockedProps = createProps(); - const canShareProps = { - ...mockedProps, - userCanShare: true, - }; - setup(canShareProps); - - expect(screen.getByText('Share')).toBeInTheDocument(); -}); - -test('should render the "Save as" menu item when user can save', async () => { - const mockedProps = createProps(); - const canSaveProps = { - ...mockedProps, - userCanSave: true, - }; - setup(canSaveProps); - expect(screen.getByText('Save as')).toBeInTheDocument(); -}); - -test('should NOT render the "Save as" menu item when user cannot save', async () => { - const mockedProps = createProps(); - setup(mockedProps); - expect(screen.queryByText('Save as')).not.toBeInTheDocument(); -}); - -test('should render the "Refresh dashboard" menu item as disabled when loading', async () => { - const mockedProps = createProps(); - const loadingProps = { - ...mockedProps, - isLoading: true, - }; - setup(loadingProps); - expect(screen.getByText('Refresh dashboard').parentElement).toHaveClass( - 'ant-menu-item-disabled', - ); -}); - -test('should NOT render the "Refresh dashboard" menu item as disabled', async () => { - const mockedProps = createProps(); - setup(mockedProps); - expect(screen.getByText('Refresh dashboard')).not.toHaveClass( - 'ant-menu-item-disabled', - ); -}); - -test('should render with custom css', () => { - const mockedProps = createProps(); - const { customCss } = mockedProps; - setup(mockedProps); - injectCustomCss(customCss); - expect(screen.getByTestId('header-actions-menu')).toHaveStyle( - 'margin-left: 100px', - ); -}); - -test('should refresh the charts', async () => { - const mockedProps = createProps(); - setup(mockedProps); - userEvent.click(screen.getByText('Refresh dashboard')); - expect(mockedProps.forceRefreshAllCharts).toHaveBeenCalledTimes(1); - expect(mockedProps.addSuccessToast).toHaveBeenCalledTimes(1); -}); - -test('should show the properties modal', async () => { - setup(editModeOnProps); - userEvent.click(screen.getByText('Edit properties')); - expect(editModeOnProps.showPropertiesModal).toHaveBeenCalledTimes(1); -}); - -describe('UNSAFE_componentWillReceiveProps', () => { - let wrapper: any; - - const mockedProps = createProps(); - const props = { ...mockedProps, customCss: '' }; - beforeEach(() => { - wrapper = shallow(); - wrapper.setState({ css: props.customCss }); - sinon.spy(wrapper.instance(), 'setState'); - }); - - afterEach(() => { - wrapper.instance().setState.restore(); - }); - - it('css should update state and inject custom css', () => { - wrapper.instance().UNSAFE_componentWillReceiveProps({ - ...props, - customCss: mockedProps.customCss, - }); - expect(wrapper.instance().setState.calledOnce).toBe(true); - const stateKeys = Object.keys(wrapper.instance().setState.lastCall.args[0]); - expect(stateKeys).toContain('css'); - }); -}); diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index 5ce86cc52f12c..1324d785cd1cb 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -40,7 +40,6 @@ import { Button } from 'src/components/'; import { findPermission } from 'src/utils/findPermission'; import { Tooltip } from 'src/components/Tooltip'; import { safeStringify } from 'src/utils/safeStringify'; -import ConnectedHeaderActionsDropdown from 'src/dashboard/components/Header/HeaderActionsDropdown'; import PublishedStatus from 'src/dashboard/components/PublishedStatus'; import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; @@ -53,6 +52,9 @@ import { import setPeriodicRunner, { stopPeriodicRender, } from 'src/dashboard/util/setPeriodicRunner'; +import ReportModal from 'src/features/reports/ReportModal'; +import DeleteModal from 'src/components/DeleteModal'; +import { deleteActiveReport } from 'src/features/reports/ReportModal/actions'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import DashboardEmbedModal from '../EmbeddedModal'; import OverwriteConfirm from '../OverwriteConfirm'; @@ -88,6 +90,7 @@ import { dashboardInfoChanged } from '../../actions/dashboardInfo'; import isDashboardLoading from '../../util/isDashboardLoading'; import { useChartIds } from '../../util/charts/useChartIds'; import { useDashboardMetadataBar } from './useDashboardMetadataBar'; +import { useHeaderActionsMenu } from './useHeaderActionsDropdownMenu'; const extensionsRegistry = getExtensionsRegistry(); @@ -160,8 +163,9 @@ const Header = () => { const [emphasizeUndo, setEmphasizeUndo] = useState(false); const [emphasizeRedo, setEmphasizeRedo] = useState(false); const [showingPropertiesModal, setShowingPropertiesModal] = useState(false); - const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [showingEmbedModal, setShowingEmbedModal] = useState(false); + const [showingReportModal, setShowingReportModal] = useState(false); + const [currentReportDeleting, setCurrentReportDeleting] = useState(null); const dashboardInfo = useSelector(state => state.dashboardInfo); const layout = useSelector(state => state.dashboardLayout.present); const undoLength = useSelector(state => state.dashboardLayout.past.length); @@ -348,10 +352,6 @@ const Header = () => { [boundActionCreators, dashboardTitle], ); - const setDropdownVisible = useCallback(visible => { - setIsDropdownVisible(visible); - }, []); - const handleCtrlY = useCallback(() => { boundActionCreators.onRedo(); setEmphasizeRedo(true); @@ -475,6 +475,14 @@ const Header = () => { setShowingEmbedModal(false); }, []); + const showReportModal = useCallback(() => { + setShowingReportModal(true); + }, []); + + const hideReportModal = useCallback(() => { + setShowingReportModal(false); + }, []); + const metadataBar = useDashboardMetadataBar(dashboardInfo); const userCanEdit = @@ -689,92 +697,47 @@ const Header = () => { ], ); - const menuDropdownProps = useMemo( - () => ({ - getPopupContainer: triggerNode => - triggerNode.closest('.header-with-actions'), - visible: isDropdownVisible, - onVisibleChange: setDropdownVisible, - }), - [isDropdownVisible, setDropdownVisible], - ); - - const additionalActionsMenu = useMemo( - () => ( - - ), - [ - actualLastModifiedTime, - boundActionCreators.addDangerToast, - boundActionCreators.addSuccessToast, - boundActionCreators.logEvent, - boundActionCreators.onChange, - boundActionCreators.onSave, - boundActionCreators.setRefreshFrequency, - boundActionCreators.updateCss, - colorNamespace, - colorScheme, - customCss, - dashboardInfo, - dashboardTitle, - dataMask, - editMode, - expandedSlices, - forceRefresh, - hasUnsavedChanges, - isDropdownVisible, - isLoading, - layout, - refreshFrequency, - refreshLimit, - refreshWarning, - setDropdownVisible, - shouldPersistRefreshFrequency, - showEmbedModal, - showPropertiesModal, - startPeriodicRender, - userCanCurate, - userCanEdit, - userCanSaveAs, - userCanShare, - ], - ); + const handleReportDelete = async report => { + await dispatch(deleteActiveReport(report)); + setCurrentReportDeleting(null); + }; + const [menu, isDropdownVisible, setIsDropdownVisible] = useHeaderActionsMenu({ + addSuccessToast: boundActionCreators.addSuccessToast, + addDangerToast: boundActionCreators.addDangerToast, + dashboardInfo, + dashboardId: dashboardInfo.id, + dashboardTitle, + dataMask, + layout, + expandedSlices, + customCss, + colorNamespace, + colorScheme, + onSave: boundActionCreators.onSave, + onChange: boundActionCreators.onChange, + forceRefreshAllCharts: forceRefresh, + startPeriodicRender, + refreshFrequency, + shouldPersistRefreshFrequency, + setRefreshFrequency: boundActionCreators.setRefreshFrequency, + updateCss: boundActionCreators.updateCss, + editMode, + hasUnsavedChanges, + userCanEdit, + userCanShare, + userCanSave: userCanSaveAs, + userCanCurate, + isLoading, + showReportModal, + showPropertiesModal, + setCurrentReportDeleting, + manageEmbedded: showEmbedModal, + refreshLimit, + refreshWarning, + lastModifiedTime: actualLastModifiedTime, + logEvent: boundActionCreators.logEvent, + }); return (
{ faveStarProps={faveStarProps} titlePanelAdditionalItems={titlePanelAdditionalItems} rightPanelAdditionalItems={rightPanelAdditionalItems} - menuDropdownProps={menuDropdownProps} - additionalActionsMenu={additionalActionsMenu} + menuDropdownProps={{ + open: isDropdownVisible, + onOpenChange: setIsDropdownVisible, + }} + additionalActionsMenu={menu} showFaveStar={user?.userId && dashboardInfo?.id} showTitlePanelItems /> @@ -806,6 +772,32 @@ const Header = () => { /> )} + + + {currentReportDeleting && ( + { + if (currentReportDeleting) { + handleReportDelete(currentReportDeleting); + } + }} + onHide={() => setCurrentReportDeleting(null)} + open + title={t('Delete Report?')} + /> + )} + {userCanCurate && ( @@ -817,7 +809,7 @@ const Header = () => { )} void; - setIsDropdownVisible: (visible: boolean) => void; - isDropdownVisible: boolean; refreshLimit: number; refreshWarning: string; directPathToChild: string[]; + showReportModal: () => void; + setCurrentReportDeleting: (alert: AlertObject | null) => void; } export interface HeaderProps { diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx similarity index 53% rename from superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.tsx rename to superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx index 6bc712f6b8490..420e91e5524b6 100644 --- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.tsx +++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx @@ -16,11 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; -import { isEmpty } from 'lodash'; -import { connect } from 'react-redux'; -import { t } from '@superset-ui/core'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { Menu } from 'src/components/Menu'; +import { t } from '@superset-ui/core'; +import { isEmpty } from 'lodash'; import { URL_PARAMS } from 'src/constants'; import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems'; @@ -37,158 +37,156 @@ import { getUrlParam } from 'src/utils/urlUtils'; import { MenuKeys, RootState } from 'src/dashboard/types'; import { HeaderDropdownProps } from 'src/dashboard/components/Header/types'; -const mapStateToProps = (state: RootState) => ({ - directPathToChild: state.dashboardState.directPathToChild, -}); - -interface HeaderActionsDropdownState { - css: string; - showReportSubMenu: boolean | null; -} - -export class HeaderActionsDropdown extends PureComponent< - HeaderDropdownProps, - HeaderActionsDropdownState -> { - static defaultProps = { - colorNamespace: undefined, - colorScheme: undefined, - refreshLimit: 0, - refreshWarning: null, - }; - - constructor(props: HeaderDropdownProps) { - super(props); - this.state = { - css: props.customCss || '', - showReportSubMenu: null, - }; - } - - UNSAFE_componentWillReceiveProps(nextProps: HeaderDropdownProps) { - if (this.props.customCss !== nextProps.customCss) { - this.setState({ css: nextProps.customCss }, () => { - injectCustomCss(nextProps.customCss); - }); +export const useHeaderActionsMenu = ({ + customCss, + dashboardId, + dashboardInfo, + refreshFrequency, + shouldPersistRefreshFrequency, + editMode, + colorNamespace, + colorScheme, + layout, + expandedSlices, + onSave, + userCanEdit, + userCanShare, + userCanSave, + userCanCurate, + isLoading, + refreshLimit, + refreshWarning, + lastModifiedTime, + addSuccessToast, + addDangerToast, + forceRefreshAllCharts, + showPropertiesModal, + showReportModal, + manageEmbedded, + onChange, + updateCss, + startPeriodicRender, + setRefreshFrequency, + dashboardTitle, + logEvent, + setCurrentReportDeleting, +}: HeaderDropdownProps) => { + const [css, setCss] = useState(customCss || ''); + const [showReportSubMenu, setShowReportSubMenu] = useState( + null, + ); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const directPathToChild = useSelector( + (state: RootState) => state.dashboardState.directPathToChild, + ); + useEffect(() => { + if (customCss !== css) { + setCss(customCss || ''); + injectCustomCss(customCss); } - } + }, [css, customCss]); - setShowReportSubMenu = (show: boolean) => { - this.setState({ showReportSubMenu: show }); - }; + const handleMenuClick = useCallback( + ({ key }: { key: string }) => { + switch (key) { + case MenuKeys.RefreshDashboard: + forceRefreshAllCharts(); + addSuccessToast(t('Refreshing charts')); + break; + case MenuKeys.EditProperties: + showPropertiesModal(); + break; + case MenuKeys.ToggleFullscreen: { + const url = getDashboardUrl({ + pathname: window.location.pathname, + filters: getActiveFilters(), + hash: window.location.hash, + standalone: getUrlParam(URL_PARAMS.standalone), + }); + window.location.replace(url); + break; + } + case MenuKeys.ManageEmbedded: + manageEmbedded(); + break; + default: + break; + } + setIsDropdownVisible(false); + }, + [ + forceRefreshAllCharts, + addSuccessToast, + showPropertiesModal, + manageEmbedded, + ], + ); - changeCss = (css: string) => { - this.props.onChange(); - this.props.updateCss(css); - }; + const changeCss = useCallback( + (newCss: string) => { + onChange(); + updateCss(newCss); + }, + [onChange, updateCss], + ); - changeRefreshInterval = (refreshInterval: number, isPersistent: boolean) => { - this.props.setRefreshFrequency(refreshInterval, isPersistent); - this.props.startPeriodicRender(refreshInterval * 1000); - }; + const changeRefreshInterval = useCallback( + (refreshInterval: number, isPersistent: boolean) => { + setRefreshFrequency(refreshInterval, isPersistent); + startPeriodicRender(refreshInterval * 1000); + }, + [setRefreshFrequency, startPeriodicRender], + ); - handleMenuClick = ({ key }: Record) => { - switch (key) { - case MenuKeys.RefreshDashboard: - this.props.forceRefreshAllCharts(); - this.props.addSuccessToast(t('Refreshing charts')); - break; - case MenuKeys.EditProperties: - this.props.showPropertiesModal(); - break; - case MenuKeys.ToggleFullscreen: { - const url = getDashboardUrl({ - pathname: window.location.pathname, - filters: getActiveFilters(), - hash: window.location.hash, - standalone: getUrlParam(URL_PARAMS.standalone), - }); - window.location.replace(url); - break; - } - case MenuKeys.ManageEmbedded: { - this.props.manageEmbedded(); - break; - } - default: - break; - } - }; + const emailSubject = useMemo( + () => `${t('Superset dashboard')} ${dashboardTitle}`, + [dashboardTitle], + ); - render() { - const { - dashboardTitle, - dashboardId, - dashboardInfo, - refreshFrequency, - shouldPersistRefreshFrequency, - editMode, - customCss, - colorNamespace, - colorScheme, - layout, - expandedSlices, - onSave, - userCanEdit, - userCanShare, - userCanSave, - userCanCurate, - isLoading, - refreshLimit, - refreshWarning, - lastModifiedTime, - addSuccessToast, - addDangerToast, - setIsDropdownVisible, - isDropdownVisible, - directPathToChild, - ...rest - } = this.props; + const url = useMemo( + () => + getDashboardUrl({ + pathname: window.location.pathname, + filters: getActiveFilters(), + hash: window.location.hash, + }), + [], + ); - const emailTitle = t('Superset dashboard'); - const emailSubject = `${emailTitle} ${dashboardTitle}`; - const emailBody = t('Check out this dashboard: '); + const dashboardComponentId = useMemo( + () => [...(directPathToChild || [])].pop(), + [directPathToChild], + ); + const menu = useMemo(() => { const isEmbedded = !dashboardInfo?.userId; - - const url = getDashboardUrl({ - pathname: window.location.pathname, - filters: getActiveFilters(), - hash: window.location.hash, - }); - const refreshIntervalOptions = dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS; - const dashboardComponentId = [...(directPathToChild || [])].pop(); - return ( - + {!editMode && ( {t('Refresh dashboard')} )} {!editMode && !isEmbedded && ( - + {getUrlParam(URL_PARAMS.standalone) ? t('Exit fullscreen') : t('Enter fullscreen')} )} {editMode && ( - + {t('Edit properties')} )} @@ -196,8 +194,8 @@ export class HeaderActionsDropdown extends PureComponent< {t('Edit CSS')}
} - initialCss={this.state.css} - onChange={this.changeCss} + initialCss={css} + onChange={changeCss} addDangerToast={addDangerToast} /> @@ -228,29 +226,26 @@ export class HeaderActionsDropdown extends PureComponent< /> )} - - - + pdfMenuItemTitle={t('Export to PDF')} + imageMenuItemTitle={t('Download as Image')} + dashboardTitle={dashboardTitle} + dashboardId={dashboardId} + logEvent={logEvent} + /> {userCanShare && ( )} {!editMode && userCanCurate && ( - + {t('Embed dashboard')} )} {!editMode ? ( - this.state.showReportSubMenu ? ( + showReportSubMenu ? ( <> - - - - - - ) : ( - - + + + ) : ( + ) ) : null} {editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes) && ( @@ -302,14 +291,13 @@ export class HeaderActionsDropdown extends PureComponent< /> )} - {t('Set auto-refresh interval')}
} @@ -317,7 +305,18 @@ export class HeaderActionsDropdown extends PureComponent< ); - } -} + }, [ + css, + showReportSubMenu, + isDropdownVisible, + directPathToChild, + handleMenuClick, + changeCss, + changeRefreshInterval, + emailSubject, + url, + dashboardComponentId, + ]); -export default connect(mapStateToProps)(HeaderActionsDropdown); + return [menu, isDropdownVisible, setIsDropdownVisible]; +}; diff --git a/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx index a683dfa2b291c..3a9878f4fce3f 100644 --- a/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx +++ b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx @@ -22,16 +22,16 @@ import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal'; -import { HeaderActionsDropdown } from 'src/dashboard/components/Header/HeaderActionsDropdown'; import { Provider } from 'react-redux'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; +import { useHeaderActionsMenu } from './Header/useHeaderActionsDropdownMenu'; const createProps = () => ({ addSuccessToast: jest.fn(), addDangerToast: jest.fn(), customCss: - '.header-with-actions .right-button-panel .ant-dropdown-trigger{margin-left: 100px;}', + '.header-with-actions .right-button-panel .antd5-dropdown-trigger{margin-left: 100px;}', dashboardId: 1, dashboardInfo: { id: 1, @@ -85,12 +85,22 @@ const editModeOnProps = { }; const mockStore = configureStore([thunk]); -const store = mockStore({}); +const store = mockStore({ + dashboardState: { + dashboardInfo: createProps().dashboardInfo, + }, +}); + +const HeaderActionsMenu = (props: any) => { + const [menu] = useHeaderActionsMenu(props); + + return <>{menu}; +}; const setup = (overrides?: any) => (
- +
); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx index e1f20b5505ea3..7c813809c0974 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx @@ -112,11 +112,13 @@ const renderWrapper = ( }); }; +const openMenu = () => { + userEvent.click(screen.getByRole('button', { name: 'More Options' })); +}; + test('Should render', () => { renderWrapper(); - expect( - screen.getByRole('button', { name: 'More Options' }), - ).toBeInTheDocument(); + openMenu(); expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument(); }); @@ -143,6 +145,7 @@ test('Should render default props', () => { delete props.isExpanded; renderWrapper(props); + openMenu(); expect(screen.getByText('Enter fullscreen')).toBeInTheDocument(); expect(screen.getByText('Force refresh')).toBeInTheDocument(); expect(screen.getByText('Show chart description')).toBeInTheDocument(); @@ -159,6 +162,7 @@ test('Should render default props', () => { test('Should "export to CSV"', async () => { const props = createProps(); renderWrapper(props); + openMenu(); expect(props.exportCSV).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to .CSV')); @@ -169,6 +173,7 @@ test('Should "export to CSV"', async () => { test('Should "export to Excel"', async () => { const props = createProps(); renderWrapper(props); + openMenu(); expect(props.exportXLSX).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to Excel')); @@ -182,6 +187,7 @@ test('Export full CSV is under featureflag', async () => { }; const props = createProps(VizType.Table); renderWrapper(props); + openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to .CSV')).toBeInTheDocument(); expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument(); @@ -193,6 +199,7 @@ test('Should "export full CSV"', async () => { }; const props = createProps(VizType.Table); renderWrapper(props); + openMenu(); expect(props.exportFullCSV).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to full .CSV')); @@ -205,6 +212,7 @@ test('Should not show export full CSV if report is not table', async () => { [FeatureFlag.AllowFullCsvExport]: true, }; renderWrapper(); + openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to .CSV')).toBeInTheDocument(); expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument(); @@ -216,6 +224,7 @@ test('Export full Excel is under featureflag', async () => { }; const props = createProps(VizType.Table); renderWrapper(props); + openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to Excel')).toBeInTheDocument(); expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument(); @@ -227,6 +236,7 @@ test('Should "export full Excel"', async () => { }; const props = createProps(VizType.Table); renderWrapper(props); + openMenu(); expect(props.exportFullXLSX).toHaveBeenCalledTimes(0); userEvent.hover(screen.getByText('Download')); userEvent.click(await screen.findByText('Export to full Excel')); @@ -239,6 +249,7 @@ test('Should not show export full Excel if report is not table', async () => { [FeatureFlag.AllowFullCsvExport]: true, }; renderWrapper(); + openMenu(); userEvent.hover(screen.getByText('Download')); expect(await screen.findByText('Export to Excel')).toBeInTheDocument(); expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument(); @@ -247,6 +258,7 @@ test('Should not show export full Excel if report is not table', async () => { test('Should "Show chart description"', () => { const props = createProps(); renderWrapper(props); + openMenu(); expect(props.toggleExpandSlice).toHaveBeenCalledTimes(0); userEvent.click(screen.getByText('Show chart description')); expect(props.toggleExpandSlice).toHaveBeenCalledTimes(1); @@ -256,6 +268,7 @@ test('Should "Show chart description"', () => { test('Should "Force refresh"', () => { const props = createProps(); renderWrapper(props); + openMenu(); expect(props.forceRefresh).toHaveBeenCalledTimes(0); userEvent.click(screen.getByText('Force refresh')); expect(props.forceRefresh).toHaveBeenCalledTimes(1); @@ -266,6 +279,7 @@ test('Should "Force refresh"', () => { test('Should "Enter fullscreen"', () => { const props = createProps(); renderWrapper(props); + openMenu(); expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); userEvent.click(screen.getByText('Enter fullscreen')); @@ -278,6 +292,7 @@ test('Drill to detail modal is under featureflag', () => { }; const props = createProps(); renderWrapper(props); + openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); @@ -293,6 +308,7 @@ test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', ( ['can_explore', 'Superset'], ], }); + openMenu(); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); }); @@ -311,6 +327,7 @@ test('Should show "Drill to detail" with `can_drill` & `can_samples` perms', () ['can_drill', 'Dashboard'], ], }); + openMenu(); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); }); @@ -329,6 +346,7 @@ test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_ ['can_drill', 'Dashboard'], ], }); + openMenu(); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); }); @@ -344,6 +362,7 @@ test('Should not show "Drill to detail" with neither of required perms', () => { renderWrapper(props, { Admin: [['invalid_permission', 'Dashboard']], }); + openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); @@ -359,6 +378,7 @@ test('Should not show "Drill to detail" only `can_dril` perm', () => { renderWrapper(props, { Admin: [['can_drill', 'Dashboard']], }); + openMenu(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); @@ -371,6 +391,7 @@ test('Should show "View query"', () => { renderWrapper(props, { Admin: [['can_view_query', 'Dashboard']], }); + openMenu(); expect(screen.getByText('View query')).toBeInTheDocument(); }); @@ -383,6 +404,7 @@ test('Should not show "View query"', () => { renderWrapper(props, { Admin: [['invalid_permission', 'Dashboard']], }); + openMenu(); expect(screen.queryByText('View query')).not.toBeInTheDocument(); }); @@ -395,6 +417,7 @@ test('Should show "View as table"', () => { renderWrapper(props, { Admin: [['can_view_chart_as_table', 'Dashboard']], }); + openMenu(); expect(screen.getByText('View as table')).toBeInTheDocument(); }); @@ -407,6 +430,7 @@ test('Should not show "View as table"', () => { renderWrapper(props, { Admin: [['invalid_permission', 'Dashboard']], }); + openMenu(); expect(screen.queryByText('View as table')).not.toBeInTheDocument(); }); @@ -423,5 +447,6 @@ test('Should not show the "Edit chart" button', () => { ['can_view_chart_as_table', 'Dashboard'], ], }); + openMenu(); expect(screen.queryByText('Edit chart')).not.toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 12479d02f6b46..1c31b6752979a 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -55,6 +55,7 @@ import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; import { MenuKeys, RootState } from 'src/dashboard/types'; import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal'; import { usePermissions } from 'src/hooks/usePermissions'; +import Button from 'src/components/Button'; import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal'; import { ViewResultsModalTrigger } from './ViewResultsModalTrigger'; @@ -158,9 +159,8 @@ const SliceHeaderControls = ( props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps, ) => { const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); - const [selectedKeys, setSelectedKeys] = useState([]); // setting openKeys undefined falls back to uncontrolled behaviour - const [openKeys, setOpenKeys] = useState(undefined); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [openScopingModal, scopingModal] = useCrossFiltersScopingModal( props.slice.slice_id, ); @@ -241,7 +241,7 @@ const SliceHeaderControls = ( // menu closes with a delay, we need to hide it manually, // so that we don't capture it on the screenshot const menu = document.querySelector( - '.ant-dropdown:not(.ant-dropdown-hidden)', + '.antd5-dropdown:not(.antd5-dropdown-hidden)', ) as HTMLElement; if (menu) { menu.style.visibility = 'hidden'; @@ -284,6 +284,7 @@ const SliceHeaderControls = ( default: break; } + setIsDropdownVisible(false); }; const { @@ -334,24 +335,12 @@ const SliceHeaderControls = ( animationDuration: '0s', }; - // controlled/uncontrolled behaviour for submenus - const openKeysProps: Record = {}; - if (openKeys) { - openKeysProps.openKeys = openKeys; - } - const menu = ( setSelectedKeys(keys)} - openKeys={openKeys} id={`slice_${slice.slice_id}-menu`} - // submenus must be rendered for handleDropdownNavigation - forceSubMenuRender - {...openKeysProps} + selectable={false} > )} @@ -532,22 +519,17 @@ const SliceHeaderControls = ( overlayStyle={dropdownOverlayStyle} trigger={['click']} placement="bottomRight" - autoFocus - forceRender + open={isDropdownVisible} + onOpenChange={visible => setIsDropdownVisible(visible)} > - css` - display: flex; - align-items: center; - `} + { expect(mockAddDangerToast).toHaveBeenCalledTimes(0); }); - userEvent.click(screen.getByRole('button', { name: 'Download as Image' })); + userEvent.click(screen.getByRole('menuitem', { name: 'Download as Image' })); await waitFor(() => { expect(downloadAsImage).toHaveBeenCalledTimes(1); @@ -68,8 +68,8 @@ test('Should call download image on click', async () => { }); }); -test('Component is rendered with role="button"', async () => { +test('Component is rendered with role="menuitem"', async () => { renderComponent(); - const button = screen.getByRole('button', { name: 'Download as Image' }); + const button = screen.getByRole('menuitem', { name: 'Download as Image' }); expect(button).toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx index 505a9b8184ae1..5490d7b8059ad 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx @@ -27,7 +27,6 @@ export default function DownloadAsImage({ text, logEvent, dashboardTitle, - ...rest }: { text: string; dashboardTitle: string; @@ -46,10 +45,13 @@ export default function DownloadAsImage({ }; return ( - -
- {text} -
+ { + onDownloadImage(e.domEvent); + }} + > + {text} ); } diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx index 56916f4b64763..ec6652ca418e3 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx @@ -58,7 +58,7 @@ test('Should call download pdf on click', async () => { expect(mockAddDangerToast).toHaveBeenCalledTimes(0); }); - userEvent.click(screen.getByRole('button', { name: 'Export as PDF' })); + userEvent.click(screen.getByRole('menuitem', { name: 'Export as PDF' })); await waitFor(() => { expect(downloadAsPdf).toHaveBeenCalledTimes(1); @@ -66,8 +66,8 @@ test('Should call download pdf on click', async () => { }); }); -test('Component is rendered with role="button"', async () => { +test('Component is rendered with role="menuitem"', async () => { renderComponent(); - const button = screen.getByRole('button', { name: 'Export as PDF' }); + const button = screen.getByRole('menuitem', { name: 'Export as PDF' }); expect(button).toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx index a07a2e232c6dc..47309b842a9af 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx @@ -27,7 +27,6 @@ export default function DownloadAsPdf({ text, logEvent, dashboardTitle, - ...rest }: { text: string; dashboardTitle: string; @@ -46,10 +45,13 @@ export default function DownloadAsPdf({ }; return ( - -
- {text} -
+ { + onDownloadPdf(e.domEvent); + }} + > + {text} ); } diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx index 0f3049d84776b..2bd3a2cd9d0d2 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx @@ -26,11 +26,13 @@ const createProps = () => ({ dashboardTitle: 'Test Dashboard', logEvent: jest.fn(), dashboardId: 123, + title: 'Download', + submenuKey: 'download', }); const renderComponent = () => { render( - + , { @@ -41,10 +43,6 @@ const renderComponent = () => { test('Should render menu items', () => { renderComponent(); - expect( - screen.getByRole('menuitem', { name: 'Export to PDF' }), - ).toBeInTheDocument(); - expect( - screen.getByRole('menuitem', { name: 'Download as Image' }), - ).toBeInTheDocument(); + expect(screen.getByText('Export to PDF')).toBeInTheDocument(); + expect(screen.getByText('Download as Image')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx index d9ffaaaedf5a3..cdb73d5e17bb4 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx @@ -17,17 +17,23 @@ * under the License. */ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; -import DownloadScreenshot from './DownloadScreenshot'; +import { Menu } from 'src/components/Menu'; +import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot'; +import { ComponentProps } from 'react'; import { DownloadScreenshotFormat } from './types'; import DownloadAsPdf from './DownloadAsPdf'; import DownloadAsImage from './DownloadAsImage'; -export interface DownloadMenuItemProps { +export interface DownloadMenuItemProps + extends ComponentProps { pdfMenuItemTitle: string; imageMenuItemTitle: string; dashboardTitle: string; logEvent?: Function; dashboardId: number; + title: string; + disabled?: boolean; + submenuKey: string; } const DownloadMenuItems = (props: DownloadMenuItemProps) => { @@ -37,44 +43,45 @@ const DownloadMenuItems = (props: DownloadMenuItemProps) => { logEvent, dashboardId, dashboardTitle, + submenuKey, + disabled, + title, ...rest } = props; const isWebDriverScreenshotEnabled = isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) && isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot); + const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent); + return isWebDriverScreenshotEnabled ? ( - <> - - - + + downloadScreenshot(DownloadScreenshotFormat.PDF)} + > + {pdfMenuItemTitle} + + downloadScreenshot(DownloadScreenshotFormat.PNG)} + > + {imageMenuItemTitle} + + ) : ( - <> + - + ); }; diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx index 16bbcff9eccc3..f290a1a4b32a8 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx @@ -37,6 +37,7 @@ const createProps = () => ({ emailBody: 'Check out this dashboard: ', dashboardId: DASHBOARD_ID, title: 'Test Dashboard', + submenuKey: 'share', }); const { location } = window; diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index 6c5468da24264..4fb191a06762a 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { RefObject } from 'react'; +import { ComponentProps, RefObject } from 'react'; import copyTextToClipboard from 'src/utils/copy'; import { t, logging } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; @@ -24,7 +24,7 @@ import { getDashboardPermalink } from 'src/utils/urlUtils'; import { MenuKeys, RootState } from 'src/dashboard/types'; import { shallowEqual, useSelector } from 'react-redux'; -interface ShareMenuItemProps { +interface ShareMenuItemProps extends ComponentProps { url?: string; copyMenuItemTitle: string; emailMenuItemTitle: string; @@ -38,8 +38,8 @@ interface ShareMenuItemProps { shareByEmailMenuItemRef?: RefObject; selectedKeys?: string[]; setOpenKeys?: Function; - key?: string; title: string; + disabled?: boolean; } const ShareMenuItems = (props: ShareMenuItemProps) => { @@ -52,8 +52,9 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { addSuccessToast, dashboardId, dashboardComponentId, - key, title, + disabled, + ...rest } = props; const { dataMask, activeTabs } = useSelector( (state: RootState) => ({ @@ -96,7 +97,12 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { } return ( - + onCopyLink()}> {copyMenuItemTitle} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx index b073fe655e995..45a34b110f285 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/FilterBarSettings.test.tsx @@ -121,13 +121,13 @@ test('Can enable/disable cross-filtering', async () => { }); await setup(); userEvent.click(screen.getByLabelText('gear')); - const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeChecked(); + const initialCheckbox = screen.getByRole('checkbox'); + expect(initialCheckbox).toBeChecked(); - userEvent.click(checkbox); + userEvent.click(initialCheckbox); userEvent.click(screen.getByLabelText('gear')); - expect(checkbox).not.toBeChecked(); + expect(screen.getByRole('checkbox')).not.toBeChecked(); }); test('Popover opens with "Vertical" selected', async () => { @@ -178,19 +178,21 @@ test('On selection change, send request and update checked value', async () => { userEvent.click(screen.getByLabelText('gear')); userEvent.hover(screen.getByText('Orientation of filter bar')); - expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument(); - expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument(); + const verticalItem = await screen.findByText('Vertical (Left)'); expect( - within(screen.getAllByRole('menuitem')[4]).getByLabelText('check'), + within(verticalItem.closest('li')!).getByLabelText('check'), ).toBeInTheDocument(); userEvent.click(screen.getByText('Horizontal (Top)')); - // 1st check - checkmark appears immediately after click + userEvent.click(screen.getByLabelText('gear')); + userEvent.hover(screen.getByText('Orientation of filter bar')); + + const horizontalItem = await screen.findByText('Horizontal (Top)'); expect( - await within(screen.getAllByRole('menuitem')[5]).findByLabelText('check'), + within(horizontalItem.closest('li')!).getByLabelText('check'), ).toBeInTheDocument(); - // successful query + await waitFor(() => expect(fetchMock.lastCall()?.[1]?.body).toEqual( JSON.stringify({ @@ -201,23 +203,18 @@ test('On selection change, send request and update checked value', async () => { }), ), ); + await waitFor(() => { - const menuitems = screen.getAllByRole('menuitem'); - expect(menuitems.length).toBeGreaterThanOrEqual(6); + userEvent.click(screen.getByLabelText('gear')); + userEvent.hover(screen.getByText('Orientation of filter bar')); + const updatedHorizontalItem = screen.getByText('Horizontal (Top)'); + expect( + within(updatedHorizontalItem.closest('li')!).getByLabelText('check'), + ).toBeInTheDocument(); + expect( + within(verticalItem.closest('li')!).queryByLabelText('check'), + ).not.toBeInTheDocument(); }); - - userEvent.click(screen.getByLabelText('gear')); - userEvent.hover(screen.getByText('Orientation of filter bar')); - expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument(); - expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument(); - - // 2nd check - checkmark stays after successful query - expect( - await within(screen.getAllByRole('menuitem')[5]).findByLabelText('check'), - ).toBeInTheDocument(); - expect( - within(screen.getAllByRole('menuitem')[4]).queryByLabelText('check'), - ).not.toBeInTheDocument(); }); test('On failed request, restore previous selection', async () => { @@ -254,9 +251,8 @@ test('On failed request, restore previous selection', async () => { userEvent.click(screen.getByLabelText('gear')); userEvent.hover(screen.getByText('Orientation of filter bar')); - expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument(); - await waitFor(() => { + expect(screen.getByText('Vertical (Left)')).toBeInTheDocument(); const menuitems = screen.getAllByRole('menuitem'); expect(menuitems.length).toBeGreaterThanOrEqual(6); }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx index 404d73799959d..7b617abf18751 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx @@ -33,12 +33,13 @@ import { saveCrossFiltersSetting, } from 'src/dashboard/actions/dashboardInfo'; import Icons from 'src/components/Icons'; -import DropdownSelectableIcon, { - DropDownSelectableProps, -} from 'src/components/DropdownSelectableIcon'; import Checkbox from 'src/components/Checkbox'; +import { Dropdown } from 'src/components/Dropdown'; +import { Button } from 'src/components'; +import { Space } from 'src/components/Space'; import { clearDataMaskState } from 'src/dataMask/actions'; import { useFilters } from 'src/dashboard/components/nativeFilters/FilterBar/state'; +import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal'; import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal'; import FilterConfigurationLink from '../FilterConfigurationLink'; @@ -100,6 +101,12 @@ const FilterBarSettings = () => { const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(); + const { openFilterConfigModal, FilterConfigModalComponent } = + useFilterConfigModal({ + createNewOnOpen: filterValues.length === 0, + dashboardId, + }); + const updateCrossFiltersSetting = useCallback( async isEnabled => { if (!isEnabled) { @@ -133,7 +140,7 @@ const FilterBarSettings = () => { [dispatch, filterBarOrientation], ); - const handleSelect = useCallback( + const handleClick = useCallback( ( selection: Parameters< Required>['onSelect'] @@ -146,9 +153,16 @@ const FilterBarSettings = () => { toggleFilterBarOrientation(selectedKey); } else if (selectedKey === CROSS_FILTERS_SCOPING_MENU_KEY) { openScopingModal(); + } else if (selectedKey === ADD_EDIT_FILTERS_MENU_KEY) { + openFilterConfigModal(); } }, - [openScopingModal, toggleCrossFiltering, toggleFilterBarOrientation], + [ + openScopingModal, + toggleCrossFiltering, + toggleFilterBarOrientation, + openFilterConfigModal, + ], ); const crossFiltersMenuItem = useMemo( @@ -168,21 +182,20 @@ const FilterBarSettings = () => { ); const menuItems = useMemo(() => { - const items: DropDownSelectableProps['menuItems'] = []; + const items: MenuProps['items'] = []; if (canEdit) { items.push({ key: ADD_EDIT_FILTERS_MENU_KEY, label: ( - + {t('Add or edit filters')} ), - divider: canSetHorizontalFilterBar, }); + if (canSetHorizontalFilterBar) { + items.push({ type: 'divider' }); + } } if (canEdit) { items.push({ @@ -192,8 +205,10 @@ const FilterBarSettings = () => { items.push({ key: CROSS_FILTERS_SCOPING_MENU_KEY, label: t('Cross-filtering scoping'), - divider: canSetHorizontalFilterBar, }); + if (canSetHorizontalFilterBar) { + items.push({ type: 'divider' }); + } } if (canSetHorizontalFilterBar) { items.push({ @@ -202,17 +217,31 @@ const FilterBarSettings = () => { children: [ { key: FilterBarOrientation.Vertical, - label: t('Vertical (Left)'), + label: ( + + {t('Vertical (Left)')} + {selectedFilterBarOrientation === + FilterBarOrientation.Vertical && } + + ), }, { key: FilterBarOrientation.Horizontal, - label: t('Horizontal (Top)'), + label: ( + + {t('Horizontal (Top)')} + {selectedFilterBarOrientation === + FilterBarOrientation.Horizontal && } + + ), }, ], + ...{ 'data-test': 'dropdown-selectable-icon-submenu' }, }); } return items; }, [ + selectedFilterBarOrientation, canEdit, canSetHorizontalFilterBar, crossFiltersMenuItem, @@ -226,19 +255,24 @@ const FilterBarSettings = () => { return ( <> - + + {scopingModal} + {FilterConfigModalComponent} ); }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/FilterConfigurationLink.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/FilterConfigurationLink.test.tsx index 0b5a18835df24..7ce156a7c4b3e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/FilterConfigurationLink.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/FilterConfigurationLink.test.tsx @@ -38,11 +38,16 @@ test('should render the config link text', () => { }); test('should render the modal on click', () => { - render(Config link, { - useRedux: true, - }); + const showModal = jest.fn(); + render( + + Config link + , + { + useRedux: true, + }, + ); const configLink = screen.getByText('Config link'); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); userEvent.click(configLink); - expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(showModal).toHaveBeenCalled(); }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx index 6b4fea62a4d99..367072fbab9e5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/index.tsx @@ -16,70 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactNode, FC, useCallback, useState, memo } from 'react'; +import { ReactNode, FC, memo } from 'react'; -import { useDispatch } from 'react-redux'; -import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters'; -import FiltersConfigModal from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal'; import { getFilterBarTestId } from '../utils'; -import { SaveFilterChangesType } from '../../FiltersConfigModal/types'; export interface FCBProps { - createNewOnOpen?: boolean; - dashboardId?: number; - initialFilterId?: string; onClick?: () => void; children?: ReactNode; } export const FilterConfigurationLink: FC = ({ - createNewOnOpen, - dashboardId, - initialFilterId, onClick, children, -}) => { - const dispatch = useDispatch(); - const [isOpen, setOpen] = useState(false); - const close = useCallback(() => { - setOpen(false); - }, [setOpen]); - - const submit = useCallback( - async (filterChanges: SaveFilterChangesType) => { - dispatch(await setFilterConfiguration(filterChanges)); - close(); - }, - [dispatch, close], - ); - - const handleClick = useCallback(() => { - setOpen(true); - if (onClick) { - onClick(); - } - }, [setOpen, onClick]); - - return ( - <> -
- {children} -
- - - ); -}; +}) => ( +
+ {children} +
+); export default memo(FilterConfigurationLink); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal.tsx new file mode 100644 index 0000000000000..2a08f7800f4f5 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal.tsx @@ -0,0 +1,82 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters'; +import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types'; +import FiltersConfigModal from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal'; + +interface UseFilterConfigModalProps { + createNewOnOpen?: boolean; + dashboardId: number; + initialFilterId?: string; +} + +interface UseFilterConfigModalReturn { + isFilterConfigModalOpen: boolean; + openFilterConfigModal: () => void; + closeFilterConfigModal: () => void; + handleFilterSave: (filterChanges: SaveFilterChangesType) => Promise; + FilterConfigModalComponent: JSX.Element | null; +} + +export const useFilterConfigModal = ({ + createNewOnOpen = false, + dashboardId, + initialFilterId, +}: UseFilterConfigModalProps): UseFilterConfigModalReturn => { + const dispatch = useDispatch(); + const [isFilterConfigModalOpen, setIsFilterConfigModalOpen] = useState(false); + + const openFilterConfigModal = useCallback(() => { + setIsFilterConfigModalOpen(true); + }, []); + + const closeFilterConfigModal = useCallback(() => { + setIsFilterConfigModalOpen(false); + }, []); + + const handleFilterSave = useCallback( + async (filterChanges: SaveFilterChangesType) => { + dispatch(await setFilterConfiguration(filterChanges)); + closeFilterConfigModal(); + }, + [dispatch, closeFilterConfigModal], + ); + + const FilterConfigModalComponent = isFilterConfigModalOpen ? ( + + ) : null; + + return { + isFilterConfigModalOpen, + openFilterConfigModal, + closeFilterConfigModal, + handleFilterSave, + FilterConfigModalComponent, + }; +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx index b82b96767f232..0d6887f0c5c52 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx @@ -58,9 +58,6 @@ const Wrapper = styled.div` padding: ${theme.gridUnit * 3}px ${theme.gridUnit * 2}px ${ theme.gridUnit }px; - .ant-dropdown-trigger span { - padding-right: ${theme.gridUnit * 2}px; - } `} `; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx index 01679b19264a8..9346a72ab5661 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx @@ -19,6 +19,7 @@ import { useSelector } from 'react-redux'; import { css, SupersetTheme, useTheme, useTruncation } from '@superset-ui/core'; import Icons from 'src/components/Icons'; +import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal'; import { RootState } from 'src/dashboard/types'; import { Row, FilterName, InternalRow } from './Styles'; import { FilterCardRowProps } from './types'; @@ -39,6 +40,12 @@ export const NameRow = ({ ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, ); + const { FilterConfigModalComponent, openFilterConfigModal } = + useFilterConfigModal({ + dashboardId, + initialFilterId: filter.id, + }); + return ( css` @@ -58,9 +65,10 @@ export const NameRow = ({ {canEdit && ( { + openFilterConfigModal(); + hidePopover(); + }} > )} + {FilterConfigModalComponent} ); }; diff --git a/superset-frontend/src/dashboard/hooks/useDownloadScreenshot.ts b/superset-frontend/src/dashboard/hooks/useDownloadScreenshot.ts new file mode 100644 index 0000000000000..bc8ef7d1ffae1 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useDownloadScreenshot.ts @@ -0,0 +1,184 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { last } from 'lodash'; +import { + logging, + t, + SupersetClient, + SupersetApiError, +} from '@superset-ui/core'; +import { + LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE, + LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF, +} from 'src/logger/LogUtils'; +import { RootState } from 'src/dashboard/types'; +import { getDashboardUrlParams } from 'src/utils/urlUtils'; +import { DownloadScreenshotFormat } from '../components/menu/DownloadMenuItems/types'; + +const RETRY_INTERVAL = 3000; +const MAX_RETRIES = 30; + +export const useDownloadScreenshot = ( + dashboardId: number, + logEvent?: Function, +) => { + const activeTabs = useSelector( + (state: RootState) => state.dashboardState.activeTabs || undefined, + ); + const anchor = useSelector( + (state: RootState) => + last(state.dashboardState.directPathToChild) || undefined, + ); + const dataMask = useSelector( + (state: RootState) => state.dataMask || undefined, + ); + + const { addDangerToast, addSuccessToast, addInfoToast } = useToasts(); + + const currentIntervalIds = useRef([]); + + const stopIntervals = useCallback( + (message?: 'success' | 'failure') => { + currentIntervalIds.current.forEach(clearInterval); + + if (message === 'failure') { + addDangerToast( + t('The screenshot could not be downloaded. Please, try again later.'), + ); + } + if (message === 'success') { + addSuccessToast(t('The screenshot has been downloaded.')); + } + }, + [addDangerToast, addSuccessToast], + ); + + const downloadScreenshot = useCallback( + (format: DownloadScreenshotFormat) => { + let retries = 0; + + const toastIntervalId = setInterval( + () => + addInfoToast( + t( + 'The screenshot is being generated. Please, do not leave the page.', + ), + { noDuplicate: true }, + ), + RETRY_INTERVAL, + ); + + currentIntervalIds.current = [ + ...(currentIntervalIds.current || []), + toastIntervalId, + ]; + + const checkImageReady = (cacheKey: string) => + SupersetClient.get({ + endpoint: `/api/v1/dashboard/${dashboardId}/screenshot/${cacheKey}/?download_format=${format}`, + headers: { Accept: 'application/pdf, image/png' }, + parseMethod: 'raw', + }) + .then((response: Response) => response.blob()) + .then(blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `screenshot.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + stopIntervals('success'); + }) + .catch(err => { + if ((err as SupersetApiError).status === 404) { + throw new Error('Image not ready'); + } + }); + + const fetchImageWithRetry = (cacheKey: string) => { + if (retries >= MAX_RETRIES) { + stopIntervals('failure'); + logging.error('Max retries reached'); + return; + } + checkImageReady(cacheKey).catch(() => { + retries += 1; + }); + }; + + SupersetClient.post({ + endpoint: `/api/v1/dashboard/${dashboardId}/cache_dashboard_screenshot/`, + jsonPayload: { + anchor, + activeTabs, + dataMask, + urlParams: getDashboardUrlParams(['edit']), + }, + }) + .then(({ json }) => { + const cacheKey = json?.cache_key; + if (!cacheKey) { + throw new Error('No image URL in response'); + } + const retryIntervalId = setInterval(() => { + fetchImageWithRetry(cacheKey); + }, RETRY_INTERVAL); + currentIntervalIds.current.push(retryIntervalId); + fetchImageWithRetry(cacheKey); + }) + .catch(error => { + logging.error(error); + stopIntervals('failure'); + }) + .finally(() => { + logEvent?.( + format === DownloadScreenshotFormat.PNG + ? LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE + : LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF, + ); + }); + }, + [ + dashboardId, + anchor, + activeTabs, + dataMask, + addInfoToast, + stopIntervals, + logEvent, + ], + ); + + useEffect( + () => () => { + if (currentIntervalIds.current.length > 0) { + stopIntervals(); + } + currentIntervalIds.current = []; + }, + [stopIntervals], + ); + + return downloadScreenshot; +}; diff --git a/superset-frontend/src/dashboard/styles.ts b/superset-frontend/src/dashboard/styles.ts index 0b8ce70b98ca6..1ac9a2ee2ae8e 100644 --- a/superset-frontend/src/dashboard/styles.ts +++ b/superset-frontend/src/dashboard/styles.ts @@ -77,10 +77,10 @@ export const filterCardPopoverStyle = (theme: SupersetTheme) => css` `; export const chartContextMenuStyles = (theme: SupersetTheme) => css` - .ant-dropdown-menu.chart-context-menu { + .antd5-dropdown-menu.chart-context-menu { min-width: ${theme.gridUnit * 43}px; } - .ant-dropdown-menu-submenu.chart-context-submenu { + .antd5-dropdown-menu-submenu.chart-context-submenu { max-width: ${theme.gridUnit * 60}px; min-width: ${theme.gridUnit * 40}px; } @@ -91,7 +91,7 @@ export const focusStyle = (theme: SupersetTheme) => css` .ant-tabs-tabpane, .ant-tabs-tab-btn, .superset-button, - .superset-button.ant-dropdown-trigger, + .superset-button.antd5-dropdown-trigger, .header-controls span { &:focus-visible { box-shadow: 0 0 0 2px ${theme.colors.primary.dark1}; diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts index da702ac16ff57..25b03ec9f2d13 100644 --- a/superset-frontend/src/explore/actions/exploreActions.ts +++ b/superset-frontend/src/explore/actions/exploreActions.ts @@ -164,6 +164,21 @@ export function setStashFormData( }; } +export const START_METADATA_LOADING = 'START_METADATA_LOADING'; +export function startMetaDataLoading() { + return { type: START_METADATA_LOADING }; +} + +export const STOP_METADATA_LOADING = 'STOP_METADATA_LOADING'; +export function stopMetaDataLoading() { + return { type: STOP_METADATA_LOADING }; +} + +export const SYNC_DATASOURCE_METADATA = 'SYNC_DATASOURCE_METADATA'; +export function syncDatasourceMetadata(datasource: Dataset) { + return { type: SYNC_DATASOURCE_METADATA, datasource }; +} + export const exploreActions = { ...toastActions, fetchDatasourcesStarted, @@ -178,6 +193,7 @@ export const exploreActions = { createNewSlice, sliceUpdated, setForceQuery, + syncDatasourceMetadata, }; export type ExploreActions = typeof exploreActions; diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index 97bd8808eb545..69718f55fb090 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -31,6 +31,9 @@ import { sliceUpdated } from 'src/explore/actions/exploreActions'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions'; import { applyColors, resetColors } from 'src/utils/colorScheme'; +import ReportModal from 'src/features/reports/ReportModal'; +import DeleteModal from 'src/components/DeleteModal'; +import { deleteActiveReport } from 'src/features/reports/ReportModal/actions'; import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu'; import { useExploreMetadataBar } from './useExploreMetadataBar'; @@ -86,6 +89,8 @@ export const ExploreChartHeader = ({ const dispatch = useDispatch(); const { latestQueryFormData, sliceFormData } = chart; const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false); + const [isReportModalOpen, setIsReportModalOpen] = useState(false); + const [currentReportDeleting, setCurrentReportDeleting] = useState(null); const updateCategoricalNamespace = async () => { const { dashboards } = metadata || {}; const dashboard = @@ -128,6 +133,14 @@ export const ExploreChartHeader = ({ setIsPropertiesModalOpen(false); }; + const showReportModal = () => { + setIsReportModalOpen(true); + }; + + const closeReportModal = () => { + setIsReportModalOpen(false); + }; + const showModal = useCallback(() => { dispatch(setSaveChartModalVisibility(true)); }, [dispatch]); @@ -139,6 +152,11 @@ export const ExploreChartHeader = ({ [dispatch], ); + const handleReportDelete = async report => { + await dispatch(deleteActiveReport(report)); + setCurrentReportDeleting(null); + }; + const history = useHistory(); const { redirectSQLLab } = actions; @@ -158,6 +176,8 @@ export const ExploreChartHeader = ({ openPropertiesModal, ownState, metadata?.dashboards, + showReportModal, + setCurrentReportDeleting, ); const metadataBar = useExploreMetadataBar(metadata, slice); @@ -229,8 +249,8 @@ export const ExploreChartHeader = ({ } additionalActionsMenu={menu} menuDropdownProps={{ - visible: isDropdownVisible, - onVisibleChange: setIsDropdownVisible, + open: isDropdownVisible, + onOpenChange: setIsDropdownVisible, }} /> {isPropertiesModalOpen && ( @@ -241,6 +261,33 @@ export const ExploreChartHeader = ({ slice={slice} /> )} + + + + {currentReportDeleting && ( + { + if (currentReportDeleting) { + handleReportDelete(currentReportDeleting); + } + }} + onHide={() => setCurrentReportDeleting(null)} + open + title={t('Delete Report?')} + /> + )} ); }; diff --git a/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx b/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx index 2490ee7544f30..cc7fd23d30849 100644 --- a/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx +++ b/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx @@ -20,7 +20,7 @@ import { ReactChild, useCallback, Key } from 'react'; import { t, styled } from '@superset-ui/core'; import Icons from 'src/components/Icons'; -import { AntdDropdown } from 'src/components'; +import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; enum MenuKeys { @@ -67,9 +67,9 @@ export const ExportToCSVDropdown = ({ ); return ( - ( @@ -84,9 +84,9 @@ export const ExportToCSVDropdown = ({ - } + )} > {children} - + ); }; diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.jsx b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.jsx index 130e96443ac8e..92694dc1ae488 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.jsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.jsx @@ -107,6 +107,12 @@ describe('DatasourceControl', () => { expect(screen.queryAllByRole('menuitem')).toHaveLength(3); }); + // Close the menu + userEvent.click(document.body); + await waitFor(() => { + expect(screen.queryAllByRole('menuitem')).toHaveLength(0); + }); + rerender(, { useRedux: true, useRouter: true, diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx index 4e5063b1e4a31..3070cdc7d1116 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx @@ -29,7 +29,7 @@ import { } from '@superset-ui/core'; import { getTemporalColumns } from '@superset-ui/chart-controls'; import { getUrlParam } from 'src/utils/urlUtils'; -import { AntdDropdown } from 'src/components'; +import { Dropdown } from 'src/components/Dropdown'; import { Menu } from 'src/components/Menu'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; @@ -82,12 +82,8 @@ const Styles = styled.div` .error-alert { margin: ${({ theme }) => 2 * theme.gridUnit}px; } - .ant-dropdown-trigger { + .antd5-dropdown-trigger { margin-left: ${({ theme }) => 2 * theme.gridUnit}px; - box-shadow: none; - &:active { - box-shadow: none; - } } .btn-group .open .dropdown-toggle { box-shadow: none; @@ -410,8 +406,8 @@ class DatasourceControl extends PureComponent { {extra?.warning_markdown && ( )} - datasource.type === DatasourceType.Query ? queryDatasourceMenu : defaultDatasourceMenu @@ -423,7 +419,7 @@ class DatasourceControl extends PureComponent { className="datasource-modal-trigger" data-test="datasource-menu-trigger" /> - +
{/* missing dataset */} {isMissingDatasource && isMissingParams && ( diff --git a/superset-frontend/src/explore/components/controls/TextAreaControl.jsx b/superset-frontend/src/explore/components/controls/TextAreaControl.jsx index fc0545d8c44ed..e8f165c8e1185 100644 --- a/superset-frontend/src/explore/components/controls/TextAreaControl.jsx +++ b/superset-frontend/src/explore/components/controls/TextAreaControl.jsx @@ -19,6 +19,10 @@ import { Component } from 'react'; import PropTypes from 'prop-types'; import { TextArea } from 'src/components/Input'; +import { + Tooltip, + TooltipProps as TooltipOptions, +} from 'src/components/Tooltip'; import { t, withTheme } from '@superset-ui/core'; import Button from 'src/components/Button'; @@ -55,6 +59,7 @@ const propTypes = { 'vertical', ]), textAreaStyles: PropTypes.object, + tooltipOptions: PropTypes.oneOf([null, TooltipOptions]), }; const defaultProps = { @@ -67,6 +72,7 @@ const defaultProps = { readOnly: false, resize: null, textAreaStyles: {}, + tooltipOptions: {}, }; class TextAreaControl extends Component { @@ -94,31 +100,44 @@ class TextAreaControl extends Component { if (this.props.readOnly) { style.backgroundColor = '#f2f2f2'; } + const codeEditor = ( +
+ +
+ ); + + if (this.props.tooltipOptions) { + return {codeEditor}; + } + return codeEditor; + } - return ( - +