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:
Kamil Gabryjelski 2022-02-09 10:29:11 +01:00 committed by GitHub
parent 28e729b835
commit 830f2e71d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 475 additions and 88 deletions

View File

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

View File

@ -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],
);

View File

@ -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) => {

View File

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

View File

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

View File

@ -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] ?? []
: [],
);

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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>(

View File

@ -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[

View File

@ -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:

View File

@ -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