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
6 changed files with 496 additions and 361 deletions

View File

@@ -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();
});
});

View File

@@ -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>
);
};