feat(explore): Allow using time formatter on temporal columns in data table (#18569)
* feat(explore): Allow using time formatter on temporal columns in data table * Fix data table loading * Return colnames and coltypes from results request * Fix types * Fix tests * Fix copy button * Fix df is none * Fix test * Address comments * Move useTimeFormattedColumns out of useTableColumns * Make reducer more readable
This commit is contained in:
parent
28e729b835
commit
830f2e71d3
|
|
@ -140,6 +140,30 @@ export function sliceUpdated(slice: Slice) {
|
|||
return { type: SLICE_UPDATED, slice };
|
||||
}
|
||||
|
||||
export const SET_TIME_FORMATTED_COLUMN = 'SET_TIME_FORMATTED_COLUMN';
|
||||
export function setTimeFormattedColumn(
|
||||
datasourceId: string,
|
||||
columnName: string,
|
||||
) {
|
||||
return {
|
||||
type: SET_TIME_FORMATTED_COLUMN,
|
||||
datasourceId,
|
||||
columnName,
|
||||
};
|
||||
}
|
||||
|
||||
export const UNSET_TIME_FORMATTED_COLUMN = 'UNSET_TIME_FORMATTED_COLUMN';
|
||||
export function unsetTimeFormattedColumn(
|
||||
datasourceId: string,
|
||||
columnIndex: number,
|
||||
) {
|
||||
return {
|
||||
type: UNSET_TIME_FORMATTED_COLUMN,
|
||||
datasourceId,
|
||||
columnIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export const exploreActions = {
|
||||
...toastActions,
|
||||
setDatasourceType,
|
||||
|
|
@ -155,6 +179,8 @@ export const exploreActions = {
|
|||
updateChartTitle,
|
||||
createNewSlice,
|
||||
sliceUpdated,
|
||||
setTimeFormattedColumn,
|
||||
unsetTimeFormattedColumn,
|
||||
};
|
||||
|
||||
export type ExploreActions = typeof exploreActions;
|
||||
|
|
|
|||
|
|
@ -16,20 +16,37 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
css,
|
||||
GenericDataType,
|
||||
getTimeFormatter,
|
||||
styled,
|
||||
t,
|
||||
TimeFormats,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { Global } from '@emotion/react';
|
||||
import { Column } from 'react-table';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Input } from 'src/common/components';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Input, Space } from 'src/common/components';
|
||||
import {
|
||||
BOOL_FALSE_DISPLAY,
|
||||
BOOL_TRUE_DISPLAY,
|
||||
SLOW_DEBOUNCE,
|
||||
} from 'src/constants';
|
||||
import { Radio } from 'src/components/Radio';
|
||||
import Icons from 'src/components/Icons';
|
||||
import Button from 'src/components/Button';
|
||||
import Popover from 'src/components/Popover';
|
||||
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
|
||||
import CopyToClipboard from 'src/components/CopyToClipboard';
|
||||
import RowCountLabel from 'src/explore/components/RowCountLabel';
|
||||
import {
|
||||
setTimeFormattedColumn,
|
||||
unsetTimeFormattedColumn,
|
||||
} from 'src/explore/actions/exploreActions';
|
||||
|
||||
export const CopyButton = styled(Button)`
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
|
|
@ -97,6 +114,129 @@ export const RowCount = ({
|
|||
/>
|
||||
);
|
||||
|
||||
enum FormatPickerValue {
|
||||
Formatted,
|
||||
Original,
|
||||
}
|
||||
|
||||
const FormatPicker = ({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: any;
|
||||
value: FormatPickerValue;
|
||||
}) => (
|
||||
<Radio.Group value={value} onChange={onChange}>
|
||||
<Space direction="vertical">
|
||||
<Radio value={FormatPickerValue.Original}>{t('Original value')}</Radio>
|
||||
<Radio value={FormatPickerValue.Formatted}>{t('Formatted date')}</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
);
|
||||
|
||||
const FormatPickerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: ${({ theme }) => `${theme.gridUnit * 4}px`};
|
||||
`;
|
||||
|
||||
const FormatPickerLabel = styled.span`
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const DataTableTemporalHeaderCell = ({
|
||||
columnName,
|
||||
datasourceId,
|
||||
timeFormattedColumnIndex,
|
||||
}: {
|
||||
columnName: string;
|
||||
datasourceId?: string;
|
||||
timeFormattedColumnIndex: number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const isColumnTimeFormatted = timeFormattedColumnIndex > -1;
|
||||
|
||||
const onChange = useCallback(
|
||||
e => {
|
||||
if (!datasourceId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.target.value === FormatPickerValue.Original &&
|
||||
isColumnTimeFormatted
|
||||
) {
|
||||
dispatch(
|
||||
unsetTimeFormattedColumn(datasourceId, timeFormattedColumnIndex),
|
||||
);
|
||||
} else if (
|
||||
e.target.value === FormatPickerValue.Formatted &&
|
||||
!isColumnTimeFormatted
|
||||
) {
|
||||
dispatch(setTimeFormattedColumn(datasourceId, columnName));
|
||||
}
|
||||
},
|
||||
[
|
||||
timeFormattedColumnIndex,
|
||||
columnName,
|
||||
datasourceId,
|
||||
dispatch,
|
||||
isColumnTimeFormatted,
|
||||
],
|
||||
);
|
||||
const overlayContent = useMemo(
|
||||
() =>
|
||||
datasourceId ? ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<FormatPickerContainer onClick={e => e.stopPropagation()}>
|
||||
{/* hack to disable click propagation from popover content to table header, which triggers sorting column */}
|
||||
<Global
|
||||
styles={css`
|
||||
.column-formatting-popover .ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<FormatPickerLabel>{t('Column Formatting')}</FormatPickerLabel>
|
||||
<FormatPicker
|
||||
onChange={onChange}
|
||||
value={
|
||||
isColumnTimeFormatted
|
||||
? FormatPickerValue.Formatted
|
||||
: FormatPickerValue.Original
|
||||
}
|
||||
/>
|
||||
</FormatPickerContainer>
|
||||
) : null,
|
||||
[datasourceId, isColumnTimeFormatted, onChange],
|
||||
);
|
||||
|
||||
return datasourceId ? (
|
||||
<span>
|
||||
<Popover
|
||||
overlayClassName="column-formatting-popover"
|
||||
trigger="click"
|
||||
content={overlayContent}
|
||||
placement="bottomLeft"
|
||||
arrowPointAtCenter
|
||||
>
|
||||
<Icons.SettingOutlined
|
||||
iconSize="m"
|
||||
iconColor={theme.colors.grayscale.light1}
|
||||
css={{ marginRight: `${theme.gridUnit}px` }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</Popover>
|
||||
{columnName}
|
||||
</span>
|
||||
) : (
|
||||
<span>{columnName}</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFilteredTableData = (
|
||||
filterText: string,
|
||||
data?: Record<string, any>[],
|
||||
|
|
@ -121,9 +261,14 @@ export const useFilteredTableData = (
|
|||
}, [data, filterText, rowsAsStrings]);
|
||||
};
|
||||
|
||||
const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
||||
|
||||
export const useTableColumns = (
|
||||
colnames?: string[],
|
||||
coltypes?: GenericDataType[],
|
||||
data?: Record<string, any>[],
|
||||
datasourceId?: string,
|
||||
timeFormattedColumns: string[] = [],
|
||||
moreConfigs?: { [key: string]: Partial<Column> },
|
||||
) =>
|
||||
useMemo(
|
||||
|
|
@ -131,24 +276,40 @@ export const useTableColumns = (
|
|||
colnames && data?.length
|
||||
? colnames
|
||||
.filter((column: string) => Object.keys(data[0]).includes(column))
|
||||
.map(
|
||||
key =>
|
||||
({
|
||||
accessor: row => row[key],
|
||||
// When the key is empty, have to give a string of length greater than 0
|
||||
Header: key || ' ',
|
||||
Cell: ({ value }) => {
|
||||
if (value === true) {
|
||||
return BOOL_TRUE_DISPLAY;
|
||||
}
|
||||
if (value === false) {
|
||||
return BOOL_FALSE_DISPLAY;
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
...moreConfigs?.[key],
|
||||
} as Column),
|
||||
)
|
||||
.map((key, index) => {
|
||||
const timeFormattedColumnIndex =
|
||||
coltypes?.[index] === GenericDataType.TEMPORAL
|
||||
? timeFormattedColumns.indexOf(key)
|
||||
: -1;
|
||||
return {
|
||||
id: key,
|
||||
accessor: row => row[key],
|
||||
// When the key is empty, have to give a string of length greater than 0
|
||||
Header:
|
||||
coltypes?.[index] === GenericDataType.TEMPORAL ? (
|
||||
<DataTableTemporalHeaderCell
|
||||
columnName={key}
|
||||
datasourceId={datasourceId}
|
||||
timeFormattedColumnIndex={timeFormattedColumnIndex}
|
||||
/>
|
||||
) : (
|
||||
key
|
||||
),
|
||||
Cell: ({ value }) => {
|
||||
if (value === true) {
|
||||
return BOOL_TRUE_DISPLAY;
|
||||
}
|
||||
if (value === false) {
|
||||
return BOOL_FALSE_DISPLAY;
|
||||
}
|
||||
if (timeFormattedColumnIndex > -1) {
|
||||
return timeFormatter(value);
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
...moreConfigs?.[key],
|
||||
} as Column;
|
||||
})
|
||||
: [],
|
||||
[data, colnames, moreConfigs],
|
||||
[colnames, data, coltypes, datasourceId, moreConfigs, timeFormattedColumns],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { GenericDataType } from '@superset-ui/core';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { BOOL_FALSE_DISPLAY, BOOL_TRUE_DISPLAY } from 'src/constants';
|
||||
import { useTableColumns } from '.';
|
||||
|
|
@ -43,29 +44,39 @@ const data = [
|
|||
},
|
||||
];
|
||||
const all_columns = ['col01', 'col02', 'col03', asciiKey, unicodeKey];
|
||||
const coltypes = [
|
||||
GenericDataType.BOOLEAN,
|
||||
GenericDataType.BOOLEAN,
|
||||
GenericDataType.STRING,
|
||||
GenericDataType.STRING,
|
||||
];
|
||||
|
||||
test('useTableColumns with no options', () => {
|
||||
const hook = renderHook(() => useTableColumns(all_columns, data));
|
||||
const hook = renderHook(() => useTableColumns(all_columns, coltypes, data));
|
||||
expect(hook.result.current).toEqual([
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: 'col01',
|
||||
accessor: expect.any(Function),
|
||||
id: 'col01',
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: 'col02',
|
||||
accessor: expect.any(Function),
|
||||
id: 'col02',
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: asciiKey,
|
||||
accessor: expect.any(Function),
|
||||
id: asciiKey,
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: unicodeKey,
|
||||
accessor: expect.any(Function),
|
||||
id: unicodeKey,
|
||||
},
|
||||
]);
|
||||
hook.result.current.forEach((col: JsonObject) => {
|
||||
|
|
@ -84,32 +95,39 @@ test('useTableColumns with no options', () => {
|
|||
|
||||
test('use only the first record columns', () => {
|
||||
const newData = [data[3], data[0]];
|
||||
const hook = renderHook(() => useTableColumns(all_columns, newData));
|
||||
const hook = renderHook(() =>
|
||||
useTableColumns(all_columns, coltypes, newData),
|
||||
);
|
||||
expect(hook.result.current).toEqual([
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: 'col01',
|
||||
accessor: expect.any(Function),
|
||||
id: 'col01',
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: 'col02',
|
||||
accessor: expect.any(Function),
|
||||
id: 'col02',
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: 'col03',
|
||||
accessor: expect.any(Function),
|
||||
id: 'col03',
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: asciiKey,
|
||||
accessor: expect.any(Function),
|
||||
id: asciiKey,
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: unicodeKey,
|
||||
accessor: expect.any(Function),
|
||||
id: unicodeKey,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -136,7 +154,9 @@ test('use only the first record columns', () => {
|
|||
|
||||
test('useTableColumns with options', () => {
|
||||
const hook = renderHook(() =>
|
||||
useTableColumns(all_columns, data, { col01: { id: 'ID' } }),
|
||||
useTableColumns(all_columns, coltypes, data, undefined, [], {
|
||||
col01: { id: 'ID' },
|
||||
}),
|
||||
);
|
||||
expect(hook.result.current).toEqual([
|
||||
{
|
||||
|
|
@ -149,16 +169,19 @@ test('useTableColumns with options', () => {
|
|||
Cell: expect.any(Function),
|
||||
Header: 'col02',
|
||||
accessor: expect.any(Function),
|
||||
id: 'col02',
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: asciiKey,
|
||||
accessor: expect.any(Function),
|
||||
id: asciiKey,
|
||||
},
|
||||
{
|
||||
Cell: expect.any(Function),
|
||||
Header: unicodeKey,
|
||||
accessor: expect.any(Function),
|
||||
id: unicodeKey,
|
||||
},
|
||||
]);
|
||||
hook.result.current.forEach((col: JsonObject) => {
|
||||
|
|
|
|||
|
|
@ -105,7 +105,13 @@ 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' }] }],
|
||||
result: [
|
||||
{
|
||||
data: [{ __timestamp: 1230768000000, genre: 'Action' }],
|
||||
colnames: ['__timestamp', 'genre'],
|
||||
coltypes: [2, 1],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
|
||||
|
|
@ -118,12 +124,20 @@ test('Should copy data table content correctly', async () => {
|
|||
queriesResponse: [
|
||||
{
|
||||
colnames: ['__timestamp', 'genre'],
|
||||
coltypes: [2, 1],
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
explore: {
|
||||
timeFormattedColumns: {
|
||||
'34__table': ['__timestamp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
userEvent.click(await screen.findByText('Data'));
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@
|
|||
* under the License.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { JsonObject, styled, t } from '@superset-ui/core';
|
||||
import {
|
||||
ensureIsArray,
|
||||
GenericDataType,
|
||||
JsonObject,
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import Collapse from 'src/components/Collapse';
|
||||
import Tabs from 'src/components/Tabs';
|
||||
import Loading from 'src/components/Loading';
|
||||
|
|
@ -37,16 +43,17 @@ import {
|
|||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
import { applyFormattingToTabularData } from 'src/utils/common';
|
||||
import { useTimeFormattedColumns } from '../useTimeFormattedColumns';
|
||||
|
||||
const RESULT_TYPES = {
|
||||
results: 'results' as const,
|
||||
samples: 'samples' as const,
|
||||
};
|
||||
|
||||
const NULLISH_RESULTS_STATE = {
|
||||
[RESULT_TYPES.results]: undefined,
|
||||
[RESULT_TYPES.samples]: undefined,
|
||||
};
|
||||
const getDefaultDataTablesState = (value: any) => ({
|
||||
[RESULT_TYPES.results]: value,
|
||||
[RESULT_TYPES.samples]: value,
|
||||
});
|
||||
|
||||
const DATA_TABLE_PAGE_SIZE = 50;
|
||||
|
||||
|
|
@ -105,8 +112,11 @@ const Error = styled.pre`
|
|||
|
||||
interface DataTableProps {
|
||||
columnNames: string[];
|
||||
columnTypes: GenericDataType[] | undefined;
|
||||
datasource: string | undefined;
|
||||
filterText: string;
|
||||
data: object[] | undefined;
|
||||
timeFormattedColumns: string[] | undefined;
|
||||
isLoading: boolean;
|
||||
error: string | undefined;
|
||||
errorMessage: React.ReactElement | undefined;
|
||||
|
|
@ -114,15 +124,24 @@ interface DataTableProps {
|
|||
|
||||
const DataTable = ({
|
||||
columnNames,
|
||||
columnTypes,
|
||||
datasource,
|
||||
filterText,
|
||||
data,
|
||||
timeFormattedColumns,
|
||||
isLoading,
|
||||
error,
|
||||
errorMessage,
|
||||
}: DataTableProps) => {
|
||||
// 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, data);
|
||||
const columns = useTableColumns(
|
||||
columnNames,
|
||||
columnTypes,
|
||||
data,
|
||||
datasource,
|
||||
timeFormattedColumns,
|
||||
);
|
||||
const filteredData = useFilteredTableData(filterText, data);
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -172,48 +191,42 @@ export const DataTablesPane = ({
|
|||
errorMessage?: JSX.Element;
|
||||
queriesResponse: Record<string, any>;
|
||||
}) => {
|
||||
const [data, setData] = useState<{
|
||||
[RESULT_TYPES.results]?: Record<string, any>[];
|
||||
[RESULT_TYPES.samples]?: Record<string, any>[];
|
||||
}>(NULLISH_RESULTS_STATE);
|
||||
const [isLoading, setIsLoading] = useState({
|
||||
[RESULT_TYPES.results]: true,
|
||||
[RESULT_TYPES.samples]: true,
|
||||
});
|
||||
const [columnNames, setColumnNames] = useState<{
|
||||
[RESULT_TYPES.results]: string[];
|
||||
[RESULT_TYPES.samples]: string[];
|
||||
}>({
|
||||
[RESULT_TYPES.results]: [],
|
||||
[RESULT_TYPES.samples]: [],
|
||||
});
|
||||
const [error, setError] = useState(NULLISH_RESULTS_STATE);
|
||||
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 [activeTabKey, setActiveTabKey] = useState<string>(
|
||||
RESULT_TYPES.results,
|
||||
);
|
||||
const [isRequestPending, setIsRequestPending] = useState<{
|
||||
[RESULT_TYPES.results]?: boolean;
|
||||
[RESULT_TYPES.samples]?: boolean;
|
||||
}>(NULLISH_RESULTS_STATE);
|
||||
const [isRequestPending, setIsRequestPending] = useState(
|
||||
getDefaultDataTablesState(false),
|
||||
);
|
||||
const [panelOpen, setPanelOpen] = useState(
|
||||
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],
|
||||
[data, timeFormattedColumns],
|
||||
);
|
||||
|
||||
const getData = useCallback(
|
||||
(resultType: string) => {
|
||||
(resultType: 'samples' | 'results') => {
|
||||
setIsLoading(prevIsLoading => ({
|
||||
...prevIsLoading,
|
||||
[resultType]: true,
|
||||
|
|
@ -247,12 +260,16 @@ export const DataTablesPane = ({
|
|||
[resultType]: json.result[0].data,
|
||||
}));
|
||||
}
|
||||
const checkCols = json?.result[0]?.data?.length
|
||||
? Object.keys(json.result[0].data[0])
|
||||
: null;
|
||||
|
||||
const colNames = ensureIsArray(json.result[0].colnames);
|
||||
|
||||
setColumnNames(prevColumnNames => ({
|
||||
...prevColumnNames,
|
||||
[resultType]: json.result[0].columns || checkCols,
|
||||
[resultType]: colNames,
|
||||
}));
|
||||
setColumnTypes(prevColumnTypes => ({
|
||||
...prevColumnTypes,
|
||||
[resultType]: json.result[0].coltypes || [],
|
||||
}));
|
||||
setIsLoading(prevIsLoading => ({
|
||||
...prevIsLoading,
|
||||
|
|
@ -260,14 +277,14 @@ export const DataTablesPane = ({
|
|||
}));
|
||||
setError(prevError => ({
|
||||
...prevError,
|
||||
[resultType]: null,
|
||||
[resultType]: undefined,
|
||||
}));
|
||||
})
|
||||
.catch(response => {
|
||||
getClientErrorObject(response).then(({ error, message }) => {
|
||||
setError(prevError => ({
|
||||
...prevError,
|
||||
[resultType]: error || message || t('Sorry, An error occurred'),
|
||||
[resultType]: error || message || t('Sorry, an error occurred'),
|
||||
}));
|
||||
setIsLoading(prevIsLoading => ({
|
||||
...prevIsLoading,
|
||||
|
|
@ -295,14 +312,14 @@ export const DataTablesPane = ({
|
|||
...prevState,
|
||||
[RESULT_TYPES.samples]: true,
|
||||
}));
|
||||
}, [queryFormData?.adhoc_filters, queryFormData?.datasource]);
|
||||
}, [queryFormData?.datasource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queriesResponse && chartStatus === 'success') {
|
||||
const { colnames } = queriesResponse[0];
|
||||
setColumnNames(prevColumnNames => ({
|
||||
...prevColumnNames,
|
||||
[RESULT_TYPES.results]: colnames ? [...colnames] : [],
|
||||
[RESULT_TYPES.results]: colnames ?? [],
|
||||
}));
|
||||
}
|
||||
}, [queriesResponse, chartStatus]);
|
||||
|
|
@ -396,7 +413,10 @@ export const DataTablesPane = ({
|
|||
<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}
|
||||
|
|
@ -409,7 +429,10 @@ export const DataTablesPane = ({
|
|||
<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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* 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 { useSelector } from 'react-redux';
|
||||
import { ExplorePageState } from '../reducers/getInitialState';
|
||||
|
||||
export const useTimeFormattedColumns = (datasourceId?: string) =>
|
||||
useSelector<ExplorePageState, string[]>(state =>
|
||||
datasourceId
|
||||
? state.explore.timeFormattedColumns?.[datasourceId] ?? []
|
||||
: [],
|
||||
);
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
getControlValuesCompatibleWithDatasource,
|
||||
} from 'src/explore/controlUtils';
|
||||
import * as actions from 'src/explore/actions/exploreActions';
|
||||
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
||||
|
||||
export default function exploreReducer(state = {}, action) {
|
||||
const actionHandlers = {
|
||||
|
|
@ -236,7 +237,44 @@ export default function exploreReducer(state = {}, action) {
|
|||
sliceName: action.slice.slice_name ?? state.sliceName,
|
||||
};
|
||||
},
|
||||
[actions.SET_TIME_FORMATTED_COLUMN]() {
|
||||
const { datasourceId, columnName } = action;
|
||||
const newTimeFormattedColumns = { ...state.timeFormattedColumns };
|
||||
const newTimeFormattedColumnsForDatasource = ensureIsArray(
|
||||
newTimeFormattedColumns[datasourceId],
|
||||
).slice();
|
||||
|
||||
newTimeFormattedColumnsForDatasource.push(columnName);
|
||||
newTimeFormattedColumns[datasourceId] =
|
||||
newTimeFormattedColumnsForDatasource;
|
||||
setItem(
|
||||
LocalStorageKeys.explore__data_table_time_formatted_columns,
|
||||
newTimeFormattedColumns,
|
||||
);
|
||||
return { ...state, timeFormattedColumns: newTimeFormattedColumns };
|
||||
},
|
||||
[actions.UNSET_TIME_FORMATTED_COLUMN]() {
|
||||
const { datasourceId, columnIndex } = action;
|
||||
const newTimeFormattedColumns = { ...state.timeFormattedColumns };
|
||||
const newTimeFormattedColumnsForDatasource = ensureIsArray(
|
||||
newTimeFormattedColumns[datasourceId],
|
||||
).slice();
|
||||
|
||||
newTimeFormattedColumnsForDatasource.splice(columnIndex, 1);
|
||||
newTimeFormattedColumns[datasourceId] =
|
||||
newTimeFormattedColumnsForDatasource;
|
||||
|
||||
if (newTimeFormattedColumnsForDatasource.length === 0) {
|
||||
delete newTimeFormattedColumns[datasourceId];
|
||||
}
|
||||
setItem(
|
||||
LocalStorageKeys.explore__data_table_time_formatted_columns,
|
||||
newTimeFormattedColumns,
|
||||
);
|
||||
return { ...state, timeFormattedColumns: newTimeFormattedColumns };
|
||||
},
|
||||
};
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
return actionHandlers[action.type]();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
getFormDataFromControls,
|
||||
applyMapStateToPropsToControl,
|
||||
} from 'src/explore/controlUtils';
|
||||
import { getItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
|
||||
export interface ExplorePageBootstrapData extends JsonObject {
|
||||
can_add: boolean;
|
||||
|
|
@ -77,6 +78,10 @@ export default function getInitialState(
|
|||
initialFormData,
|
||||
) as ControlStateMapping,
|
||||
controlsTransferred: [],
|
||||
timeFormattedColumns: getItem(
|
||||
LocalStorageKeys.explore__data_table_time_formatted_columns,
|
||||
{},
|
||||
),
|
||||
};
|
||||
|
||||
// apply initial mapStateToProps for all controls, must execute AFTER
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
SupersetClient,
|
||||
getTimeFormatter,
|
||||
TimeFormats,
|
||||
ensureIsArray,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
// ATTENTION: If you change any constants, make sure to also change constants.py
|
||||
|
|
@ -107,18 +108,24 @@ export function prepareCopyToClipboardTabularData(data, columns) {
|
|||
return result;
|
||||
}
|
||||
|
||||
export function applyFormattingToTabularData(data) {
|
||||
if (!data || data.length === 0 || !('__timestamp' in data[0])) {
|
||||
export function applyFormattingToTabularData(data, timeFormattedColumns) {
|
||||
if (
|
||||
!data ||
|
||||
data.length === 0 ||
|
||||
ensureIsArray(timeFormattedColumns).length === 0
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.map(row => ({
|
||||
...row,
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
__timestamp:
|
||||
row.__timestamp === 0 || row.__timestamp
|
||||
? DATETIME_FORMATTER(new Date(row.__timestamp))
|
||||
: row.__timestamp,
|
||||
/* eslint-enable no-underscore-dangle */
|
||||
...timeFormattedColumns.reduce((acc, colName) => {
|
||||
if (row[colName] !== null && row[colName] !== undefined) {
|
||||
acc[colName] = DATETIME_FORMATTER(row[colName]);
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,29 +63,72 @@ describe('utils/common', () => {
|
|||
describe('applyFormattingToTabularData', () => {
|
||||
it('does not mutate empty array', () => {
|
||||
const data = [];
|
||||
expect(applyFormattingToTabularData(data)).toEqual(data);
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
it('does not mutate array without temporal column', () => {
|
||||
const data = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
expect(applyFormattingToTabularData(data)).toEqual(data);
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
it('changes formatting of temporal column', () => {
|
||||
it('changes formatting of columns selected for formatting', () => {
|
||||
const originalData = [
|
||||
{ __timestamp: null, column1: 'lorem' },
|
||||
{ __timestamp: 0, column1: 'ipsum' },
|
||||
{ __timestamp: 1594285437771, column1: 'dolor' },
|
||||
{ __timestamp: 1594285441675, column1: 'sit' },
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: 1507680000000,
|
||||
},
|
||||
{
|
||||
__timestamp: 0,
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: 1513641600000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285437771,
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: 1516924800000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285441675,
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: 1518566400000,
|
||||
},
|
||||
];
|
||||
const timeFormattedColumns = ['__timestamp', 'column3'];
|
||||
const expectedData = [
|
||||
{ __timestamp: null, column1: 'lorem' },
|
||||
{ __timestamp: '1970-01-01 00:00:00', column1: 'ipsum' },
|
||||
{ __timestamp: '2020-07-09 09:03:57', column1: 'dolor' },
|
||||
{ __timestamp: '2020-07-09 09:04:01', column1: 'sit' },
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: '2017-10-11 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '1970-01-01 00:00:00',
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: '2017-12-19 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:03:57',
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: '2018-01-26 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:04:01',
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: '2018-02-14 00:00:00',
|
||||
},
|
||||
];
|
||||
expect(applyFormattingToTabularData(originalData)).toEqual(expectedData);
|
||||
expect(
|
||||
applyFormattingToTabularData(originalData, timeFormattedColumns),
|
||||
).toEqual(expectedData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export enum LocalStorageKeys {
|
|||
* sqllab__is_autocomplete_enabled
|
||||
*/
|
||||
sqllab__is_autocomplete_enabled = 'sqllab__is_autocomplete_enabled',
|
||||
explore__data_table_time_formatted_columns = 'explore__data_table_time_formatted_columns',
|
||||
}
|
||||
|
||||
export type LocalStorageValues = {
|
||||
|
|
@ -62,6 +63,7 @@ export type LocalStorageValues = {
|
|||
homepage_collapse_state: string[];
|
||||
homepage_activity_filter: SetTabType | null;
|
||||
sqllab__is_autocomplete_enabled: boolean;
|
||||
explore__data_table_time_formatted_columns: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export function getItem<K extends LocalStorageKeys>(
|
||||
|
|
|
|||
|
|
@ -129,7 +129,11 @@ def _get_full(
|
|||
] + rejected_time_columns
|
||||
|
||||
if result_type == ChartDataResultType.RESULTS and status != QueryStatus.FAILED:
|
||||
return {"data": payload.get("data")}
|
||||
return {
|
||||
"data": payload.get("data"),
|
||||
"colnames": payload.get("colnames"),
|
||||
"coltypes": payload.get("coltypes"),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -152,7 +156,7 @@ def _get_results(
|
|||
query_context: "QueryContext", query_obj: "QueryObject", force_cached: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
payload = _get_full(query_context, query_obj, force_cached)
|
||||
return {"data": payload.get("data"), "error": payload.get("error")}
|
||||
return payload
|
||||
|
||||
|
||||
_result_type_functions: Dict[
|
||||
|
|
|
|||
|
|
@ -449,10 +449,16 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
|
|||
payload = viz_obj.get_df_payload()
|
||||
if viz_obj.has_error(payload):
|
||||
return json_error_response(payload=payload, status=400)
|
||||
return self.json_response({"data": payload["df"].to_dict("records")})
|
||||
return self.json_response(
|
||||
{
|
||||
"data": payload["df"].to_dict("records"),
|
||||
"colnames": payload.get("colnames"),
|
||||
"coltypes": payload.get("coltypes"),
|
||||
},
|
||||
)
|
||||
|
||||
def get_samples(self, viz_obj: BaseViz) -> FlaskResponse:
|
||||
return self.json_response({"data": viz_obj.get_samples()})
|
||||
return self.json_response(viz_obj.get_samples())
|
||||
|
||||
@staticmethod
|
||||
def send_data_payload_response(viz_obj: BaseViz, payload: Any) -> FlaskResponse:
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ class BaseViz: # pylint: disable=too-many-public-methods
|
|||
)
|
||||
return df
|
||||
|
||||
def get_samples(self) -> List[Dict[str, Any]]:
|
||||
def get_samples(self) -> Dict[str, Any]:
|
||||
query_obj = self.query_obj()
|
||||
query_obj.update(
|
||||
{
|
||||
|
|
@ -258,8 +258,12 @@ class BaseViz: # pylint: disable=too-many-public-methods
|
|||
"to_dttm": None,
|
||||
}
|
||||
)
|
||||
df = self.get_df_payload(query_obj)["df"] # leverage caching logic
|
||||
return df.to_dict(orient="records")
|
||||
payload = self.get_df_payload(query_obj) # leverage caching logic
|
||||
return {
|
||||
"data": payload["df"].to_dict(orient="records"),
|
||||
"colnames": payload.get("colnames"),
|
||||
"coltypes": payload.get("coltypes"),
|
||||
}
|
||||
|
||||
def get_df(self, query_obj: Optional[QueryObjectDict] = None) -> pd.DataFrame:
|
||||
"""Returns a pandas dataframe based on the query object"""
|
||||
|
|
@ -621,6 +625,10 @@ class BaseViz: # pylint: disable=too-many-public-methods
|
|||
"status": self.status,
|
||||
"stacktrace": stacktrace,
|
||||
"rowcount": len(df.index) if df is not None else 0,
|
||||
"colnames": list(df.columns) if df is not None else None,
|
||||
"coltypes": utils.extract_dataframe_dtypes(df, self.datasource)
|
||||
if df is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
Loading…
Reference in New Issue