console: refactor one-off scheduled event

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7252
GitOrigin-RevId: 4c463aa0c88879363c7a5d57c64f7444e2e59c15
This commit is contained in:
Varun Choudhary 2022-12-14 17:20:04 +05:30 committed by hasura-bot
parent 82be6f0b3b
commit cd3942f15e
17 changed files with 543 additions and 0 deletions

View File

@ -0,0 +1,168 @@
import React from 'react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentMeta, Story } from '@storybook/react';
import { within, userEvent, waitFor } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { waitForElementToBeRemoved } from '@testing-library/react';
import { handlers } from '@/mocks/metadata.mock';
import { AdhocEvents } from '../..';
export default {
title: 'Features/Scheduled Triggers/components/Form',
component: AdhocEvents.Form,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as ComponentMeta<typeof AdhocEvents.Form>;
export const Create: Story = () => {
const [showSuccessText, setShowSuccessText] = React.useState(false);
const onSuccess = () => {
setShowSuccessText(true);
};
return (
<>
<AdhocEvents.Form onSuccess={onSuccess} />
<p data-testid="@onSuccess">
{showSuccessText ? 'Form saved successfully!' : null}
</p>
</>
);
};
Create.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Click the submit button without filling up the form
await userEvent.click(canvas.getByText('Create scheduled event'));
// Step 1: Expect error messages due to required fields being empty
expect(
await canvas.findByText('Webhook url is a required field!')
).toBeVisible();
expect(await canvas.findByText('Payload must be valid json')).toBeVisible();
// --------------------------------------------------
// Step 2: Fill up webhook url
await userEvent.type(
canvas.getByRole('textbox', {
name: 'Webhook URL',
}),
'http://example.com'
);
// --------------------------------------------------
// Step 3: Test the JSON Payload editor
// Bug: Using class selector as ace textbox is unaccessible otherwise https://github.com/ajaxorg/ace/issues/2164
const aceTextbox = canvasElement.getElementsByClassName(
'ace_text-input'
)[0] as HTMLTextAreaElement;
// Test error message being displayed if payload type is not JSON
// Bug: userEvent.type doesn't work in case of Ace editor https://github.com/securingsincity/react-ace/issues/923#issuecomment-1066025492
await userEvent.paste(aceTextbox, '{"user"}');
expect(await canvas.findByText('Payload must be valid json')).toBeVisible();
// Fill the correct payload and expect for error to not be there
// Bug: setSelectionRange does not work with ace, so we manually type backspace as many times we need
await userEvent.type(
aceTextbox,
'a{backspace}{backspace}{backspace}{backspace}'
);
await userEvent.paste(aceTextbox, ' : "test" }');
await waitForElementToBeRemoved(() =>
canvas.queryByText('Payload must be valid json')
);
// Add request headers
await userEvent.click(canvas.getByText('Advance Settings'));
await userEvent.click(canvas.getByText('Add request headers'));
await userEvent.type(
canvas.getByRole('textbox', {
name: 'headers[0].name',
}),
'x-hasura-name'
);
await userEvent.type(
canvas.getByRole('textbox', {
name: 'headers[0].value',
}),
'hasura_user'
);
await userEvent.click(canvas.getByText('Add request headers'));
await userEvent.type(
canvas.getByRole('textbox', {
name: 'headers[1].name',
}),
'x-hasura-Id'
);
await userEvent.selectOptions(
canvas.getByRole('combobox', {
name: 'headers[1].type',
}),
'from_env'
);
await userEvent.type(
canvas.getByRole('textbox', {
name: 'headers[1].value',
}),
'HASURA_ENV_ID'
);
// --------------------------------------------------
// Step 4: Update retry configuration fields
await userEvent.click(canvas.getByText('Retry Configuration'));
await userEvent.clear(
canvas.getByRole('spinbutton', {
name: 'num_retries',
})
);
await userEvent.type(
canvas.getByRole('spinbutton', {
name: 'num_retries',
}),
'12'
);
await userEvent.clear(
canvas.getByRole('spinbutton', {
name: 'retry_interval_seconds',
})
);
await userEvent.type(
canvas.getByRole('spinbutton', {
name: 'retry_interval_seconds',
}),
'100'
);
await userEvent.clear(
canvas.getByRole('spinbutton', {
name: 'timeout_seconds',
})
);
await userEvent.type(
canvas.getByRole('spinbutton', {
name: 'timeout_seconds',
}),
'20'
);
// --------------------------------------------------
// Step 5: Save the form and assert success by observing behaviour in UI
await userEvent.click(canvas.getByText('Create scheduled event'));
// TODO: Ideally we should be checking if the success notification got fired, but our redux-based notifications does not work in storybook
waitFor(
async () => {
await expect(await canvas.findByTestId('@onSuccess')).toHaveTextContent(
'Form saved successfully!'
);
},
{ timeout: 5000 }
);
};

View File

@ -0,0 +1,81 @@
import React from 'react';
import { Form, InputField } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import { schema, Schema } from './schema';
import { useDefaultValues } from './hooks';
import { useOneOffScheduledTriggerMigration } from './hooks/useOneOffScheduledTriggerMigration';
import { getScheduledEventCreateQuery } from './utils';
import {
AdvancedSettings,
RetryConfiguration,
ScheduleEventPayloadInput,
} from './components';
import { ScheduledTime } from './components/ScheduledTime';
type Props = {
/**
* Success callback which can be used to apply custom logic on success, for ex. closing the form
*/
onSuccess?: () => void;
};
const OneOffScheduledEventForm = (props: Props) => {
const { onSuccess } = props;
const { data: defaultValues } = useDefaultValues();
const { mutation } = useOneOffScheduledTriggerMigration({ onSuccess });
const onSubmit = (values: Record<string, unknown>) => {
mutation.mutate({
query: getScheduledEventCreateQuery(values as Schema),
});
};
return (
<Form
schema={schema}
onSubmit={onSubmit}
options={{ defaultValues }}
className="overflow-y-hidden p-4"
>
{() => (
<>
<div className="mb-md">
<InputField
name="comment"
label="Comment / Description"
placeholder="Comment / Description..."
tooltip="A statement to help describe the scheduled event in brief"
/>
</div>
<div className="mb-md">
<ScheduledTime />
</div>
<div className="mb-md">
<InputField
name="webhook"
label="Webhook URL"
placeholder="https://httpbin.com/post"
tooltip="The HTTP URL that should be triggered."
/>
</div>
<div className="mb-md">
<ScheduleEventPayloadInput />
</div>
<div className="mb-md">
<AdvancedSettings />
</div>
<div className="mb-md">
<RetryConfiguration />
</div>
<div className="flex items-center mb-lg">
<Button type="submit" mode="primary" isLoading={mutation.isLoading}>
Create scheduled event
</Button>
</div>
</>
)}
</Form>
);
};
export default OneOffScheduledEventForm;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { IconTooltip } from '@/new-components/Tooltip';
import { Collapse } from '@/new-components/deprecated';
import { RequestHeadersSelector } from '@/new-components/RequestHeadersSelector';
export const AdvancedSettings = () => {
return (
<>
<Collapse title="Advance Settings">
<Collapse.Content>
<div className="relative max-w-xl">
<div className="mb-md">
<label className="block flex items-center text-gray-600 font-semibold mb-xs">
Headers
<IconTooltip message="Configure headers for the request to the webhook" />
</label>
<RequestHeadersSelector
name="headers"
addButtonText="Add request headers"
/>
</div>
</div>
</Collapse.Content>
</Collapse>
</>
);
};

View File

@ -0,0 +1,69 @@
import React from 'react';
import { Collapse } from '@/new-components/deprecated';
import { IconTooltip } from '@/new-components/Tooltip';
import { useFormContext } from 'react-hook-form';
const inputStyes =
'block w-full h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-0 focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400';
export const RetryConfiguration = () => {
const { register } = useFormContext();
return (
<>
<Collapse
title="Retry Configuration"
tooltip="Retry configuration if the call to the webhook fails"
>
<Collapse.Content>
<div className="space-y-sm relative max-w-xl">
<div className="grid grid-cols-12 gap-3">
<div className="col-span-6 flex items-center">
<label className="block">Number of retries</label>
<IconTooltip message="Number of retries that Hasura makes to the webhook in case of failure" />
</div>
<div className="col-span-6">
{/* TODO: This is a horizontal/inline input field, currently we do not have it in common so this component implements its own,
we should replace this in future with the common component */}
<input
type="number"
className={inputStyes}
aria-label="num_retries"
{...register('num_retries')}
/>
</div>
</div>
<div className="grid grid-cols-12 gap-3">
<div className="col-span-6 flex items-center">
<label className="block">Retry interval in seconds</label>
<IconTooltip message="Interval (in seconds) between each retry" />
</div>
<div className="col-span-6">
<input
type="number"
className={inputStyes}
aria-label="retry_interval_seconds"
{...register('retry_interval_seconds')}
/>
</div>
</div>
<div className="grid grid-cols-12 gap-3">
<div className="col-span-6 flex items-center">
<label className="block">Timeout in seconds</label>
<IconTooltip message="Request timeout for the webhook" />
</div>
<div className="col-span-6">
<input
type="number"
className={inputStyes}
aria-label="timeout_seconds"
{...register('timeout_seconds')}
/>
</div>
</div>
</div>
</Collapse.Content>
</Collapse>
</>
);
};

View File

@ -0,0 +1,24 @@
import React from 'react';
import { CodeEditorField } from '@/new-components/Form';
import { IAceOptions } from 'react-ace';
export const ScheduleEventPayloadInput = () => {
const editorOptions: IAceOptions = {
fontSize: 14,
showGutter: true,
tabSize: 2,
showLineNumbers: true,
minLines: 10,
maxLines: 10,
};
return (
<CodeEditorField
name="payload"
label="Payload"
tooltip="The request payload for the cron trigger, should be a valid JSON"
editorOptions={editorOptions}
theme="eclipse"
/>
);
};

View File

@ -0,0 +1,33 @@
import React from 'react';
import DateTimePicker from 'react-datetime';
import { IconTooltip } from '@/new-components/Tooltip';
import { useFormContext } from 'react-hook-form';
import { Moment } from 'moment';
import { inputStyles } from '@/components/Services/Events/constants';
export const ScheduledTime = () => {
const { setValue, watch } = useFormContext();
const setTime = (value: string | Moment) => {
setValue('time', value, { shouldValidate: false });
};
const time = watch('time');
return (
<>
<div className="block flex items-center text-gray-600 font-semibold mb-xs">
<label htmlFor="schedule">Time</label>
<IconTooltip message="The time that this event must be delivered" />
</div>
<div className="relative w-full max-w-xl mb-xs" />
<div className="mb-6">
<DateTimePicker
value={time}
onChange={setTime}
inputProps={{
className: `${inputStyles}`,
}}
/>
</div>
</>
);
};

View File

@ -0,0 +1,3 @@
export { AdvancedSettings } from './AdvancedSettings';
export { ScheduleEventPayloadInput } from './ScheduleEventPayloadInput';
export { RetryConfiguration } from './RetryConfiguration';

View File

@ -0,0 +1 @@
export { useDefaultValues } from './useDefaultValues';

View File

@ -0,0 +1,15 @@
import { Schema } from '../schema';
export const useDefaultValues = () => {
const emptyDefaultValues: Schema = {
webhook: '',
time: new Date(),
payload: '',
headers: [],
num_retries: '0',
retry_interval_seconds: '10',
timeout_seconds: '60',
comment: '',
};
return { data: emptyDefaultValues };
};

View File

@ -0,0 +1,31 @@
import { useMetadataMigration } from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { APIError } from '@/hooks/error';
interface Props {
onSuccess?: () => void;
}
export const useOneOffScheduledTriggerMigration = (props: Props) => {
const { onSuccess } = props;
const { fireNotification } = useFireNotification();
const mutation = useMetadataMigration({
onError: (error: APIError) => {
fireNotification({
type: 'error',
title: 'Error',
message: error?.message ?? 'Unable to schedule Event',
});
},
onSuccess: () => {
fireNotification({
type: 'success',
title: 'Success!',
message: 'Event scheduled successfully',
});
onSuccess?.();
},
});
return { mutation };
};

View File

@ -0,0 +1,3 @@
import Form from './Form';
export { Form };

View File

@ -0,0 +1,11 @@
import { allowedMetadataTypes } from '@/features/MetadataAPI';
import { MetadataReducer } from '@/mocks/actions';
// scheduled events doesn't modify the metadata
export const metadataHandlers: Partial<
Record<allowedMetadataTypes, MetadataReducer>
> = {
create_scheduled_event: state => {
return state;
},
};

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
import { requestHeadersSelectorSchema } from '@/new-components/RequestHeadersSelector';
import { isJsonString } from '@/components/Common/utils/jsUtils';
export const schema = z.object({
webhook: z.string().min(1, 'Webhook url is a required field!'),
time: z.union([z.string(), z.any()]),
payload: z.string().refine((arg: string) => isJsonString(arg), {
message: 'Payload must be valid json',
}),
headers: requestHeadersSelectorSchema,
num_retries: z.string(),
retry_interval_seconds: z.string(),
timeout_seconds: z.string(),
comment: z.string(),
});
export type Schema = z.infer<typeof schema>;

View File

@ -0,0 +1,34 @@
import { isJsonString } from '@/components/Common/utils/jsUtils';
import { ScheduledTrigger } from '@/metadata/types';
import { Schema } from './schema';
const transformFormData = (values: Schema) => {
const apiPayload: ScheduledTrigger = {
webhook: values.webhook,
schedule_at: values.time,
payload: isJsonString(values.payload)
? JSON.parse(values.payload)
: values.payload,
headers: values.headers.map(header => {
if (header.type === 'from_env')
return { name: header.name, value_from_env: header.value };
return { name: header.name, value: header.value };
}),
retry_conf: {
num_retries: Number(values.num_retries),
retry_interval_seconds: Number(values.retry_interval_seconds),
timeout_seconds: Number(values.timeout_seconds),
},
comment: values.comment,
};
return apiPayload;
};
export const getScheduledEventCreateQuery = (values: Schema) => {
const args = transformFormData(values);
return {
type: 'create_scheduled_event' as const,
args,
};
};

View File

@ -0,0 +1,7 @@
import { Form } from './components/Form';
export const AdhocEvents = {
Form,
};
export { metadataHandlers } from '../AdhocEvents/components/Form/mocks/metadata.mock';

View File

@ -1,3 +1,4 @@
import { Moment } from 'moment';
import { Nullable } from './../components/Common/utils/tsUtils';
import { Driver } from '../dataSources';
import { PermissionsType } from '../components/Services/RemoteSchema/Permissions/types';
@ -538,6 +539,21 @@ export interface CronTrigger {
request_transform?: RequestTransform;
}
export interface ScheduledTrigger {
/** URL of the webhook */
webhook: WebhookURL;
/** Scheduled time at which the trigger should be invoked. */
schedule_at: string | Moment;
/** Any JSON payload which will be sent when the webhook is invoked. */
payload?: Record<string, any>;
/** List of headers to be sent with the webhook */
headers: ServerHeader[];
/** Retry configuration if scheduled invocation delivery fails */
retry_conf?: RetryConfST;
/** Custom comment. */
comment?: string;
}
/**
* https://hasura.io/docs/latest/graphql/core/api-reference/schema-metadata-api/scheduled-triggers.html#retryconfst
*/

View File

@ -3,6 +3,7 @@ import { Metadata } from '@/features/hasura-metadata-types';
import { metadataHandlers as allowListMetadataHandlers } from '@/features/AllowLists';
import { metadataHandlers as queryCollectionMetadataHandlers } from '@/features/QueryCollections';
import { metadataHandlers as adhocEventMetadataHandlers } from '@/features/AdhocEvents';
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';
@ -28,6 +29,7 @@ const metadataHandlers: Partial<Record<allowedMetadataTypes, MetadataReducer>> =
export_metadata: state => state,
...allowListMetadataHandlers,
...queryCollectionMetadataHandlers,
...adhocEventMetadataHandlers,
};
export const metadataReducer: MetadataReducer = (state, action) => {