diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 5b951d857..9028ae457 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -318,6 +318,17 @@ export type CreateOneFieldMetadataInput = { field: CreateFieldInput; }; +export type CreateRoleInput = { + canDestroyAllObjectRecords?: InputMaybe; + canReadAllObjectRecords?: InputMaybe; + canSoftDeleteAllObjectRecords?: InputMaybe; + canUpdateAllObjectRecords?: InputMaybe; + canUpdateAllSettings?: InputMaybe; + description?: InputMaybe; + icon?: InputMaybe; + label: Scalars['String']; +}; + export type CreateServerlessFunctionInput = { description?: InputMaybe; 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; + canReadAllObjectRecords?: InputMaybe; + canSoftDeleteAllObjectRecords?: InputMaybe; + canUpdateAllObjectRecords?: InputMaybe; + canUpdateAllSettings?: InputMaybe; + description?: InputMaybe; + icon?: InputMaybe; + label?: InputMaybe; +}; + export type UpdateServerlessFunctionInput = { code: Scalars['JSON']; description?: InputMaybe; @@ -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; export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult; export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions; +export const SubmitFormStepDocument = gql` + mutation SubmitFormStep($input: SubmitFormStepInput!) { + submitFormStep(input: $input) +} + `; +export type SubmitFormStepMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SubmitFormStepDocument, options); + } +export type SubmitFormStepMutationHookResult = ReturnType; +export type SubmitFormStepMutationResult = Apollo.MutationResult; +export type SubmitFormStepMutationOptions = Apollo.BaseMutationOptions; export const DeleteWorkspaceInvitationDocument = gql` mutation DeleteWorkspaceInvitation($appTokenId: String!) { deleteWorkspaceInvitation(appTokenId: $appTokenId) diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/step/view-run/components/CommandMenuWorkflowRunViewStep.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/step/view-run/components/CommandMenuWorkflowRunViewStep.tsx index 3d914064c..9aad28a84 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/step/view-run/components/CommandMenuWorkflowRunViewStep.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/step/view-run/components/CommandMenuWorkflowRunViewStep.tsx @@ -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 ( - - - {activeTabId === WorkflowRunTabId.NODE ? ( - + - ) : null} - {activeTabId === WorkflowRunTabId.INPUT ? ( - - ) : null} + {activeTabId === WorkflowRunTabId.NODE ? ( + + ) : null} - {activeTabId === WorkflowRunTabId.OUTPUT ? ( - - ) : null} + {activeTabId === WorkflowRunTabId.INPUT ? ( + + ) : null} + + {activeTabId === WorkflowRunTabId.OUTPUT ? ( + + ) : null} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx index c665ab7e9..36036343b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx @@ -43,7 +43,7 @@ import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUu import { JsonValue } from 'type-fest'; type FormFieldInputProps = { - field: FieldDefinition; + field: Pick, 'label' | 'metadata' | 'type'>; defaultValue: JsonValue; onChange: (value: JsonValue) => void; VariablePicker?: VariablePickerComponent; diff --git a/packages/twenty-front/src/modules/workflow/states/context/WorkflowStepContext.ts b/packages/twenty-front/src/modules/workflow/states/context/WorkflowStepContext.ts index 3f1962eab..0e1186f90 100644 --- a/packages/twenty-front/src/modules/workflow/states/context/WorkflowStepContext.ts +++ b/packages/twenty-front/src/modules/workflow/states/context/WorkflowStepContext.ts @@ -2,6 +2,7 @@ import { createRequiredContext } from '~/utils/createRequiredContext'; type WorkflowStepContextType = { workflowVersionId: string; + workflowRunId?: string; }; export const [WorkflowStepContextProvider, useWorkflowStepContextOrThrow] = diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts index fac611791..a95e099ca 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -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( diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts index d750a64b6..813b74ac9 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts @@ -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)) { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx index 0648eccf4..ef911857e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx @@ -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 | 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 ( + {}, + }} + /> + ); } } } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus.ts index 697473e79..32ad24706 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus.ts @@ -20,6 +20,10 @@ export const getWorkflowRunStepExecutionStatus = ({ return 'failure'; } + if (isDefined(stepOutput?.pendingEvent)) { + return 'running'; + } + if (isDefined(stepOutput?.result)) { return 'success'; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx index 9dde8a057..019caa514 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx @@ -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; -}; - type FormData = WorkflowFormActionField[]; const StyledRowContainer = styled.div` @@ -193,12 +180,7 @@ export const WorkflowEditActionFormBuilder = ({ > {field.placeholder} - {isFieldSelected(field.id) ? ( - - ) : ( + {!isFieldSelected(field.id) && ( - {isFieldSelected(field.id) && ( + {!actionOptions.readonly && isFieldSelected(field.id) && ( { - if (actionOptions.readonly === true) { - return; - } - const updatedFormData = formData.filter( (currentField) => currentField.id !== field.id, ); @@ -253,12 +231,12 @@ export const WorkflowEditActionFormBuilder = ({ { - const { label, placeholder } = getDefaultFormFieldSettings( - FieldMetadataType.TEXT, - ); + const { label, placeholder, name } = + getDefaultFormFieldSettings(FieldMetadataType.TEXT); const newField: WorkflowFormActionField = { id: v4(), + name, type: FieldMetadataType.TEXT, label, placeholder, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings.tsx index e7a474c8b..cc59028a2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings.tsx @@ -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 ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller.tsx new file mode 100644 index 000000000..77dedc88c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller.tsx @@ -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(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, + ); + + await submitFormStep({ + stepId: action.id, + workflowRunId, + response, + }); + + closeCommandMenu(); + }; + + useEffect(() => { + return () => { + saveAction.flush(); + }; + }, [saveAction]); + + return ( + <> + { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate({ + ...action, + name: newName, + }); + }} + Icon={getIcon(headerIcon)} + iconColor={theme.font.color.tertiary} + initialTitle={headerTitle} + headerType="Action" + disabled + /> + + {formData.map((field) => ( + { + onFieldUpdate({ + fieldId: field.id, + value, + }); + }} + defaultValue={field.value ?? ''} + readonly={actionOptions.readonly} + /> + ))} + + {!actionOptions.readonly && ( + , + ]} + /> + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsByType.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsByType.tsx index a1a103882..dcb960af2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsByType.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsByType.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx index f67b52d55..4682494ec 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx @@ -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', diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormFieldSettings.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormFieldSettings.stories.tsx index bcf32189e..92047ad3e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormFieldSettings.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormFieldSettings.stories.tsx @@ -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', diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormFiller.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormFiller.stories.tsx new file mode 100644 index 000000000..ffe0ac1f5 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormFiller.stories.tsx @@ -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 = { + 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; + +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(); + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/graphql/mutations/submitFormStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/graphql/mutations/submitFormStep.ts new file mode 100644 index 000000000..a8ad41b77 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/graphql/mutations/submitFormStep.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const SUBMIT_FORM_STEP = gql` + mutation SubmitFormStep($input: SubmitFormStepInput!) { + submitFormStep(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/hooks/useSubmitFormStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/hooks/useSubmitFormStep.ts new file mode 100644 index 000000000..0d17c93b7 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/hooks/useSubmitFormStep.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField.ts new file mode 100644 index 000000000..6a3cf6d92 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField.ts @@ -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; + value?: JsonValue; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts index ab83e66fe..2c64dcb82 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts @@ -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', }; diff --git a/packages/twenty-front/src/testing/decorators/WorkflowStepDecorator.tsx b/packages/twenty-front/src/testing/decorators/WorkflowStepDecorator.tsx index 9e1969d6b..943e8bfd4 100644 --- a/packages/twenty-front/src/testing/decorators/WorkflowStepDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/WorkflowStepDecorator.tsx @@ -41,7 +41,10 @@ export const WorkflowStepDecorator: Decorator = (Story) => { return ( {ready && } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/generate-fake-form-response.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/generate-fake-form-response.spec.ts index 7c0f52e37..9580d0751 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/generate-fake-form-response.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/generate-fake-form-response.spec.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response.ts index 28b73b22a..f48b390b6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response.ts @@ -9,7 +9,7 @@ export const generateFakeFormResponse = ( formMetadata: FormFieldMetadata[], ): Record => { return formMetadata.reduce((acc, formFieldMetadata) => { - acc[formFieldMetadata.id] = generateFakeField({ + acc[formFieldMetadata.name] = generateFakeField({ type: formFieldMetadata.type, label: formFieldMetadata.label, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index aaa6fb2b5..f91d5391f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type.ts index 813aa6d80..446f98ef9 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type.ts @@ -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; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts index 2b85f87a6..f2a8dd092 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts @@ -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, + ); + } + }); +}