mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
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:
parent
82be6f0b3b
commit
cd3942f15e
@ -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 }
|
||||
);
|
||||
};
|
81
console/src/features/AdhocEvents/components/Form/Form.tsx
Normal file
81
console/src/features/AdhocEvents/components/Form/Form.tsx
Normal 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;
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export { AdvancedSettings } from './AdvancedSettings';
|
||||
export { ScheduleEventPayloadInput } from './ScheduleEventPayloadInput';
|
||||
export { RetryConfiguration } from './RetryConfiguration';
|
@ -0,0 +1 @@
|
||||
export { useDefaultValues } from './useDefaultValues';
|
@ -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 };
|
||||
};
|
@ -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 };
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
import Form from './Form';
|
||||
|
||||
export { Form };
|
@ -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;
|
||||
},
|
||||
};
|
18
console/src/features/AdhocEvents/components/Form/schema.ts
Normal file
18
console/src/features/AdhocEvents/components/Form/schema.ts
Normal 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>;
|
34
console/src/features/AdhocEvents/components/Form/utils.ts
Normal file
34
console/src/features/AdhocEvents/components/Form/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
7
console/src/features/AdhocEvents/index.ts
Normal file
7
console/src/features/AdhocEvents/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Form } from './components/Form';
|
||||
|
||||
export const AdhocEvents = {
|
||||
Form,
|
||||
};
|
||||
|
||||
export { metadataHandlers } from '../AdhocEvents/components/Form/mocks/metadata.mock';
|
@ -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
|
||||
*/
|
||||
|
@ -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) => {
|
||||
|
Loading…
Reference in New Issue
Block a user