mirror of https://github.com/apache/superset.git
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:
parent
34323f9b5f
commit
594523e895
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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 = ({
|
||||
data,
|
||||
columns,
|
||||
}: {
|
||||
data?: Record<string, any>;
|
||||
columns?: string[];
|
||||
}) => (
|
||||
<CopyToClipboard
|
||||
text={
|
||||
data && columns ? prepareCopyToClipboardTabularData(data, columns) : ''
|
||||
}
|
||||
wrapped={false}
|
||||
copyNode={CopyNode}
|
||||
/>
|
||||
);
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={
|
||||
data && columns ? prepareCopyToClipboardTabularData(data, columns) : ''
|
||||
}
|
||||
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 = ({
|
||||
onChangeHandler,
|
||||
}: {
|
||||
onChangeHandler(filterText: string): void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const debouncedChangeHandler = debounce(onChangeHandler, SLOW_DEBOUNCE);
|
||||
return (
|
||||
<Input
|
||||
prefix={<Icons.Search iconColor={theme.colors.grayscale.base} />}
|
||||
placeholder={t('Search')}
|
||||
onChange={(event: any) => {
|
||||
const filterText = event.target.value;
|
||||
debouncedChangeHandler(filterText);
|
||||
}}
|
||||
css={css`
|
||||
width: 200px;
|
||||
margin-right: ${theme.gridUnit * 2}px;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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(<DataTablesPane {...props} />, { 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();
|
||||
});
|
||||
|
||||
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,
|
||||
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();
|
||||
});
|
||||
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(<DataTablesPane {...props} />, {
|
||||
useRedux: true,
|
||||
afterAll(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
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],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
|
||||
const props = createProps();
|
||||
render(
|
||||
<DataTablesPane
|
||||
{...{
|
||||
...props,
|
||||
chartStatus: 'success',
|
||||
queriesResponse: [
|
||||
test('Rendering DataTablesPane correctly', () => {
|
||||
const props = createProps();
|
||||
render(<DataTablesPane {...props} />, { useRedux: true });
|
||||
expect(screen.getByText('Results')).toBeVisible();
|
||||
expect(screen.getByText('Samples')).toBeVisible();
|
||||
expect(screen.getByLabelText('Expand data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Collapse/Expand buttons', async () => {
|
||||
const props = createProps();
|
||||
render(<DataTablesPane {...props} />, {
|
||||
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();
|
||||
});
|
||||
|
||||
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'],
|
||||
coltypes: [2, 1],
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
explore: {
|
||||
timeFormattedColumns: {
|
||||
'34__table': ['__timestamp'],
|
||||
},
|
||||
);
|
||||
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
|
||||
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(await screen.findByText('Data'));
|
||||
expect(await screen.findByText('1 rows retrieved')).toBeVisible();
|
||||
);
|
||||
userEvent.click(screen.getByText('Results'));
|
||||
expect(await screen.findByText('1 rows retrieved')).toBeVisible();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
||||
expect(copyToClipboardSpy).toHaveBeenCalledWith(
|
||||
'2009-01-01 00:00:00\tAction\n',
|
||||
);
|
||||
fetchMock.done();
|
||||
userEvent.click(screen.getByLabelText('Copy'));
|
||||
expect(copyToClipboardSpy).toHaveBeenCalledWith(
|
||||
'2009-01-01 00:00:00\tAction\n',
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${theme.gridUnit * 2}px;
|
||||
|
||||
span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const SouthPane = styled.div`
|
||||
position: relative;
|
||||
background-color: ${({ theme }) => theme.colors.grayscale.light5};
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
`;
|
||||
${({ theme }) => `
|
||||
position: relative;
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
|
||||
const TabsWrapper = styled.div<{ contentHeight: number }>`
|
||||
height: ${({ contentHeight }) => contentHeight}px;
|
||||
overflow: hidden;
|
||||
.ant-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-condensed {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
`;
|
||||
.ant-tabs-content-holder {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
const CollapseWrapper = styled.div`
|
||||
height: 100%;
|
||||
.ant-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.collapse-inner {
|
||||
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<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 = ({
|
||||
queryFormData,
|
||||
tableSectionHeight,
|
||||
onCollapseChange,
|
||||
chartStatus,
|
||||
ownState,
|
||||
|
@ -195,19 +240,19 @@ export const DataTablesPane = ({
|
|||
queriesResponse,
|
||||
}: {
|
||||
queryFormData: Record<string, any>;
|
||||
tableSectionHeight: number;
|
||||
chartStatus: string;
|
||||
ownState?: JsonObject;
|
||||
onCollapseChange: (openPanelName: string) => void;
|
||||
onCollapseChange: (isOpen: boolean) => void;
|
||||
errorMessage?: JSX.Element;
|
||||
queriesResponse: Record<string, any>;
|
||||
}) => {
|
||||
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<string>(
|
||||
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 = (
|
||||
<TableControlsWrapper>
|
||||
<RowCount data={data[activeTabKey]} loading={isLoading[activeTabKey]} />
|
||||
<CopyToClipboardButton
|
||||
data={formattedData[activeTabKey]}
|
||||
columns={columnNames[activeTabKey]}
|
||||
/>
|
||||
<FilterInput onChangeHandler={setFilterText} />
|
||||
</TableControlsWrapper>
|
||||
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 ? (
|
||||
<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 (
|
||||
<SouthPane data-test="some-purposeful-instance">
|
||||
<TabsWrapper contentHeight={tableSectionHeight}>
|
||||
<CollapseWrapper data-test="data-tab">
|
||||
<Collapse
|
||||
accordion
|
||||
bordered={false}
|
||||
defaultActiveKey={panelOpen ? DATAPANEL_KEY : undefined}
|
||||
onChange={handleCollapseChange}
|
||||
bold
|
||||
ghost
|
||||
className="collapse-inner"
|
||||
>
|
||||
<Collapse.Panel header={t('Data')} key={DATAPANEL_KEY}>
|
||||
<Tabs
|
||||
fullWidth={false}
|
||||
tabBarExtraContent={TableControls}
|
||||
activeKey={activeTabKey}
|
||||
onChange={setActiveTabKey}
|
||||
>
|
||||
<Tabs.TabPane
|
||||
tab={t('View results')}
|
||||
key={RESULT_TYPES.results}
|
||||
>
|
||||
<DataTable
|
||||
isLoading={isLoading[RESULT_TYPES.results]}
|
||||
data={data[RESULT_TYPES.results]}
|
||||
datasource={queryFormData?.datasource}
|
||||
timeFormattedColumns={timeFormattedColumns}
|
||||
columnNames={columnNames[RESULT_TYPES.results]}
|
||||
columnTypes={columnTypes[RESULT_TYPES.results]}
|
||||
filterText={filterText}
|
||||
error={error[RESULT_TYPES.results]}
|
||||
errorMessage={errorMessage}
|
||||
type={RESULT_TYPES.results}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
tab={t('View samples')}
|
||||
key={RESULT_TYPES.samples}
|
||||
>
|
||||
<DataTable
|
||||
isLoading={isLoading[RESULT_TYPES.samples]}
|
||||
data={data[RESULT_TYPES.samples]}
|
||||
datasource={queryFormData?.datasource}
|
||||
timeFormattedColumns={timeFormattedColumns}
|
||||
columnNames={columnNames[RESULT_TYPES.samples]}
|
||||
columnTypes={columnTypes[RESULT_TYPES.samples]}
|
||||
filterText={filterText}
|
||||
error={error[RESULT_TYPES.samples]}
|
||||
errorMessage={errorMessage}
|
||||
type={RESULT_TYPES.samples}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</CollapseWrapper>
|
||||
</TabsWrapper>
|
||||
<Tabs
|
||||
fullWidth={false}
|
||||
tabBarExtraContent={CollapseButton}
|
||||
activeKey={panelOpen ? activeTabKey : ''}
|
||||
onTabClick={handleTabClick}
|
||||
>
|
||||
<Tabs.TabPane tab={t('Results')} key={RESULT_TYPES.results}>
|
||||
<TableControls
|
||||
data={data[RESULT_TYPES.results]}
|
||||
columnNames={columnNames[RESULT_TYPES.results]}
|
||||
datasourceId={queryFormData?.datasource}
|
||||
onInputChange={input =>
|
||||
setFilterText(prevState => ({
|
||||
...prevState,
|
||||
[RESULT_TYPES.results]: input,
|
||||
}))
|
||||
}
|
||||
isLoading={isLoading[RESULT_TYPES.results]}
|
||||
/>
|
||||
<DataTable
|
||||
isLoading={isLoading[RESULT_TYPES.results]}
|
||||
data={data[RESULT_TYPES.results]}
|
||||
datasource={queryFormData?.datasource}
|
||||
columnNames={columnNames[RESULT_TYPES.results]}
|
||||
columnTypes={columnTypes[RESULT_TYPES.results]}
|
||||
filterText={filterText[RESULT_TYPES.results]}
|
||||
error={error[RESULT_TYPES.results]}
|
||||
errorMessage={errorMessage}
|
||||
type={RESULT_TYPES.results}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Samples')} key={RESULT_TYPES.samples}>
|
||||
<TableControls
|
||||
data={data[RESULT_TYPES.samples]}
|
||||
columnNames={columnNames[RESULT_TYPES.samples]}
|
||||
datasourceId={queryFormData?.datasource}
|
||||
onInputChange={input =>
|
||||
setFilterText(prevState => ({
|
||||
...prevState,
|
||||
[RESULT_TYPES.samples]: input,
|
||||
}))
|
||||
}
|
||||
isLoading={isLoading[RESULT_TYPES.samples]}
|
||||
/>
|
||||
<DataTable
|
||||
isLoading={isLoading[RESULT_TYPES.samples]}
|
||||
data={data[RESULT_TYPES.samples]}
|
||||
datasource={queryFormData?.datasource}
|
||||
columnNames={columnNames[RESULT_TYPES.samples]}
|
||||
columnTypes={columnTypes[RESULT_TYPES.samples]}
|
||||
filterText={filterText[RESULT_TYPES.samples]}
|
||||
error={error[RESULT_TYPES.samples]}
|
||||
errorMessage={errorMessage}
|
||||
type={RESULT_TYPES.samples}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</SouthPane>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 && (
|
||||
<ChartContainer
|
||||
width={Math.floor(chartWidth)}
|
||||
height={newHeight}
|
||||
ownState={props.ownState}
|
||||
annotationData={chart.annotationData}
|
||||
chartAlert={chart.chartAlert}
|
||||
chartStackTrace={chart.chartStackTrace}
|
||||
chartId={chart.id}
|
||||
chartStatus={chart.chartStatus}
|
||||
triggerRender={props.triggerRender}
|
||||
force={props.force}
|
||||
datasource={props.datasource}
|
||||
errorMessage={props.errorMessage}
|
||||
formData={props.form_data}
|
||||
onQuery={props.onQuery}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
refreshOverlayVisible={props.refreshOverlayVisible}
|
||||
setControlValue={props.actions.setControlValue}
|
||||
timeout={props.timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
vizType={props.vizType}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}, [calcSectionHeight, chartPanelWidth, props, splitSizes]);
|
||||
}, []);
|
||||
|
||||
const renderChart = useCallback(
|
||||
() => (
|
||||
<div
|
||||
css={css`
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
`}
|
||||
ref={chartPanelRef}
|
||||
>
|
||||
{chartPanelWidth && chartPanelHeight && (
|
||||
<ChartContainer
|
||||
width={Math.floor(chartPanelWidth)}
|
||||
height={chartPanelHeight}
|
||||
ownState={ownState}
|
||||
annotationData={chart.annotationData}
|
||||
chartAlert={chart.chartAlert}
|
||||
chartStackTrace={chart.chartStackTrace}
|
||||
chartId={chart.id}
|
||||
chartStatus={chart.chartStatus}
|
||||
triggerRender={triggerRender}
|
||||
force={force}
|
||||
datasource={datasource}
|
||||
errorMessage={errorMessage}
|
||||
formData={formData}
|
||||
onQuery={onQuery}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
refreshOverlayVisible={refreshOverlayVisible}
|
||||
setControlValue={actions.setControlValue}
|
||||
timeout={timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
vizType={vizType}
|
||||
/>
|
||||
)}
|
||||
</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(
|
||||
() => (
|
||||
<div className="panel-body" ref={chartPanelRef}>
|
||||
<div
|
||||
className="panel-body"
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`}
|
||||
>
|
||||
<ChartPills
|
||||
queriesResponse={props.chart.queriesResponse}
|
||||
chartStatus={props.chart.chartStatus}
|
||||
chartUpdateStartTime={props.chart.chartUpdateStartTime}
|
||||
chartUpdateEndTime={props.chart.chartUpdateEndTime}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
chartStatus={chart.chartStatus}
|
||||
chartUpdateStartTime={chart.chartUpdateStartTime}
|
||||
chartUpdateEndTime={chart.chartUpdateEndTime}
|
||||
refreshCachedQuery={refreshCachedQuery}
|
||||
rowLimit={props.form_data?.row_limit}
|
||||
ref={pillsRef}
|
||||
rowLimit={formData?.row_limit}
|
||||
/>
|
||||
{renderChart()}
|
||||
</div>
|
||||
|
@ -266,14 +278,9 @@ const ExploreChartPanel = props => {
|
|||
[chartPanelRef, renderChart],
|
||||
);
|
||||
|
||||
const standaloneChartBody = useMemo(
|
||||
() => <div ref={chartPanelRef}>{renderChart()}</div>,
|
||||
[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 (
|
||||
<Styles className="panel panel-default chart-container" ref={chartPanelRef}>
|
||||
{props.vizType === 'filter_box' ? (
|
||||
<Styles className="panel panel-default chart-container">
|
||||
{vizType === 'filter_box' ? (
|
||||
panelBody
|
||||
) : (
|
||||
<Split
|
||||
|
@ -313,16 +320,16 @@ const ExploreChartPanel = props => {
|
|||
gutterSize={gutterHeight}
|
||||
onDragEnd={onDragEnd}
|
||||
elementStyle={elementStyle}
|
||||
expandToMin
|
||||
>
|
||||
{panelBody}
|
||||
<DataTablesPane
|
||||
ownState={props.ownState}
|
||||
ownState={ownState}
|
||||
queryFormData={queryFormData}
|
||||
tableSectionHeight={tableSectionHeight}
|
||||
onCollapseChange={onCollapseChange}
|
||||
chartStatus={props.chart.chartStatus}
|
||||
errorMessage={props.errorMessage}
|
||||
queriesResponse={props.chart.queriesResponse}
|
||||
chartStatus={chart.chartStatus}
|
||||
errorMessage={errorMessage}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
/>
|
||||
</Split>
|
||||
)}
|
||||
|
|
|
@ -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 (
|
||||
<ExploreChartPanel
|
||||
width={width}
|
||||
height={height}
|
||||
{...props}
|
||||
errorMessage={errorMessage}
|
||||
refreshOverlayVisible={chartIsStale}
|
||||
|
|
Loading…
Reference in New Issue