Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(explore): Allow using time formatter on temporal columns in data table #18569

Merged
merged 11 commits into from
Feb 9, 2022
6 changes: 5 additions & 1 deletion superset-frontend/spec/helpers/testing-library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import '@testing-library/jest-dom/extend-expect';
import React, { ReactNode, ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { ThemeProvider, supersetTheme } from '@superset-ui/core';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
Expand Down Expand Up @@ -82,11 +83,14 @@ function createWrapper(options?: Options) {
const customRender = (ui: ReactElement, options?: Options) =>
render(ui, { wrapper: createWrapper(options), ...options });

const customRenderHook = (callback: (props: any) => any, options?: Options) =>
renderHook(callback, { wrapper: createWrapper(options), ...options });

export function sleep(time: number) {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}

export * from '@testing-library/react';
export { customRender as render };
export { customRender as render, customRenderHook as renderHook };
26 changes: 26 additions & 0 deletions superset-frontend/src/explore/actions/exploreActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -155,6 +179,8 @@ export const exploreActions = {
updateChartTitle,
createNewSlice,
sliceUpdated,
setTimeFormattedColumn,
unsetTimeFormattedColumn,
};

export type ExploreActions = typeof exploreActions;
212 changes: 188 additions & 24 deletions superset-frontend/src/explore/components/DataTableControl/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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, useSelector } 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 { ExplorePageState } from 'src/explore/reducers/getInitialState';
import {
setTimeFormattedColumn,
unsetTimeFormattedColumn,
} from 'src/explore/actions/exploreActions';

export const CopyButton = styled(Button)`
font-size: ${({ theme }) => theme.typography.sizes.s}px;
Expand Down Expand Up @@ -97,6 +114,126 @@ export const RowCount = ({
/>
);

enum FormatPickerValue {
formatted,
epoch,
kgabryje marked this conversation as resolved.
Show resolved Hide resolved
}

const FormatPicker = ({
onChange,
value,
}: {
onChange: any;
value: FormatPickerValue;
}) => (
<Radio.Group value={value} onChange={onChange}>
<Space direction="vertical">
<Radio value={FormatPickerValue.epoch}>{t('Epoch')}</Radio>
<Radio value={FormatPickerValue.formatted}>{t('Formatted date')}</Radio>
</Space>
</Radio.Group>
);
villebro marked this conversation as resolved.
Show resolved Hide resolved

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.epoch && 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.epoch
}
/>
</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>[],
Expand All @@ -121,34 +258,61 @@ export const useFilteredTableData = (
}, [data, filterText, rowsAsStrings]);
};

const timeFormatter = getTimeFormatter('%Y-%m-%d %H:%M:%S');

export const useTableColumns = (
colnames?: string[],
coltypes?: GenericDataType[],
data?: Record<string, any>[],
datasourceId?: string,
moreConfigs?: { [key: string]: Partial<Column> },
) =>
useMemo(
) => {
const timeFormattedColumns = useSelector<ExplorePageState, string[]>(state =>
datasourceId
? state.explore.timeFormattedColumns?.[datasourceId] ?? []
: [],
);

return useMemo(
() =>
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],
);
};
Loading