Add Manual Triggers (#8024)

In this PR:

- Add support for manual triggers in the backend
- Add a right drawer to let users select the type of trigger they want
- Create a specific right drawer for database event triggers
- Create a right drawer for manual triggers; let the user select where
the manual trigger should be made available
- Create a default trigger as soon as the user selects the type of
trigger they want. It prevents the user to see empty selects for record
type and event type. By default, the database event trigger will be set
to "company.created". It should be visible enough for users to
understand what happens and choose another record type or event type.



https://github.com/user-attachments/assets/29a21985-1823-4890-9eb3-e4f876459c7a
This commit is contained in:
Baptiste Devessier 2024-10-25 14:24:56 +02:00 committed by GitHub
parent bf2ba25a6e
commit 0144553667
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 609 additions and 205 deletions

View File

@ -15,6 +15,7 @@ import { RightDrawerWorkflowViewStep } from '@/workflow/components/RightDrawerWo
import { isDefined } from 'twenty-ui';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
import { RightDrawerWorkflowSelectTriggerType } from '@/workflow/components/RightDrawerWorkflowSelectTriggerType';
const StyledRightDrawerPage = styled.div`
display: flex;
@ -38,6 +39,9 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
[RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />,
[RightDrawerPages.ViewRecord]: <RightDrawerRecord />,
[RightDrawerPages.Copilot]: <RightDrawerAIChat />,
[RightDrawerPages.WorkflowStepSelectTriggerType]: (
<RightDrawerWorkflowSelectTriggerType />
),
[RightDrawerPages.WorkflowStepSelectAction]: (
<RightDrawerWorkflowSelectAction />
),

View File

@ -5,7 +5,8 @@ export const RIGHT_DRAWER_PAGE_ICONS = {
[RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent',
[RightDrawerPages.ViewRecord]: 'Icon123',
[RightDrawerPages.Copilot]: 'IconSparkles',
[RightDrawerPages.WorkflowStepEdit]: 'IconSparkles',
[RightDrawerPages.WorkflowStepSelectTriggerType]: 'IconSparkles',
[RightDrawerPages.WorkflowStepSelectAction]: 'IconSparkles',
[RightDrawerPages.WorkflowStepEdit]: 'IconSparkles',
[RightDrawerPages.WorkflowStepView]: 'IconSparkles',
};

View File

@ -5,7 +5,8 @@ export const RIGHT_DRAWER_PAGE_TITLES = {
[RightDrawerPages.ViewCalendarEvent]: 'Calendar Event',
[RightDrawerPages.ViewRecord]: 'Record Editor',
[RightDrawerPages.Copilot]: 'Copilot',
[RightDrawerPages.WorkflowStepEdit]: 'Workflow',
[RightDrawerPages.WorkflowStepSelectTriggerType]: 'Workflow',
[RightDrawerPages.WorkflowStepSelectAction]: 'Workflow',
[RightDrawerPages.WorkflowStepEdit]: 'Workflow',
[RightDrawerPages.WorkflowStepView]: 'Workflow',
};

View File

@ -3,6 +3,7 @@ export enum RightDrawerPages {
ViewCalendarEvent = 'view-calendar-event',
ViewRecord = 'view-record',
Copilot = 'copilot',
WorkflowStepSelectTriggerType = 'workflow-step-select-trigger-type',
WorkflowStepSelectAction = 'workflow-step-select-action',
WorkflowStepView = 'workflow-step-view',
WorkflowStepEdit = 'workflow-step-edit',

View File

@ -24,18 +24,16 @@ export const RightDrawerWorkflowSelectActionContent = ({
});
return (
<>
<StyledActionListContainer>
{ACTIONS.map((action) => (
<MenuItem
LeftIcon={action.icon}
text={action.label}
onClick={() => {
return createStep(action.type);
}}
/>
))}
</StyledActionListContainer>
</>
<StyledActionListContainer>
{ACTIONS.map((action) => (
<MenuItem
LeftIcon={action.icon}
text={action.label}
onClick={() => {
return createStep(action.type);
}}
/>
))}
</StyledActionListContainer>
);
};

View File

@ -0,0 +1,16 @@
import { RightDrawerWorkflowSelectTriggerTypeContent } from '@/workflow/components/RightDrawerWorkflowSelectTriggerTypeContent';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowSelectTriggerType = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowSelectTriggerTypeContent workflow={workflow} />;
};

View File

@ -0,0 +1,58 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { TRIGGER_TYPES } from '@/workflow/constants/TriggerTypes';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { getTriggerDefaultDefinition } from '@/workflow/utils/getTriggerDefaultDefinition';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
const StyledActionListContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding-block: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
export const RightDrawerWorkflowSelectTriggerTypeContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const { openRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
return (
<StyledActionListContainer>
{TRIGGER_TYPES.map((action) => (
<MenuItem
LeftIcon={action.icon}
text={action.label}
onClick={async () => {
await updateTrigger(
getTriggerDefaultDefinition({
type: action.type,
activeObjectMetadataItems,
}),
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
}}
/>
))}
</StyledActionListContainer>
);
};

View File

@ -1,5 +1,7 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { CREATE_STEP_STEP_ID } from '@/workflow/constants/CreateStepStepId';
import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/constants/EmptyTriggerStepId';
import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation';
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
@ -26,7 +28,14 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
return;
}
const isCreateStepNode = selectedNode.type === 'create-step';
const isEmptyTriggerNode = selectedNode.type === EMPTY_TRIGGER_STEP_ID;
if (isEmptyTriggerNode) {
openRightDrawer(RightDrawerPages.WorkflowStepSelectTriggerType);
return;
}
const isCreateStepNode = selectedNode.type === CREATE_STEP_STEP_ID;
if (isCreateStepNode) {
if (selectedNode.data.nodeType !== 'create-step') {
throw new Error('Expected selected node to be a create step node.');

View File

@ -3,7 +3,7 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui';
import { IconCode, IconHandMove, IconMail, IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
@ -26,17 +26,30 @@ export const WorkflowDiagramStepNodeBase = ({
const renderStepIcon = () => {
switch (data.nodeType) {
case 'trigger': {
return (
<StyledStepNodeLabelIconContainer>
<IconPlaylistAdd
size={theme.icon.size.sm}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'condition': {
return null;
switch (data.triggerType) {
case 'DATABASE_EVENT': {
return (
<StyledStepNodeLabelIconContainer>
<IconPlaylistAdd
size={theme.icon.size.sm}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'MANUAL': {
return (
<StyledStepNodeLabelIconContainer>
<IconHandMove
size={theme.icon.size.sm}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
}
return assertUnreachable(data.triggerType);
}
case 'action': {
switch (data.actionType) {

View File

@ -5,25 +5,17 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditActionFormSendEmailProps =
| {
action: WorkflowSendEmailStep;
@ -174,87 +166,85 @@ export const WorkflowEditActionFormSendEmail = (
return (
!loading && (
<WorkflowEditActionFormBase
ActionIcon={<IconMail color={theme.color.blue} />}
actionTitle="Send Email"
actionType="Email"
<WorkflowEditGenericFormBase
HeaderIcon={<IconMail color={theme.color.blue} />}
headerTitle="Send Email"
headerType="Email"
>
<StyledTriggerSettings>
<Controller
name="connectedAccountId"
control={form.control}
render={({ field }) => (
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={field.value}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerGoogleApisOAuth({ redirectLocation: redirectUrl }),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
field.onChange(connectedAccountId);
handleSave(true);
}}
/>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field }) => (
<VariableTagInput
inputId="email-input"
label="Email"
placeholder="Enter receiver email (use {{variable}} for dynamic content)"
value={field.value}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<VariableTagInput
inputId="email-subject-input"
label="Subject"
placeholder="Enter email subject (use {{variable}} for dynamic content)"
value={field.value}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
<Controller
name="connectedAccountId"
control={form.control}
render={({ field }) => (
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={field.value}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerGoogleApisOAuth({ redirectLocation: redirectUrl }),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
field.onChange(connectedAccountId);
handleSave(true);
}}
/>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field }) => (
<VariableTagInput
inputId="email-input"
label="Email"
placeholder="Enter receiver email (use {{variable}} for dynamic content)"
value={field.value}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<VariableTagInput
inputId="email-subject-input"
label="Subject"
placeholder="Enter email subject (use {{variable}} for dynamic content)"
value={field.value}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<TextArea
label="Body"
placeholder="Enter email body (use {{variable}} for dynamic content)"
value={field.value}
minRows={4}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
</StyledTriggerSettings>
</WorkflowEditActionFormBase>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<TextArea
label="Body"
placeholder="Enter email body (use {{variable}} for dynamic content)"
value={field.value}
minRows={4}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
</WorkflowEditGenericFormBase>
)
);
};

View File

@ -1,18 +1,10 @@
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowCodeStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCode, isDefined } from 'twenty-ui';
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditActionFormServerlessFunctionProps =
| {
action: WorkflowCodeStep;
@ -44,36 +36,34 @@ export const WorkflowEditActionFormServerlessFunction = (
];
return (
<WorkflowEditActionFormBase
ActionIcon={<IconCode color={theme.color.orange} />}
actionTitle="Code - Serverless Function"
actionType="Code"
<WorkflowEditGenericFormBase
HeaderIcon={<IconCode color={theme.color.orange} />}
headerTitle="Code - Serverless Function"
headerType="Code"
>
<StyledTriggerSettings>
<Select
dropdownId="workflow-edit-action-function"
label="Function"
fullWidth
value={props.action.settings.input.serverlessFunctionId}
options={availableFunctions}
disabled={props.readonly}
onChange={(updatedFunction) => {
if (props.readonly === true) {
return;
}
<Select
dropdownId="workflow-edit-action-function"
label="Function"
fullWidth
value={props.action.settings.input.serverlessFunctionId}
options={availableFunctions}
disabled={props.readonly}
onChange={(updatedFunction) => {
if (props.readonly === true) {
return;
}
props.onActionUpdate({
...props.action,
settings: {
...props.action.settings,
input: {
serverlessFunctionId: updatedFunction,
},
props.onActionUpdate({
...props.action,
settings: {
...props.action.settings,
input: {
serverlessFunctionId: updatedFunction,
},
});
}}
/>
</StyledTriggerSettings>
</WorkflowEditActionFormBase>
},
});
}}
/>
</WorkflowEditGenericFormBase>
);
};

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled';
import React from 'react';
const StyledTriggerHeader = styled.div`
const StyledHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
@ -9,7 +9,7 @@ const StyledTriggerHeader = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
`;
const StyledTriggerHeaderTitle = styled.p`
const StyledHeaderTitle = styled.p`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
@ -17,12 +17,12 @@ const StyledTriggerHeaderTitle = styled.p`
margin: ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledTriggerHeaderType = styled.p`
const StyledHeaderType = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
`;
const StyledTriggerHeaderIconContainer = styled.div`
const StyledHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
@ -32,30 +32,35 @@ const StyledTriggerHeaderIconContainer = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowEditActionFormBase = ({
ActionIcon,
actionTitle,
actionType,
const StyledContentContainer = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditGenericFormBase = ({
HeaderIcon,
headerTitle,
headerType,
children,
}: {
ActionIcon: React.ReactNode;
actionTitle: string;
actionType: string;
HeaderIcon: React.ReactNode;
headerTitle: string;
headerType: string;
children: React.ReactNode;
}) => {
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
{ActionIcon}
</StyledTriggerHeaderIconContainer>
<StyledHeader>
<StyledHeaderIconContainer>{HeaderIcon}</StyledHeaderIconContainer>
<StyledTriggerHeaderTitle>{actionTitle}</StyledTriggerHeaderTitle>
<StyledHeaderTitle>{headerTitle}</StyledHeaderTitle>
<StyledTriggerHeaderType>{actionType}</StyledTriggerHeaderType>
</StyledTriggerHeader>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeader>
{children}
<StyledContentContainer>{children}</StyledContentContainer>
</>
);
};

View File

@ -1,7 +1,7 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { OBJECT_EVENT_TRIGGERS } from '@/workflow/constants/ObjectEventTriggers';
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@ -45,23 +45,23 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditTriggerFormProps =
type WorkflowEditTriggerDatabaseEventFormProps =
| {
trigger: WorkflowTrigger | undefined;
trigger: WorkflowDatabaseEventTrigger;
readonly: true;
onTriggerUpdate?: undefined;
}
| {
trigger: WorkflowTrigger | undefined;
trigger: WorkflowDatabaseEventTrigger;
readonly?: false;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
onTriggerUpdate: (trigger: WorkflowDatabaseEventTrigger) => void;
};
export const WorkflowEditTriggerForm = ({
export const WorkflowEditTriggerDatabaseEventForm = ({
trigger,
readonly,
onTriggerUpdate,
}: WorkflowEditTriggerFormProps) => {
}: WorkflowEditTriggerDatabaseEventFormProps) => {
const theme = useTheme();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
@ -102,7 +102,7 @@ export const WorkflowEditTriggerForm = ({
<StyledTriggerHeaderType>
{isDefined(selectedEvent)
? `Trigger . Record is ${selectedEvent.label}`
? `Trigger · Record is ${selectedEvent.label}`
: '-'}
</StyledTriggerHeaderType>
</StyledTriggerHeader>

View File

@ -0,0 +1,97 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/constants/ManualTriggerAvailabilityOptions';
import {
WorkflowManualTrigger,
WorkflowManualTriggerAvailability,
} from '@/workflow/types/Workflow';
import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { IconHandMove, isDefined } from 'twenty-ui';
type WorkflowEditTriggerManualFormProps =
| {
trigger: WorkflowManualTrigger;
readonly: true;
onTriggerUpdate?: undefined;
}
| {
trigger: WorkflowManualTrigger;
readonly?: false;
onTriggerUpdate: (trigger: WorkflowManualTrigger) => void;
};
export const WorkflowEditTriggerManualForm = ({
trigger,
readonly,
onTriggerUpdate,
}: WorkflowEditTriggerManualFormProps) => {
const theme = useTheme();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
}));
const manualTriggerAvailability: WorkflowManualTriggerAvailability =
isDefined(trigger.settings.objectType)
? 'WHEN_RECORD_SELECTED'
: 'EVERYWHERE';
return (
<WorkflowEditGenericFormBase
HeaderIcon={<IconHandMove color={theme.font.color.tertiary} />}
headerTitle="Manual Trigger"
headerType="Trigger · Manual"
>
<Select
dropdownId="workflow-edit-manual-trigger-availability"
label="Available"
fullWidth
disabled={readonly}
value={manualTriggerAvailability}
options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS}
onChange={(updatedTriggerType) => {
if (readonly === true) {
return;
}
onTriggerUpdate({
...trigger,
settings: getManualTriggerDefaultSettings({
availability: updatedTriggerType,
activeObjectMetadataItems,
}),
});
}}
/>
{manualTriggerAvailability === 'WHEN_RECORD_SELECTED' ? (
<Select
dropdownId="workflow-edit-manual-trigger-object"
label="Object"
fullWidth
value={trigger.settings.objectType}
options={availableMetadata}
disabled={readonly}
onChange={(updatedObject) => {
if (readonly === true) {
return;
}
onTriggerUpdate({
...trigger,
settings: {
objectType: updatedObject,
},
});
}}
/>
) : null}
</WorkflowEditGenericFormBase>
);
};

View File

@ -1,6 +1,7 @@
import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction';
import { WorkflowEditTriggerForm } from '@/workflow/components/WorkflowEditTriggerForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/components/WorkflowEditTriggerManualForm';
import {
WorkflowAction,
WorkflowTrigger,
@ -41,12 +42,36 @@ export const WorkflowStepDetail = ({
switch (stepDefinition.type) {
case 'trigger': {
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
if (!isDefined(stepDefinition.definition)) {
throw new Error(
'Expected the trigger to be defined at this point. Ensure the trigger has been set with a default value before trying to edit it.',
);
}
switch (stepDefinition.definition.type) {
case 'DATABASE_EVENT': {
return (
<WorkflowEditTriggerDatabaseEventForm
trigger={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}
case 'MANUAL': {
return (
<WorkflowEditTriggerManualForm
trigger={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}
}
return assertUnreachable(
stepDefinition.definition,
`Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`,
);
}
case 'action': {
@ -70,6 +95,11 @@ export const WorkflowStepDetail = ({
);
}
}
return assertUnreachable(
stepDefinition.definition,
`Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`,
);
}
}

View File

@ -0,0 +1 @@
export const CREATE_STEP_STEP_ID = 'create-step';

View File

@ -0,0 +1 @@
export const EMPTY_TRIGGER_STEP_ID = 'empty-trigger';

View File

@ -0,0 +1,19 @@
import { WorkflowManualTriggerAvailability } from '@/workflow/types/Workflow';
import { IconCheckbox, IconComponent, IconSquare } from 'twenty-ui';
export const MANUAL_TRIGGER_AVAILABILITY_OPTIONS: Array<{
label: string;
value: WorkflowManualTriggerAvailability;
Icon: IconComponent;
}> = [
{
label: 'When record(s) are selected',
value: 'WHEN_RECORD_SELECTED',
Icon: IconCheckbox,
},
{
label: 'When no record(s) are selected',
value: 'EVERYWHERE',
Icon: IconSquare,
},
];

View File

@ -0,0 +1,19 @@
import { WorkflowTriggerType } from '@/workflow/types/Workflow';
import { IconComponent, IconSettingsAutomation } from 'twenty-ui';
export const TRIGGER_TYPES: Array<{
label: string;
type: WorkflowTriggerType;
icon: IconComponent;
}> = [
{
label: 'Database Event',
type: 'DATABASE_EVENT',
icon: IconSettingsAutomation,
},
{
label: 'Manual',
type: 'MANUAL',
icon: IconSettingsAutomation,
},
];

View File

@ -48,10 +48,8 @@ export type WorkflowActionType = WorkflowAction['type'];
export type WorkflowStepType = WorkflowStep['type'];
export type WorkflowTriggerType = 'DATABASE_EVENT';
type BaseTrigger = {
type: WorkflowTriggerType;
type: string;
input?: object;
};
@ -62,7 +60,24 @@ export type WorkflowDatabaseEventTrigger = BaseTrigger & {
};
};
export type WorkflowTrigger = WorkflowDatabaseEventTrigger;
export type WorkflowManualTrigger = BaseTrigger & {
type: 'MANUAL';
settings: {
objectType?: string;
};
};
export type WorkflowManualTriggerSettings = WorkflowManualTrigger['settings'];
export type WorkflowManualTriggerAvailability =
| 'EVERYWHERE'
| 'WHEN_RECORD_SELECTED';
export type WorkflowTrigger =
| WorkflowDatabaseEventTrigger
| WorkflowManualTrigger;
export type WorkflowTriggerType = WorkflowTrigger['type'];
export type WorkflowStatus = 'DRAFT' | 'ACTIVE' | 'DEACTIVATED';

View File

@ -1,4 +1,7 @@
import { WorkflowActionType } from '@/workflow/types/Workflow';
import {
WorkflowActionType,
WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { Edge, Node } from '@xyflow/react';
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
@ -11,7 +14,8 @@ export type WorkflowDiagram = {
export type WorkflowDiagramStepNodeData =
| {
nodeType: 'trigger' | 'condition';
nodeType: 'trigger';
triggerType: WorkflowTriggerType;
label: string;
}
| {

View File

@ -55,6 +55,7 @@ describe('getWorkflowVersionDiagram', () => {
data: {
label: 'Company is Created',
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
},
id: 'trigger',
position: { x: 0, y: 0 },

View File

@ -5,6 +5,7 @@ import {
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { MarkerType } from '@xyflow/react';
import { isDefined } from 'twenty-ui';
@ -59,15 +60,37 @@ export const generateWorkflowDiagram = ({
const triggerNodeId = TRIGGER_STEP_ID;
if (isDefined(trigger)) {
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
let triggerLabel: string;
switch (trigger.type) {
case 'MANUAL': {
triggerLabel = 'Manual Trigger';
break;
}
case 'DATABASE_EVENT': {
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
triggerLabel = `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`;
break;
}
default: {
return assertUnreachable(
trigger,
`Expected the trigger "${JSON.stringify(trigger)}" to be supported.`,
);
}
}
nodes.push({
id: triggerNodeId,
data: {
nodeType: 'trigger',
label: `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`,
triggerType: trigger.type,
label: triggerLabel,
},
position: {
x: 0,

View File

@ -0,0 +1,29 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
WorkflowManualTriggerAvailability,
WorkflowManualTriggerSettings,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
export const getManualTriggerDefaultSettings = ({
availability,
activeObjectMetadataItems,
}: {
availability: WorkflowManualTriggerAvailability;
activeObjectMetadataItems: ObjectMetadataItem[];
}): WorkflowManualTriggerSettings => {
switch (availability) {
case 'EVERYWHERE': {
return {
objectType: undefined,
};
}
case 'WHEN_RECORD_SELECTED': {
return {
objectType: activeObjectMetadataItems[0].nameSingular,
};
}
}
return assertUnreachable(availability);
};

View File

@ -1,4 +1,5 @@
import { WorkflowStep, WorkflowStepType } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { v4 } from 'uuid';
export const getStepDefaultDefinition = (
@ -53,7 +54,7 @@ export const getStepDefaultDefinition = (
};
}
default: {
throw new Error(`Unknown type: ${type}`);
return assertUnreachable(type, `Unknown type: ${type}`);
}
}
};

View File

@ -0,0 +1,45 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OBJECT_EVENT_TRIGGERS } from '@/workflow/constants/ObjectEventTriggers';
import {
WorkflowTrigger,
WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTriggerDefaultSettings';
export const getTriggerDefaultDefinition = ({
type,
activeObjectMetadataItems,
}: {
type: WorkflowTriggerType;
activeObjectMetadataItems: ObjectMetadataItem[];
}): WorkflowTrigger => {
if (activeObjectMetadataItems.length === 0) {
throw new Error(
'This function need to receive at least one object metadata item to run.',
);
}
switch (type) {
case 'DATABASE_EVENT': {
return {
type,
settings: {
eventName: `${activeObjectMetadataItems[0].nameSingular}.${OBJECT_EVENT_TRIGGERS[0].value}`,
},
};
}
case 'MANUAL': {
return {
type,
settings: getManualTriggerDefaultSettings({
availability: 'WHEN_RECORD_SELECTED',
activeObjectMetadataItems,
}),
};
}
default: {
return assertUnreachable(type, `Unknown type: ${type}`);
}
}
};

View File

@ -1,5 +1,6 @@
export enum WorkflowTriggerType {
DATABASE_EVENT = 'DATABASE_EVENT',
MANUAL = 'MANUAL',
}
type BaseTrigger = {
@ -15,4 +16,20 @@ export type WorkflowDatabaseEventTrigger = BaseTrigger & {
};
};
export type WorkflowTrigger = WorkflowDatabaseEventTrigger;
export enum WorkflowManualTriggerAvailability {
EVERYWHERE = 'EVERYWHERE',
WHEN_RECORD_SELECTED = 'WHEN_RECORD_SELECTED',
}
export type WorkflowManualTrigger = BaseTrigger & {
type: WorkflowTriggerType.MANUAL;
settings: {
objectType?: string;
};
};
export type WorkflowManualTriggerSettings = WorkflowManualTrigger['settings'];
export type WorkflowTrigger =
| WorkflowDatabaseEventTrigger
| WorkflowManualTrigger;

View File

@ -68,6 +68,8 @@ function assertTriggerSettingsAreValid(
case WorkflowTriggerType.DATABASE_EVENT:
assertDatabaseEventTriggerSettingsAreValid(settings);
break;
case WorkflowTriggerType.MANUAL:
break;
default:
throw new WorkflowTriggerException(
'Invalid trigger type for enabling workflow trigger',

View File

@ -24,6 +24,7 @@ import {
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { assertVersionCanBeActivated } from 'src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util';
import { assertNever } from 'src/utils/assert';
@Injectable()
export class WorkflowTriggerWorkspaceService {
@ -315,9 +316,13 @@ export class WorkflowTriggerWorkspaceService {
workflowVersion.trigger,
manager,
);
break;
default:
break;
return;
case WorkflowTriggerType.MANUAL:
return;
default: {
assertNever(workflowVersion.trigger);
}
}
}
@ -333,9 +338,12 @@ export class WorkflowTriggerWorkspaceService {
workflowVersion.workflowId,
manager,
);
break;
return;
case WorkflowTriggerType.MANUAL:
return;
default:
break;
assertNever(workflowVersion.trigger);
}
}

View File

@ -25,3 +25,7 @@ export const assert: Assert = (condition, message, ErrorType) => {
export const assertNotNull = <T>(item: T): item is NonNullable<T> =>
item !== null && item !== undefined;
export const assertNever = (_value: never, message?: string): never => {
throw new Error(message ?? "Didn't expect to get here.");
};

View File

@ -181,6 +181,8 @@ export {
IconPlayerPlay,
IconPlayerStop,
IconPlaylistAdd,
IconHandMove,
IconSquare,
IconPlaystationSquare,
IconPlug,
IconPlus,