diff --git a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts index 271f86e9ed3b8..97dfd2945aaf5 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -121,14 +121,12 @@ describe('Test datatable', () => { cy.visitChartByName('Daily Totals'); }); it('Data Pane opens and loads results', () => { - cy.get('[data-test="data-tab"]').click(); + cy.contains('Results').click(); cy.get('[data-test="row-count-label"]').contains('26 rows retrieved'); - cy.contains('View results'); cy.get('.ant-empty-description').should('not.exist'); }); it('Datapane loads view samples', () => { - cy.get('[data-test="data-tab"]').click(); - cy.contains('View samples').click(); + cy.contains('Samples').click(); cy.get('[data-test="row-count-label"]').contains('1k rows retrieved'); cy.get('.ant-empty-description').should('not.exist'); }); diff --git a/superset-frontend/src/components/ImportModal/ErrorAlert.tsx b/superset-frontend/src/components/ImportModal/ErrorAlert.tsx new file mode 100644 index 0000000000000..91ee6467f4f9a --- /dev/null +++ b/superset-frontend/src/components/ImportModal/ErrorAlert.tsx @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { FunctionComponent } from 'react'; +import { t, SupersetTheme } from '@superset-ui/core'; + +import { getDatabaseDocumentationLinks } from 'src/views/CRUD/hooks'; +import Alert from 'src/components/Alert'; +import { antdWarningAlertStyles } from './styles'; + +const supersetTextDocs = getDatabaseDocumentationLinks(); +export const DOCUMENTATION_LINK = supersetTextDocs + ? supersetTextDocs.support + : 'https://superset.apache.org/docs/databases/installing-database-drivers'; + +export interface IProps { + errorMessage: string; +} + +const ErrorAlert: FunctionComponent = ({ errorMessage }) => ( + antdWarningAlertStyles(theme)} + type="error" + showIcon + message={errorMessage} + description={ + <> +
+ {t( + 'Database driver for importing maybe not installed. Visit the Superset documentation page for installation instructions:', + )} + + {t('here')} + + . + + } + /> +); + +export default ErrorAlert; diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx index e8c29b94e9561..c13546f7d3bff 100644 --- a/superset-frontend/src/components/ImportModal/index.tsx +++ b/superset-frontend/src/components/ImportModal/index.tsx @@ -25,6 +25,7 @@ import Modal from 'src/components/Modal'; import { Upload } from 'src/components'; import { useImportResource } from 'src/views/CRUD/hooks'; import { ImportResourceName } from 'src/views/CRUD/types'; +import ErrorAlert from './ErrorAlert'; const HelperMessage = styled.div` display: block; @@ -116,7 +117,6 @@ const ImportModelsModal: FunctionComponent = ({ resourceLabel, passwordsNeededMessage, confirmOverwriteMessage, - addDangerToast, onModelImport, show, onHide, @@ -130,6 +130,7 @@ const ImportModelsModal: FunctionComponent = ({ const [confirmedOverwrite, setConfirmedOverwrite] = useState(false); const [fileList, setFileList] = useState([]); const [importingModel, setImportingModel] = useState(false); + const [errorMessage, setErrorMessage] = useState(); const clearModal = () => { setFileList([]); @@ -138,11 +139,11 @@ const ImportModelsModal: FunctionComponent = ({ setNeedsOverwriteConfirm(false); setConfirmedOverwrite(false); setImportingModel(false); + setErrorMessage(''); }; const handleErrorMsg = (msg: string) => { - clearModal(); - addDangerToast(msg); + setErrorMessage(msg); }; const { @@ -294,10 +295,12 @@ const ImportModelsModal: FunctionComponent = ({ onRemove={removeFile} // upload is handled by hook customRequest={() => {}} + disabled={importingModel} > + {errorMessage && } {renderPasswordFields()} {renderOverwriteConfirmation()} diff --git a/superset-frontend/src/components/ImportModal/styles.ts b/superset-frontend/src/components/ImportModal/styles.ts new file mode 100644 index 0000000000000..c73dc7c1ab277 --- /dev/null +++ b/superset-frontend/src/components/ImportModal/styles.ts @@ -0,0 +1,43 @@ +/** + * 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 { css, SupersetTheme } from '@superset-ui/core'; + +export const antdWarningAlertStyles = (theme: SupersetTheme) => css` + border: 1px solid ${theme.colors.warning.light1}; + padding: ${theme.gridUnit * 4}px; + margin: ${theme.gridUnit * 4}px 0; + color: ${theme.colors.warning.dark2}; + + .ant-alert-message { + margin: 0; + } + + .ant-alert-description { + font-size: ${theme.typography.sizes.s + 1}px; + line-height: ${theme.gridUnit * 4}px; + + .ant-alert-icon { + margin-right: ${theme.gridUnit * 2.5}px; + font-size: ${theme.typography.sizes.l + 1}px; + position: relative; + top: ${theme.gridUnit / 4}px; + } + } +`; diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx index c94c07cb74fd9..7a25c374bd8ac 100644 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -67,41 +67,56 @@ export const CopyButton = styled(Button)` } `; -const CopyNode = ( - - - -); - export const CopyToClipboardButton = ({ data, columns, }: { data?: Record; columns?: string[]; -}) => ( - -); +}) => { + const theme = useTheme(); + return ( + * { + line-height: 0; + } + `} + /> + } + /> + ); +}; export const FilterInput = ({ onChangeHandler, }: { onChangeHandler(filterText: string): void; }) => { + const theme = useTheme(); const debouncedChangeHandler = debounce(onChangeHandler, SLOW_DEBOUNCE); return ( } placeholder={t('Search')} onChange={(event: any) => { const filterText = event.target.value; debouncedChangeHandler(filterText); }} + css={css` + width: 200px; + margin-right: ${theme.gridUnit * 2}px; + `} /> ); }; @@ -250,7 +265,9 @@ export const useFilteredTableData = ( const rowsAsStrings = useMemo( () => data?.map((row: Record) => - Object.values(row).map(value => value?.toString().toLowerCase()), + Object.values(row).map(value => + value ? value.toString().toLowerCase() : t('N/A'), + ), ) ?? [], [data], ); diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx index 9905d8f5c6d3c..786150449ee20 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx @@ -21,7 +21,11 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; import * as copyUtils from 'src/utils/copy'; -import { render, screen } from 'spec/helpers/testing-library'; +import { + render, + screen, + waitForElementToBeRemoved, +} from 'spec/helpers/testing-library'; import { DataTablesPane } from '.'; const createProps = () => ({ @@ -50,7 +54,6 @@ const createProps = () => ({ sort_y_axis: 'alpha_asc', extra_form_data: {}, }, - tableSectionHeight: 156.9, chartStatus: 'rendered', onCollapseChange: jest.fn(), queriesResponse: [ @@ -60,91 +63,162 @@ const createProps = () => ({ ], }); -test('Rendering DataTablesPane correctly', () => { - const props = createProps(); - render(, { useRedux: true }); - expect(screen.getByTestId('some-purposeful-instance')).toBeVisible(); - expect(screen.getByRole('tablist')).toBeVisible(); - expect(screen.getByRole('tab', { name: 'right Data' })).toBeVisible(); - expect(screen.getByRole('img', { name: 'right' })).toBeVisible(); -}); +describe('DataTablesPane', () => { + // Collapsed/expanded state depends on local storage + // We need to clear it manually - otherwise initial state would depend on the order of tests + beforeEach(() => { + localStorage.clear(); + }); -test('Should show tabs', async () => { - const props = createProps(); - render(, { useRedux: true }); - expect(screen.queryByText('View results')).not.toBeInTheDocument(); - expect(screen.queryByText('View samples')).not.toBeInTheDocument(); - userEvent.click(await screen.findByText('Data')); - expect(await screen.findByText('View results')).toBeVisible(); - expect(screen.getByText('View samples')).toBeVisible(); -}); + afterAll(() => { + localStorage.clear(); + }); -test('Should show tabs: View results', async () => { - const props = createProps(); - render(, { - useRedux: true, + test('Rendering DataTablesPane correctly', () => { + const props = createProps(); + render(, { useRedux: true }); + expect(screen.getByText('Results')).toBeVisible(); + expect(screen.getByText('Samples')).toBeVisible(); + expect(screen.getByLabelText('Expand data panel')).toBeVisible(); }); - userEvent.click(await screen.findByText('Data')); - userEvent.click(await screen.findByText('View results')); - expect(screen.getByText('0 rows retrieved')).toBeVisible(); -}); -test('Should show tabs: View samples', async () => { - const props = createProps(); - render(, { - useRedux: true, + test('Collapse/Expand buttons', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + expect( + screen.queryByLabelText('Collapse data panel'), + ).not.toBeInTheDocument(); + userEvent.click(screen.getByLabelText('Expand data panel')); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + expect( + screen.queryByLabelText('Expand data panel'), + ).not.toBeInTheDocument(); }); - userEvent.click(await screen.findByText('Data')); - expect(screen.queryByText('0 rows retrieved')).not.toBeInTheDocument(); - userEvent.click(await screen.findByText('View samples')); - expect(await screen.findByText('0 rows retrieved')).toBeVisible(); -}); -test('Should copy data table content correctly', async () => { - fetchMock.post( - 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', - { - result: [ - { - data: [{ __timestamp: 1230768000000, genre: 'Action' }], - colnames: ['__timestamp', 'genre'], - coltypes: [2, 1], + test('Should show tabs: View results', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + userEvent.click(screen.getByText('Results')); + expect(await screen.findByText('0 rows retrieved')).toBeVisible(); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + localStorage.clear(); + }); + + test('Should show tabs: View samples', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + userEvent.click(screen.getByText('Samples')); + expect(await screen.findByText('0 rows retrieved')).toBeVisible(); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + }); + + test('Should copy data table content correctly', async () => { + fetchMock.post( + 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', + { + result: [ + { + data: [{ __timestamp: 1230768000000, genre: 'Action' }], + colnames: ['__timestamp', 'genre'], + coltypes: [2, 1], + }, + ], + }, + ); + const copyToClipboardSpy = jest.spyOn(copyUtils, 'default'); + const props = createProps(); + render( + , + { + useRedux: true, + initialState: { + explore: { + timeFormattedColumns: { + '34__table': ['__timestamp'], + }, + }, }, - ], - }, - ); - const copyToClipboardSpy = jest.spyOn(copyUtils, 'default'); - const props = createProps(); - render( - { + fetchMock.post( + 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', + { + result: [ { + data: [ + { __timestamp: 1230768000000, genre: 'Action' }, + { __timestamp: 1230768000010, genre: 'Horror' }, + ], colnames: ['__timestamp', 'genre'], coltypes: [2, 1], }, ], - }} - />, - { - useRedux: true, - initialState: { - explore: { - timeFormattedColumns: { - '34__table': ['__timestamp'], + }, + ); + const props = createProps(); + render( + , + { + useRedux: true, + initialState: { + explore: { + timeFormattedColumns: { + '34__table': ['__timestamp'], + }, }, }, }, - }, - ); - userEvent.click(await screen.findByText('Data')); - expect(await screen.findByText('1 rows retrieved')).toBeVisible(); + ); + userEvent.click(screen.getByText('Results')); + expect(await screen.findByText('2 rows retrieved')).toBeVisible(); + expect(screen.getByText('Action')).toBeVisible(); + expect(screen.getByText('Horror')).toBeVisible(); - userEvent.click(screen.getByRole('button', { name: 'Copy' })); - expect(copyToClipboardSpy).toHaveBeenCalledWith( - '2009-01-01 00:00:00\tAction\n', - ); - fetchMock.done(); + userEvent.type(screen.getByPlaceholderText('Search'), 'hor'); + + await waitForElementToBeRemoved(() => screen.queryByText('Action')); + expect(screen.getByText('Horror')).toBeVisible(); + expect(screen.queryByText('Action')).not.toBeInTheDocument(); + fetchMock.restore(); + }); }); diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.tsx b/superset-frontend/src/explore/components/DataTablesPane/index.tsx index 5d935caa63ddd..a41af3626f1e4 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/index.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/index.tsx @@ -16,15 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useState, + MouseEvent, +} from 'react'; import { + css, ensureIsArray, GenericDataType, JsonObject, styled, t, + useTheme, } from '@superset-ui/core'; -import Collapse from 'src/components/Collapse'; +import Icons from 'src/components/Icons'; import Tabs from 'src/components/Tabs'; import Loading from 'src/components/Loading'; import { EmptyStateMedium } from 'src/components/EmptyState'; @@ -58,53 +66,58 @@ const getDefaultDataTablesState = (value: any) => ({ const DATA_TABLE_PAGE_SIZE = 50; -const DATAPANEL_KEY = 'data'; - const TableControlsWrapper = styled.div` - display: flex; - align-items: center; - - span { - flex-shrink: 0; - } + ${({ theme }) => ` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: ${theme.gridUnit * 2}px; + + span { + flex-shrink: 0; + } + `} `; const SouthPane = styled.div` - position: relative; - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - z-index: 5; - overflow: hidden; -`; - -const TabsWrapper = styled.div<{ contentHeight: number }>` - height: ${({ contentHeight }) => contentHeight}px; - overflow: hidden; + ${({ theme }) => ` + position: relative; + background-color: ${theme.colors.grayscale.light5}; + z-index: 5; + overflow: hidden; - .table-condensed { - height: 100%; - overflow: auto; - } -`; + .ant-tabs { + height: 100%; + } -const CollapseWrapper = styled.div` - height: 100%; + .ant-tabs-content-holder { + height: 100%; + } - .collapse-inner { - height: 100%; + .ant-tabs-content { + height: 100%; + } - .ant-collapse-item { + .ant-tabs-tabpane { + display: flex; + flex-direction: column; height: 100%; - .ant-collapse-content { - height: calc(100% - ${({ theme }) => theme.gridUnit * 8}px); + .table-condensed { + height: 100%; + overflow: auto; + margin-bottom: ${theme.gridUnit * 4}px; - .ant-collapse-content-box { - padding-top: 0; - height: 100%; + .table { + margin-bottom: ${theme.gridUnit * 2}px; } } + + .pagination-container > ul[role='navigation'] { + margin-top: 0; + } } - } + `} `; const Error = styled.pre` @@ -117,7 +130,6 @@ interface DataTableProps { datasource: string | undefined; filterText: string; data: object[] | undefined; - timeFormattedColumns: string[] | undefined; isLoading: boolean; error: string | undefined; errorMessage: React.ReactElement | undefined; @@ -130,12 +142,12 @@ const DataTable = ({ datasource, filterText, data, - timeFormattedColumns, isLoading, error, errorMessage, type, }: DataTableProps) => { + const timeFormattedColumns = useTimeFormattedColumns(datasource); // this is to preserve the order of the columns, even if there are integer values, // while also only grabbing the first column's keys const columns = useTableColumns( @@ -185,9 +197,42 @@ const DataTable = ({ return null; }; +const TableControls = ({ + data, + datasourceId, + onInputChange, + columnNames, + isLoading, +}: { + data: Record[]; + datasourceId?: string; + onInputChange: (input: string) => void; + columnNames: string[]; + isLoading: boolean; +}) => { + const timeFormattedColumns = useTimeFormattedColumns(datasourceId); + const formattedData = useMemo( + () => applyFormattingToTabularData(data, timeFormattedColumns), + [data, timeFormattedColumns], + ); + return ( + + +
+ + +
+
+ ); +}; + export const DataTablesPane = ({ queryFormData, - tableSectionHeight, onCollapseChange, chartStatus, ownState, @@ -195,19 +240,19 @@ export const DataTablesPane = ({ queriesResponse, }: { queryFormData: Record; - tableSectionHeight: number; chartStatus: string; ownState?: JsonObject; - onCollapseChange: (openPanelName: string) => void; + onCollapseChange: (isOpen: boolean) => void; errorMessage?: JSX.Element; queriesResponse: Record; }) => { + const theme = useTheme(); const [data, setData] = useState(getDefaultDataTablesState(undefined)); const [isLoading, setIsLoading] = useState(getDefaultDataTablesState(true)); const [columnNames, setColumnNames] = useState(getDefaultDataTablesState([])); const [columnTypes, setColumnTypes] = useState(getDefaultDataTablesState([])); const [error, setError] = useState(getDefaultDataTablesState('')); - const [filterText, setFilterText] = useState(''); + const [filterText, setFilterText] = useState(getDefaultDataTablesState('')); const [activeTabKey, setActiveTabKey] = useState( RESULT_TYPES.results, ); @@ -218,24 +263,6 @@ export const DataTablesPane = ({ getItem(LocalStorageKeys.is_datapanel_open, false), ); - const timeFormattedColumns = useTimeFormattedColumns( - queryFormData?.datasource, - ); - - const formattedData = useMemo( - () => ({ - [RESULT_TYPES.results]: applyFormattingToTabularData( - data[RESULT_TYPES.results], - timeFormattedColumns, - ), - [RESULT_TYPES.samples]: applyFormattingToTabularData( - data[RESULT_TYPES.samples], - timeFormattedColumns, - ), - }), - [data, timeFormattedColumns], - ); - const getData = useCallback( (resultType: 'samples' | 'results') => { setIsLoading(prevIsLoading => ({ @@ -381,81 +408,121 @@ export const DataTablesPane = ({ errorMessage, ]); - const TableControls = ( - - - - - + const handleCollapseChange = useCallback( + (isOpen: boolean) => { + onCollapseChange(isOpen); + setPanelOpen(isOpen); + }, + [onCollapseChange], ); - const handleCollapseChange = (openPanelName: string) => { - onCollapseChange(openPanelName); - setPanelOpen(!!openPanelName); - }; + const handleTabClick = useCallback( + (tabKey: string, e: MouseEvent) => { + if (!panelOpen) { + handleCollapseChange(true); + } else if (tabKey === activeTabKey) { + e.preventDefault(); + handleCollapseChange(false); + } + setActiveTabKey(tabKey); + }, + [activeTabKey, handleCollapseChange, panelOpen], + ); + + const CollapseButton = useMemo(() => { + const caretIcon = panelOpen ? ( + + ) : ( + + ); + return ( + + {panelOpen ? ( + handleCollapseChange(false)} + > + {caretIcon} + + ) : ( + handleCollapseChange(true)} + > + {caretIcon} + + )} + + ); + }, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]); return ( - - - - - - - - - - - - - - - - + + + + setFilterText(prevState => ({ + ...prevState, + [RESULT_TYPES.results]: input, + })) + } + isLoading={isLoading[RESULT_TYPES.results]} + /> + + + + + setFilterText(prevState => ({ + ...prevState, + [RESULT_TYPES.samples]: input, + })) + } + isLoading={isLoading[RESULT_TYPES.samples]} + /> + + + ); }; diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.jsx index 8fb1c3ef073d9..37523713a8e71 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel.jsx @@ -19,7 +19,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import Split from 'react-split'; -import { styled, SupersetClient, useTheme } from '@superset-ui/core'; +import { css, styled, SupersetClient, useTheme } from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; import { chartPropShape } from 'src/dashboard/util/propShapes'; import ChartContainer from 'src/components/Chart/ChartContainer'; @@ -41,8 +41,6 @@ const propTypes = { dashboardId: PropTypes.number, column_formats: PropTypes.object, containerId: PropTypes.string.isRequired, - height: PropTypes.string.isRequired, - width: PropTypes.string.isRequired, isStarred: PropTypes.bool.isRequired, slice: PropTypes.object, sliceName: PropTypes.string, @@ -61,11 +59,8 @@ const propTypes = { const GUTTER_SIZE_FACTOR = 1.25; -const CHART_PANEL_PADDING_HORIZ = 30; -const CHART_PANEL_PADDING_VERTICAL = 15; - -const INITIAL_SIZES = [90, 10]; -const MIN_SIZES = [300, 50]; +const INITIAL_SIZES = [100, 0]; +const MIN_SIZES = [300, 65]; const DEFAULT_SOUTH_PANE_HEIGHT_PERCENT = 40; const Styles = styled.div` @@ -109,28 +104,42 @@ const Styles = styled.div` } `; -const ExploreChartPanel = props => { +const ExploreChartPanel = ({ + chart, + slice, + vizType, + ownState, + triggerRender, + force, + datasource, + errorMessage, + form_data: formData, + onQuery, + refreshOverlayVisible, + actions, + timeout, + standalone, +}) => { const theme = useTheme(); const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR; const gutterHeight = theme.gridUnit * GUTTER_SIZE_FACTOR; - const { width: chartPanelWidth, ref: chartPanelRef } = useResizeDetector({ + const { + width: chartPanelWidth, + height: chartPanelHeight, + ref: chartPanelRef, + } = useResizeDetector({ refreshMode: 'debounce', refreshRate: 300, }); - const { height: pillsHeight, ref: pillsRef } = useResizeDetector({ - refreshMode: 'debounce', - refreshRate: 1000, - }); const [splitSizes, setSplitSizes] = useState( getItem(LocalStorageKeys.chart_split_sizes, INITIAL_SIZES), ); - const { slice } = props; const updateQueryContext = useCallback( async function fetchChartData() { if (slice && slice.query_context === null) { const queryContext = buildV1ChartDataPayload({ formData: slice.form_data, - force: props.force, + force, resultFormat: 'json', resultType: 'full', setDataMask: null, @@ -154,34 +163,6 @@ const ExploreChartPanel = props => { updateQueryContext(); }, [updateQueryContext]); - const calcSectionHeight = useCallback( - percent => { - let containerHeight = parseInt(props.height, 10); - if (pillsHeight) { - containerHeight -= pillsHeight; - } - return ( - (containerHeight * percent) / 100 - (gutterHeight / 2 + gutterMargin) - ); - }, - [gutterHeight, gutterMargin, pillsHeight, props.height, props.standalone], - ); - - const [tableSectionHeight, setTableSectionHeight] = useState( - calcSectionHeight(INITIAL_SIZES[1]), - ); - - const recalcPanelSizes = useCallback( - ([, southPercent]) => { - setTableSectionHeight(calcSectionHeight(southPercent)); - }, - [calcSectionHeight], - ); - - useEffect(() => { - recalcPanelSizes(splitSizes); - }, [recalcPanelSizes, splitSizes]); - useEffect(() => { setItem(LocalStorageKeys.chart_split_sizes, splitSizes); }, [splitSizes]); @@ -191,19 +172,19 @@ const ExploreChartPanel = props => { }; const refreshCachedQuery = () => { - props.actions.postChartFormData( - props.form_data, + actions.postChartFormData( + formData, true, - props.timeout, - props.chart.id, + timeout, + chart.id, undefined, - props.ownState, + ownState, ); }; - const onCollapseChange = openPanelName => { + const onCollapseChange = useCallback(isOpen => { let splitSizes; - if (!openPanelName) { + if (!isOpen) { splitSizes = INITIAL_SIZES; } else { splitSizes = [ @@ -212,53 +193,84 @@ const ExploreChartPanel = props => { ]; } setSplitSizes(splitSizes); - }; - const renderChart = useCallback(() => { - const { chart, vizType } = props; - const newHeight = - vizType === 'filter_box' - ? calcSectionHeight(100) - CHART_PANEL_PADDING_VERTICAL - : calcSectionHeight(splitSizes[0]) - CHART_PANEL_PADDING_VERTICAL; - const chartWidth = chartPanelWidth - CHART_PANEL_PADDING_HORIZ; - return ( - chartWidth > 0 && ( - - ) - ); - }, [calcSectionHeight, chartPanelWidth, props, splitSizes]); + }, []); + + const renderChart = useCallback( + () => ( +
+ {chartPanelWidth && chartPanelHeight && ( + + )} +
+ ), + [ + actions.setControlValue, + chart.annotationData, + chart.chartAlert, + chart.chartStackTrace, + chart.chartStatus, + chart.id, + chart.queriesResponse, + chart.triggerQuery, + chartPanelHeight, + chartPanelRef, + chartPanelWidth, + datasource, + errorMessage, + force, + formData, + onQuery, + ownState, + refreshOverlayVisible, + timeout, + triggerRender, + vizType, + ], + ); const panelBody = useMemo( () => ( -
+
{renderChart()}
@@ -266,14 +278,9 @@ const ExploreChartPanel = props => { [chartPanelRef, renderChart], ); - const standaloneChartBody = useMemo( - () =>
{renderChart()}
, - [chartPanelRef, renderChart], - ); + const standaloneChartBody = useMemo(() => renderChart(), [renderChart]); - const [queryFormData, setQueryFormData] = useState( - props.chart.latestQueryFormData, - ); + const [queryFormData, setQueryFormData] = useState(chart.latestQueryFormData); useEffect(() => { // only update when `latestQueryFormData` changes AND `triggerRender` @@ -281,13 +288,13 @@ const ExploreChartPanel = props => { // as this can trigger a query downstream based on incomplete form data. // (`latestQueryFormData` is only updated when a a valid request has been // triggered). - if (!props.triggerRender) { - setQueryFormData(props.chart.latestQueryFormData); + if (!triggerRender) { + setQueryFormData(chart.latestQueryFormData); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.chart.latestQueryFormData]); + }, [chart.latestQueryFormData]); - if (props.standalone) { + if (standalone) { // dom manipulation hack to get rid of the boostrap theme's body background const standaloneClass = 'background-transparent'; const bodyClasses = document.body.className.split(' '); @@ -302,8 +309,8 @@ const ExploreChartPanel = props => { }); return ( - - {props.vizType === 'filter_box' ? ( + + {vizType === 'filter_box' ? ( panelBody ) : ( { gutterSize={gutterHeight} onDragEnd={onDragEnd} elementStyle={elementStyle} + expandToMin > {panelBody} )} diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index b856ba706cf88..7299adf251085 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -63,8 +63,6 @@ import ConnectedExploreChartHeader from '../ExploreChartHeader'; const propTypes = { ...ExploreChartPanel.propTypes, - height: PropTypes.string, - width: PropTypes.string, actions: PropTypes.object.isRequired, datasource_type: PropTypes.string.isRequired, dashboardId: PropTypes.number, @@ -135,6 +133,7 @@ const ExplorePanelContainer = styled.div` flex: 1; min-width: ${theme.gridUnit * 128}px; border-left: 1px solid ${theme.colors.grayscale.light2}; + padding: 0 ${theme.gridUnit * 4}px; .panel { margin-bottom: 0; } @@ -172,23 +171,6 @@ const ExplorePanelContainer = styled.div` `}; `; -const getWindowSize = () => ({ - height: window.innerHeight, - width: window.innerWidth, -}); - -function useWindowSize({ delayMs = 250 } = {}) { - const [size, setSize] = useState(getWindowSize()); - - useEffect(() => { - const onWindowResize = debounce(() => setSize(getWindowSize()), delayMs); - window.addEventListener('resize', onWindowResize); - return () => window.removeEventListener('resize', onWindowResize); - }, []); - - return size; -} - const updateHistory = debounce( async (formData, datasetId, isReplace, standalone, force, title, tabId) => { const payload = { ...formData }; @@ -246,7 +228,6 @@ function ExploreViewContainer(props) { const [lastQueriedControls, setLastQueriedControls] = useState( props.controls, ); - const windowSize = useWindowSize(); const [showingModal, setShowingModal] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); @@ -254,11 +235,6 @@ function ExploreViewContainer(props) { const tabId = useTabId(); const theme = useTheme(); - const width = `${windowSize.width}px`; - const navHeight = props.standalone ? 0 : 120; - const height = props.forcedHeight - ? `${props.forcedHeight}px` - : `${windowSize.height - navHeight}px`; const defaultSidebarsWidth = { controls_width: 320, @@ -515,8 +491,6 @@ function ExploreViewContainer(props) { function renderChartContainer() { return ( [ { id: 'type', @@ -117,7 +117,7 @@ function AlertList({ addDangerToast, true, undefined, - initalFilters, + initialFilters, ); const { updateResource } = useSingleViewResource>( @@ -261,9 +261,15 @@ function AlertList({ size: 'xl', }, { - accessor: 'created_by', + Cell: ({ + row: { + original: { created_by }, + }, + }: any) => + created_by ? `${created_by.first_name} ${created_by.last_name}` : '', + Header: t('Created by'), + id: 'created_by', disableSortBy: true, - hidden: true, size: 'xl', }, { @@ -378,6 +384,22 @@ function AlertList({ const filters: Filters = useMemo( () => [ + { + Header: t('Owner'), + id: 'owners', + input: 'select', + operator: FilterOperator.relationManyMany, + unfilteredLabel: 'All', + fetchSelects: createFetchRelated( + 'report', + 'owners', + createErrorHandler(errMsg => + t('An error occurred while fetching owners values: %s', errMsg), + ), + user, + ), + paginate: true, + }, { Header: t('Created by'), id: 'created_by', diff --git a/superset/key_value/shared_entries.py b/superset/key_value/shared_entries.py index 5dda89a7b3163..5f4ded949808c 100644 --- a/superset/key_value/shared_entries.py +++ b/superset/key_value/shared_entries.py @@ -20,7 +20,6 @@ from superset.key_value.types import KeyValueResource, SharedKey from superset.key_value.utils import get_uuid_namespace, random_key -from superset.utils.memoized import memoized RESOURCE = KeyValueResource.APP NAMESPACE = get_uuid_namespace("") @@ -42,7 +41,6 @@ def set_shared_value(key: SharedKey, value: Any) -> None: CreateKeyValueCommand(resource=RESOURCE, value=value, key=uuid_key).run() -@memoized def get_permalink_salt(key: SharedKey) -> str: salt = get_shared_value(key) if salt is None: diff --git a/superset/reports/api.py b/superset/reports/api.py index e0d2598249d66..2871125c9a322 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -189,6 +189,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "name", "active", "created_by", + "owners", "type", "last_state", "creation_method", @@ -212,6 +213,7 @@ def ensure_alert_reports_enabled(self) -> Optional[Response]: "chart": "slice_name", "database": "database_name", "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), + "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), } apispec_parameter_schemas = {