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
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;
210 changes: 186 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,128 @@ 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 DataTableHeaderCell = ({
columnName,
type,
datasourceId,
timeFormattedColumnIndex,
}: {
columnName: string;
type?: GenericDataType;
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 type === GenericDataType.TEMPORAL && 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 +260,57 @@ 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: (
<DataTableHeaderCell
columnName={key}
type={coltypes?.[index]}
datasourceId={datasourceId}
timeFormattedColumnIndex={timeFormattedColumnIndex}
/>
),
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],
);
};