mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
console: native queries gardening
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9235 GitOrigin-RevId: 452d306613e5f57bdf209d55116162d9c2b6bdf0
This commit is contained in:
parent
ec63ea6ed0
commit
87da15a596
@ -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,
|
||||
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
@ -109,7 +109,7 @@ export const ArgumentsField = ({ types }: { types: string[] }) => {
|
||||
onClick={() => {
|
||||
append({
|
||||
name: '',
|
||||
type: 'string',
|
||||
type: 'text',
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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,
|
@ -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;
|
@ -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');
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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: '',
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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 ? (
|
||||
|
@ -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 {};
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user