diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts
index 2528bc06027b..395df7aa13c0 100644
--- a/superset-frontend/src/explore/actions/exploreActions.ts
+++ b/superset-frontend/src/explore/actions/exploreActions.ts
@@ -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;
diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx
index a1383cf4a9e1..68e83bc1a647 100644
--- a/superset-frontend/src/explore/components/DataTableControl/index.tsx
+++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx
@@ -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;
+}) => (
+
+
+ {t('Original value')}
+ {t('Formatted date')}
+
+
+);
+
+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
+ e.stopPropagation()}>
+ {/* hack to disable click propagation from popover content to table header, which triggers sorting column */}
+
+ {t('Column Formatting')}
+
+
+ ) : null,
+ [datasourceId, isColumnTimeFormatted, onChange],
+ );
+
+ return datasourceId ? (
+
+
+ e.stopPropagation()}
+ />
+
+ {columnName}
+
+ ) : (
+ {columnName}
+ );
+};
+
export const useFilteredTableData = (
filterText: string,
data?: Record[],
@@ -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[],
+ datasourceId?: string,
+ timeFormattedColumns: string[] = [],
moreConfigs?: { [key: string]: Partial },
) =>
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 ? (
+
+ ) : (
+ 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],
);
diff --git a/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts b/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts
index 537f12bc0cb4..bfc4b6d96468 100644
--- a/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts
+++ b/superset-frontend/src/explore/components/DataTableControl/useTableColumns.test.ts
@@ -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) => {
diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx
index 42f996ae0a0b..380285b81156 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.test.tsx
@@ -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'));
diff --git a/superset-frontend/src/explore/components/DataTablesPane/index.tsx b/superset-frontend/src/explore/components/DataTablesPane/index.tsx
index be0409ffeb92..d6cfcc257e24 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/index.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/index.tsx
@@ -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;
}) => {
- const [data, setData] = useState<{
- [RESULT_TYPES.results]?: Record[];
- [RESULT_TYPES.samples]?: Record[];
- }>(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(
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 = ({
+ useSelector(state =>
+ datasourceId
+ ? state.explore.timeFormattedColumns?.[datasourceId] ?? []
+ : [],
+ );
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js b/superset-frontend/src/explore/reducers/exploreReducer.js
index a68530d870d5..e2ec3a74dcdc 100644
--- a/superset-frontend/src/explore/reducers/exploreReducer.js
+++ b/superset-frontend/src/explore/reducers/exploreReducer.js
@@ -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]();
}
diff --git a/superset-frontend/src/explore/reducers/getInitialState.ts b/superset-frontend/src/explore/reducers/getInitialState.ts
index 45440f6f5b4b..e82586c5082a 100644
--- a/superset-frontend/src/explore/reducers/getInitialState.ts
+++ b/superset-frontend/src/explore/reducers/getInitialState.ts
@@ -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
diff --git a/superset-frontend/src/utils/common.js b/superset-frontend/src/utils/common.js
index 3e077075406e..26fdfb4bd6e4 100644
--- a/superset-frontend/src/utils/common.js
+++ b/superset-frontend/src/utils/common.js
@@ -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;
+ }, {}),
}));
}
diff --git a/superset-frontend/src/utils/common.test.jsx b/superset-frontend/src/utils/common.test.jsx
index 56b9500d5158..6c73b1011cd9 100644
--- a/superset-frontend/src/utils/common.test.jsx
+++ b/superset-frontend/src/utils/common.test.jsx
@@ -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);
});
});
});
diff --git a/superset-frontend/src/utils/localStorageHelpers.ts b/superset-frontend/src/utils/localStorageHelpers.ts
index b530f27b910c..e3a37933418d 100644
--- a/superset-frontend/src/utils/localStorageHelpers.ts
+++ b/superset-frontend/src/utils/localStorageHelpers.ts
@@ -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;
};
export function getItem(
diff --git a/superset/common/query_actions.py b/superset/common/query_actions.py
index 6ed18d195820..a09cc39f1d6c 100644
--- a/superset/common/query_actions.py
+++ b/superset/common/query_actions.py
@@ -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[
diff --git a/superset/views/core.py b/superset/views/core.py
index f2368e344158..fa5cd0cb5f86 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -449,10 +449,16 @@ def get_raw_results(self, viz_obj: BaseViz) -> FlaskResponse:
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:
diff --git a/superset/viz.py b/superset/viz.py
index c1b506e25675..26c77c115a40 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -244,7 +244,7 @@ def apply_rolling(self, df: pd.DataFrame) -> pd.DataFrame:
)
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 @@ def get_samples(self) -> List[Dict[str, Any]]:
"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 @@ def get_df_payload( # pylint: disable=too-many-statements
"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