console: native queries gardening

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9235
GitOrigin-RevId: 452d306613e5f57bdf209d55116162d9c2b6bdf0
This commit is contained in:
Matthew Goodwin 2023-05-23 12:58:14 -05:00 committed by hasura-bot
parent ec63ea6ed0
commit 87da15a596
23 changed files with 382 additions and 277 deletions

View File

@ -38,7 +38,7 @@ import { TableInsertItemContainer } from './TableInsertItem/TableInsertItemConta
import { ModifyTableContainer } from './TableModify/ModifyTableContainer';
import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage';
import { AddNativeQueryRoute } from '../../../features/Data/LogicalModels/AddNativeQuery/AddNativeQueryRoute';
import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidgetWrapper';
import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidget.route';
const makeDataRouter = (
connect,

View File

@ -1,9 +1,13 @@
import { expect } from '@storybook/jest';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { StoryObj } from '@storybook/react';
import { screen, userEvent, within } from '@storybook/testing-library';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { dismissToast } from '../../../../utils/StoryUtils';
import { AddNativeQuery } from './AddNativeQuery';
import { nativeQueryHandlers } from './mocks';
import { RouteWrapper } from '../components/RouteWrapper';
type Story = StoryObj<typeof AddNativeQuery>;
export default {
component: AddNativeQuery,
@ -15,17 +19,9 @@ export default {
}),
layout: 'fullscreen',
},
} as ComponentMeta<typeof AddNativeQuery>;
export const Basic: ComponentStory<typeof AddNativeQuery> = args => {
return <AddNativeQuery />;
};
const fillAndSubmitForm = async ({
canvasElement,
}: {
canvasElement: HTMLElement;
}) => {
const fillAndSubmitForm: Story['play'] = async ({ canvasElement }) => {
const c = within(canvasElement);
/**
@ -80,27 +76,50 @@ const fillAndSubmitForm = async ({
await userEvent.click(c.getByText('Save'));
};
export const HappyPath: ComponentStory<typeof AddNativeQuery> = args => {
return (
<AddNativeQuery
defaultFormValues={{
code: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
}}
/>
);
const defaultArgs: Story['args'] = {
defaultFormValues: {
code: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
},
};
HappyPath.storyName = '😊 Happy Path';
export const Basic: Story = {
render: args => (
<div className="p-5">
<AddNativeQuery {...args} />
</div>
),
};
HappyPath.play = async ({ canvasElement }) => {
fillAndSubmitForm({ canvasElement });
expect(
await screen.findByText(
`Successfully tracked native query as: my_native_query`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
export const WithRouteWrapper: Story = {
render: args => (
<RouteWrapper pathname={'/data/native-queries/create'}>
<AddNativeQuery {...args} />
</RouteWrapper>
),
name: '🚏 Route Wrapper',
parameters: {
consoleType: 'pro',
},
};
export const HappyPath: Story = {
...Basic,
args: {
...defaultArgs,
},
name: '😊 Happy Path',
play: async context => {
fillAndSubmitForm(context);
expect(
await screen.findByText(
`Successfully tracked native query as: my_native_query`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
dismissToast();
},
};
/**
@ -108,32 +127,28 @@ HappyPath.play = async ({ canvasElement }) => {
* Query already exists Error
*
*/
export const ErrorExists: ComponentStory<typeof AddNativeQuery> = args => {
return (
<AddNativeQuery
defaultFormValues={{
code: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
}}
/>
);
};
ErrorExists.storyName = '🚨 Already Exists';
ErrorExists.parameters = {
msw: nativeQueryHandlers({
metadataOptions: { postgres: { models: true, queries: true } },
trackNativeQueryResult: 'already_exists',
}),
};
ErrorExists.play = async ({ canvasElement }) => {
fillAndSubmitForm({ canvasElement });
expect(
await screen.findByText(
`Native query 'my_native_query' is already tracked.`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
export const ErrorExists: Story = {
...HappyPath,
name: '🚨 Already Exists',
parameters: {
msw: nativeQueryHandlers({
metadataOptions: { postgres: { models: true, queries: true } },
trackNativeQueryResult: 'already_exists',
}),
},
play: async context => {
fillAndSubmitForm(context);
expect(
await screen.findByText(
`Native query 'my_native_query' is already tracked.`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
dismissToast();
},
};
/**
@ -141,32 +156,27 @@ ErrorExists.play = async ({ canvasElement }) => {
* Validation Error
*
*/
export const ErrorValidation: ComponentStory<typeof AddNativeQuery> = args => {
return (
<AddNativeQuery
defaultFormValues={{
code: `select * from foo`,
}}
/>
);
};
ErrorValidation.storyName = '🚨 Validation Error';
ErrorValidation.parameters = {
msw: nativeQueryHandlers({
metadataOptions: { postgres: { models: true, queries: true } },
trackNativeQueryResult: 'validation_failed',
}),
};
export const ErrorValidation: Story = {
...HappyPath,
name: '🚨 Validation Error',
parameters: {
msw: nativeQueryHandlers({
metadataOptions: { postgres: { models: true, queries: true } },
trackNativeQueryResult: 'validation_failed',
}),
},
play: async context => {
fillAndSubmitForm(context);
expect(
await screen.findByText(
`"exec_status": "FatalError"`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
ErrorValidation.play = async ({ canvasElement }) => {
fillAndSubmitForm({ canvasElement });
expect(
await screen.findByText(
`"exec_status": "FatalError"`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
dismissToast();
},
};
/**
@ -174,30 +184,25 @@ ErrorValidation.play = async ({ canvasElement }) => {
* Native Queries disabled
*
*/
export const ErrorDisabled: ComponentStory<typeof AddNativeQuery> = args => {
return (
<AddNativeQuery
defaultFormValues={{
code: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
}}
/>
);
};
ErrorDisabled.storyName = '🚨 Logical Models Disabled';
ErrorDisabled.parameters = {
msw: nativeQueryHandlers({
metadataOptions: { postgres: { models: true, queries: true } },
trackNativeQueryResult: 'native_queries_disabled',
}),
};
export const ErrorDisabled: Story = {
...HappyPath,
name: '🚨 Logical Models Disabled',
parameters: {
msw: nativeQueryHandlers({
metadataOptions: { postgres: { models: true, queries: true } },
trackNativeQueryResult: 'native_queries_disabled',
}),
},
play: async context => {
fillAndSubmitForm(context);
expect(
await screen.findByText(
`NativeQueries is disabled!`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
ErrorDisabled.play = async ({ canvasElement }) => {
fillAndSubmitForm({ canvasElement });
expect(
await screen.findByText(
`NativeQueries is disabled!`,
{ exact: false },
{ timeout: 3000 }
)
).toBeInTheDocument();
dismissToast();
},
};

View File

@ -109,7 +109,7 @@ export const ArgumentsField = ({ types }: { types: string[] }) => {
onClick={() => {
append({
name: '',
type: 'string',
type: 'text',
});
}}
>

View File

@ -72,7 +72,7 @@ const testRemoveQueryAndModel = async ({
)[0]
);
await confirmAlert();
await confirmAlert('Remove');
if (removeResponse?.nativeQueries) {
await expect(
@ -86,7 +86,7 @@ const testRemoveQueryAndModel = async ({
await userEvent.click((await c.findAllByText('Remove'))[0]);
await confirmAlert();
await confirmAlert('Remove');
if (removeResponse?.logicalModels) {
await expect(

View File

@ -16,7 +16,7 @@ import { InjectedRouter, Link, withRouter } from 'react-router';
import { LogicalModelWidget } from '../LogicalModelWidget/LogicalModelWidget';
import { Button } from '../../../../new-components/Button';
import { RouteWrapper } from '../components/RouteWrapper';
import { ListStoredProcedures } from '../StoredProcedures/ListStoredProcedures';
import { ListStoredProcedures } from './components/ListStoredProcedures';
export const LandingPage = ({
push,

View File

@ -1,7 +1,7 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
import { ListStoredProcedures } from './ListStoredProcedures';
import { handlers } from '../LogicalModelWidget/mocks/handlers';
import { handlers } from '../../LogicalModelWidget/mocks/handlers';
export default {
component: ListStoredProcedures,

View File

@ -3,19 +3,21 @@ import {
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useMetadata } from '../../../hasura-metadata-api';
import { CardedTableFromReactTable } from '../components/CardedTableFromReactTable';
import { StoredProcedure } from '../../../hasura-metadata-types';
import { useCallback, useState } from 'react';
import { Button } from '../../../../new-components/Button';
import { StoredProcedureDisplayName } from './components/StoredProcedureDisplayName';
import { useTrackStoredProcedure } from '../../hooks/useTrackStoredProcedure';
import { hasuraToast } from '../../../../new-components/Toasts';
import { Button } from '../../../../../new-components/Button';
import { StoredProcedure } from '../../../../hasura-metadata-types';
import { CardedTableFromReactTable } from '../../components/CardedTableFromReactTable';
import { hasuraToast } from '../../../../../new-components/Toasts';
import { useMetadata } from '../../../../hasura-metadata-api';
import { DisplayToastErrorMessage } from '../../../components/DisplayErrorMessage';
import { useTrackStoredProcedure } from '../../../hooks/useTrackStoredProcedure';
import { StoredProcedureDisplayName } from '../../StoredProcedures/components/StoredProcedureDisplayName';
import {
STORED_PROCEDURE_UNTRACK_ERROR,
STORED_PROCEDURE_UNTRACK_SUCCESS,
} from '../constants';
import { DisplayToastErrorMessage } from '../../components/DisplayErrorMessage';
} from '../../constants';
// this is local type for the table row. Do not export
type RowType = { dataSourceName: string } & StoredProcedure;

View File

@ -25,6 +25,10 @@ export const DefaultView: ComponentStory<typeof LogicalModelWidget> = () => (
<LogicalModelWidget />
);
DefaultView.parameters = {
msw: handlers['200'],
};
export const DialogVariant: ComponentStory<typeof LogicalModelWidget> = () => (
<LogicalModelWidget asDialog />
);
@ -67,7 +71,7 @@ BasicUserFlow.play = async ({ canvasElement }) => {
await canvas.findByLabelText('Logical Model Name', {}, { timeout: 4000 }),
'foobar'
);
fireEvent.click(canvas.getByText('Add new field'));
fireEvent.click(canvas.getByText('Add Field'));
await userEvent.type(canvas.getByTestId('fields[0].name'), 'id');
await userEvent.selectOptions(
@ -75,7 +79,7 @@ BasicUserFlow.play = async ({ canvasElement }) => {
'integer'
);
fireEvent.click(canvas.getByText('Add new field'));
fireEvent.click(canvas.getByText('Add Field'));
await userEvent.type(canvas.getByTestId('fields[1].name'), 'name');
await userEvent.selectOptions(canvas.getByTestId('fields[1].type'), 'text');
@ -105,7 +109,7 @@ NetworkErrorOnSubmit.play = async ({ canvasElement }) => {
await canvas.findByLabelText('Logical Model Name', {}, { timeout: 4000 }),
'foobar'
);
fireEvent.click(canvas.getByText('Add new field'));
fireEvent.click(canvas.getByText('Add Field'));
await userEvent.type(canvas.getByTestId('fields[0].name'), 'id');
await userEvent.selectOptions(
@ -113,7 +117,7 @@ NetworkErrorOnSubmit.play = async ({ canvasElement }) => {
'integer'
);
fireEvent.click(canvas.getByText('Add new field'));
fireEvent.click(canvas.getByText('Add Field'));
await userEvent.type(canvas.getByTestId('fields[1].name'), 'name');
await userEvent.selectOptions(canvas.getByTestId('fields[1].type'), 'text');

View File

@ -15,7 +15,7 @@ import {
LOGICAL_MODEL_CREATE_ERROR,
LOGICAL_MODEL_CREATE_SUCCESS,
} from '../constants';
import { LogicalModelFormInputs } from './parts/LogicalModelFormInputs';
import { LogicalModelFormInputs } from './components/LogicalModelFormInputs';
import {
AddLogicalModelFormData,
addLogicalModelValidationSchema,
@ -106,7 +106,7 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
if (sourceOptionError || typeOptionError)
return (
<IndicatorCard status="negative" headline="Internal Error">
<div>{sourceOptionError}</div>
<div>{sourceOptionError?.toString()}</div>
<div> {typeOptionError?.message}</div>
</IndicatorCard>
);
@ -121,7 +121,6 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
typeOptions={typeOptions}
disabled={props.disabled}
/>
<div className="flex justify-end">
<Button type="submit" mode="primary" isLoading={isLoading}>
{isEditMode ? 'Edit Logical Model' : 'Create Logical Model'}
@ -133,19 +132,16 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
return (
<Dialog
size="xl"
footer={
<Dialog.Footer
onSubmit={() => {
handleSubmit(onSubmit)();
}}
onClose={props.onCancel}
isLoading={isLoading}
callToDeny="Cancel"
callToAction="Create Logical Model"
onSubmitAnalyticsName="actions-tab-generate-types-submit"
onCancelAnalyticsName="actions-tab-generate-types-cancel"
/>
}
description="Creating a logical model in advance can help generate Native Queries faster"
footer={{
onSubmit: () => handleSubmit(onSubmit)(),
onClose: props.onCancel,
isLoading,
callToDeny: 'Cancel',
callToAction: 'Create Logical Model',
onSubmitAnalyticsName: 'actions-tab-generate-types-submit',
onCancelAnalyticsName: 'actions-tab-generate-types-cancel',
}}
title="Add Logical Model"
hasBackdrop
onClose={props.onCancel}
@ -154,19 +150,17 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
{isMetadataLoading || isIntrospectionLoading ? (
<Skeleton count={8} height={20} />
) : (
<>
<p className="text-muted mb-6">
Creating a logical model in advance can help generate Native
Queries faster
</p>
<Form onSubmit={() => {}}>
<LogicalModelFormInputs
sourceOptions={sourceOptions}
typeOptions={typeOptions}
disabled={props.disabled}
/>
</Form>
</>
<Form
onSubmit={() => {
// this is handled in the footer of the dialog
}}
>
<LogicalModelFormInputs
sourceOptions={sourceOptions}
typeOptions={typeOptions}
disabled={props.disabled}
/>
</Form>
)}
</div>
</Dialog>

View File

@ -0,0 +1,133 @@
import {
createColumnHelper,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import clsx from 'clsx';
import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { FaPlusCircle } from 'react-icons/fa';
import { Button } from '../../../../../new-components/Button';
import {
GraphQLSanitizedInputField,
Select,
fieldLabelStyles,
} from '../../../../../new-components/Form';
import { BooleanInput } from '../../components/BooleanInput';
import { useCardedTableFromReactTableWithRef } from '../../components/CardedTableFromReactTable';
import {
AddLogicalModelField,
AddLogicalModelFormData,
} from '../validationSchema';
const columnHelper = createColumnHelper<AddLogicalModelField>();
export const FieldsInput = ({
name,
types,
disabled,
}: {
name: string;
types: string[];
disabled?: boolean;
}) => {
const { control } = useFormContext<AddLogicalModelFormData>();
const { append, remove, fields } = useFieldArray({
control,
name: 'fields',
});
const tableRef = React.useRef<HTMLDivElement>(null);
const fieldsColumns = React.useMemo(
() => [
columnHelper.accessor('name', {
id: 'name',
cell: ({ row }) => (
<GraphQLSanitizedInputField
noErrorPlaceholder
hideTips
dataTestId={`${name}[${row.index}].name`}
placeholder="Field Name"
name={`fields.${row.index}.name`}
disabled={disabled}
/>
),
header: 'Name',
}),
columnHelper.accessor('type', {
id: 'type',
cell: ({ row }) => (
<Select
noErrorPlaceholder
dataTestId={`${name}[${row.index}].type`}
name={`fields.${row.index}.type`}
options={types.map(t => ({ label: t, value: t }))}
disabled={disabled}
/>
),
header: 'Type',
}),
columnHelper.accessor('nullable', {
id: 'nullable',
cell: ({ row }) => (
<BooleanInput
disabled={disabled}
name={`fields.${row.index}.nullable`}
/>
),
header: 'Nullable',
}),
columnHelper.display({
id: 'action',
header: 'Actions',
cell: ({ row }) => (
<div className="flex flex-row gap-2">
<Button
disabled={disabled}
mode="destructive"
onClick={() => remove(row.index)}
>
Remove
</Button>
</div>
),
}),
],
[disabled, name, remove, types]
);
const argumentsTable = useReactTable({
data: fields,
columns: fieldsColumns,
getCoreRowModel: getCoreRowModel(),
});
const FieldsTableElement =
useCardedTableFromReactTableWithRef<AddLogicalModelField>();
return (
<div>
<div className="flex flex-col gap-2 ">
<div className="flex justify-between items-center">
<div className={clsx(fieldLabelStyles, 'mb-0')}>Fields</div>
<Button
icon={<FaPlusCircle />}
onClick={() => {
append({ name: '', type: 'text', nullable: true });
}}
>
Add Field
</Button>
</div>
<FieldsTableElement
table={argumentsTable}
ref={tableRef}
noRowsMessage={'No fields added'}
/>
</div>
</div>
);
};

View File

@ -1,93 +0,0 @@
import clsx from 'clsx';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { FiTrash2 } from 'react-icons/fi';
import { Button } from '../../../../../new-components/Button';
import { CardedTable } from '../../../../../new-components/CardedTable';
import { InputField, Select } from '../../../../../new-components/Form';
import { BooleanInput } from '../../components/BooleanInput';
export const FieldsInput = ({
name,
types,
disabled,
}: {
name: string;
types: string[];
disabled?: boolean;
}) => {
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
name,
});
return (
<div>
<label className={clsx('block pt-1 text-gray-600 mb-xs')}>
<span className={clsx('flex items-center')}>
<span className={clsx('font-semibold')}>Fields</span>
</span>
</label>
{fields.length ? (
<CardedTable.Table>
<CardedTable.TableHead>
<CardedTable.TableHeadRow>
<CardedTable.TableHeadCell>Field name</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Type</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Nullable</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Actions</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{fields.map((field, index) => (
<CardedTable.TableBodyRow key={field.id}>
<CardedTable.TableBodyCell>
<InputField
dataTestId={`${name}[${index}].name`}
name={`${name}[${index}].name`}
placeholder="Field Name"
disabled={disabled}
/>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<Select
name={`${name}[${index}].type`}
label=""
options={types.map(t => ({ label: t, value: t }))}
disabled={disabled}
/>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<BooleanInput
name={`${name}[${index}].nullable`}
disabled={disabled}
/>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell className="align-top">
<div className="px-sm py-xs">
<Button
icon={<FiTrash2 />}
onClick={() => remove(index)}
mode="destructive"
disabled={disabled}
/>
</div>
</CardedTable.TableBodyCell>
</CardedTable.TableBodyRow>
))}
</CardedTable.TableBody>
</CardedTable.Table>
) : null}
<Button
className="mb-sm"
onClick={() => {
append({ name: '', type: 'text', nullable: true });
}}
disabled={disabled}
>
Add new field
</Button>
</div>
);
};

View File

@ -3,19 +3,21 @@ import { z } from 'zod';
export const addLogicalModelValidationSchema = z.object({
dataSourceName: z.string(),
name: z.string().min(1, 'Name is a required field'),
fields: z.array(
z.object({
fields: z
.object({
name: z.string().min(1, 'Field Name is a required field'),
type: z.string().min(1, 'Type is a required field'),
nullable: z.boolean(),
nullable: z.boolean({ required_error: 'Nullable is a required field' }),
})
),
.array(),
});
export type AddLogicalModelFormData = z.infer<
typeof addLogicalModelValidationSchema
>;
export type AddLogicalModelField = AddLogicalModelFormData['fields'][number];
export const defaultEmptyValues: AddLogicalModelFormData = {
name: '',
dataSourceName: '',

View File

@ -19,7 +19,13 @@ export const BooleanInput = ({
<Controller
name={name}
render={({ field: { onChange, value }, fieldState }) => (
<FieldWrapper id={name} {...wrapperProps} error={fieldState.error}>
<FieldWrapper
id={name}
{...wrapperProps}
className="items-center flex"
doNotWrapChildren
error={fieldState.error}
>
<Switch
data-testid="required-switch"
checked={value}

View File

@ -90,6 +90,10 @@ type FieldWrapperProps = FieldWrapperPassThroughProps & {
* The field error
*/
error?: FieldError | undefined;
/**
* Disable wrapping children in layers of divs to enable impacting children with styles (e.g. centering a switch element)
*/
doNotWrapChildren?: boolean;
};
export const fieldLabelStyles = clsx(
@ -137,6 +141,7 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
loading,
noErrorPlaceholder = false,
renderDescriptionLineBreaks = false,
doNotWrapChildren = false,
} = props;
let FieldLabel = () => <></>;
@ -215,21 +220,39 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
)}
>
<FieldLabel />
<div>
{/*
{doNotWrapChildren ? (
<>
{loading ? (
<div className={'relative'}>
{/* Just in case anyone is wondering... we render the children here b/c the height/width of the children takes up the space that the loading skeleton appears on top of. So, without the children, the skeleton would not appear. */}
{children}
<Skeleton
containerClassName="block leading-[0]"
className="absolute inset-0"
/>
</div>
) : (
children
)}
<FieldErrors />
</>
) : (
<div>
{/*
Remove line height to prevent skeleton bug
*/}
<div className={loading ? 'relative' : ''}>
{children}
{loading && (
<Skeleton
containerClassName="block leading-[0]"
className="absolute inset-0"
/>
)}
<div className={loading ? 'relative' : ''}>
{children}
{loading && (
<Skeleton
containerClassName="block leading-[0]"
className="absolute inset-0"
/>
)}
</div>
<FieldErrors />
</div>
<FieldErrors />
</div>
)}
</div>
);
};

View File

@ -120,6 +120,7 @@ export const Input = ({
prependLabel = '',
appendLabel = '',
dataTest,
dataTestId,
clearButton,
inputClassName,
maybeError,
@ -179,7 +180,7 @@ export const Input = ({
onChange={onChange}
onInput={onInput}
disabled={disabled}
data-testid={name}
data-testid={dataTestId || name}
onWheelCapture={fieldProps?.onWheelCapture || undefined}
{...fieldProps}
/>

View File

@ -71,7 +71,7 @@ export const Select: React.VFC<SelectProps> = ({
disabled={disabled}
value={val || watchValue}
data-test={dataTest}
data-testid={name}
data-testid={wrapperProps.dataTestId || name}
{...register(name)}
>
{placeholder ? (

View File

@ -9,6 +9,29 @@ import { consoleDevToolsEnabled } from '../../../utils/console-dev-tools/console
* FormDebugWindow is usually preferrable, but this is handy to render directly for forms inside of dialogs
*
*/
type FieldArrayObject = Record<
string,
{ message: string; type: string; ref?: unknown }
>[];
type ErrorObject = Record<
string,
{ message: string; type: string; ref?: unknown } | FieldArrayObject
>;
function removeRefProperty(obj: ErrorObject): ErrorObject {
return Object.entries(obj).reduce<ErrorObject>((acc, [key, value]) => {
if (Array.isArray(value)) {
acc[key] = value.map(item => removeRefProperty(item)) as FieldArrayObject;
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ref, ...rest } = value;
acc[key] = { ...rest };
}
return acc;
}, {});
}
export const FormDebug = () => {
const methods = useFormContext();
const formValues = methods.watch();
@ -16,17 +39,7 @@ export const FormDebug = () => {
const friendlyErrors = () => {
try {
return Object.entries(formState.errors).reduce<
Record<string, { message: string; type: string }>
>((result, [key, value]) => {
return {
...result,
[key]: {
message: value.message,
type: value.type,
},
};
}, {});
return removeRefProperty(formState.errors);
} catch {
return {};
}

View File

@ -32,9 +32,10 @@ export const TemplateStoriesFactory =
</div>
);
export const dismissToast = (delay = 500) => {
export const dismissToast = (delay = 300) => {
return new Promise<void>(resolve => {
// waiting a brief delay to ensures toast is available to dismiss after slide in transition
// delay is the same as the notificationOpen animation duration in tailwind.config.js
setTimeout(() => {
dismissAllToasts();
resolve();
@ -44,7 +45,7 @@ export const dismissToast = (delay = 500) => {
// confirms a hasuraAlert by button text
export const confirmAlert = async (
confirmText = 'Remove',
confirmText = 'Confirm',
removalTimout = 3000
) => {
const alert = await screen.findByRole('alertdialog');
@ -55,3 +56,17 @@ export const confirmAlert = async (
timeout: removalTimout,
});
};
// confirms a hasuraAlert by button text
export const cancelAlert = async (
cancelText = 'Cancel',
removalTimout = 3000
) => {
const alert = await screen.findByRole('alertdialog');
await userEvent.click(await within(alert).findByText(cancelText));
// this is important b/c in successfull async workflows, the alert remains on screen to indicate success
await waitForElementToBeRemoved(() => screen.queryByRole('alertdialog'), {
timeout: removalTimout,
});
};