feat(explore): Implement data panel redesign (#19751)

* feat(explore): Redesign of data panel

* Auto calculate chart panel height and width

* Add tests

* Fix e2e tests

* Increase collapsed data panel height
This commit is contained in:
Kamil Gabryjelski 2022-04-19 10:10:40 +02:00 committed by GitHub
parent 34323f9b5f
commit 594523e895
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 496 additions and 361 deletions

View File

@ -121,14 +121,12 @@ describe('Test datatable', () => {
cy.visitChartByName('Daily Totals'); cy.visitChartByName('Daily Totals');
}); });
it('Data Pane opens and loads results', () => { 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.get('[data-test="row-count-label"]').contains('26 rows retrieved');
cy.contains('View results');
cy.get('.ant-empty-description').should('not.exist'); cy.get('.ant-empty-description').should('not.exist');
}); });
it('Datapane loads view samples', () => { it('Datapane loads view samples', () => {
cy.get('[data-test="data-tab"]').click(); cy.contains('Samples').click();
cy.contains('View samples').click();
cy.get('[data-test="row-count-label"]').contains('1k rows retrieved'); cy.get('[data-test="row-count-label"]').contains('1k rows retrieved');
cy.get('.ant-empty-description').should('not.exist'); cy.get('.ant-empty-description').should('not.exist');
}); });

View File

@ -67,41 +67,56 @@ export const CopyButton = styled(Button)`
} }
`; `;
const CopyNode = (
<CopyButton buttonSize="xsmall" aria-label={t('Copy')}>
<i className="fa fa-clipboard" />
</CopyButton>
);
export const CopyToClipboardButton = ({ export const CopyToClipboardButton = ({
data, data,
columns, columns,
}: { }: {
data?: Record<string, any>; data?: Record<string, any>;
columns?: string[]; columns?: string[];
}) => ( }) => {
<CopyToClipboard const theme = useTheme();
text={ return (
data && columns ? prepareCopyToClipboardTabularData(data, columns) : '' <CopyToClipboard
} text={
wrapped={false} data && columns ? prepareCopyToClipboardTabularData(data, columns) : ''
copyNode={CopyNode} }
/> wrapped={false}
); copyNode={
<Icons.CopyOutlined
iconColor={theme.colors.grayscale.base}
iconSize="l"
aria-label={t('Copy')}
role="button"
css={css`
&.anticon > * {
line-height: 0;
}
`}
/>
}
/>
);
};
export const FilterInput = ({ export const FilterInput = ({
onChangeHandler, onChangeHandler,
}: { }: {
onChangeHandler(filterText: string): void; onChangeHandler(filterText: string): void;
}) => { }) => {
const theme = useTheme();
const debouncedChangeHandler = debounce(onChangeHandler, SLOW_DEBOUNCE); const debouncedChangeHandler = debounce(onChangeHandler, SLOW_DEBOUNCE);
return ( return (
<Input <Input
prefix={<Icons.Search iconColor={theme.colors.grayscale.base} />}
placeholder={t('Search')} placeholder={t('Search')}
onChange={(event: any) => { onChange={(event: any) => {
const filterText = event.target.value; const filterText = event.target.value;
debouncedChangeHandler(filterText); debouncedChangeHandler(filterText);
}} }}
css={css`
width: 200px;
margin-right: ${theme.gridUnit * 2}px;
`}
/> />
); );
}; };

View File

@ -21,7 +21,11 @@ import React from 'react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import * as copyUtils from 'src/utils/copy'; 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 '.'; import { DataTablesPane } from '.';
const createProps = () => ({ const createProps = () => ({
@ -50,7 +54,6 @@ const createProps = () => ({
sort_y_axis: 'alpha_asc', sort_y_axis: 'alpha_asc',
extra_form_data: {}, extra_form_data: {},
}, },
tableSectionHeight: 156.9,
chartStatus: 'rendered', chartStatus: 'rendered',
onCollapseChange: jest.fn(), onCollapseChange: jest.fn(),
queriesResponse: [ queriesResponse: [
@ -60,91 +63,162 @@ const createProps = () => ({
], ],
}); });
test('Rendering DataTablesPane correctly', () => { describe('DataTablesPane', () => {
const props = createProps(); // Collapsed/expanded state depends on local storage
render(<DataTablesPane {...props} />, { useRedux: true }); // We need to clear it manually - otherwise initial state would depend on the order of tests
expect(screen.getByTestId('some-purposeful-instance')).toBeVisible(); beforeEach(() => {
expect(screen.getByRole('tablist')).toBeVisible(); localStorage.clear();
expect(screen.getByRole('tab', { name: 'right Data' })).toBeVisible();
expect(screen.getByRole('img', { name: 'right' })).toBeVisible();
});
test('Should show tabs', async () => {
const props = createProps();
render(<DataTablesPane {...props} />, { 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();
});
test('Should show tabs: View results', async () => {
const props = createProps();
render(<DataTablesPane {...props} />, {
useRedux: true,
}); });
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 () => { afterAll(() => {
const props = createProps(); localStorage.clear();
render(<DataTablesPane {...props} />, {
useRedux: true,
}); });
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 () => { test('Rendering DataTablesPane correctly', () => {
fetchMock.post( const props = createProps();
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', render(<DataTablesPane {...props} />, { useRedux: true });
{ expect(screen.getByText('Results')).toBeVisible();
result: [ expect(screen.getByText('Samples')).toBeVisible();
{ expect(screen.getByLabelText('Expand data panel')).toBeVisible();
data: [{ __timestamp: 1230768000000, genre: 'Action' }], });
colnames: ['__timestamp', 'genre'],
coltypes: [2, 1], test('Collapse/Expand buttons', async () => {
}, const props = createProps();
], render(<DataTablesPane {...props} />, {
}, useRedux: true,
); });
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default'); expect(
const props = createProps(); screen.queryByLabelText('Collapse data panel'),
render( ).not.toBeInTheDocument();
<DataTablesPane userEvent.click(screen.getByLabelText('Expand data panel'));
{...{ expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
...props, expect(
chartStatus: 'success', screen.queryByLabelText('Expand data panel'),
queriesResponse: [ ).not.toBeInTheDocument();
});
test('Should show tabs: View results', async () => {
const props = createProps();
render(<DataTablesPane {...props} />, {
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(<DataTablesPane {...props} />, {
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'], colnames: ['__timestamp', 'genre'],
coltypes: [2, 1], coltypes: [2, 1],
}, },
], ],
}} },
/>, );
{ const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
useRedux: true, const props = createProps();
initialState: { render(
explore: { <DataTablesPane
timeFormattedColumns: { {...{
'34__table': ['__timestamp'], ...props,
chartStatus: 'success',
queriesResponse: [
{
colnames: ['__timestamp', 'genre'],
coltypes: [2, 1],
},
],
}}
/>,
{
useRedux: true,
initialState: {
explore: {
timeFormattedColumns: {
'34__table': ['__timestamp'],
},
}, },
}, },
}, },
}, );
); userEvent.click(screen.getByText('Results'));
userEvent.click(await screen.findByText('Data')); expect(await screen.findByText('1 rows retrieved')).toBeVisible();
expect(await screen.findByText('1 rows retrieved')).toBeVisible();
userEvent.click(screen.getByRole('button', { name: 'Copy' })); userEvent.click(screen.getByLabelText('Copy'));
expect(copyToClipboardSpy).toHaveBeenCalledWith( expect(copyToClipboardSpy).toHaveBeenCalledWith(
'2009-01-01 00:00:00\tAction\n', '2009-01-01 00:00:00\tAction\n',
); );
fetchMock.done(); copyToClipboardSpy.mockRestore();
fetchMock.restore();
});
test('Search table', async () => {
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],
},
],
},
);
const props = createProps();
render(
<DataTablesPane
{...{
...props,
chartStatus: 'success',
queriesResponse: [
{
colnames: ['__timestamp', 'genre'],
coltypes: [2, 1],
},
],
}}
/>,
{
useRedux: true,
initialState: {
explore: {
timeFormattedColumns: {
'34__table': ['__timestamp'],
},
},
},
},
);
userEvent.click(screen.getByText('Results'));
expect(await screen.findByText('2 rows retrieved')).toBeVisible();
expect(screen.getByText('Action')).toBeVisible();
expect(screen.getByText('Horror')).toBeVisible();
userEvent.type(screen.getByPlaceholderText('Search'), 'hor');
await waitForElementToBeRemoved(() => screen.queryByText('Action'));
expect(screen.getByText('Horror')).toBeVisible();
expect(screen.queryByText('Action')).not.toBeInTheDocument();
fetchMock.restore();
});
}); });

View File

@ -16,15 +16,23 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, {
useCallback,
useEffect,
useMemo,
useState,
MouseEvent,
} from 'react';
import { import {
css,
ensureIsArray, ensureIsArray,
GenericDataType, GenericDataType,
JsonObject, JsonObject,
styled, styled,
t, t,
useTheme,
} from '@superset-ui/core'; } from '@superset-ui/core';
import Collapse from 'src/components/Collapse'; import Icons from 'src/components/Icons';
import Tabs from 'src/components/Tabs'; import Tabs from 'src/components/Tabs';
import Loading from 'src/components/Loading'; import Loading from 'src/components/Loading';
import { EmptyStateMedium } from 'src/components/EmptyState'; import { EmptyStateMedium } from 'src/components/EmptyState';
@ -58,53 +66,58 @@ const getDefaultDataTablesState = (value: any) => ({
const DATA_TABLE_PAGE_SIZE = 50; const DATA_TABLE_PAGE_SIZE = 50;
const DATAPANEL_KEY = 'data';
const TableControlsWrapper = styled.div` const TableControlsWrapper = styled.div`
display: flex; ${({ theme }) => `
align-items: center; display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${theme.gridUnit * 2}px;
span { span {
flex-shrink: 0; flex-shrink: 0;
} }
`}
`; `;
const SouthPane = styled.div` const SouthPane = styled.div`
position: relative; ${({ theme }) => `
background-color: ${({ theme }) => theme.colors.grayscale.light5}; position: relative;
z-index: 5; background-color: ${theme.colors.grayscale.light5};
overflow: hidden; z-index: 5;
`; overflow: hidden;
const TabsWrapper = styled.div<{ contentHeight: number }>` .ant-tabs {
height: ${({ contentHeight }) => contentHeight}px; height: 100%;
overflow: hidden; }
.table-condensed { .ant-tabs-content-holder {
height: 100%; height: 100%;
overflow: auto; }
}
`;
const CollapseWrapper = styled.div` .ant-tabs-content {
height: 100%; height: 100%;
}
.collapse-inner { .ant-tabs-tabpane {
height: 100%; display: flex;
flex-direction: column;
.ant-collapse-item {
height: 100%; height: 100%;
.ant-collapse-content { .table-condensed {
height: calc(100% - ${({ theme }) => theme.gridUnit * 8}px); height: 100%;
overflow: auto;
margin-bottom: ${theme.gridUnit * 4}px;
.ant-collapse-content-box { .table {
padding-top: 0; margin-bottom: ${theme.gridUnit * 2}px;
height: 100%;
} }
} }
.pagination-container > ul[role='navigation'] {
margin-top: 0;
}
} }
} `}
`; `;
const Error = styled.pre` const Error = styled.pre`
@ -117,7 +130,6 @@ interface DataTableProps {
datasource: string | undefined; datasource: string | undefined;
filterText: string; filterText: string;
data: object[] | undefined; data: object[] | undefined;
timeFormattedColumns: string[] | undefined;
isLoading: boolean; isLoading: boolean;
error: string | undefined; error: string | undefined;
errorMessage: React.ReactElement | undefined; errorMessage: React.ReactElement | undefined;
@ -130,12 +142,12 @@ const DataTable = ({
datasource, datasource,
filterText, filterText,
data, data,
timeFormattedColumns,
isLoading, isLoading,
error, error,
errorMessage, errorMessage,
type, type,
}: DataTableProps) => { }: DataTableProps) => {
const timeFormattedColumns = useTimeFormattedColumns(datasource);
// this is to preserve the order of the columns, even if there are integer values, // this is to preserve the order of the columns, even if there are integer values,
// while also only grabbing the first column's keys // while also only grabbing the first column's keys
const columns = useTableColumns( const columns = useTableColumns(
@ -185,9 +197,42 @@ const DataTable = ({
return null; return null;
}; };
const TableControls = ({
data,
datasourceId,
onInputChange,
columnNames,
isLoading,
}: {
data: Record<string, any>[];
datasourceId?: string;
onInputChange: (input: string) => void;
columnNames: string[];
isLoading: boolean;
}) => {
const timeFormattedColumns = useTimeFormattedColumns(datasourceId);
const formattedData = useMemo(
() => applyFormattingToTabularData(data, timeFormattedColumns),
[data, timeFormattedColumns],
);
return (
<TableControlsWrapper>
<FilterInput onChangeHandler={onInputChange} />
<div
css={css`
display: flex;
align-items: center;
`}
>
<RowCount data={data} loading={isLoading} />
<CopyToClipboardButton data={formattedData} columns={columnNames} />
</div>
</TableControlsWrapper>
);
};
export const DataTablesPane = ({ export const DataTablesPane = ({
queryFormData, queryFormData,
tableSectionHeight,
onCollapseChange, onCollapseChange,
chartStatus, chartStatus,
ownState, ownState,
@ -195,19 +240,19 @@ export const DataTablesPane = ({
queriesResponse, queriesResponse,
}: { }: {
queryFormData: Record<string, any>; queryFormData: Record<string, any>;
tableSectionHeight: number;
chartStatus: string; chartStatus: string;
ownState?: JsonObject; ownState?: JsonObject;
onCollapseChange: (openPanelName: string) => void; onCollapseChange: (isOpen: boolean) => void;
errorMessage?: JSX.Element; errorMessage?: JSX.Element;
queriesResponse: Record<string, any>; queriesResponse: Record<string, any>;
}) => { }) => {
const theme = useTheme();
const [data, setData] = useState(getDefaultDataTablesState(undefined)); const [data, setData] = useState(getDefaultDataTablesState(undefined));
const [isLoading, setIsLoading] = useState(getDefaultDataTablesState(true)); const [isLoading, setIsLoading] = useState(getDefaultDataTablesState(true));
const [columnNames, setColumnNames] = useState(getDefaultDataTablesState([])); const [columnNames, setColumnNames] = useState(getDefaultDataTablesState([]));
const [columnTypes, setColumnTypes] = useState(getDefaultDataTablesState([])); const [columnTypes, setColumnTypes] = useState(getDefaultDataTablesState([]));
const [error, setError] = useState(getDefaultDataTablesState('')); const [error, setError] = useState(getDefaultDataTablesState(''));
const [filterText, setFilterText] = useState(''); const [filterText, setFilterText] = useState(getDefaultDataTablesState(''));
const [activeTabKey, setActiveTabKey] = useState<string>( const [activeTabKey, setActiveTabKey] = useState<string>(
RESULT_TYPES.results, RESULT_TYPES.results,
); );
@ -218,24 +263,6 @@ export const DataTablesPane = ({
getItem(LocalStorageKeys.is_datapanel_open, false), 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( const getData = useCallback(
(resultType: 'samples' | 'results') => { (resultType: 'samples' | 'results') => {
setIsLoading(prevIsLoading => ({ setIsLoading(prevIsLoading => ({
@ -381,81 +408,121 @@ export const DataTablesPane = ({
errorMessage, errorMessage,
]); ]);
const TableControls = ( const handleCollapseChange = useCallback(
<TableControlsWrapper> (isOpen: boolean) => {
<RowCount data={data[activeTabKey]} loading={isLoading[activeTabKey]} /> onCollapseChange(isOpen);
<CopyToClipboardButton setPanelOpen(isOpen);
data={formattedData[activeTabKey]} },
columns={columnNames[activeTabKey]} [onCollapseChange],
/>
<FilterInput onChangeHandler={setFilterText} />
</TableControlsWrapper>
); );
const handleCollapseChange = (openPanelName: string) => { const handleTabClick = useCallback(
onCollapseChange(openPanelName); (tabKey: string, e: MouseEvent) => {
setPanelOpen(!!openPanelName); if (!panelOpen) {
}; handleCollapseChange(true);
} else if (tabKey === activeTabKey) {
e.preventDefault();
handleCollapseChange(false);
}
setActiveTabKey(tabKey);
},
[activeTabKey, handleCollapseChange, panelOpen],
);
const CollapseButton = useMemo(() => {
const caretIcon = panelOpen ? (
<Icons.CaretUp
iconColor={theme.colors.grayscale.base}
aria-label={t('Collapse data panel')}
/>
) : (
<Icons.CaretDown
iconColor={theme.colors.grayscale.base}
aria-label={t('Expand data panel')}
/>
);
return (
<TableControlsWrapper>
{panelOpen ? (
<span
role="button"
tabIndex={0}
onClick={() => handleCollapseChange(false)}
>
{caretIcon}
</span>
) : (
<span
role="button"
tabIndex={0}
onClick={() => handleCollapseChange(true)}
>
{caretIcon}
</span>
)}
</TableControlsWrapper>
);
}, [handleCollapseChange, panelOpen, theme.colors.grayscale.base]);
return ( return (
<SouthPane data-test="some-purposeful-instance"> <SouthPane data-test="some-purposeful-instance">
<TabsWrapper contentHeight={tableSectionHeight}> <Tabs
<CollapseWrapper data-test="data-tab"> fullWidth={false}
<Collapse tabBarExtraContent={CollapseButton}
accordion activeKey={panelOpen ? activeTabKey : ''}
bordered={false} onTabClick={handleTabClick}
defaultActiveKey={panelOpen ? DATAPANEL_KEY : undefined} >
onChange={handleCollapseChange} <Tabs.TabPane tab={t('Results')} key={RESULT_TYPES.results}>
bold <TableControls
ghost data={data[RESULT_TYPES.results]}
className="collapse-inner" columnNames={columnNames[RESULT_TYPES.results]}
> datasourceId={queryFormData?.datasource}
<Collapse.Panel header={t('Data')} key={DATAPANEL_KEY}> onInputChange={input =>
<Tabs setFilterText(prevState => ({
fullWidth={false} ...prevState,
tabBarExtraContent={TableControls} [RESULT_TYPES.results]: input,
activeKey={activeTabKey} }))
onChange={setActiveTabKey} }
> isLoading={isLoading[RESULT_TYPES.results]}
<Tabs.TabPane />
tab={t('View results')} <DataTable
key={RESULT_TYPES.results} isLoading={isLoading[RESULT_TYPES.results]}
> data={data[RESULT_TYPES.results]}
<DataTable datasource={queryFormData?.datasource}
isLoading={isLoading[RESULT_TYPES.results]} columnNames={columnNames[RESULT_TYPES.results]}
data={data[RESULT_TYPES.results]} columnTypes={columnTypes[RESULT_TYPES.results]}
datasource={queryFormData?.datasource} filterText={filterText[RESULT_TYPES.results]}
timeFormattedColumns={timeFormattedColumns} error={error[RESULT_TYPES.results]}
columnNames={columnNames[RESULT_TYPES.results]} errorMessage={errorMessage}
columnTypes={columnTypes[RESULT_TYPES.results]} type={RESULT_TYPES.results}
filterText={filterText} />
error={error[RESULT_TYPES.results]} </Tabs.TabPane>
errorMessage={errorMessage} <Tabs.TabPane tab={t('Samples')} key={RESULT_TYPES.samples}>
type={RESULT_TYPES.results} <TableControls
/> data={data[RESULT_TYPES.samples]}
</Tabs.TabPane> columnNames={columnNames[RESULT_TYPES.samples]}
<Tabs.TabPane datasourceId={queryFormData?.datasource}
tab={t('View samples')} onInputChange={input =>
key={RESULT_TYPES.samples} setFilterText(prevState => ({
> ...prevState,
<DataTable [RESULT_TYPES.samples]: input,
isLoading={isLoading[RESULT_TYPES.samples]} }))
data={data[RESULT_TYPES.samples]} }
datasource={queryFormData?.datasource} isLoading={isLoading[RESULT_TYPES.samples]}
timeFormattedColumns={timeFormattedColumns} />
columnNames={columnNames[RESULT_TYPES.samples]} <DataTable
columnTypes={columnTypes[RESULT_TYPES.samples]} isLoading={isLoading[RESULT_TYPES.samples]}
filterText={filterText} data={data[RESULT_TYPES.samples]}
error={error[RESULT_TYPES.samples]} datasource={queryFormData?.datasource}
errorMessage={errorMessage} columnNames={columnNames[RESULT_TYPES.samples]}
type={RESULT_TYPES.samples} columnTypes={columnTypes[RESULT_TYPES.samples]}
/> filterText={filterText[RESULT_TYPES.samples]}
</Tabs.TabPane> error={error[RESULT_TYPES.samples]}
</Tabs> errorMessage={errorMessage}
</Collapse.Panel> type={RESULT_TYPES.samples}
</Collapse> />
</CollapseWrapper> </Tabs.TabPane>
</TabsWrapper> </Tabs>
</SouthPane> </SouthPane>
); );
}; };

View File

@ -19,7 +19,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Split from 'react-split'; 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 { useResizeDetector } from 'react-resize-detector';
import { chartPropShape } from 'src/dashboard/util/propShapes'; import { chartPropShape } from 'src/dashboard/util/propShapes';
import ChartContainer from 'src/components/Chart/ChartContainer'; import ChartContainer from 'src/components/Chart/ChartContainer';
@ -41,8 +41,6 @@ const propTypes = {
dashboardId: PropTypes.number, dashboardId: PropTypes.number,
column_formats: PropTypes.object, column_formats: PropTypes.object,
containerId: PropTypes.string.isRequired, containerId: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
width: PropTypes.string.isRequired,
isStarred: PropTypes.bool.isRequired, isStarred: PropTypes.bool.isRequired,
slice: PropTypes.object, slice: PropTypes.object,
sliceName: PropTypes.string, sliceName: PropTypes.string,
@ -61,11 +59,8 @@ const propTypes = {
const GUTTER_SIZE_FACTOR = 1.25; const GUTTER_SIZE_FACTOR = 1.25;
const CHART_PANEL_PADDING_HORIZ = 30; const INITIAL_SIZES = [100, 0];
const CHART_PANEL_PADDING_VERTICAL = 15; const MIN_SIZES = [300, 65];
const INITIAL_SIZES = [90, 10];
const MIN_SIZES = [300, 50];
const DEFAULT_SOUTH_PANE_HEIGHT_PERCENT = 40; const DEFAULT_SOUTH_PANE_HEIGHT_PERCENT = 40;
const Styles = styled.div` 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 theme = useTheme();
const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR; const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR;
const gutterHeight = 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', refreshMode: 'debounce',
refreshRate: 300, refreshRate: 300,
}); });
const { height: pillsHeight, ref: pillsRef } = useResizeDetector({
refreshMode: 'debounce',
refreshRate: 1000,
});
const [splitSizes, setSplitSizes] = useState( const [splitSizes, setSplitSizes] = useState(
getItem(LocalStorageKeys.chart_split_sizes, INITIAL_SIZES), getItem(LocalStorageKeys.chart_split_sizes, INITIAL_SIZES),
); );
const { slice } = props;
const updateQueryContext = useCallback( const updateQueryContext = useCallback(
async function fetchChartData() { async function fetchChartData() {
if (slice && slice.query_context === null) { if (slice && slice.query_context === null) {
const queryContext = buildV1ChartDataPayload({ const queryContext = buildV1ChartDataPayload({
formData: slice.form_data, formData: slice.form_data,
force: props.force, force,
resultFormat: 'json', resultFormat: 'json',
resultType: 'full', resultType: 'full',
setDataMask: null, setDataMask: null,
@ -154,34 +163,6 @@ const ExploreChartPanel = props => {
updateQueryContext(); updateQueryContext();
}, [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(() => { useEffect(() => {
setItem(LocalStorageKeys.chart_split_sizes, splitSizes); setItem(LocalStorageKeys.chart_split_sizes, splitSizes);
}, [splitSizes]); }, [splitSizes]);
@ -191,19 +172,19 @@ const ExploreChartPanel = props => {
}; };
const refreshCachedQuery = () => { const refreshCachedQuery = () => {
props.actions.postChartFormData( actions.postChartFormData(
props.form_data, formData,
true, true,
props.timeout, timeout,
props.chart.id, chart.id,
undefined, undefined,
props.ownState, ownState,
); );
}; };
const onCollapseChange = openPanelName => { const onCollapseChange = useCallback(isOpen => {
let splitSizes; let splitSizes;
if (!openPanelName) { if (!isOpen) {
splitSizes = INITIAL_SIZES; splitSizes = INITIAL_SIZES;
} else { } else {
splitSizes = [ splitSizes = [
@ -212,53 +193,84 @@ const ExploreChartPanel = props => {
]; ];
} }
setSplitSizes(splitSizes); setSplitSizes(splitSizes);
}; }, []);
const renderChart = useCallback(() => {
const { chart, vizType } = props; const renderChart = useCallback(
const newHeight = () => (
vizType === 'filter_box' <div
? calcSectionHeight(100) - CHART_PANEL_PADDING_VERTICAL css={css`
: calcSectionHeight(splitSizes[0]) - CHART_PANEL_PADDING_VERTICAL; min-height: 0;
const chartWidth = chartPanelWidth - CHART_PANEL_PADDING_HORIZ; flex: 1;
return ( `}
chartWidth > 0 && ( ref={chartPanelRef}
<ChartContainer >
width={Math.floor(chartWidth)} {chartPanelWidth && chartPanelHeight && (
height={newHeight} <ChartContainer
ownState={props.ownState} width={Math.floor(chartPanelWidth)}
annotationData={chart.annotationData} height={chartPanelHeight}
chartAlert={chart.chartAlert} ownState={ownState}
chartStackTrace={chart.chartStackTrace} annotationData={chart.annotationData}
chartId={chart.id} chartAlert={chart.chartAlert}
chartStatus={chart.chartStatus} chartStackTrace={chart.chartStackTrace}
triggerRender={props.triggerRender} chartId={chart.id}
force={props.force} chartStatus={chart.chartStatus}
datasource={props.datasource} triggerRender={triggerRender}
errorMessage={props.errorMessage} force={force}
formData={props.form_data} datasource={datasource}
onQuery={props.onQuery} errorMessage={errorMessage}
queriesResponse={chart.queriesResponse} formData={formData}
refreshOverlayVisible={props.refreshOverlayVisible} onQuery={onQuery}
setControlValue={props.actions.setControlValue} queriesResponse={chart.queriesResponse}
timeout={props.timeout} refreshOverlayVisible={refreshOverlayVisible}
triggerQuery={chart.triggerQuery} setControlValue={actions.setControlValue}
vizType={props.vizType} timeout={timeout}
/> triggerQuery={chart.triggerQuery}
) vizType={vizType}
); />
}, [calcSectionHeight, chartPanelWidth, props, splitSizes]); )}
</div>
),
[
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( const panelBody = useMemo(
() => ( () => (
<div className="panel-body" ref={chartPanelRef}> <div
className="panel-body"
css={css`
display: flex;
flex-direction: column;
`}
>
<ChartPills <ChartPills
queriesResponse={props.chart.queriesResponse} queriesResponse={chart.queriesResponse}
chartStatus={props.chart.chartStatus} chartStatus={chart.chartStatus}
chartUpdateStartTime={props.chart.chartUpdateStartTime} chartUpdateStartTime={chart.chartUpdateStartTime}
chartUpdateEndTime={props.chart.chartUpdateEndTime} chartUpdateEndTime={chart.chartUpdateEndTime}
refreshCachedQuery={refreshCachedQuery} refreshCachedQuery={refreshCachedQuery}
rowLimit={props.form_data?.row_limit} rowLimit={formData?.row_limit}
ref={pillsRef}
/> />
{renderChart()} {renderChart()}
</div> </div>
@ -266,14 +278,9 @@ const ExploreChartPanel = props => {
[chartPanelRef, renderChart], [chartPanelRef, renderChart],
); );
const standaloneChartBody = useMemo( const standaloneChartBody = useMemo(() => renderChart(), [renderChart]);
() => <div ref={chartPanelRef}>{renderChart()}</div>,
[chartPanelRef, renderChart],
);
const [queryFormData, setQueryFormData] = useState( const [queryFormData, setQueryFormData] = useState(chart.latestQueryFormData);
props.chart.latestQueryFormData,
);
useEffect(() => { useEffect(() => {
// only update when `latestQueryFormData` changes AND `triggerRender` // 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. // as this can trigger a query downstream based on incomplete form data.
// (`latestQueryFormData` is only updated when a a valid request has been // (`latestQueryFormData` is only updated when a a valid request has been
// triggered). // triggered).
if (!props.triggerRender) { if (!triggerRender) {
setQueryFormData(props.chart.latestQueryFormData); setQueryFormData(chart.latestQueryFormData);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // dom manipulation hack to get rid of the boostrap theme's body background
const standaloneClass = 'background-transparent'; const standaloneClass = 'background-transparent';
const bodyClasses = document.body.className.split(' '); const bodyClasses = document.body.className.split(' ');
@ -302,8 +309,8 @@ const ExploreChartPanel = props => {
}); });
return ( return (
<Styles className="panel panel-default chart-container" ref={chartPanelRef}> <Styles className="panel panel-default chart-container">
{props.vizType === 'filter_box' ? ( {vizType === 'filter_box' ? (
panelBody panelBody
) : ( ) : (
<Split <Split
@ -313,16 +320,16 @@ const ExploreChartPanel = props => {
gutterSize={gutterHeight} gutterSize={gutterHeight}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
elementStyle={elementStyle} elementStyle={elementStyle}
expandToMin
> >
{panelBody} {panelBody}
<DataTablesPane <DataTablesPane
ownState={props.ownState} ownState={ownState}
queryFormData={queryFormData} queryFormData={queryFormData}
tableSectionHeight={tableSectionHeight}
onCollapseChange={onCollapseChange} onCollapseChange={onCollapseChange}
chartStatus={props.chart.chartStatus} chartStatus={chart.chartStatus}
errorMessage={props.errorMessage} errorMessage={errorMessage}
queriesResponse={props.chart.queriesResponse} queriesResponse={chart.queriesResponse}
/> />
</Split> </Split>
)} )}

View File

@ -63,8 +63,6 @@ import ConnectedExploreChartHeader from '../ExploreChartHeader';
const propTypes = { const propTypes = {
...ExploreChartPanel.propTypes, ...ExploreChartPanel.propTypes,
height: PropTypes.string,
width: PropTypes.string,
actions: PropTypes.object.isRequired, actions: PropTypes.object.isRequired,
datasource_type: PropTypes.string.isRequired, datasource_type: PropTypes.string.isRequired,
dashboardId: PropTypes.number, dashboardId: PropTypes.number,
@ -135,6 +133,7 @@ const ExplorePanelContainer = styled.div`
flex: 1; flex: 1;
min-width: ${theme.gridUnit * 128}px; min-width: ${theme.gridUnit * 128}px;
border-left: 1px solid ${theme.colors.grayscale.light2}; border-left: 1px solid ${theme.colors.grayscale.light2};
padding: 0 ${theme.gridUnit * 4}px;
.panel { .panel {
margin-bottom: 0; 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( const updateHistory = debounce(
async (formData, datasetId, isReplace, standalone, force, title, tabId) => { async (formData, datasetId, isReplace, standalone, force, title, tabId) => {
const payload = { ...formData }; const payload = { ...formData };
@ -246,7 +228,6 @@ function ExploreViewContainer(props) {
const [lastQueriedControls, setLastQueriedControls] = useState( const [lastQueriedControls, setLastQueriedControls] = useState(
props.controls, props.controls,
); );
const windowSize = useWindowSize();
const [showingModal, setShowingModal] = useState(false); const [showingModal, setShowingModal] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
@ -254,11 +235,6 @@ function ExploreViewContainer(props) {
const tabId = useTabId(); const tabId = useTabId();
const theme = useTheme(); 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 = { const defaultSidebarsWidth = {
controls_width: 320, controls_width: 320,
@ -515,8 +491,6 @@ function ExploreViewContainer(props) {
function renderChartContainer() { function renderChartContainer() {
return ( return (
<ExploreChartPanel <ExploreChartPanel
width={width}
height={height}
{...props} {...props}
errorMessage={errorMessage} errorMessage={errorMessage}
refreshOverlayVisible={chartIsStale} refreshOverlayVisible={chartIsStale}