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