console: use new cron trigger form

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7236
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
GitOrigin-RevId: 2b32f418258d1cd6ba269de3e88fee939d1b640b
This commit is contained in:
Daniele Cammareri 2022-12-22 10:34:59 +01:00 committed by hasura-bot
parent c256dcef7b
commit a8a36bf7ee
27 changed files with 748 additions and 637 deletions

View File

@ -19,12 +19,12 @@ const NumberedSidebar: React.FC<NumberedSidebarProps> = ({
return (
<>
{number ? <div className={sidebarNumberStyles}>{number}</div> : null}
<label className="flex items-center block text-gray-600 font-medium">
{title}
{url ? <KnowMore url={url} /> : null}
</label>
<div className="flex items-center mb-sm">
<div>
<label className="flex items-center block text-gray-600 font-medium">
{title}
{url ? <KnowMore url={url} /> : null}
</label>
{description ? (
<p className="text-sm text-gray-600">{description}</p>
) : null}

View File

@ -62,7 +62,7 @@ const SampleContextTransforms: React.FC<SampleContextTransformsProps> = ({
_BASE_URL
</span>
}
number="1"
number={transformationType === 'event' ? '' : '1'}
/>
<div className="grid gap-3 grid-cols-3">
<div>
@ -87,40 +87,42 @@ const SampleContextTransforms: React.FC<SampleContextTransformsProps> = ({
</div>
</div>
<div className="mb-md">
<NumberedSidebar
title="Sample Session Variables"
description={
<span>
Enter a sample input for your provided session variables.
<br />
e.g. the sample value for x-hasura-user-id
</span>
}
number="2"
/>
<div className="grid gap-3 grid-cols-3">
<div>
<label className="block text-gray-600 font-medium mb-xs">
Session Variables
</label>
</div>
<div>
<label className="block text-gray-600 font-medium mb-xs">
Value
</label>
</div>
</div>
<div className="grid gap-3 grid-cols-3 mb-sm">
<KeyValueInput
pairs={localSessionVars}
setPairs={sv => {
setLocalSessionVars(sv);
}}
testId="session-vars"
{transformationType !== 'event' && (
<div className="mb-md">
<NumberedSidebar
title="Sample Session Variables"
description={
<span>
Enter a sample input for your provided session variables.
<br />
e.g. the sample value for x-hasura-user-id
</span>
}
number="2"
/>
<div className="grid gap-3 grid-cols-3">
<div>
<label className="block text-gray-600 font-medium mb-xs">
Session Variables
</label>
</div>
<div>
<label className="block text-gray-600 font-medium mb-xs">
Value
</label>
</div>
</div>
<div className="grid gap-3 grid-cols-3 mb-sm">
<KeyValueInput
pairs={localSessionVars}
setPairs={sv => {
setLocalSessionVars(sv);
}}
testId="session-vars"
/>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -56,6 +56,8 @@ import {
SET_RESPONSE_TRANSFORM_STATE,
defaultActionResponseBody,
SetResponseTransformState,
defaultCronTriggerRequestBody,
defaultCronTriggerSampleInput,
} from './stateDefaults';
import { getSessionVarsFromLS, getEnvVarsFromLS } from './utils';
@ -274,6 +276,23 @@ export const getEventRequestTransformDefaultState =
};
};
export const getCronTriggerRequestTransformDefaultState =
(): RequestTransformState => {
return {
...requestTransformState,
envVars: getEnvVarsFromLS(),
sessionVars: getSessionVarsFromLS(),
requestQueryParams: [{ name: '', value: '' }],
requestAddHeaders: [{ name: '', value: '' }],
requestBody: {
action: requestBodyActionState.transformApplicationJson,
template: defaultCronTriggerRequestBody,
form_template: [{ name: 'payload', value: '{{$body.payload}}' }],
},
requestSampleInput: defaultCronTriggerSampleInput,
};
};
export const requestTransformReducer = (
state = requestTransformState,
action: RequestTransformEvents

View File

@ -1,5 +1,8 @@
import { Action as ReduxAction } from 'redux';
import { getEventRequestSampleInput } from '@/components/Services/Events/EventTriggers/utils';
import {
getCronTriggerRequestSampleInput,
getEventRequestSampleInput,
} from '@/components/Services/Events/EventTriggers/utils';
import { getActionRequestSampleInput } from '../../Services/Actions/Add/utils';
import {
defaultActionDefSdl,
@ -224,6 +227,8 @@ export const defaultActionRequestSampleInput = getActionRequestSampleInput(
export const defaultEventRequestSampleInput = getEventRequestSampleInput();
export const defaultCronTriggerSampleInput = getCronTriggerRequestSampleInput();
export const defaultActionRequestBody = `{
"users": {
"name": {{$body.input.arg1.username}},
@ -242,6 +247,10 @@ export const defaultEventRequestBody = `{
}
}`;
export const defaultCronTriggerRequestBody = `{
"payload": {{$body.payload}}
}`;
const defaultActionRequestJsonPayload = `{
"users": {
"name": "username",

View File

@ -1,158 +0,0 @@
import React from 'react';
// import DateTimePicker from 'react-datetime';
// import { Moment } from 'moment';
import './ReactDateTime.css';
import { useScheduledTrigger, defaultCronExpr } from '../../CronTriggers/state';
import AceEditor from '../../../../Common/AceEditor/BaseEditor';
import Toggle from '../../../../Common/Toggle/Toggle';
import CollapsibleToggle from '../../../../Common/CollapsibleToggle/CollapsibleToggle';
import Headers from '../../../../Common/Headers/Headers';
import RetryConf from './RetryConfEditor';
import FrequentlyUsedCrons from './FrequentlyUsedCrons';
import FormSection from './FormSection';
import { heading, inputStyles } from '../../constants';
type Props = ReturnType<typeof useScheduledTrigger>;
const Form: React.FC<Props> = props => {
const { state, setState } = props;
const {
name,
webhook,
schedule,
payload,
headers,
comment,
includeInMetadata,
} = state;
const setName = (e: React.ChangeEvent<HTMLInputElement>) =>
setState.name(e.target.value);
const setWebhookValue = (e: React.ChangeEvent<HTMLInputElement>) =>
setState.webhook(e.target.value);
const setScheduleValue = (e: React.ChangeEvent<HTMLInputElement>) =>
setState.schedule(e.target.value);
const setComment = (e: React.ChangeEvent<HTMLInputElement>) =>
setState.comment(e.target.value);
return (
<React.Fragment>
<FormSection
heading="Name"
tooltip="Name of the trigger"
id="trigger-name"
>
<input
type="text"
placeholder="name"
className={`${inputStyles} w-80`}
value={name}
onChange={setName}
/>
</FormSection>
<FormSection
heading="Webhook"
tooltip="The HTTP URL that should be triggered. You can also provide the URL from environment variables, e.g. {{MY_WEBHOOK_URL}}"
id="trigger-webhook"
>
<input
type="text"
placeholder="http://httpbin.org/post"
className={`${inputStyles} w-80`}
value={webhook}
onChange={setWebhookValue}
/>
</FormSection>
<FormSection
id="trigger-schedule"
tooltip="Schedule for your cron (events are created based on the UTC timezone)"
heading="Cron Schedule"
>
<div className="mb-5 flex flex-row items-center">
<input
type="text"
placeholder={defaultCronExpr}
className={`${inputStyles} w-80 mr-5`}
value={schedule}
onChange={setScheduleValue}
/>
<a
className="cursor-pointer"
href="https://crontab.guru/#*_*_*_*_*"
target="_blank"
rel="noopener noreferrer"
>
Build a cron expression
</a>
</div>
<FrequentlyUsedCrons setCron={setState.schedule} />
</FormSection>
<FormSection
id="trigger-payload"
tooltip="The request payload for the HTTP trigger"
heading="Payload"
>
<AceEditor
mode="json"
value={payload}
onChange={setState.payload}
height="200px"
setOptions={{ useWorker: false }}
/>
</FormSection>
<CollapsibleToggle
title={<h2 className={heading}>Advanced</h2>}
testId="advanced-configuration"
>
<FormSection
id="trigger-headers"
heading="Headers"
tooltip="Configure headers for the request to the webhook"
>
<Headers headers={headers} setHeaders={setState.headers} />
</FormSection>
<FormSection
id="trigger-retry-conf"
heading="Retry configuration"
tooltip="Retry configuration if the call to the webhook fails"
>
<RetryConf
retryConf={state.retryConf}
setRetryConf={setState.retryConf}
/>
</FormSection>
<FormSection
id="trigger-include-in-metadata"
heading="Include in Metadata"
tooltip="If enabled, this cron trigger will be included in the metadata of GraphqL Engine i.e. it will be a part of the metadata that is exported as migrations."
>
<div className="flex mr-xs">
<Toggle
checked={includeInMetadata}
onChange={setState.toggleIncludeInMetadata}
className="mr-xs"
icons={false}
/>
<span>Include this trigger in Hasura Metadata</span>
</div>
</FormSection>
<FormSection
id="trigger-comment"
heading="Comment"
tooltip="Description of your cron trigger"
>
<input
type="text"
placeholder="comment"
className={`${inputStyles} mr-2.5 w-4/12`}
value={comment || ''}
onChange={setComment}
/>
</FormSection>
</CollapsibleToggle>
</React.Fragment>
);
};
export default Form;

View File

@ -1,33 +1,13 @@
import React from 'react';
import Helmet from 'react-helmet';
import { connect, ConnectedProps } from 'react-redux';
import { browserHistory } from 'react-router';
import { Analytics, REDACT_EVERYTHING } from '@/features/Analytics';
import { Button } from '@/new-components/Button';
import { useScheduledTrigger, LocalScheduledTriggerState } from '../state';
import CronTriggerFrom from '../../Common/Components/CronTriggerForm';
import {
getReactHelmetTitle,
mapDispatchToPropsEmpty,
} from '../../../../Common/utils/reactUtils';
import { MapStateToProps } from '../../../../../types';
import { addScheduledTrigger } from '../../ServerIO';
import { CronTriggers } from '@/features/CronTriggers';
import { getReactHelmetTitle } from '../../../../Common/utils/reactUtils';
import { EVENTS_SERVICE_HEADING, CRON_TRIGGER } from '../../constants';
interface Props extends InjectedProps {
initState?: LocalScheduledTriggerState;
}
const Main: React.FC<Props> = props => {
const { dispatch, initState, readOnlyMode } = props;
const { state, setState } = useScheduledTrigger(initState);
const callback = () => setState.loading('add', false);
const onSave = (e: React.SyntheticEvent) => {
e.preventDefault();
setState.loading('add', true);
dispatch(addScheduledTrigger(state, callback, callback));
};
export const AddConnector: React.FC = () => {
return (
<Analytics name="AddScheduledTrigger" {...REDACT_EVERYTHING}>
<div className="md-md">
@ -37,42 +17,17 @@ const Main: React.FC<Props> = props => {
EVENTS_SERVICE_HEADING
)}
/>
<div className="font-bold mb-xl text-[18px] pb-0">
Create a cron trigger
</div>
<CronTriggerFrom state={state} setState={setState} />
{!readOnlyMode && (
<div className="mr-xl">
<Analytics
name="events-tab-button-create-cron-trigger"
passHtmlAttributesToChildren
>
<Button
isLoading={state.loading.add}
loadingText="Creating..."
onClick={onSave}
mode="primary"
disabled={state.loading.add}
>
Create
</Button>
</Analytics>
</div>
)}
<h2 className="text-subtitle font-bold pt-md pb-md mt-0 mb-0 pl-4">
Create a new cron trigger
</h2>
<CronTriggers.Form
onSuccess={(triggerName?: string) => {
browserHistory.push(`/events/cron/${triggerName}/modify`);
}}
/>
</div>
</Analytics>
);
};
type PropsFromState = {
readOnlyMode: boolean;
};
const mapStateToProps: MapStateToProps<PropsFromState> = state => ({
readOnlyMode: state.main.readOnlyMode,
});
const connector = connect(mapStateToProps, mapDispatchToPropsEmpty);
type InjectedProps = ConnectedProps<typeof connector>;
const AddConnector = connector(Main);
export default AddConnector;

View File

@ -112,7 +112,7 @@ const STContainer: React.FC<Props> = ({
return (
<Analytics name="CronTriggers" {...REDACT_EVERYTHING}>
<div
className={`${styles.view_stitch_schema_wrapper} ${styles.addWrapper}`}
className={`${styles.view_stitch_schema_wrapper} ${styles.addWrapper} pt-5`}
>
<Helmet
title={getReactHelmetTitle(

View File

@ -4,6 +4,8 @@ import { useAppSelector } from '@/store';
import { useQuery, UseQueryOptions } from 'react-query';
import { CronTriggerAPIResult, ScheduledTrigger } from '../../types';
export const CRON_TRIGGERS_QUERY_KEY = 'cronTrigger';
export function useGetCronTriggers(
queryOptions?: Omit<
UseQueryOptions<
@ -30,7 +32,7 @@ export function useGetCronTriggers(
};
return useQuery({
queryKey: 'cronTrigger',
queryKey: CRON_TRIGGERS_QUERY_KEY,
queryFn,
...queryOptions,
select: data => {

View File

@ -1,87 +1,25 @@
import React from 'react';
import { useQueryClient } from 'react-query';
import { Analytics, REDACT_EVERYTHING } from '@/features/Analytics';
import { Button } from '@/new-components/Button';
import { useScheduledTrigger } from '../state';
import { CronTriggers } from '@/features/CronTriggers';
import { browserHistory } from 'react-router';
import { ScheduledTrigger } from '../../types';
import { Dispatch } from '../../../../../types';
import { parseServerScheduledTrigger } from '../utils';
import CronTriggerFrom from '../../Common/Components/CronTriggerForm';
import { saveScheduledTrigger, deleteScheduledTrigger } from '../../ServerIO';
type Props = {
dispatch: Dispatch;
currentTrigger?: ScheduledTrigger;
};
const Modify: React.FC<Props> = props => {
const { dispatch, currentTrigger } = props;
const { state, setState } = useScheduledTrigger();
const queryClient = useQueryClient();
React.useEffect(() => {
if (currentTrigger) {
const initState = parseServerScheduledTrigger(currentTrigger);
setState.bulk(initState);
}
}, [currentTrigger]);
if (!currentTrigger) {
return null;
}
const deleteFunc = () => {
const requestCallback = () => {
setState.loading('delete', false);
queryClient.refetchQueries(['cronTrigger'], { active: true });
};
setState.loading('delete', true);
dispatch(
deleteScheduledTrigger(currentTrigger, requestCallback, requestCallback)
);
};
const onSave = (e: React.SyntheticEvent) => {
e.preventDefault();
const requestCallback = () => {
setState.loading('modify', false);
};
setState.loading('modify', true);
dispatch(
saveScheduledTrigger(
state,
currentTrigger,
requestCallback,
requestCallback
)
);
};
const name = props.currentTrigger?.name;
return (
<Analytics name="ScheduledTriggerModify" {...REDACT_EVERYTHING}>
<div className="mb-md">
<CronTriggerFrom state={state} setState={setState} />
<div className="flex">
<div className="mr-md">
<Button
onClick={onSave}
mode="primary"
disabled={state.loading.modify}
loadingText="Saving..."
isLoading={state.loading.modify}
>
Save
</Button>
</div>
<div className="mr-md">
<Button
onClick={deleteFunc}
mode="destructive"
disabled={state.loading.delete}
>
{state.loading.delete ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
<CronTriggers.Form
cronTriggerName={name}
onSuccess={(triggerName?: string) => {
browserHistory.push(`/events/cron/${triggerName}/modify`);
}}
onDeleteSuccess={() => browserHistory.push('/events/cron')}
/>
</div>
</Analytics>
);

View File

@ -28,11 +28,7 @@ const ModifyContainer: React.FC<Props> = props => {
allTriggers={cronTriggers}
eventsLoading={eventsLoading}
>
{readOnlyMode ? (
'Cannot modify in read-only mode'
) : (
<Modify dispatch={dispatch} />
)}
{readOnlyMode ? 'Cannot modify in read-only mode' : <Modify />}
</STContainer>
);
};

View File

@ -237,3 +237,16 @@ export const getEventRequestSampleInput = (
const value = JSON.stringify(obj, null, 2);
return value;
};
export const getCronTriggerRequestSampleInput = () => {
const obj = {
comment: 'comment',
id: '06af0430-e4d8-4335-8659-c27225e8edfd',
name: 'name',
payload: {},
scheduled_time: new Date().toISOString(),
};
const value = JSON.stringify(obj, null, 2);
return value;
};

View File

@ -1,5 +1,6 @@
import React from 'react';
import DateTimePicker from 'react-datetime';
import 'react-datetime/css/react-datetime.css';
import { IconTooltip } from '@/new-components/Tooltip';
import { useFormContext } from 'react-hook-form';
import { Moment } from 'moment';

View File

@ -107,9 +107,7 @@ Create.play = async ({ canvasElement }) => {
// --------------------------------------------------
// Step 5: Add request transform options
await userEvent.click(
canvas.getByText('Header, URL, and Advanced Request Options')
);
await userEvent.click(canvas.getByText('Advanced Settings'));
// Add request headers
await userEvent.click(canvas.getByText('Add request headers'));
@ -146,35 +144,6 @@ Create.play = async ({ canvasElement }) => {
'HASURA_ENV_ID'
);
// Add request method and url template
await userEvent.click(
canvas.getByRole('radio', {
name: 'GET',
})
);
await userEvent.type(
canvas.getByRole('textbox', {
name: 'Request URL Template',
}),
'/users'
);
// Add request query params
await userEvent.click(canvas.getByText('Add query params'));
await userEvent.type(
canvas.getByRole('textbox', {
name: 'query_params[0].name',
}),
'userId'
);
await userEvent.type(
canvas.getByRole('textbox', {
name: 'query_params[0].value',
}),
'12'
);
// --------------------------------------------------
// Step 6: Update retry configuration fields
await userEvent.click(canvas.getByText('Retry Configuration'));

View File

@ -1,16 +1,24 @@
import React from 'react';
import { InputField, SimpleForm } from '@/new-components/Form';
import React, { useState } from 'react';
import { SimpleForm, InputField } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import { Analytics } from '@/features/Analytics';
import { useReadOnlyMode } from '@/hooks';
import { getConfirmation } from '@/components/Common/utils/jsUtils';
import { useFormContext } from 'react-hook-form';
import { RequestTransform } from '@/metadata/types';
import { schema, Schema } from './schema';
import {
AdvancedSettings,
CronPayloadInput,
AdvancedSettings,
CronScheduleSelector,
IncludeInMetadataSwitch,
RetryConfiguration,
} from './components';
import { getCronTriggerCreateQuery, getCronTriggerUpdateQuery } from './utils';
import {
getCronTriggerCreateQuery,
getCronTriggerDeleteQuery,
getCronTriggerUpdateQuery,
} from './utils';
import { useCronMetadataMigration, useDefaultValues } from './hooks';
import { CronRequestTransformation } from './components/CronRequestTransformation';
type Props = {
/**
@ -20,29 +28,150 @@ type Props = {
/**
* Success callback which can be used to apply custom logic on success, for ex. closing the form
*/
onSuccess?: () => void;
onSuccess?: (name?: string) => void;
onDeleteSuccess?: () => void;
};
interface FormContentProps {
initialTransform?: RequestTransform;
setTransform: (data: RequestTransform) => void;
}
const FormContent = (props: FormContentProps) => {
const { setTransform, initialTransform } = props;
const { watch } = useFormContext();
const webhookUrl = watch('webhook');
const payload = watch('payload');
return (
<>
<div className="mb-xs w-1/2">
<InputField
name="name"
label="Name"
placeholder="Name..."
tooltip="Give this cron trigger a friendly name"
/>
</div>
<div className="mb-xs w-1/2">
<InputField
name="comment"
label="Comment / Description"
placeholder="Comment / Description..."
tooltip="A statement to help describe the cron trigger in brief"
/>
</div>
<hr className="my-md" />
<div className="mb-xs w-1/2">
<InputField
name="webhook"
label="Webhook URL"
placeholder="https://httpbin.com/post"
tooltip="The HTTP URL that should be triggered. You can also provide the URL from environment variables, e.g. {{MY_WEBHOOK_URL}}"
/>
</div>
<div className="mb-xs w-1/2">
<CronScheduleSelector />
</div>
<div className="mb-xs w-1/2">
<CronPayloadInput />
</div>
<hr className="my-md" />
<div className="my-md w-1/2">
<AdvancedSettings />
</div>
<hr className="my-md" />
<div className="mb-xs pt-xs w-1/2">
<CronRequestTransformation
webhookUrl={webhookUrl}
payload={payload}
initialValue={initialTransform}
onChange={setTransform}
/>
</div>
</>
);
};
const CronTriggersForm = (props: Props) => {
const { onSuccess, cronTriggerName } = props;
const { onSuccess, onDeleteSuccess, cronTriggerName } = props;
const {
requestTransform,
data: defaultValues,
isLoading,
isError,
} = useDefaultValues({
cronTriggerName,
});
const { mutation } = useCronMetadataMigration({ onSuccess });
const { data: readOnlyMode } = useReadOnlyMode();
const [transform, setTransform] = useState<RequestTransform | undefined>();
const { mutation: deleteMutation } = useCronMetadataMigration({
onSuccess: onDeleteSuccess,
successMessage: 'Cron trigger deleted successfully',
errorMessage: 'Something went wrong while deleting cron trigger',
});
const { mutation: updateMutation } = useCronMetadataMigration({
successMessage: 'Cron trigger updated successfully',
errorMessage: 'Something went wrong while updating cron trigger',
});
const { mutation: createMutation } = useCronMetadataMigration({
successMessage: 'Cron trigger created successfully',
errorMessage: 'Something went wrong while creating cron trigger',
});
const onDelete = () => {
const isOk = getConfirmation(
'Are you sure you want to delete this cron trigger?',
true,
cronTriggerName
);
if (isOk && cronTriggerName) {
deleteMutation.mutate({
query: getCronTriggerDeleteQuery(cronTriggerName),
});
}
};
const onSubmit = (values: Record<string, unknown>) => {
if (cronTriggerName) {
mutation.mutate({
query: getCronTriggerUpdateQuery(values as Schema),
});
const isOk =
cronTriggerName === values?.name ||
getConfirmation(
'Renaming a trigger deletes the current trigger and creates a new trigger with this configuration. All the events of the current trigger will be dropped. Are you sure you want to continue?',
true,
'RENAME'
);
if (isOk) {
updateMutation.mutate(
{
query: getCronTriggerUpdateQuery(
cronTriggerName,
values as Schema,
transform
),
},
{
onSuccess: () => {
onSuccess?.(values?.name as string);
},
}
);
}
} else {
mutation.mutate({
query: getCronTriggerCreateQuery(values as Schema),
});
createMutation.mutate(
{
query: getCronTriggerCreateQuery(values as Schema, transform),
},
{
onSuccess: () => {
onSuccess?.(values?.name as string);
},
}
);
}
};
@ -61,52 +190,57 @@ const CronTriggersForm = (props: Props) => {
options={{ defaultValues }}
className="overflow-y-hidden p-4"
>
<>
<div className="mb-md">
<InputField
name="name"
label="Name"
placeholder="Name..."
tooltip="Give this cron trigger a friendly name"
/>
</div>
<div className="mb-md">
<InputField
name="comment"
label="Comment / Description"
placeholder="Comment / Description..."
tooltip="A statement to help describe the cron trigger in brief"
/>
</div>
<div className="mb-md">
<InputField
name="webhook"
label="Webhook URL"
placeholder="https://httpbin.com/post"
tooltip="The HTTP URL that should be triggered. You can also provide the URL from environment variables, e.g. {{MY_WEBHOOK_URL}}"
/>
</div>
<div className="mb-md">
<CronScheduleSelector />
</div>
<div className="mb-md">
<CronPayloadInput />
</div>
<div className="mb-md">
<IncludeInMetadataSwitch />
</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}>
{cronTriggerName ? 'Update Cron Trigger' : 'Add Cron Trigger'}
</Button>
</div>
</>
<FormContent
initialTransform={requestTransform}
setTransform={setTransform}
/>
<div className="flex items-center mb-lg gap-2">
{cronTriggerName ? (
<Analytics
name="events-tab-button-create-cron-trigger"
passHtmlAttributesToChildren
>
<Button
type="submit"
mode="primary"
isLoading={updateMutation.isLoading}
disabled={readOnlyMode}
>
Update Cron Trigger
</Button>
</Analytics>
) : (
<Analytics
name="events-tab-button-update-cron-trigger"
passHtmlAttributesToChildren
>
<Button
type="submit"
mode="primary"
isLoading={createMutation.isLoading}
disabled={readOnlyMode}
>
Add Cron Trigger
</Button>
</Analytics>
)}
{cronTriggerName && (
<Analytics
name="events-tab-button-delete-cron-trigger"
passHtmlAttributesToChildren
>
<Button
onClick={onDelete}
mode="destructive"
isLoading={deleteMutation.isLoading}
disabled={readOnlyMode}
>
Delete trigger
</Button>
</Analytics>
)}
</div>
</SimpleForm>
);
};

View File

@ -1,66 +1,45 @@
import React from 'react';
import { IconTooltip } from '@/new-components/Tooltip';
import { InputField, Radio } from '@/new-components/Form';
import { Collapse } from '@/new-components/deprecated';
import { RequestHeadersSelector } from '@/new-components/RequestHeadersSelector';
import { IconTooltip } from '@/new-components/Tooltip';
import { Collapsible } from '@/new-components/Collapsible';
import { IncludeInMetadataSwitch } from './IncludeInMetadataSwitch';
import { RetryConfiguration } from './RetryConfiguration';
export const AdvancedSettings = () => {
const requestMethodOptions = [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
];
return (
<>
<Collapse title="Header, URL, and Advanced Request Options">
<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 className="mb-md">
<label className="block flex items-center text-gray-600 font-semibold">
Method
<IconTooltip message="Configure method to transform your request to (optional). GET requests will be sent without a payload or content-type header" />
</label>
<Radio
orientation="horizontal"
name="request_method"
options={requestMethodOptions}
/>
</div>
<div className="mb-md">
<InputField
name="url_template"
prependLabel="{{$base_url}}"
label="Request URL Template"
placeholder="URL Template (Optional)..."
/>
</div>
<div className="mb-md">
<label className="block flex items-center text-gray-600 font-semibold mb-xs">
Query Params
<IconTooltip message="Configure headers for the request to the webhook" />
</label>
<RequestHeadersSelector
name="query_params"
typeSelect={false}
addButtonText="Add query params"
/>
</div>
</div>
</Collapse.Content>
</Collapse>
</>
<div className="my-md">
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Advanced Settings
</h2>
}
>
<div className="mb-xs">
<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>
<hr className="my-md" />
<div className="mb-xs">
<h4
className="text-lg font-bold pb-md mt-0 mb-0
"
>
Retry Configuration
</h4>
<RetryConfiguration />
</div>
<hr className="my-md" />
<div className="mb-xs">
<IncludeInMetadataSwitch />
</div>
</Collapsible>
</div>
);
};

View File

@ -0,0 +1,289 @@
import React, { useEffect, useReducer } from 'react';
import ConfigureTransformation from '@/components/Common/ConfigureTransformation/ConfigureTransformation';
import {
getCronTriggerRequestTransformDefaultState,
requestTransformReducer,
setEnvVars,
setRequestAddHeaders,
setRequestBody,
setRequestBodyError,
setRequestContentType,
setRequestMethod,
setRequestPayloadTransform,
setRequestQueryParams,
setRequestSampleInput,
setRequestTransformedBody,
setRequestTransformState,
setRequestUrl,
setRequestUrlError,
setRequestUrlPreview,
setRequestUrlTransform,
setSessionVars,
} from '@/components/Common/ConfigureTransformation/requestTransformState';
import {
RequestTransform,
RequestTransformContentType,
RequestTransformMethod,
} from '@/metadata/types';
import {
KeyValuePair,
RequestTransformStateBody,
} from '@/components/Common/ConfigureTransformation/stateDefaults';
import {
getRequestTransformObject,
getTransformState,
getValidateTransformOptions,
parseValidateApiData,
} from '@/components/Common/ConfigureTransformation/utils';
import { useDispatch } from 'react-redux';
import Endpoints from '@/Endpoints';
import requestAction from '@/utils/requestAction';
import { useDebouncedEffect } from '@/hooks/useDebounceEffect';
interface CronRequestTransformationProps {
initialValue?: RequestTransform;
webhookUrl: string;
payload: string;
onChange: (data: RequestTransform) => void;
}
/**
*
* Using the legacy request transformation component to be consistent with other part of the console.
* Replace with the new component once the it is ready.
*/
export const CronRequestTransformation = (
props: CronRequestTransformationProps
) => {
const { webhookUrl, payload, onChange, initialValue } = props;
const webhook = {
value: webhookUrl,
type: 'url',
};
const [transformState, transformDispatch] = useReducer(
requestTransformReducer,
getCronTriggerRequestTransformDefaultState()
);
const resetSampleInput = () => {
const value = {
comment: 'test',
id: '06af0430-e4d8-4335-8659-c27225e8edfd',
name: 'test',
payload: JSON.parse(payload || '{}'),
scheduled_time: '2022-12-13T10:32:00Z',
};
transformDispatch(setRequestSampleInput(JSON.stringify(value, null, 2)));
};
useEffect(() => {
resetSampleInput();
if (initialValue) {
transformDispatch(
setRequestTransformState(
getTransformState(initialValue, transformState.requestSampleInput)
)
);
}
}, [initialValue, transformDispatch]);
const dispatch = useDispatch();
const envVarsOnChange = (envVars: KeyValuePair[]) => {
transformDispatch(setEnvVars(envVars));
};
const sessionVarsOnChange = (sessionVars: KeyValuePair[]) => {
transformDispatch(setSessionVars(sessionVars));
};
const requestMethodOnChange = (requestMethod: RequestTransformMethod) => {
transformDispatch(setRequestMethod(requestMethod));
};
const requestUrlOnChange = (requestUrl: string) => {
transformDispatch(setRequestUrl(requestUrl));
};
const requestUrlErrorOnChange = (requestUrlError: string) => {
transformDispatch(setRequestUrlError(requestUrlError));
};
const requestUrlPreviewOnChange = (requestUrlPreview: string) => {
transformDispatch(setRequestUrlPreview(requestUrlPreview));
};
const requestQueryParamsOnChange = (requestQueryParams: KeyValuePair[]) => {
transformDispatch(setRequestQueryParams(requestQueryParams));
};
const requestAddHeadersOnChange = (requestAddHeaders: KeyValuePair[]) => {
transformDispatch(setRequestAddHeaders(requestAddHeaders));
};
const requestBodyOnChange = (requestBody: RequestTransformStateBody) => {
transformDispatch(setRequestBody(requestBody));
};
const requestBodyErrorOnChange = (requestBodyError: string) => {
transformDispatch(setRequestBodyError(requestBodyError));
};
const requestSampleInputOnChange = (requestSampleInput: string) => {
transformDispatch(setRequestSampleInput(requestSampleInput));
};
const requestTransformedBodyOnChange = (requestTransformedBody: string) => {
transformDispatch(setRequestTransformedBody(requestTransformedBody));
};
const requestContentTypeOnChange = (
requestContentType: RequestTransformContentType
) => {
transformDispatch(setRequestContentType(requestContentType));
};
const requestUrlTransformOnChange = (data: boolean) => {
transformDispatch(setRequestUrlTransform(data));
};
const requestPayloadTransformOnChange = (data: boolean) => {
transformDispatch(setRequestPayloadTransform(data));
};
useDebouncedEffect(
() => {
requestUrlErrorOnChange('');
requestUrlPreviewOnChange('');
const onResponse = (data: Record<string, any>) => {
parseValidateApiData(
data,
requestUrlErrorOnChange,
requestUrlPreviewOnChange
);
};
const options = getValidateTransformOptions({
version: transformState.version,
inputPayloadString: transformState.requestSampleInput,
webhookUrl: webhook.value,
envVarsFromContext: transformState.envVars,
sessionVarsFromContext: transformState.sessionVars,
requestUrl: transformState.requestUrl,
queryParams: transformState.requestQueryParams,
isEnvVar: webhook.type === 'env',
});
if (!webhook.value) {
requestUrlErrorOnChange(
'Please configure your webhook handler to generate request url transform'
);
} else if (
transformState.requestSampleInput &&
transformState.requestBody
) {
(
dispatch(
requestAction(
Endpoints.metadata,
options,
undefined,
undefined,
true,
true
)
) as unknown as Promise<Record<string, unknown>>
).then(onResponse, onResponse); // parseValidateApiData will parse both success and error
}
},
500,
[
transformState.requestSampleInput,
webhookUrl,
transformState.requestUrl,
transformState.requestQueryParams,
transformState.envVars,
transformState.sessionVars,
]
);
useDebouncedEffect(
() => {
requestBodyErrorOnChange('');
requestTransformedBodyOnChange('');
const onResponse = (data: Record<string, any>) => {
parseValidateApiData(
data,
requestBodyErrorOnChange,
undefined,
requestTransformedBodyOnChange
);
};
const options = getValidateTransformOptions({
version: transformState.version,
inputPayloadString: transformState.requestSampleInput,
webhookUrl: webhook.value,
envVarsFromContext: transformState.envVars,
sessionVarsFromContext: transformState.sessionVars,
transformerBody: transformState.requestBody,
isEnvVar: webhook.type === 'env',
});
if (!webhook.value) {
requestBodyErrorOnChange(
'Please configure your webhook handler to generate request body transform'
);
} else if (
transformState.requestSampleInput &&
transformState.requestBody
) {
(
dispatch(
requestAction(
Endpoints.metadata,
options,
undefined,
undefined,
true,
true
)
) as unknown as Promise<Record<string, unknown>>
).then(onResponse, onResponse); // parseValidateApiData will parse both success and error
}
},
500,
[
transformState.requestSampleInput,
transformState.requestBody,
webhookUrl,
transformState.envVars,
transformState.sessionVars,
]
);
useEffect(() => {
const data = getRequestTransformObject(transformState);
if (data) {
onChange(data);
}
}, [transformState]);
return (
<ConfigureTransformation
transformationType="event"
requestTransfromState={transformState}
resetSampleInput={resetSampleInput}
envVarsOnChange={envVarsOnChange}
sessionVarsOnChange={sessionVarsOnChange}
requestMethodOnChange={requestMethodOnChange}
requestUrlOnChange={requestUrlOnChange}
requestQueryParamsOnChange={requestQueryParamsOnChange}
requestAddHeadersOnChange={requestAddHeadersOnChange}
requestBodyOnChange={requestBodyOnChange}
requestSampleInputOnChange={requestSampleInputOnChange}
requestContentTypeOnChange={requestContentTypeOnChange}
requestUrlTransformOnChange={requestUrlTransformOnChange}
requestPayloadTransformOnChange={requestPayloadTransformOnChange}
/>
);
};

View File

@ -14,7 +14,7 @@ export const CronScheduleSelector = () => {
return (
<>
<div className="block flex items-center text-gray-600 font-semibold mb-xs">
<div className="block flex items-center text-gray-600 font-semibold">
<label htmlFor="schedule">Cron Schedule</label>
<IconTooltip message="Schedule for your cron (events are created based on the UTC timezone)" />
<a
@ -26,10 +26,12 @@ export const CronScheduleSelector = () => {
(Build a cron expression)
</a>
</div>
<div className="relative w-full max-w-xl mb-xs">
<div className="relative w-full">
<InputField name="schedule" type="text" placeholder={defaultCronExpr} />
<div className="-mt-xs mb-md">
<FrequentlyUsedCrons setCron={setCron} />
</div>
</div>
<FrequentlyUsedCrons setCron={setCron} />
</>
);
};

View File

@ -1,5 +1,4 @@
import React from 'react';
import { Collapse } from '@/new-components/deprecated';
import { IconTooltip } from '@/new-components/Tooltip';
import { useFormContext } from 'react-hook-form';
@ -11,59 +10,52 @@ export const RetryConfiguration = () => {
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 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>
</Collapse.Content>
</Collapse>
<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>
</>
);
};

View File

@ -1,29 +1,43 @@
import { useMetadataMigration } from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { APIError } from '@/hooks/error';
import { CRON_TRIGGERS_QUERY_KEY } from '@/components/Services/Events/CronTriggers/Hooks/useGetCronTriggers';
import { useQueryClient } from 'react-query';
import { ALL_CRON_TRIGGERS_QUERY_KEY } from './useGetAllCronTriggers';
interface Props {
onSuccess?: () => void;
triggerName?: string;
successMessage: string;
errorMessage: string;
}
export const useCronMetadataMigration = (props: Props) => {
const { onSuccess } = props;
const { onSuccess, successMessage, errorMessage } = props;
const { fireNotification } = useFireNotification();
const queryClient = useQueryClient();
const mutation = useMetadataMigration({
onError: (error: APIError) => {
fireNotification({
type: 'error',
title: 'Error',
message: error?.message ?? 'Unable to create Cron Trigger',
message: `${errorMessage}: ${error.message}`,
});
},
onSuccess: () => {
fireNotification({
type: 'success',
title: 'Success!',
message: 'Cron Trigger created successfully',
message: successMessage,
});
/* this query is performed in legacy section, remove it will completely be refactored */
queryClient.invalidateQueries({
queryKey: [CRON_TRIGGERS_QUERY_KEY],
});
queryClient.invalidateQueries({
queryKey: [ALL_CRON_TRIGGERS_QUERY_KEY],
});
onSuccess?.();
},
});

View File

@ -1,8 +1,8 @@
import { RequestTransform } from '@/metadata/types';
import { Schema } from '../schema';
import {
emptyDefaultValues,
serverHeadersToKeyValueArray,
serverQueryParamsToKeyValueArray,
stringifyNumberValue,
} from './utils';
import { useGetAllCronTriggers } from './useGetAllCronTriggers';
@ -32,7 +32,9 @@ export const useDefaultValues = (props: Props) => {
return { data: emptyDefaultValues, isLoading, isError: true };
}
const existingCronTriggerValues: Schema = {
const existingCronTriggerValues: Schema & {
requestTransform?: RequestTransform;
} = {
name: currentTrigger.name,
webhook: currentTrigger.webhook,
schedule: currentTrigger.schedule,
@ -52,15 +54,19 @@ export const useDefaultValues = (props: Props) => {
),
include_in_metadata: currentTrigger.include_in_metadata,
comment: currentTrigger.comment ?? '',
url_template: currentTrigger.request_transform?.url ?? '',
request_method: currentTrigger.request_transform?.method ?? null,
query_params: serverQueryParamsToKeyValueArray(
currentTrigger.request_transform?.query_params
),
};
return { data: existingCronTriggerValues, isLoading, isError };
return {
data: existingCronTriggerValues,
isLoading,
isError,
requestTransform: currentTrigger?.request_transform,
};
}
// return error and loading states with empty default values
return { data: emptyDefaultValues, isLoading, isError };
return {
data: emptyDefaultValues,
isLoading,
isError,
};
};

View File

@ -8,6 +8,7 @@ interface GetCronTriggersResponse {
cron_triggers: CronTrigger[];
}
export const ALL_CRON_TRIGGERS_QUERY_KEY = 'allCronTriggers';
export const useGetAllCronTriggers = () => {
const headers = useAppSelector(state => state.tables.dataHeaders);
@ -17,7 +18,7 @@ export const useGetAllCronTriggers = () => {
args: {},
};
return useQuery(['allCronTriggers'], () =>
return useQuery([ALL_CRON_TRIGGERS_QUERY_KEY], () =>
Api.post<GetCronTriggersResponse>({
headers,
body,

View File

@ -1,4 +1,3 @@
import { Nullable } from '@/components/Common/utils/tsUtils';
import { ServerHeader } from '@/metadata/types';
import { Schema } from '../schema';
@ -24,18 +23,6 @@ export const serverHeadersToKeyValueArray = (
return [];
};
export const serverQueryParamsToKeyValueArray = (
serverObject: Nullable<Record<string, string>>
): Schema['query_params'] => {
if (serverObject) {
return Object.entries(serverObject).map(([key, value]) => ({
name: key,
value,
}));
}
return [];
};
export const stringifyNumberValue = (
value: number | undefined,
defaultValue: string
@ -57,7 +44,4 @@ export const emptyDefaultValues: Schema = {
timeout_seconds: '60',
include_in_metadata: true,
comment: '',
url_template: '',
request_method: null,
query_params: [],
};

View File

@ -15,9 +15,6 @@ export const schema = z.object({
timeout_seconds: z.string(),
include_in_metadata: z.boolean(),
comment: z.string(),
url_template: z.string(),
request_method: z.enum(['POST', 'GET', 'PUT', 'DELETE', 'PATCH']).nullable(),
query_params: requestHeadersSelectorSchema,
});
export type Schema = z.infer<typeof schema>;

View File

@ -1,60 +1,18 @@
import { isEmpty, isJsonString } from '@/components/Common/utils/jsUtils';
import { isJsonString } from '@/components/Common/utils/jsUtils';
import { CronTrigger, RequestTransform } from '@/metadata/types';
import { Schema } from './schema';
type RequestTransformBlockProps = {
url_template: Schema['url_template'];
request_method: Schema['request_method'];
query_params: Schema['query_params'];
};
/**
* Get request transform / rest connectors field of the cron trigger object, this field is added optionally
* if at least one of the transform fields are present
*/
const getRequestTransformBlock = (
props: RequestTransformBlockProps
): RequestTransform | undefined => {
const { url_template, request_method, query_params } = props;
// add transform block if atleast one of the field is present
if (isEmpty(url_template) && isEmpty(query_params) && isEmpty(request_method))
return;
return {
version: 2,
template_engine: 'Kriti',
method: request_method,
url: url_template,
query_params: query_params.reduce(
(allParams, param) => ({
...allParams,
[param.name]: param.value,
}),
{}
),
// in case of GET requests, do not send request body or content-type header to the webhook
// https://github.com/hasura/graphql-engine/issues/7937
...(request_method === 'GET' && {
request_headers: {
remove_headers: ['content-type'],
},
body: {
action: 'remove',
},
}),
};
};
/**
* Transforms form data to `create_cron_trigger` api payload
* @param values React hook form schema containing all form fields
* @returns api payload for `create_cron_trigger`
*/
const transformFormData = (values: Schema) => {
const { url_template, request_method, query_params } = values;
const apiPayload: CronTrigger = {
const transformFormData = (
values: Schema,
replace: boolean,
requestTransform?: RequestTransform
) => {
const apiPayload: CronTrigger & { replace?: true } = {
name: values.name,
webhook: values.webhook,
schedule: values.schedule,
@ -73,18 +31,19 @@ const transformFormData = (values: Schema) => {
},
include_in_metadata: values.include_in_metadata,
comment: values.comment,
request_transform: getRequestTransformBlock({
url_template,
request_method,
query_params,
}),
request_transform: requestTransform,
...(replace && { replace: true }),
};
return apiPayload;
};
export const getCronTriggerCreateQuery = (values: Schema) => {
const args = transformFormData(values);
export const getCronTriggerCreateQuery = (
values: Schema,
requestTransform?: RequestTransform,
replace = false
) => {
const args = transformFormData(values, replace, requestTransform);
return {
type: 'create_cron_trigger' as const,
args,
@ -98,10 +57,17 @@ export const getCronTriggerDeleteQuery = (name: string) => ({
},
});
export const getCronTriggerUpdateQuery = (values: Schema) => ({
export const getCronTriggerUpdateQuery = (
name: string,
values: Schema,
requestTransform?: RequestTransform
) => ({
type: 'bulk' as const,
args: [
getCronTriggerDeleteQuery(values.name),
getCronTriggerCreateQuery(values),
],
args:
name === values.name
? [getCronTriggerCreateQuery(values, requestTransform, true)]
: [
getCronTriggerDeleteQuery(name),
getCronTriggerCreateQuery(values, requestTransform),
],
});

View File

@ -33,6 +33,8 @@ The following fields are required to define a cron trigger:
timezone. You can use tools like [crontab guru](https://crontab.guru/#*_*_*_*_*) to help build a cron expression.
- **Payload**: The JSON payload which will be sent to the webhook.
You can also define a **comment** that helps you identify the cron trigger.
For example, we can create a cron trigger called `eod_reports`, to trigger the webhook `https://mywebhook.com/eod` with
the cron schedule `0 22 * * 1-5`, which means "At 22:00 on every day-of-week from Monday through Friday" (you can check
this [here](https://crontab.guru/#0_22_*_*_1-5)).
@ -104,7 +106,6 @@ If you like, you can also define the following values:
- **Retry configuration**: In case the call to the webhook fails.
- **Include in metadata**: When set to true, the cron trigger will be included in the metadata and can be exported along
with it.
- **Comment**: Custom description of the cron trigger.
<Tabs groupId="user-preference" className="api-tabs">
<TabItem value="console" label="Console">
@ -191,5 +192,5 @@ webhooks without needing any middleware or modifications to the upstream code.
REST Connectors modify the cron trigger's HTTP request to adapt to your webhook's expected format by adding suitable
transforms.
Currently, rest connectors for cron triggers can only be configured through the CLI and the
[Metadata API](/api-reference/metadata-api/scheduled-triggers.mdx#metadata-create-cron-trigger).
Rest Connectors for cron triggers can be configured in the same way as rest connectors for event triggers. You can read more about it
in the [REST Connectors](/docs/latest/event-triggers/rest-connectors/) section.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 52 KiB