Execute workflow form action (#11099)

- create a form filler component
- send the response on submit
- put back a field name. We need it for the step output
- validate a form is well set before activation

TODO:
- we need to refresh to see the form submitted. We need to discuss about
a strategy
- the response is not saved in the step settings. We need a new endpoint
to update workflow run step



https://github.com/user-attachments/assets/0f34a6cd-ed8c-4d9a-a1d4-51455cc83443
This commit is contained in:
Thomas Trompette
2025-03-21 18:38:14 +01:00
committed by GitHub
parent 07bd2486ca
commit c50cdd9510
25 changed files with 582 additions and 70 deletions

View File

@ -318,6 +318,17 @@ export type CreateOneFieldMetadataInput = {
field: CreateFieldInput;
};
export type CreateRoleInput = {
canDestroyAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canReadAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canSoftDeleteAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canUpdateAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canUpdateAllSettings?: InputMaybe<Scalars['Boolean']>;
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
label: Scalars['String'];
};
export type CreateServerlessFunctionInput = {
description?: InputMaybe<Scalars['String']>;
name: Scalars['String'];
@ -497,6 +508,7 @@ export enum FeatureFlagKey {
IsJsonFilterEnabled = 'IsJsonFilterEnabled',
IsNewRelationEnabled = 'IsNewRelationEnabled',
IsPermissionsEnabled = 'IsPermissionsEnabled',
IsPermissionsV2Enabled = 'IsPermissionsV2Enabled',
IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
@ -810,6 +822,7 @@ export type Mutation = {
createOneAppToken: AppToken;
createOneField: Field;
createOneObject: Object;
createOneRole: Role;
createOneServerlessFunction: ServerlessFunction;
createSAMLIdentityProvider: SetupSsoOutput;
createWorkflowVersionStep: WorkflowAction;
@ -850,6 +863,7 @@ export type Mutation = {
updateLabPublicFeatureFlag: FeatureFlag;
updateOneField: Field;
updateOneObject: Object;
updateOneRole: Role;
updateOneServerlessFunction: ServerlessFunction;
updatePasswordViaResetToken: InvalidatePassword;
updateWorkflowVersionStep: WorkflowAction;
@ -915,6 +929,11 @@ export type MutationCreateOneFieldArgs = {
};
export type MutationCreateOneRoleArgs = {
createRoleInput: CreateRoleInput;
};
export type MutationCreateOneServerlessFunctionArgs = {
input: CreateServerlessFunctionInput;
};
@ -1088,6 +1107,11 @@ export type MutationUpdateOneObjectArgs = {
};
export type MutationUpdateOneRoleArgs = {
updateRoleInput: UpdateRoleInput;
};
export type MutationUpdateOneServerlessFunctionArgs = {
input: UpdateServerlessFunctionInput;
};
@ -1904,6 +1928,23 @@ export type UpdateOneObjectInput = {
update: UpdateObjectPayload;
};
export type UpdateRoleInput = {
/** The id of the role to update */
id: Scalars['UUID'];
update: UpdateRolePayload;
};
export type UpdateRolePayload = {
canDestroyAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canReadAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canSoftDeleteAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canUpdateAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canUpdateAllSettings?: InputMaybe<Scalars['Boolean']>;
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
label?: InputMaybe<Scalars['String']>;
};
export type UpdateServerlessFunctionInput = {
code: Scalars['JSON'];
description?: InputMaybe<Scalars['String']>;
@ -2628,6 +2669,13 @@ export type UpdateWorkflowVersionStepMutationVariables = Exact<{
export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean } };
export type SubmitFormStepMutationVariables = Exact<{
input: SubmitFormStepInput;
}>;
export type SubmitFormStepMutation = { __typename?: 'Mutation', submitFormStep: boolean };
export type DeleteWorkspaceInvitationMutationVariables = Exact<{
appTokenId: Scalars['String'];
}>;
@ -5254,6 +5302,37 @@ export function useUpdateWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati
export type UpdateWorkflowVersionStepMutationHookResult = ReturnType<typeof useUpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult<UpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<UpdateWorkflowVersionStepMutation, UpdateWorkflowVersionStepMutationVariables>;
export const SubmitFormStepDocument = gql`
mutation SubmitFormStep($input: SubmitFormStepInput!) {
submitFormStep(input: $input)
}
`;
export type SubmitFormStepMutationFn = Apollo.MutationFunction<SubmitFormStepMutation, SubmitFormStepMutationVariables>;
/**
* __useSubmitFormStepMutation__
*
* To run a mutation, you first call `useSubmitFormStepMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSubmitFormStepMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [submitFormStepMutation, { data, loading, error }] = useSubmitFormStepMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useSubmitFormStepMutation(baseOptions?: Apollo.MutationHookOptions<SubmitFormStepMutation, SubmitFormStepMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SubmitFormStepMutation, SubmitFormStepMutationVariables>(SubmitFormStepDocument, options);
}
export type SubmitFormStepMutationHookResult = ReturnType<typeof useSubmitFormStepMutation>;
export type SubmitFormStepMutationResult = Apollo.MutationResult<SubmitFormStepMutation>;
export type SubmitFormStepMutationOptions = Apollo.BaseMutationOptions<SubmitFormStepMutation, SubmitFormStepMutationVariables>;
export const DeleteWorkspaceInvitationDocument = gql`
mutation DeleteWorkspaceInvitation($appTokenId: String!) {
deleteWorkspaceInvitation(appTokenId: $appTokenId)

View File

@ -25,6 +25,12 @@ const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
type TabId = WorkflowRunTabIdType;
export const CommandMenuWorkflowRunViewStep = () => {
@ -73,35 +79,43 @@ export const CommandMenuWorkflowRunViewStep = () => {
return (
<WorkflowStepContextProvider
value={{ workflowVersionId: workflowRun.workflowVersionId }}
value={{
workflowVersionId: workflowRun.workflowVersionId,
workflowRunId: workflowRun.id,
}}
>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID}
/>
{activeTabId === WorkflowRunTabId.NODE ? (
<WorkflowRunStepNodeDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
<StyledContainer>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID
}
/>
) : null}
{activeTabId === WorkflowRunTabId.INPUT ? (
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === WorkflowRunTabId.NODE ? (
<WorkflowRunStepNodeDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
stepExecutionStatus={stepExecutionStatus}
/>
) : null}
{activeTabId === WorkflowRunTabId.OUTPUT ? (
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === WorkflowRunTabId.INPUT ? (
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === WorkflowRunTabId.OUTPUT ? (
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
</StyledContainer>
</WorkflowStepContextProvider>
);
};

View File

@ -43,7 +43,7 @@ import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUu
import { JsonValue } from 'type-fest';
type FormFieldInputProps = {
field: FieldDefinition<FieldMetadata>;
field: Pick<FieldDefinition<FieldMetadata>, 'label' | 'metadata' | 'type'>;
defaultValue: JsonValue;
onChange: (value: JsonValue) => void;
VariablePicker?: VariablePickerComponent;

View File

@ -2,6 +2,7 @@ import { createRequiredContext } from '~/utils/createRequiredContext';
type WorkflowStepContextType = {
workflowVersionId: string;
workflowRunId?: string;
};
export const [WorkflowStepContextProvider, useWorkflowStepContextOrThrow] =

View File

@ -86,7 +86,8 @@ export const workflowFormActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.array(
z.object({
id: z.string().uuid(),
id: z.string(),
name: z.string(),
label: z.string(),
type: z.union([
z.literal(FieldMetadataType.TEXT),
@ -222,6 +223,7 @@ export const workflowTriggerSchema = z.discriminatedUnion('type', [
const workflowExecutorOutputSchema = z.object({
result: z.any().optional(),
error: z.string().optional(),
pendingEvent: z.boolean().optional(),
});
export const workflowRunOutputStepsOutputSchema = z.record(

View File

@ -83,7 +83,7 @@ export const generateWorkflowRunDiagram = ({
let runStatus: WorkflowDiagramRunStatus;
if (skippedExecution) {
runStatus = 'not-executed';
} else if (!isDefined(runResult)) {
} else if (!isDefined(runResult) || isDefined(runResult.pendingEvent)) {
runStatus = 'running';
} else {
if (isDefined(runResult.error)) {

View File

@ -1,12 +1,14 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowActionServerlessFunction';
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFindRecords';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
@ -17,12 +19,14 @@ type WorkflowRunStepNodeDetailProps = {
stepId: string;
trigger: WorkflowTrigger | null;
steps: Array<WorkflowAction> | null;
stepExecutionStatus?: WorkflowDiagramRunStatus;
};
export const WorkflowRunStepNodeDetail = ({
stepId,
trigger,
steps,
stepExecutionStatus,
}: WorkflowRunStepNodeDetailProps) => {
const stepDefinition = getStepDefinitionOrThrow({
stepId,
@ -161,8 +165,17 @@ export const WorkflowRunStepNodeDetail = ({
}
case 'FORM': {
// TODO: Implement form filler
return null;
return (
<WorkflowEditActionFormFiller
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: stepExecutionStatus !== 'running',
// TODO: Implement update worklfow run flow step
onActionUpdate: () => {},
}}
/>
);
}
}
}

View File

@ -20,6 +20,10 @@ export const getWorkflowRunStepExecutionStatus = ({
return 'failure';
}
if (isDefined(stepOutput?.pendingEvent)) {
return 'running';
}
if (isDefined(stepOutput?.result)) {
return 'success';
}

View File

@ -6,6 +6,7 @@ import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useTheme } from '@emotion/react';
@ -13,13 +14,7 @@ import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { FieldMetadataType, isDefined } from 'twenty-shared';
import {
IconChevronDown,
IconChevronUp,
IconPlus,
IconTrash,
useIcons,
} from 'twenty-ui';
import { IconChevronDown, IconPlus, IconTrash, useIcons } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { v4 } from 'uuid';
@ -36,14 +31,6 @@ export type WorkflowEditActionFormBuilderProps = {
};
};
export type WorkflowFormActionField = {
id: string;
label: string;
type: FieldMetadataType.TEXT | FieldMetadataType.NUMBER;
placeholder?: string;
settings?: Record<string, unknown>;
};
type FormData = WorkflowFormActionField[];
const StyledRowContainer = styled.div`
@ -193,12 +180,7 @@ export const WorkflowEditActionFormBuilder = ({
>
<StyledFieldContainer>
<StyledPlaceholder>{field.placeholder}</StyledPlaceholder>
{isFieldSelected(field.id) ? (
<IconChevronUp
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
) : (
{!isFieldSelected(field.id) && (
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
@ -207,16 +189,12 @@ export const WorkflowEditActionFormBuilder = ({
</StyledFieldContainer>
</FormFieldInputInputContainer>
</FormFieldInputRowContainer>
{isFieldSelected(field.id) && (
{!actionOptions.readonly && isFieldSelected(field.id) && (
<StyledIconButtonContainer>
<IconTrash
size={theme.icon.size.md}
color={theme.font.color.secondary}
onClick={() => {
if (actionOptions.readonly === true) {
return;
}
const updatedFormData = formData.filter(
(currentField) => currentField.id !== field.id,
);
@ -253,12 +231,12 @@ export const WorkflowEditActionFormBuilder = ({
<FormFieldInputInputContainer
hasRightElement={false}
onClick={() => {
const { label, placeholder } = getDefaultFormFieldSettings(
FieldMetadataType.TEXT,
);
const { label, placeholder, name } =
getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const newField: WorkflowFormActionField = {
id: v4(),
name,
type: FieldMetadataType.TEXT,
label,
placeholder,

View File

@ -1,12 +1,13 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
import { WorkflowFormFieldSettingsByType } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsByType';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import camelCase from 'lodash.camelcase';
import { FieldMetadataType } from 'twenty-shared';
import {
IconSettingsAutomation,
@ -66,10 +67,18 @@ export const WorkflowEditActionFormFieldSettings = ({
}: WorkflowEditActionFormFieldSettingsProps) => {
const theme = useTheme();
const onSubFieldUpdate = (fieldName: string, value: any) => {
onChange({
...field,
[fieldName]: value,
});
if (fieldName === 'label') {
onChange({
...field,
name: camelCase(value),
label: value,
});
} else {
onChange({
...field,
[fieldName]: value,
});
}
};
return (

View File

@ -0,0 +1,161 @@
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext';
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useSubmitFormStep } from '@/workflow/workflow-steps/workflow-actions/form-action/hooks/useSubmitFormStep';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared';
import { useIcons } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
export type WorkflowEditActionFormFillerProps = {
action: WorkflowFormAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowFormAction) => void;
};
};
type FormData = WorkflowFormActionField[];
export const WorkflowEditActionFormFiller = ({
action,
actionOptions,
}: WorkflowEditActionFormFillerProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { submitFormStep } = useSubmitFormStep();
const [formData, setFormData] = useState<FormData>(action.settings.input);
const { workflowRunId } = useWorkflowStepContextOrThrow();
const { closeCommandMenu } = useCommandMenu();
if (!isDefined(workflowRunId)) {
throw new Error('Form filler action must be used in a workflow run');
}
const headerTitle = isDefined(action.name) ? action.name : `Form`;
const headerIcon = getActionIcon(action.type);
const onFieldUpdate = ({
fieldId,
value,
}: {
fieldId: string;
value: any;
}) => {
if (actionOptions.readonly === true) {
return;
}
const updatedFormData = formData.map((field) =>
field.id === fieldId ? { ...field, value } : field,
);
setFormData(updatedFormData);
saveAction(updatedFormData);
};
const saveAction = useDebouncedCallback(async (updatedFormData: FormData) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: updatedFormData,
},
});
}, 1_000);
const onSubmit = async () => {
const response = formData.reduce(
(acc, field) => {
acc[field.name] = field.value;
return acc;
},
{} as Record<string, any>,
);
await submitFormStep({
stepId: action.id,
workflowRunId,
response,
});
closeCommandMenu();
};
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
disabled
/>
<WorkflowStepBody>
{formData.map((field) => (
<FormFieldInput
key={field.id}
field={{
label: field.label,
type: field.type,
metadata: {} as FieldMetadata,
}}
onChange={(value) => {
onFieldUpdate({
fieldId: field.id,
value,
});
}}
defaultValue={field.value ?? ''}
readonly={actionOptions.readonly}
/>
))}
</WorkflowStepBody>
{!actionOptions.readonly && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton
title="Submit"
onClick={onSubmit}
disabled={actionOptions.readonly}
/>,
]}
/>
)}
</>
);
};

View File

@ -1,4 +1,4 @@
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { assertUnreachable, FieldMetadataType } from 'twenty-shared';
import { WorkflowFormFieldSettingsNumber } from './WorkflowFormFieldSettingsNumber';
import { WorkflowFormFieldSettingsText } from './WorkflowFormFieldSettingsText';

View File

@ -18,6 +18,7 @@ const DEFAULT_ACTION = {
input: [
{
id: 'ed00b897-519f-44cd-8201-a6502a3a9dc8',
name: 'company',
type: FieldMetadataType.TEXT,
label: 'Company',
placeholder: 'Select a company',
@ -25,6 +26,7 @@ const DEFAULT_ACTION = {
},
{
id: 'ed00b897-519f-44cd-8201-a6502a3a9dc9',
name: 'number',
type: FieldMetadataType.NUMBER,
label: 'Number',
placeholder: '1000',

View File

@ -29,6 +29,7 @@ const mockAction: WorkflowFormAction = {
input: [
{
id: 'field-1',
name: 'text',
label: 'Text Field',
type: FieldMetadataType.TEXT,
placeholder: 'Enter text',
@ -67,6 +68,7 @@ export const NumberFieldSettings: Story = {
args: {
field: {
id: 'field-2',
name: 'number',
label: 'Number Field',
type: FieldMetadataType.NUMBER,
placeholder: 'Enter number',

View File

@ -0,0 +1,103 @@
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowEditActionFormFiller } from '../WorkflowEditActionFormFiller';
const meta: Meta<typeof WorkflowEditActionFormFiller> = {
title: 'Modules/Workflow/Actions/Form/WorkflowEditActionFormFiller',
component: WorkflowEditActionFormFiller,
parameters: {
msw: graphqlMocks,
},
decorators: [
WorkflowStepActionDrawerDecorator,
ComponentDecorator,
I18nFrontDecorator,
WorkflowStepDecorator,
RouterDecorator,
ObjectMetadataItemsDecorator,
WorkspaceDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFormFiller>;
const mockAction: WorkflowFormAction = {
id: 'form-action-1',
type: 'FORM',
name: 'Test Form',
valid: true,
settings: {
input: [
{
id: 'field-1',
name: 'text',
label: 'Text Field',
type: FieldMetadataType.TEXT,
placeholder: 'Enter text',
settings: {},
},
{
id: 'field-2',
name: 'number',
label: 'Number Field',
type: FieldMetadataType.NUMBER,
placeholder: 'Enter number',
settings: {},
},
],
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
export const Default: Story = {
args: {
action: mockAction,
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textField = await canvas.findByText('Text Field');
expect(textField).toBeVisible();
const numberField = await canvas.findByText('Number Field');
expect(numberField).toBeVisible();
},
};
export const ReadonlyMode: Story = {
args: {
action: mockAction,
actionOptions: {
readonly: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textField = await canvas.findByText('Text Field');
expect(textField).toBeVisible();
const numberInput = await canvas.findByPlaceholderText('Number Field');
expect(numberInput).toBeDisabled();
const submitButton = await canvas.queryByText('Submit');
expect(submitButton).not.toBeInTheDocument();
},
};

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const SUBMIT_FORM_STEP = gql`
mutation SubmitFormStep($input: SubmitFormStepInput!) {
submitFormStep(input: $input)
}
`;

View File

@ -0,0 +1,54 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery';
import { SUBMIT_FORM_STEP } from '@/workflow/workflow-steps/workflow-actions/form-action/graphql/mutations/submitFormStep';
import { useApolloClient, useMutation } from '@apollo/client';
import {
SubmitFormStepInput,
SubmitFormStepMutation,
SubmitFormStepMutationVariables,
} from '~/generated/graphql';
export const useSubmitFormStep = () => {
const apolloClient = useApolloClient();
const [mutate] = useMutation<
SubmitFormStepMutation,
SubmitFormStepMutationVariables
>(SUBMIT_FORM_STEP, {
client: apolloClient,
});
const { findOneRecordQuery: findOneWorkflowRunQuery } = useFindOneRecordQuery(
{
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
recordGqlFields: {
id: true,
name: true,
status: true,
output: true,
context: true,
startedAt: true,
endedAt: true,
},
},
);
const submitFormStep = async (input: SubmitFormStepInput) => {
const result = await mutate({
variables: { input },
awaitRefetchQueries: true,
refetchQueries: [
{
query: findOneWorkflowRunQuery,
variables: {
objectRecordId: input.workflowRunId,
},
},
],
});
const isSuccess = result?.data?.submitFormStep;
return isSuccess;
};
return { submitFormStep };
};

View File

@ -0,0 +1,12 @@
import { FieldMetadataType } from 'twenty-shared';
import { JsonValue } from 'type-fest';
export type WorkflowFormActionField = {
id: string;
name: string;
label: string;
type: FieldMetadataType.TEXT | FieldMetadataType.NUMBER;
placeholder?: string;
settings?: Record<string, unknown>;
value?: JsonValue;
};

View File

@ -1,19 +1,26 @@
import { FieldMetadataType } from 'twenty-shared';
import { v4 } from 'uuid';
export const getDefaultFormFieldSettings = (type: FieldMetadataType) => {
switch (type) {
case FieldMetadataType.TEXT:
return {
id: v4(),
name: 'text',
label: 'Text',
placeholder: 'Enter your text',
};
case FieldMetadataType.NUMBER:
return {
id: v4(),
name: 'number',
label: 'Number',
placeholder: '1000',
};
default:
return {
id: v4(),
name: '',
label: type.charAt(0).toUpperCase() + type.slice(1),
placeholder: 'Enter your value',
};

View File

@ -41,7 +41,10 @@ export const WorkflowStepDecorator: Decorator = (Story) => {
return (
<WorkflowStepContextProvider
value={{ workflowVersionId: workflowVersion.id }}
value={{
workflowVersionId: workflowVersion.id,
workflowRunId: '123',
}}
>
{ready && <Story />}
</WorkflowStepContextProvider>

View File

@ -7,16 +7,19 @@ describe('generateFakeFormResponse', () => {
const schema = [
{
id: '96939213-49ac-4dee-949d-56e6c7be98e6',
name: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
},
{
id: '96939213-49ac-4dee-949d-56e6c7be98e7',
name: 'age',
type: FieldMetadataType.NUMBER,
label: 'Age',
},
{
id: '96939213-49ac-4dee-949d-56e6c7be98e8',
name: 'email',
type: FieldMetadataType.EMAILS,
label: 'Email',
},
@ -25,7 +28,7 @@ describe('generateFakeFormResponse', () => {
const result = generateFakeFormResponse(schema);
expect(result).toEqual({
'96939213-49ac-4dee-949d-56e6c7be98e8': {
email: {
isLeaf: false,
label: 'Email',
value: {
@ -44,14 +47,14 @@ describe('generateFakeFormResponse', () => {
},
icon: undefined,
},
'96939213-49ac-4dee-949d-56e6c7be98e6': {
name: {
isLeaf: true,
label: 'Name',
type: FieldMetadataType.TEXT,
value: 'My text',
icon: undefined,
},
'96939213-49ac-4dee-949d-56e6c7be98e7': {
age: {
isLeaf: true,
label: 'Age',
type: FieldMetadataType.NUMBER,

View File

@ -9,7 +9,7 @@ export const generateFakeFormResponse = (
formMetadata: FormFieldMetadata[],
): Record<string, Leaf | Node> => {
return formMetadata.reduce((acc, formFieldMetadata) => {
acc[formFieldMetadata.id] = generateFakeField({
acc[formFieldMetadata.name] = generateFakeField({
type: formFieldMetadata.type,
label: formFieldMetadata.label,
});

View File

@ -506,12 +506,14 @@ export class WorkflowVersionStepWorkspaceService {
input: [
{
id: v4(),
name: 'company',
label: 'Company',
placeholder: 'Select a company',
type: FieldMetadataType.TEXT,
},
{
id: v4(),
name: 'number',
label: 'Number',
placeholder: '1000',
type: FieldMetadataType.NUMBER,

View File

@ -4,8 +4,10 @@ import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-execut
export type FormFieldMetadata = {
id: string;
name: string;
label: string;
type: FieldMetadataType;
value?: any;
placeholder?: string;
settings?: Record<string, any>;
};

View File

@ -1,8 +1,15 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import {
WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
@ -58,6 +65,10 @@ function assertVersionIsValid(workflowVersion: WorkflowVersionWorkspaceEntity) {
workflowVersion.trigger.type,
workflowVersion.trigger.settings,
);
workflowVersion.steps.forEach((step) => {
assertStepIsValid(step);
});
}
function assertTriggerSettingsAreValid(
@ -188,3 +199,46 @@ function assertDatabaseEventTriggerSettingsAreValid(settings: any) {
);
}
}
function assertStepIsValid(step: WorkflowAction) {
switch (step.type) {
case WorkflowActionType.FORM:
assertFormStepIsValid(step.settings);
break;
default:
break;
}
}
function assertFormStepIsValid(settings: WorkflowFormActionSettings) {
if (!settings.input) {
throw new WorkflowTriggerException(
'No input provided in form step',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
// Check all fields have unique and defined names
const fieldNames = settings.input.map((fieldMetadata) => fieldMetadata.name);
const uniqueFieldNames = new Set(fieldNames);
if (fieldNames.length !== uniqueFieldNames.size) {
throw new WorkflowTriggerException(
'Form action fields must have unique names',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
// Check all fields have defined labels and types
settings.input.forEach((fieldMetadata) => {
if (
!isNonEmptyString(fieldMetadata.label) ||
!isNonEmptyString(fieldMetadata.type)
) {
throw new WorkflowTriggerException(
'Form action fields must have a defined label and type',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
});
}