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; 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 = { export type CreateServerlessFunctionInput = {
description?: InputMaybe<Scalars['String']>; description?: InputMaybe<Scalars['String']>;
name: Scalars['String']; name: Scalars['String'];
@ -497,6 +508,7 @@ export enum FeatureFlagKey {
IsJsonFilterEnabled = 'IsJsonFilterEnabled', IsJsonFilterEnabled = 'IsJsonFilterEnabled',
IsNewRelationEnabled = 'IsNewRelationEnabled', IsNewRelationEnabled = 'IsNewRelationEnabled',
IsPermissionsEnabled = 'IsPermissionsEnabled', IsPermissionsEnabled = 'IsPermissionsEnabled',
IsPermissionsV2Enabled = 'IsPermissionsV2Enabled',
IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled', IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled', IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled', IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
@ -810,6 +822,7 @@ export type Mutation = {
createOneAppToken: AppToken; createOneAppToken: AppToken;
createOneField: Field; createOneField: Field;
createOneObject: Object; createOneObject: Object;
createOneRole: Role;
createOneServerlessFunction: ServerlessFunction; createOneServerlessFunction: ServerlessFunction;
createSAMLIdentityProvider: SetupSsoOutput; createSAMLIdentityProvider: SetupSsoOutput;
createWorkflowVersionStep: WorkflowAction; createWorkflowVersionStep: WorkflowAction;
@ -850,6 +863,7 @@ export type Mutation = {
updateLabPublicFeatureFlag: FeatureFlag; updateLabPublicFeatureFlag: FeatureFlag;
updateOneField: Field; updateOneField: Field;
updateOneObject: Object; updateOneObject: Object;
updateOneRole: Role;
updateOneServerlessFunction: ServerlessFunction; updateOneServerlessFunction: ServerlessFunction;
updatePasswordViaResetToken: InvalidatePassword; updatePasswordViaResetToken: InvalidatePassword;
updateWorkflowVersionStep: WorkflowAction; updateWorkflowVersionStep: WorkflowAction;
@ -915,6 +929,11 @@ export type MutationCreateOneFieldArgs = {
}; };
export type MutationCreateOneRoleArgs = {
createRoleInput: CreateRoleInput;
};
export type MutationCreateOneServerlessFunctionArgs = { export type MutationCreateOneServerlessFunctionArgs = {
input: CreateServerlessFunctionInput; input: CreateServerlessFunctionInput;
}; };
@ -1088,6 +1107,11 @@ export type MutationUpdateOneObjectArgs = {
}; };
export type MutationUpdateOneRoleArgs = {
updateRoleInput: UpdateRoleInput;
};
export type MutationUpdateOneServerlessFunctionArgs = { export type MutationUpdateOneServerlessFunctionArgs = {
input: UpdateServerlessFunctionInput; input: UpdateServerlessFunctionInput;
}; };
@ -1904,6 +1928,23 @@ export type UpdateOneObjectInput = {
update: UpdateObjectPayload; 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 = { export type UpdateServerlessFunctionInput = {
code: Scalars['JSON']; code: Scalars['JSON'];
description?: InputMaybe<Scalars['String']>; 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 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<{ export type DeleteWorkspaceInvitationMutationVariables = Exact<{
appTokenId: Scalars['String']; appTokenId: Scalars['String'];
}>; }>;
@ -5254,6 +5302,37 @@ export function useUpdateWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati
export type UpdateWorkflowVersionStepMutationHookResult = ReturnType<typeof useUpdateWorkflowVersionStepMutation>; export type UpdateWorkflowVersionStepMutationHookResult = ReturnType<typeof useUpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult<UpdateWorkflowVersionStepMutation>; export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult<UpdateWorkflowVersionStepMutation>;
export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<UpdateWorkflowVersionStepMutation, UpdateWorkflowVersionStepMutationVariables>; 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` export const DeleteWorkspaceInvitationDocument = gql`
mutation DeleteWorkspaceInvitation($appTokenId: String!) { mutation DeleteWorkspaceInvitation($appTokenId: String!) {
deleteWorkspaceInvitation(appTokenId: $appTokenId) deleteWorkspaceInvitation(appTokenId: $appTokenId)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,14 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow'; import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; 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 { WorkflowActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowActionServerlessFunction';
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord'; import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord'; import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFindRecords'; import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFindRecords';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail'; import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord'; 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 { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm'; import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
@ -17,12 +19,14 @@ type WorkflowRunStepNodeDetailProps = {
stepId: string; stepId: string;
trigger: WorkflowTrigger | null; trigger: WorkflowTrigger | null;
steps: Array<WorkflowAction> | null; steps: Array<WorkflowAction> | null;
stepExecutionStatus?: WorkflowDiagramRunStatus;
}; };
export const WorkflowRunStepNodeDetail = ({ export const WorkflowRunStepNodeDetail = ({
stepId, stepId,
trigger, trigger,
steps, steps,
stepExecutionStatus,
}: WorkflowRunStepNodeDetailProps) => { }: WorkflowRunStepNodeDetailProps) => {
const stepDefinition = getStepDefinitionOrThrow({ const stepDefinition = getStepDefinitionOrThrow({
stepId, stepId,
@ -161,8 +165,17 @@ export const WorkflowRunStepNodeDetail = ({
} }
case 'FORM': { case 'FORM': {
// TODO: Implement form filler return (
return null; <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'; return 'failure';
} }
if (isDefined(stepOutput?.pendingEvent)) {
return 'running';
}
if (isDefined(stepOutput?.result)) { if (isDefined(stepOutput?.result)) {
return 'success'; return 'success';
} }

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ const mockAction: WorkflowFormAction = {
input: [ input: [
{ {
id: 'field-1', id: 'field-1',
name: 'text',
label: 'Text Field', label: 'Text Field',
type: FieldMetadataType.TEXT, type: FieldMetadataType.TEXT,
placeholder: 'Enter text', placeholder: 'Enter text',
@ -67,6 +68,7 @@ export const NumberFieldSettings: Story = {
args: { args: {
field: { field: {
id: 'field-2', id: 'field-2',
name: 'number',
label: 'Number Field', label: 'Number Field',
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,
placeholder: 'Enter 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 { FieldMetadataType } from 'twenty-shared';
import { v4 } from 'uuid';
export const getDefaultFormFieldSettings = (type: FieldMetadataType) => { export const getDefaultFormFieldSettings = (type: FieldMetadataType) => {
switch (type) { switch (type) {
case FieldMetadataType.TEXT: case FieldMetadataType.TEXT:
return { return {
id: v4(),
name: 'text',
label: 'Text', label: 'Text',
placeholder: 'Enter your text', placeholder: 'Enter your text',
}; };
case FieldMetadataType.NUMBER: case FieldMetadataType.NUMBER:
return { return {
id: v4(),
name: 'number',
label: 'Number', label: 'Number',
placeholder: '1000', placeholder: '1000',
}; };
default: default:
return { return {
id: v4(),
name: '',
label: type.charAt(0).toUpperCase() + type.slice(1), label: type.charAt(0).toUpperCase() + type.slice(1),
placeholder: 'Enter your value', placeholder: 'Enter your value',
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,15 @@
import { isNonEmptyString } from '@sniptt/guards';
import { import {
WorkflowVersionStatus, WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity, WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.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 { import {
WorkflowTriggerException, WorkflowTriggerException,
WorkflowTriggerExceptionCode, WorkflowTriggerExceptionCode,
@ -58,6 +65,10 @@ function assertVersionIsValid(workflowVersion: WorkflowVersionWorkspaceEntity) {
workflowVersion.trigger.type, workflowVersion.trigger.type,
workflowVersion.trigger.settings, workflowVersion.trigger.settings,
); );
workflowVersion.steps.forEach((step) => {
assertStepIsValid(step);
});
} }
function assertTriggerSettingsAreValid( 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,
);
}
});
}