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>; if (isLoading) return <IndicatorCard status="info">Loading...</IndicatorCard>;
return ( 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"> <div className="px-md pt-md mb-xs">
<Breadcrumbs dataSourceName={dataSourceName} tableName={tableName} /> <Breadcrumbs dataSourceName={dataSourceName} tableName={tableName} />
<TableName <TableName

View File

@ -5,9 +5,9 @@ import { IntrospectedTable, TableColumn } from '../../types';
export const adaptIntrospectedBigQueryTables = ( export const adaptIntrospectedBigQueryTables = (
runSqlResponse: RunSQLResponse runSqlResponse: RunSQLResponse
): IntrospectedTable[] => { ): IntrospectedTable[] => {
/* /*
The `slice(1)` on the result is done because the first item of the result is always the columns names from the SQL output. The `slice(1)` on the result is done because the first item of the result is always the columns names from the SQL output.
It is not required for the final result and should be avoided It is not required for the final result and should be avoided
*/ */
const adaptedResponse = runSqlResponse.result const adaptedResponse = runSqlResponse.result
?.slice(1) ?.slice(1)
@ -41,6 +41,7 @@ export function adaptSQLDataType(
], ],
number: ['BIGNUMERIC', 'FLOAT64', 'INT64', 'INTERVAL', 'NUMERIC'], number: ['BIGNUMERIC', 'FLOAT64', 'INT64', 'INTERVAL', 'NUMERIC'],
json: ['JSON', 'xml'], json: ['JSON', 'xml'],
float: ['FLOAT64'],
}; };
const [dataType] = getEntries(DataTypeToSQLTypeMap).find(([, value]) => const [dataType] = getEntries(DataTypeToSQLTypeMap).find(([, value]) =>

View File

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

View File

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

View File

@ -74,6 +74,7 @@ export function adaptSQLDataType(
'serial4', 'serial4',
], ],
json: ['interval', 'json', 'jsonb', 'xml'], json: ['interval', 'json', 'jsonb', 'xml'],
float: ['float8'],
}; };
const [dataType] = getEntries(consoleDataTypeToSQLTypeMap).find(([, value]) => 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 * 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; nullable?: boolean;
isPrimaryKey?: boolean; isPrimaryKey?: boolean;
graphQLProperties?: { graphQLProperties?: {

View File

@ -27,6 +27,9 @@ const columns: InsertRowFormProps['columns'] = [
}, },
isPrimaryKey: true, isPrimaryKey: true,
placeholder: 'bigint', placeholder: 'bigint',
insertable: true,
description: '',
nullable: true,
}, },
{ {
name: 'name', name: 'name',
@ -36,6 +39,9 @@ const columns: InsertRowFormProps['columns'] = [
comment: '', comment: '',
}, },
placeholder: 'text', placeholder: 'text',
insertable: true,
description: '',
nullable: true,
}, },
{ {
name: 'json', name: 'json',
@ -46,6 +52,8 @@ const columns: InsertRowFormProps['columns'] = [
}, },
nullable: true, nullable: true,
placeholder: '{"name":"john"}', placeholder: '{"name":"john"}',
insertable: true,
description: '',
}, },
{ {
name: 'date', name: 'date',
@ -55,7 +63,33 @@ const columns: InsertRowFormProps['columns'] = [
comment: '', comment: '',
}, },
nullable: true, 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} ${'false'} | ${'boolean'} | ${false}
${'true'} | ${'bool'} | ${true} ${'true'} | ${'bool'} | ${true}
${'false'} | ${'bool'} | ${false} ${'false'} | ${'bool'} | ${false}
${'123'} | ${'float'} | ${123}
`( `(
'given $dataType it returns $expected', 'given $dataType it returns $expected',
({ columnValue, dataType, expected }) => { ({ columnValue, dataType, expected }) => {

View File

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

View File

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

View File

@ -3,12 +3,12 @@ import { Input } from '../../../new-components/Form/Input';
import { ChangeEventHandler, useState } from 'react'; import { ChangeEventHandler, useState } from 'react';
import { FaCalendar } from 'react-icons/fa'; import { FaCalendar } from 'react-icons/fa';
import DatePicker, { CalendarContainer } from 'react-datepicker'; import DatePicker, { CalendarContainer } from 'react-datepicker';
import { format } from 'date-fns';
import clsx from 'clsx';
import { CustomEventHandler, TextInputProps } from './TextInput'; import { CustomEventHandler, TextInputProps } from './TextInput';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import './date-input.css'; import './date-input.css';
import { format } from 'date-fns';
import clsx from 'clsx';
export const DateInput: React.VFC<TextInputProps> = ({ export const DateInput: React.VFC<TextInputProps> = ({
name, name,
@ -88,6 +88,7 @@ export const DateInput: React.VFC<TextInputProps> = ({
<DatePicker <DatePicker
inline inline
onSelect={() => setCalendarPickerVisible(false)} onSelect={() => setCalendarPickerVisible(false)}
onClickOutside={() => setCalendarPickerVisible(false)}
onChange={onDateChange} onChange={onDateChange}
calendarContainer={CustomPickerContainer} 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 = 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> = ({ export const TextInput: React.VFC<TextInputProps> = ({
name, 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-popper[data-placement^='bottom']
.react-datepicker__triangle::before, .react-datepicker__triangle::before,
.react-datepicker-popper[data-placement^='top'] .react-datepicker-popper[data-placement^='top']
@ -106,6 +110,15 @@
padding: 7px 10px; 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-container
.react-datepicker__time .react-datepicker__time
.react-datepicker__time-box .react-datepicker__time-box

View File

@ -152,7 +152,7 @@ export const Input = ({
aria-label={label} aria-label={label}
data-test={dataTest} data-test={dataTest}
className={clsx( 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' : '', prependLabel !== '' ? 'rounded-l-none' : '',
appendLabel !== '' ? 'rounded-r-none' : '', appendLabel !== '' ? 'rounded-r-none' : '',
maybeError maybeError