refactor: decouple DataTablesPane (#20109)

This commit is contained in:
Yongjie Zhao 2022-05-26 18:59:54 +08:00 committed by GitHub
parent 694f75d376
commit e9007e3c2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 811 additions and 570 deletions

View File

@ -481,7 +481,6 @@ export function exploreJSON(
return Promise.all([
chartDataRequestCaught,
dispatch(triggerQuery(false, key)),
dispatch(updateQueryFormData(formData, key)),
...annotationLayers.map(annotation =>
dispatch(
runAnnotationQuery({
@ -595,3 +594,17 @@ export function refreshChart(chartKey, force, dashboardId) {
);
};
}
export const getDatasetSamples = async (datasetId, force) => {
const endpoint = `/api/v1/dataset/${datasetId}/samples?force=${force}`;
try {
const response = await SupersetClient.get({ endpoint });
return response.json.result;
} catch (err) {
const clientError = await getClientErrorObject(err);
throw new Error(
clientError.message || clientError.error || t('Sorry, an error occurred'),
{ cause: err },
);
}
};

View File

@ -105,8 +105,8 @@ describe('chart actions', () => {
const actionThunk = actions.postChartFormData({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
// chart update, trigger query, success
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[0][0].type).toBe(actions.CHART_UPDATE_STARTED);
});
@ -116,43 +116,32 @@ describe('chart actions', () => {
const actionThunk = actions.postChartFormData({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[1][0].type).toBe(actions.TRIGGER_QUERY);
});
});
it('should dispatch UPDATE_QUERY_FORM_DATA action with the query', () => {
const actionThunk = actions.postChartFormData({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[2][0].type).toBe(actions.UPDATE_QUERY_FORM_DATA);
});
});
it('should dispatch logEvent async action', () => {
const actionThunk = actions.postChartFormData({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
// chart update, trigger query, success
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(typeof dispatch.args[3][0]).toBe('function');
dispatch.args[3][0](dispatch);
expect(dispatch.callCount).toBe(6);
expect(dispatch.args[5][0].type).toBe(LOG_EVENT);
dispatch.args[2][0](dispatch);
expect(dispatch.callCount).toBe(5);
expect(dispatch.args[4][0].type).toBe(LOG_EVENT);
});
});
it('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => {
const actionThunk = actions.postChartFormData({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
// chart update, trigger query, success
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED);
expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED);
});
});
@ -168,8 +157,8 @@ describe('chart actions', () => {
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, fail
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.callCount).toBe(5);
expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_FAILED);
expect(dispatch.callCount).toBe(4);
expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_FAILED);
setupDefaultFetchMock();
});
});
@ -185,9 +174,9 @@ describe('chart actions', () => {
const actionThunk = actions.postChartFormData({}, false, timeoutInSec);
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, fail
expect(dispatch.callCount).toBe(5);
const updateFailedAction = dispatch.args[4][0];
// chart update, trigger query, fail
expect(dispatch.callCount).toBe(4);
const updateFailedAction = dispatch.args[3][0];
expect(updateFailedAction.type).toBe(actions.CHART_UPDATE_FAILED);
expect(updateFailedAction.queriesResponse[0].error).toBe('misc error');

View File

@ -48,6 +48,7 @@ describe('Dashboard', () => {
removeSliceFromDashboard() {},
triggerQuery() {},
logEvent() {},
updateQueryFormData() {},
},
initMessages: [],
dashboardState,

View File

@ -26,6 +26,8 @@ import {
screen,
waitForElementToBeRemoved,
} from 'spec/helpers/testing-library';
import { DatasourceType } from '@superset-ui/core';
import { exploreActions } from 'src/explore/actions/exploreActions';
import { DataTablesPane } from '.';
const createProps = () => ({
@ -62,6 +64,16 @@ const createProps = () => ({
colnames: [],
},
],
datasource: {
id: 0,
name: '',
type: DatasourceType.Table,
columns: [],
metrics: [],
columnFormats: {},
verboseMap: {},
},
actions: exploreActions,
});
describe('DataTablesPane', () => {

View File

@ -0,0 +1,214 @@
/**
* 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, {
useCallback,
useEffect,
useMemo,
useState,
MouseEvent,
} from 'react';
import { styled, t, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import Tabs from 'src/components/Tabs';
import {
getItem,
setItem,
LocalStorageKeys,
} from 'src/utils/localStorageHelpers';
import { ResultsPane, SamplesPane, TableControlsWrapper } from './components';
import { DataTablesPaneProps } from './types';
enum ResultTypes {
Results = 'results',
Samples = 'samples',
}
const SouthPane = styled.div`
${({ theme }) => `
position: relative;
background-color: ${theme.colors.grayscale.light5};
z-index: 5;
overflow: hidden;
.ant-tabs {
height: 100%;
}
.ant-tabs-content-holder {
height: 100%;
}
.ant-tabs-content {
height: 100%;
}
.ant-tabs-tabpane {
display: flex;
flex-direction: column;
height: 100%;
.table-condensed {
height: 100%;
overflow: auto;
margin-bottom: ${theme.gridUnit * 4}px;
.table {
margin-bottom: ${theme.gridUnit * 2}px;
}
}
.pagination-container > ul[role='navigation'] {
margin-top: 0;
}
}
`}
`;
export const DataTablesPane = ({
queryFormData,
datasource,
queryForce,
onCollapseChange,
ownState,
errorMessage,
actions,
}: DataTablesPaneProps) => {
const theme = useTheme();
const [activeTabKey, setActiveTabKey] = useState<string>(ResultTypes.Results);
const [isRequest, setIsRequest] = useState<Record<ResultTypes, boolean>>({
results: getItem(LocalStorageKeys.is_datapanel_open, false),
samples: false,
});
const [panelOpen, setPanelOpen] = useState(
getItem(LocalStorageKeys.is_datapanel_open, false),
);
useEffect(() => {
setItem(LocalStorageKeys.is_datapanel_open, panelOpen);
}, [panelOpen]);
useEffect(() => {
if (!panelOpen) {
setIsRequest({
results: false,
samples: false,
});
}
if (panelOpen && activeTabKey === ResultTypes.Results) {
setIsRequest({
results: true,
samples: false,
});
}
if (panelOpen && activeTabKey === ResultTypes.Samples) {
setIsRequest({
results: false,
samples: true,
});
}
}, [panelOpen, activeTabKey]);
const handleCollapseChange = useCallback(
(isOpen: boolean) => {
onCollapseChange(isOpen);
setPanelOpen(isOpen);
},
[onCollapseChange],
);
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">
<Tabs
fullWidth={false}
tabBarExtraContent={CollapseButton}
activeKey={panelOpen ? activeTabKey : ''}
onTabClick={handleTabClick}
>
<Tabs.TabPane tab={t('Results')} key={ResultTypes.Results}>
<ResultsPane
errorMessage={errorMessage}
queryFormData={queryFormData}
queryForce={queryForce}
ownState={ownState}
isRequest={isRequest.results}
actions={actions}
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Samples')} key={ResultTypes.Samples}>
<SamplesPane
datasource={datasource}
queryForce={queryForce}
isRequest={isRequest.samples}
actions={actions}
/>
</Tabs.TabPane>
</Tabs>
</SouthPane>
);
};

View File

@ -0,0 +1,75 @@
/**
* 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, { useMemo } from 'react';
import { css, styled } from '@superset-ui/core';
import {
CopyToClipboardButton,
FilterInput,
RowCount,
} from 'src/explore/components/DataTableControl';
import { applyFormattingToTabularData } from 'src/utils/common';
import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns';
export const TableControlsWrapper = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${theme.gridUnit * 2}px;
span {
flex-shrink: 0;
}
`}
`;
export const TableControls = ({
data,
datasourceId,
onInputChange,
columnNames,
isLoading,
}: {
data: Record<string, any>[];
datasourceId?: string;
onInputChange: (input: string) => void;
columnNames: string[];
isLoading: boolean;
}) => {
const originalFormattedTimeColumns =
useOriginalFormattedTimeColumns(datasourceId);
const formattedData = useMemo(
() => applyFormattingToTabularData(data, originalFormattedTimeColumns),
[data, originalFormattedTimeColumns],
);
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>
);
};

View File

@ -0,0 +1,173 @@
/**
* 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, { useState, useEffect } from 'react';
import { ensureIsArray, GenericDataType, styled, t } from '@superset-ui/core';
import Loading from 'src/components/Loading';
import { EmptyStateMedium } from 'src/components/EmptyState';
import TableView, { EmptyWrapperType } from 'src/components/TableView';
import {
useFilteredTableData,
useTableColumns,
} from 'src/explore/components/DataTableControl';
import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { TableControls } from './DataTableControls';
import { ResultsPaneProps } from '../types';
const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`};
`;
const cache = new WeakSet();
export const ResultsPane = ({
isRequest,
queryFormData,
queryForce,
ownState,
errorMessage,
actions,
dataSize = 50,
}: ResultsPaneProps) => {
const [filterText, setFilterText] = useState('');
const [data, setData] = useState<Record<string, any>[][]>([]);
const [colnames, setColnames] = useState<string[]>([]);
const [coltypes, setColtypes] = useState<GenericDataType[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [responseError, setResponseError] = useState<string>('');
useEffect(() => {
// it's an invalid formData when gets a errorMessage
if (errorMessage) return;
if (isRequest && !cache.has(queryFormData)) {
setIsLoading(true);
getChartDataRequest({
formData: queryFormData,
force: queryForce,
resultFormat: 'json',
resultType: 'results',
ownState,
})
.then(({ json }) => {
const { colnames, coltypes } = json.result[0];
// Only displaying the first query is currently supported
if (json.result.length > 1) {
// todo: move these code to the backend, shouldn't loop by row in FE
const data: any[] = [];
json.result.forEach((item: { data: any[] }) => {
item.data.forEach((row, i) => {
if (data[i] !== undefined) {
data[i] = { ...data[i], ...row };
} else {
data[i] = row;
}
});
});
setData(data);
setColnames(colnames);
setColtypes(coltypes);
} else {
setData(ensureIsArray(json.result[0].data));
setColnames(colnames);
setColtypes(coltypes);
}
setResponseError('');
cache.add(queryFormData);
if (queryForce && actions) {
actions.setForceQuery(false);
}
})
.catch(response => {
getClientErrorObject(response).then(({ error, message }) => {
setResponseError(error || message || t('Sorry, an error occurred'));
});
})
.finally(() => {
setIsLoading(false);
});
}
}, [queryFormData, isRequest]);
const originalFormattedTimeColumns = useOriginalFormattedTimeColumns(
queryFormData.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(
colnames,
coltypes,
data,
queryFormData.datasource,
originalFormattedTimeColumns,
);
const filteredData = useFilteredTableData(filterText, data);
if (errorMessage) {
const title = t('Run a query to display results');
return <EmptyStateMedium image="document.svg" title={title} />;
}
if (isLoading) {
return <Loading />;
}
if (responseError) {
return (
<>
<TableControls
data={filteredData}
columnNames={colnames}
datasourceId={queryFormData?.datasource}
onInputChange={input => setFilterText(input)}
isLoading={isLoading}
/>
<Error>{responseError}</Error>
</>
);
}
if (data.length === 0) {
const title = t('No results were returned for this query');
return <EmptyStateMedium image="document.svg" title={title} />;
}
return (
<>
<TableControls
data={filteredData}
columnNames={colnames}
datasourceId={queryFormData?.datasource}
onInputChange={input => setFilterText(input)}
isLoading={isLoading}
/>
<TableView
columns={columns}
data={filteredData}
pageSize={dataSize}
noDataText={t('No results')}
emptyWrapperType={EmptyWrapperType.Small}
className="table-condensed"
isPaginationSticky
showRowCount={false}
small
/>
</>
);
};

View File

@ -0,0 +1,143 @@
/**
* 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, { useState, useEffect, useMemo } from 'react';
import { GenericDataType, styled, t } from '@superset-ui/core';
import Loading from 'src/components/Loading';
import { EmptyStateMedium } from 'src/components/EmptyState';
import TableView, { EmptyWrapperType } from 'src/components/TableView';
import {
useFilteredTableData,
useTableColumns,
} from 'src/explore/components/DataTableControl';
import { useOriginalFormattedTimeColumns } from 'src/explore/components/useOriginalFormattedTimeColumns';
import { getDatasetSamples } from 'src/components/Chart/chartAction';
import { TableControls } from './DataTableControls';
import { SamplesPaneProps } from '../types';
const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`};
`;
const cache = new WeakSet();
export const SamplesPane = ({
isRequest,
datasource,
queryForce,
actions,
dataSize = 50,
}: SamplesPaneProps) => {
const [filterText, setFilterText] = useState('');
const [data, setData] = useState<Record<string, any>[][]>([]);
const [colnames, setColnames] = useState<string[]>([]);
const [coltypes, setColtypes] = useState<GenericDataType[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [responseError, setResponseError] = useState<string>('');
const datasourceId = useMemo(
() => `${datasource.id}__${datasource.type}`,
[datasource],
);
useEffect(() => {
if (isRequest && queryForce) {
cache.delete(datasource);
}
if (isRequest && !cache.has(datasource)) {
setIsLoading(true);
getDatasetSamples(datasource.id, queryForce)
.then(response => {
setData(response.data);
setColnames(response.colnames);
setColtypes(response.coltypes);
setResponseError('');
cache.add(datasource);
if (queryForce && actions) {
actions.setForceQuery(false);
}
})
.catch(error => {
setResponseError(`${error.name}: ${error.message}`);
})
.finally(() => {
setIsLoading(false);
});
}
}, [datasource, isRequest, queryForce]);
const originalFormattedTimeColumns =
useOriginalFormattedTimeColumns(datasourceId);
// 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(
colnames,
coltypes,
data,
datasourceId,
originalFormattedTimeColumns,
);
const filteredData = useFilteredTableData(filterText, data);
if (isLoading) {
return <Loading />;
}
if (responseError) {
return (
<>
<TableControls
data={filteredData}
columnNames={colnames}
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
isLoading={isLoading}
/>
<Error>{responseError}</Error>
</>
);
}
if (data.length === 0) {
const title = t('No samples were returned for this dataset');
return <EmptyStateMedium image="document.svg" title={title} />;
}
return (
<>
<TableControls
data={filteredData}
columnNames={colnames}
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
isLoading={isLoading}
/>
<TableView
columns={columns}
data={filteredData}
pageSize={dataSize}
noDataText={t('No results')}
emptyWrapperType={EmptyWrapperType.Small}
className="table-condensed"
isPaginationSticky
showRowCount={false}
small
/>
</>
);
};

View File

@ -0,0 +1,21 @@
/**
* 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.
*/
export { ResultsPane } from './ResultsPane';
export { SamplesPane } from './SamplesPane';
export { TableControls, TableControlsWrapper } from './DataTableControls';

View File

@ -0,0 +1,20 @@
/**
* 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.
*/
export { DataTablesPane } from './DataTablesPane';
export * from './components';

View File

@ -1,532 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, {
useCallback,
useEffect,
useMemo,
useState,
MouseEvent,
} from 'react';
import {
css,
ensureIsArray,
GenericDataType,
JsonObject,
styled,
t,
useTheme,
} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import Tabs from 'src/components/Tabs';
import Loading from 'src/components/Loading';
import { EmptyStateMedium } from 'src/components/EmptyState';
import TableView, { EmptyWrapperType } from 'src/components/TableView';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import {
getItem,
setItem,
LocalStorageKeys,
} from 'src/utils/localStorageHelpers';
import {
CopyToClipboardButton,
FilterInput,
RowCount,
useFilteredTableData,
useTableColumns,
} from 'src/explore/components/DataTableControl';
import { applyFormattingToTabularData } from 'src/utils/common';
import { useOriginalFormattedTimeColumns } from '../useOriginalFormattedTimeColumns';
const RESULT_TYPES = {
results: 'results' as const,
samples: 'samples' as const,
};
const getDefaultDataTablesState = (value: any) => ({
[RESULT_TYPES.results]: value,
[RESULT_TYPES.samples]: value,
});
const DATA_TABLE_PAGE_SIZE = 50;
const TableControlsWrapper = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${theme.gridUnit * 2}px;
span {
flex-shrink: 0;
}
`}
`;
const SouthPane = styled.div`
${({ theme }) => `
position: relative;
background-color: ${theme.colors.grayscale.light5};
z-index: 5;
overflow: hidden;
.ant-tabs {
height: 100%;
}
.ant-tabs-content-holder {
height: 100%;
}
.ant-tabs-content {
height: 100%;
}
.ant-tabs-tabpane {
display: flex;
flex-direction: column;
height: 100%;
.table-condensed {
height: 100%;
overflow: auto;
margin-bottom: ${theme.gridUnit * 4}px;
.table {
margin-bottom: ${theme.gridUnit * 2}px;
}
}
.pagination-container > ul[role='navigation'] {
margin-top: 0;
}
}
`}
`;
const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.gridUnit * 4}px`};
`;
interface DataTableProps {
columnNames: string[];
columnTypes: GenericDataType[] | undefined;
datasource: string | undefined;
filterText: string;
data: object[] | undefined;
isLoading: boolean;
error: string | undefined;
errorMessage: React.ReactElement | undefined;
type: 'results' | 'samples';
}
const DataTable = ({
columnNames,
columnTypes,
datasource,
filterText,
data,
isLoading,
error,
errorMessage,
type,
}: DataTableProps) => {
const originalFormattedTimeColumns =
useOriginalFormattedTimeColumns(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(
columnNames,
columnTypes,
data,
datasource,
originalFormattedTimeColumns,
);
const filteredData = useFilteredTableData(filterText, data);
if (isLoading) {
return <Loading />;
}
if (error) {
return <Error>{error}</Error>;
}
if (data) {
if (data.length === 0) {
const title =
type === 'samples'
? t('No samples were returned for this query')
: t('No results were returned for this query');
return <EmptyStateMedium image="document.svg" title={title} />;
}
return (
<TableView
columns={columns}
data={filteredData}
pageSize={DATA_TABLE_PAGE_SIZE}
noDataText={t('No results')}
emptyWrapperType={EmptyWrapperType.Small}
className="table-condensed"
isPaginationSticky
showRowCount={false}
small
/>
);
}
if (errorMessage) {
const title =
type === 'samples'
? t('Run a query to display samples')
: t('Run a query to display results');
return <EmptyStateMedium image="document.svg" title={title} />;
}
return null;
};
const TableControls = ({
data,
datasourceId,
onInputChange,
columnNames,
isLoading,
}: {
data: Record<string, any>[];
datasourceId?: string;
onInputChange: (input: string) => void;
columnNames: string[];
isLoading: boolean;
}) => {
const originalFormattedTimeColumns =
useOriginalFormattedTimeColumns(datasourceId);
const formattedData = useMemo(
() => applyFormattingToTabularData(data, originalFormattedTimeColumns),
[data, originalFormattedTimeColumns],
);
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,
queryForce,
onCollapseChange,
chartStatus,
ownState,
errorMessage,
queriesResponse,
}: {
queryFormData: Record<string, any>;
queryForce: boolean;
chartStatus: string;
ownState?: JsonObject;
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(getDefaultDataTablesState(''));
const [activeTabKey, setActiveTabKey] = useState<string>(
RESULT_TYPES.results,
);
const [isRequestPending, setIsRequestPending] = useState(
getDefaultDataTablesState(false),
);
const [panelOpen, setPanelOpen] = useState(
getItem(LocalStorageKeys.is_datapanel_open, false),
);
const getData = useCallback(
(resultType: 'samples' | 'results') => {
setIsLoading(prevIsLoading => ({
...prevIsLoading,
[resultType]: true,
}));
return getChartDataRequest({
formData: queryFormData,
force: queryForce,
resultFormat: 'json',
resultType,
ownState,
})
.then(({ json }) => {
// Only displaying the first query is currently supported
if (json.result.length > 1) {
const data: any[] = [];
json.result.forEach((item: { data: any[] }) => {
item.data.forEach((row, i) => {
if (data[i] !== undefined) {
data[i] = { ...data[i], ...row };
} else {
data[i] = row;
}
});
});
setData(prevData => ({
...prevData,
[resultType]: data,
}));
} else {
setData(prevData => ({
...prevData,
[resultType]: json.result[0].data,
}));
}
const colNames = ensureIsArray(json.result[0].colnames);
setColumnNames(prevColumnNames => ({
...prevColumnNames,
[resultType]: colNames,
}));
setColumnTypes(prevColumnTypes => ({
...prevColumnTypes,
[resultType]: json.result[0].coltypes || [],
}));
setIsLoading(prevIsLoading => ({
...prevIsLoading,
[resultType]: false,
}));
setError(prevError => ({
...prevError,
[resultType]: undefined,
}));
})
.catch(response => {
getClientErrorObject(response).then(({ error, message }) => {
setError(prevError => ({
...prevError,
[resultType]: error || message || t('Sorry, an error occurred'),
}));
setIsLoading(prevIsLoading => ({
...prevIsLoading,
[resultType]: false,
}));
});
});
},
[queryFormData, columnNames],
);
useEffect(() => {
setItem(LocalStorageKeys.is_datapanel_open, panelOpen);
}, [panelOpen]);
useEffect(() => {
setIsRequestPending(prevState => ({
...prevState,
[RESULT_TYPES.results]: true,
}));
}, [queryFormData]);
useEffect(() => {
setIsRequestPending(prevState => ({
...prevState,
[RESULT_TYPES.samples]: true,
}));
}, [queryFormData?.datasource]);
useEffect(() => {
if (queriesResponse && chartStatus === 'success') {
const { colnames } = queriesResponse[0];
setColumnNames(prevColumnNames => ({
...prevColumnNames,
[RESULT_TYPES.results]: colnames ?? [],
}));
}
}, [queriesResponse, chartStatus]);
useEffect(() => {
if (panelOpen && isRequestPending[RESULT_TYPES.results]) {
if (errorMessage) {
setIsRequestPending(prevState => ({
...prevState,
[RESULT_TYPES.results]: false,
}));
setIsLoading(prevIsLoading => ({
...prevIsLoading,
[RESULT_TYPES.results]: false,
}));
return;
}
if (chartStatus === 'loading') {
setIsLoading(prevIsLoading => ({
...prevIsLoading,
[RESULT_TYPES.results]: true,
}));
} else {
setIsRequestPending(prevState => ({
...prevState,
[RESULT_TYPES.results]: false,
}));
getData(RESULT_TYPES.results);
}
}
if (
panelOpen &&
isRequestPending[RESULT_TYPES.samples] &&
activeTabKey === RESULT_TYPES.samples
) {
setIsRequestPending(prevState => ({
...prevState,
[RESULT_TYPES.samples]: false,
}));
getData(RESULT_TYPES.samples);
}
}, [
panelOpen,
isRequestPending,
getData,
activeTabKey,
chartStatus,
errorMessage,
]);
const handleCollapseChange = useCallback(
(isOpen: boolean) => {
onCollapseChange(isOpen);
setPanelOpen(isOpen);
},
[onCollapseChange],
);
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">
<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>
);
};

View File

@ -0,0 +1,48 @@
/**
* 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 { Datasource, JsonObject, QueryFormData } from '@superset-ui/core';
import { ExploreActions } from 'src/explore/actions/exploreActions';
export interface DataTablesPaneProps {
queryFormData: QueryFormData;
datasource: Datasource;
queryForce: boolean;
ownState?: JsonObject;
onCollapseChange: (isOpen: boolean) => void;
errorMessage?: JSX.Element;
actions: ExploreActions;
}
export interface ResultsPaneProps {
isRequest: boolean;
queryFormData: QueryFormData;
queryForce: boolean;
ownState?: JsonObject;
errorMessage?: React.ReactElement;
actions?: ExploreActions;
dataSize?: number;
}
export interface SamplesPaneProps {
isRequest: boolean;
datasource: Datasource;
queryForce: boolean;
actions?: ExploreActions;
dataSize?: number;
}

View File

@ -97,6 +97,7 @@ const createProps = () => ({
fetchFaveStar: jest.fn(),
saveFaveStar: jest.fn(),
redirectSQLLab: jest.fn(),
updateQueryFormData: jest.fn(),
},
user: {
userId: 1,

View File

@ -198,6 +198,7 @@ const ExploreChartPanel = ({
undefined,
ownState,
);
actions.updateQueryFormData(formData, chart.id);
}, [actions, chart.id, formData, ownState, timeout]);
const onCollapseChange = useCallback(isOpen => {
@ -388,11 +389,11 @@ const ExploreChartPanel = ({
<DataTablesPane
ownState={ownState}
queryFormData={queryFormData}
datasource={datasource}
queryForce={force}
onCollapseChange={onCollapseChange}
chartStatus={chart.chartStatus}
errorMessage={errorMessage}
queriesResponse={chart.queriesResponse}
actions={actions}
/>
</Split>
)}

View File

@ -17,12 +17,12 @@
* under the License.
*/
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import ChartContainer from 'src/explore/components/ExploreChartPanel';
const createProps = (overrides = {}) => ({
sliceName: 'Trend Line',
vizType: 'line',
height: '500px',
actions: {},
can_overwrite: false,
@ -30,9 +30,29 @@ const createProps = (overrides = {}) => ({
containerId: 'foo',
width: '500px',
isStarred: false,
chartIsStale: false,
chart: {},
form_data: {},
vizType: 'histogram',
chart: {
id: 1,
latestQueryFormData: {
viz_type: 'histogram',
datasource: '49__table',
slice_id: 318,
url_params: {},
granularity_sqla: 'time_start',
time_range: 'No filter',
all_columns_x: ['age'],
adhoc_filters: [],
row_limit: 10000,
groupby: null,
color_scheme: 'supersetColors',
label_colors: {},
link_length: '25',
x_axis_label: 'age',
y_axis_label: 'count',
},
chartStatus: 'rendered',
queriesResponse: [{ is_cached: true }],
},
...overrides,
});
@ -83,4 +103,37 @@ describe('ChartContainer', () => {
screen.getByText('Required control values have been removed'),
).toBeVisible();
});
it('should render cached button and call expected actions', () => {
const setForceQuery = jest.fn();
const postChartFormData = jest.fn();
const updateQueryFormData = jest.fn();
const props = createProps({
actions: {
setForceQuery,
postChartFormData,
updateQueryFormData,
},
});
render(<ChartContainer {...props} />, { useRedux: true });
const cached = screen.queryByText('Cached');
expect(cached).toBeInTheDocument();
userEvent.click(cached);
expect(setForceQuery).toHaveBeenCalledTimes(1);
expect(postChartFormData).toHaveBeenCalledTimes(1);
expect(updateQueryFormData).toHaveBeenCalledTimes(1);
});
it('should hide cached button', () => {
const props = createProps({
chart: {
chartStatus: 'rendered',
queriesResponse: [{ is_cached: false }],
},
});
render(<ChartContainer {...props} />, { useRedux: true });
expect(screen.queryByText('Cached')).not.toBeInTheDocument();
});
});

View File

@ -267,6 +267,7 @@ function ExploreViewContainer(props) {
const onQuery = useCallback(() => {
props.actions.setForceQuery(false);
props.actions.triggerQuery(true, props.chart.id);
props.actions.updateQueryFormData(props.form_data, props.chart.id);
addHistory();
setLastQueriedControls(props.controls);
}, [props.controls, addHistory, props.actions, props.chart.id]);

View File

@ -22,6 +22,6 @@ import { ExplorePageState } from '../reducers/getInitialState';
export const useOriginalFormattedTimeColumns = (datasourceId?: string) =>
useSelector<ExplorePageState, string[]>(state =>
datasourceId
? state.explore.originalFormattedTimeColumns?.[datasourceId] ?? []
? state?.explore?.originalFormattedTimeColumns?.[datasourceId] ?? []
: [],
);

View File

@ -21,8 +21,9 @@ from io import BytesIO
from typing import Any
from zipfile import is_zipfile, ZipFile
import simplejson
import yaml
from flask import g, request, Response, send_file
from flask import g, make_response, request, Response, send_file
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
@ -62,7 +63,7 @@ from superset.datasets.schemas import (
get_delete_ids_schema,
get_export_ids_schema,
)
from superset.utils.core import parse_boolean_string
from superset.utils.core import json_int_dttm_ser, parse_boolean_string
from superset.views.base import DatasourceFilter, generate_download_headers
from superset.views.base_api import (
BaseSupersetModelRestApi,
@ -811,7 +812,14 @@ class DatasetRestApi(BaseSupersetModelRestApi):
try:
force = parse_boolean_string(request.args.get("force"))
rv = SamplesDatasetCommand(g.user, pk, force).run()
return self.response(200, result=rv)
response_data = simplejson.dumps(
{"result": rv},
default=json_int_dttm_ser,
ignore_nan=True,
)
resp = make_response(response_data, 200)
resp.headers["Content-Type"] = "application/json; charset=utf-8"
return resp
except DatasetNotFoundError:
return self.response_404()
except DatasetForbiddenError: