console: add support for specific data types (datetime / time) for gdc

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8393
GitOrigin-RevId: c1eeb1fb6a5cb825853ec29d7ec1f8b0b702ad07
This commit is contained in:
Luca Restagno 2023-03-21 12:16:57 +01:00 committed by hasura-bot
parent 0a1628c0cc
commit 21f9918677
18 changed files with 413 additions and 10 deletions

View File

@ -133,7 +133,7 @@ export const ManageTable: React.VFC<ManageTableProps> = (
if (isLoading) return <IndicatorCard status="info">Loading...</IndicatorCard>;
return (
<div className="w-full overflow-y-auto bg-gray-50">
<div className="w-full bg-gray-50">
<div className="px-md pt-md mb-xs">
<Breadcrumbs dataSourceName={dataSourceName} tableName={tableName} />
<TableName

View File

@ -41,6 +41,7 @@ export function adaptSQLDataType(
],
number: ['BIGNUMERIC', 'FLOAT64', 'INT64', 'INTERVAL', 'NUMERIC'],
json: ['JSON', 'xml'],
float: ['FLOAT64'],
};
const [dataType] = getEntries(DataTypeToSQLTypeMap).find(([, value]) =>

View File

@ -66,6 +66,7 @@ export function adaptSQLDataType(
'serial4',
],
json: ['interval', 'json', 'jsonb', 'xml'],
float: ['float8', 'float4'],
};
const [dataType] = getEntries(consoleDataTypeToSQLTypeMap).find(([, value]) =>

View File

@ -22,6 +22,7 @@ export function adaptSQLDataType(
],
text: ['text'],
json: [],
float: ['float'],
};
const [dataType] = getEntries(DataTypeToSQLTypeMap).find(([, value]) =>

View File

@ -74,6 +74,7 @@ export function adaptSQLDataType(
'serial4',
],
json: ['interval', 'json', 'jsonb', 'xml'],
float: ['float8'],
};
const [dataType] = getEntries(consoleDataTypeToSQLTypeMap).find(([, value]) =>

View File

@ -66,7 +66,7 @@ export type TableColumn = {
/**
* console data type: the dataType property is group into one of these types and console uses this internally
*/
consoleDataType: 'string' | 'text' | 'json' | 'number' | 'boolean';
consoleDataType: 'string' | 'text' | 'json' | 'number' | 'boolean' | 'float';
nullable?: boolean;
isPrimaryKey?: boolean;
graphQLProperties?: {

View File

@ -27,6 +27,9 @@ const columns: InsertRowFormProps['columns'] = [
},
isPrimaryKey: true,
placeholder: 'bigint',
insertable: true,
description: '',
nullable: true,
},
{
name: 'name',
@ -36,6 +39,9 @@ const columns: InsertRowFormProps['columns'] = [
comment: '',
},
placeholder: 'text',
insertable: true,
description: '',
nullable: true,
},
{
name: 'json',
@ -46,6 +52,8 @@ const columns: InsertRowFormProps['columns'] = [
},
nullable: true,
placeholder: '{"name":"john"}',
insertable: true,
description: '',
},
{
name: 'date',
@ -55,7 +63,33 @@ const columns: InsertRowFormProps['columns'] = [
comment: '',
},
nullable: true,
placeholder: '2023-02-01',
insertable: true,
description: '',
placeholder: '2023-01-01',
},
{
name: 'datetime',
dataType: 'datetime',
consoleDataType: 'text',
config: {
comment: '',
},
nullable: true,
insertable: true,
description: '',
placeholder: '2020-01-14T16:30:00+01:00',
},
{
name: 'time',
dataType: 'time with time zone',
consoleDataType: 'text',
config: {
comment: '',
},
nullable: true,
insertable: true,
description: '',
placeholder: '11:00:00+01:00',
},
];

View File

@ -10,6 +10,7 @@ describe('convertTableValue', () => {
${'false'} | ${'boolean'} | ${false}
${'true'} | ${'bool'} | ${true}
${'false'} | ${'bool'} | ${false}
${'123'} | ${'float'} | ${123}
`(
'given $dataType it returns $expected',
({ columnValue, dataType, expected }) => {

View File

@ -1,11 +1,13 @@
import { TableColumn } from '../DataSource';
const numericDataTypes = ['number', 'float', 'integer'];
export const convertTableValue = (
value: unknown,
dataType: TableColumn['dataType'] | undefined
): string | number | boolean | unknown => {
if (typeof value === 'string') {
if (dataType === 'number') {
if (dataType && numericDataTypes.includes(dataType)) {
return parseFloat(value);
}

View File

@ -1,12 +1,14 @@
import { dataSource } from '../../../dataSources';
import { BooleanInput } from './BooleanInput';
import { DateInput } from './DateInput';
import { DateTimeInput } from './DateTimeInput';
import {
ExpandableTextInput,
ExpandableTextInputProps,
} from './ExpandableTextInput';
import { JsonInput } from './JsonInput';
import { TextInput } from './TextInput';
import { TimeInput } from './TimeInput';
type ColumnRowInputProps = ExpandableTextInputProps & {
dataType: string;
@ -28,6 +30,14 @@ export const ColumnRowInput: React.VFC<ColumnRowInputProps> = ({
return <DateInput {...props} />;
}
if (dataType === dataSource.columnDataTypes.DATETIME) {
return <DateTimeInput {...props} />;
}
if (dataType === dataSource.columnDataTypes.TIME) {
return <TimeInput {...props} />;
}
if (
dataType === dataSource.columnDataTypes.JSONDTYPE ||
dataType === dataSource.columnDataTypes.JSONB

View File

@ -3,12 +3,12 @@ import { Input } from '../../../new-components/Form/Input';
import { ChangeEventHandler, useState } from 'react';
import { FaCalendar } from 'react-icons/fa';
import DatePicker, { CalendarContainer } from 'react-datepicker';
import { format } from 'date-fns';
import clsx from 'clsx';
import { CustomEventHandler, TextInputProps } from './TextInput';
import 'react-datepicker/dist/react-datepicker.css';
import './date-input.css';
import { format } from 'date-fns';
import clsx from 'clsx';
export const DateInput: React.VFC<TextInputProps> = ({
name,
@ -88,6 +88,7 @@ export const DateInput: React.VFC<TextInputProps> = ({
<DatePicker
inline
onSelect={() => setCalendarPickerVisible(false)}
onClickOutside={() => setCalendarPickerVisible(false)}
onChange={onDateChange}
calendarContainer={CustomPickerContainer}
/>

View File

@ -0,0 +1,75 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { handlers } from '../../../mocks/metadata.mock';
import { userEvent, within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { DateTimeInput } from './DateTimeInput';
import { useRef } from 'react';
import { format } from 'date-fns';
export default {
title: 'Data/Insert Row/components/DateTimeInput',
component: DateTimeInput,
parameters: {
msw: handlers(),
mockdate: new Date('2020-01-14T15:47:18.502Z'),
},
argTypes: {
onChange: { action: true },
onInput: { action: true },
},
} as ComponentMeta<typeof DateTimeInput>;
const Template: ComponentStory<typeof DateTimeInput> = args => {
const inputRef = useRef<HTMLInputElement | null>(null);
return <DateTimeInput {...args} inputRef={inputRef} />;
};
export const Base = Template.bind({});
Base.args = {
name: 'date',
placeholder: 'date...',
};
export const Disabled = Template.bind({});
Disabled.args = {
...Base.args,
disabled: true,
};
Base.play = async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
userEvent.type(
await canvas.findByPlaceholderText('date...'),
'2020-01-14T12:00:00.000Z'
);
expect(args.onChange).toHaveBeenCalled();
expect(args.onInput).toHaveBeenCalled();
const baseDate = new Date();
expect(
await canvas.findByDisplayValue('2020-01-14T12:00:00.000Z')
).toBeInTheDocument();
userEvent.click(await canvas.findByRole('button'));
const baseDateLabel = `Choose ${format(baseDate, 'EEEE, LLLL do, u')}`;
userEvent.click((await canvas.findAllByLabelText(baseDateLabel))[0]);
userEvent.click(await canvas.findByText('4:30 PM'));
expect(await canvas.findByLabelText('date')).toHaveDisplayValue(
'2020-01-14T16:30:00.502Z'
);
expect(args.onChange).toHaveBeenCalled();
expect(args.onInput).toHaveBeenCalled();
// click outside the calendar picker
userEvent.click(await canvas.findByLabelText('date'));
expect(await canvas.queryByText('January 2020')).not.toBeInTheDocument();
expect(await canvas.queryByText('Time')).not.toBeInTheDocument();
};

View File

@ -0,0 +1,98 @@
import { Button } from '../../../new-components/Button';
import { Input } from '../../../new-components/Form/Input';
import { ChangeEventHandler, useState } from 'react';
import { FaCalendar } from 'react-icons/fa';
import DatePicker, { CalendarContainer } from 'react-datepicker';
import clsx from 'clsx';
import { sub } from 'date-fns';
import { CustomEventHandler, TextInputProps } from './TextInput';
import 'react-datepicker/dist/react-datepicker.css';
import './date-input.css';
const CustomPickerContainer: React.FC<{ className: string }> = ({
className,
children,
}) => (
<div className="absolute top-10 left-0 z-50 min-w-[300px]">
<CalendarContainer className={className}>
<div style={{ position: 'relative' }}>{children}</div>
</CalendarContainer>
</div>
);
export const DateTimeInput: React.VFC<TextInputProps> = ({
name,
disabled,
placeholder,
inputRef,
onChange,
onInput,
onBlur,
}) => {
const [isCalendarPickerVisible, setCalendarPickerVisible] = useState(false);
const onDateTimeChange = (date: Date) => {
const timeZoneOffsetMinutes = date.getTimezoneOffset();
const utcDate = sub(date, { minutes: timeZoneOffsetMinutes });
const dateString = utcDate.toISOString();
if (inputRef && 'current' in inputRef && inputRef.current) {
inputRef.current.value = dateString;
}
if (onChange) {
const changeCb = onChange as CustomEventHandler;
changeCb({ target: { value: dateString } });
}
if (onInput) {
const inputCb = onInput as CustomEventHandler;
inputCb({ target: { value: dateString } });
}
};
return (
<div className="w-full relative">
<Input
className="w-full"
name={name}
label={name}
type="text"
placeholder={placeholder}
onChange={onChange as ChangeEventHandler<HTMLInputElement>}
onInput={onInput as ChangeEventHandler<HTMLInputElement>}
inputProps={{
onBlur: onBlur,
ref: inputRef,
}}
rightButton={
<Button
icon={
<FaCalendar
className={clsx(
isCalendarPickerVisible && 'fill-yellow-600 stroke-yellow-600'
)}
/>
}
disabled={disabled}
onClick={() => {
if (disabled) {
return;
}
setCalendarPickerVisible(!isCalendarPickerVisible);
}}
/>
}
disabled={disabled}
/>
{isCalendarPickerVisible && (
<DatePicker
inline
showTimeSelect
onClickOutside={() => setCalendarPickerVisible(false)}
onChange={onDateTimeChange}
calendarContainer={CustomPickerContainer}
/>
)}
</div>
);
};

View File

@ -21,7 +21,7 @@ export type TextInputProps = Omit<
};
export const baseInputTw =
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400 placeholder:text-slate-300 placeholder:italic';
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400 placeholder:text-slate-400';
export const TextInput: React.VFC<TextInputProps> = ({
name,

View File

@ -0,0 +1,59 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { handlers } from '../../../mocks/metadata.mock';
import { userEvent, within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { TimeInput } from './TimeInput';
import { useRef } from 'react';
export default {
title: 'Data/Insert Row/components/TimeInput',
component: TimeInput,
parameters: {
msw: handlers(),
mockdate: new Date('2020-01-14T15:47:18.502Z'),
},
argTypes: {
onChange: { action: true },
onInput: { action: true },
},
} as ComponentMeta<typeof TimeInput>;
const Template: ComponentStory<typeof TimeInput> = args => {
const inputRef = useRef<HTMLInputElement | null>(null);
return <TimeInput {...args} inputRef={inputRef} />;
};
export const Base = Template.bind({});
Base.args = {
name: 'date',
placeholder: 'date...',
};
export const Disabled = Template.bind({});
Disabled.args = {
...Base.args,
disabled: true,
};
Base.play = async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
userEvent.type(await canvas.findByPlaceholderText('date...'), '15:30:00');
expect(args.onChange).toHaveBeenCalled();
expect(args.onInput).toHaveBeenCalled();
expect(await canvas.findByDisplayValue('15:30:00')).toBeInTheDocument();
userEvent.click(await canvas.findByRole('button'));
userEvent.click(await canvas.findByText('4:30 PM'));
expect(await canvas.findByLabelText('date')).toHaveDisplayValue('16:30:00');
expect(args.onChange).toHaveBeenCalled();
expect(args.onInput).toHaveBeenCalled();
// the picker is automatically hidden on selection
expect(await canvas.queryByText('Time')).not.toBeInTheDocument();
};

View File

@ -0,0 +1,106 @@
import { Button } from '../../../new-components/Button';
import { Input } from '../../../new-components/Form/Input';
import { ChangeEventHandler, useState } from 'react';
import { FaClock } from 'react-icons/fa';
import DatePicker, { CalendarContainer } from 'react-datepicker';
import { sub } from 'date-fns';
import clsx from 'clsx';
import { CustomEventHandler, TextInputProps } from './TextInput';
import 'react-datepicker/dist/react-datepicker.css';
import './date-input.css';
const CustomPickerContainer: React.FC<{ className: string }> = ({
className,
children,
}) => (
<div className="absolute top-10 left-0 z-50">
<CalendarContainer className={className}>
<div style={{ position: 'relative' }}>{children}</div>
</CalendarContainer>
</div>
);
const getISOTimePart = (date: Date) => date.toISOString().slice(11, 19);
export const TimeInput: React.VFC<TextInputProps> = ({
name,
disabled,
placeholder,
inputRef,
onChange,
onInput,
onBlur,
}) => {
const [isCalendarPickerVisible, setCalendarPickerVisible] = useState(false);
const onTimeChange = (date: Date) => {
const timeZoneOffsetMinutes = date.getTimezoneOffset();
const utcDate = sub(date, { minutes: timeZoneOffsetMinutes });
const timeString = getISOTimePart(utcDate);
if (inputRef && 'current' in inputRef && inputRef.current) {
inputRef.current.value = timeString;
}
if (onChange) {
const changeCb = onChange as CustomEventHandler;
changeCb({ target: { value: timeString } });
}
if (onInput) {
const inputCb = onInput as CustomEventHandler;
inputCb({ target: { value: timeString } });
}
};
return (
<div className="w-full relative">
<Input
className="w-full"
name={name}
label={name}
type="text"
placeholder={placeholder}
onChange={onChange as ChangeEventHandler<HTMLInputElement>}
onInput={onInput as ChangeEventHandler<HTMLInputElement>}
inputProps={{
onBlur: onBlur,
ref: inputRef,
}}
rightButton={
<Button
icon={
<FaClock
className={clsx(
isCalendarPickerVisible && 'fill-yellow-600 stroke-yellow-600'
)}
/>
}
disabled={disabled}
onClick={() => {
if (disabled) {
return;
}
setCalendarPickerVisible(!isCalendarPickerVisible);
}}
/>
}
disabled={disabled}
/>
{isCalendarPickerVisible && (
<DatePicker
inline
showTimeSelect
showTimeSelectOnly
onClickOutside={() => setCalendarPickerVisible(false)}
onChange={date => {
if (date) {
onTimeChange(date);
setCalendarPickerVisible(false);
}
}}
calendarContainer={CustomPickerContainer}
/>
)}
</div>
);
};

View File

@ -1,3 +1,7 @@
.react-datepicker {
overflow: hidden;
}
.react-datepicker-popper[data-placement^='bottom']
.react-datepicker__triangle::before,
.react-datepicker-popper[data-placement^='top']
@ -106,6 +110,15 @@
padding: 7px 10px;
}
.react-datepicker__time,
.react-datepicker__time ul {
height: 173px !important;
}
.react-datepicker__time-box {
height: 173px;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box

View File

@ -152,7 +152,7 @@ export const Input = ({
aria-label={label}
data-test={dataTest}
className={clsx(
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400 placeholder:text-muted',
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400 placeholder:text-slate-400',
prependLabel !== '' ? 'rounded-l-none' : '',
appendLabel !== '' ? 'rounded-r-none' : '',
maybeError