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:
@ -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)
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -2,6 +2,7 @@ import { createRequiredContext } from '~/utils/createRequiredContext';
|
||||
|
||||
type WorkflowStepContextType = {
|
||||
workflowVersionId: string;
|
||||
workflowRunId?: string;
|
||||
};
|
||||
|
||||
export const [WorkflowStepContextProvider, useWorkflowStepContextOrThrow] =
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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: () => {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,10 @@ export const getWorkflowRunStepExecutionStatus = ({
|
||||
return 'failure';
|
||||
}
|
||||
|
||||
if (isDefined(stepOutput?.pendingEvent)) {
|
||||
return 'running';
|
||||
}
|
||||
|
||||
if (isDefined(stepOutput?.result)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SUBMIT_FORM_STEP = gql`
|
||||
mutation SubmitFormStep($input: SubmitFormStepInput!) {
|
||||
submitFormStep(input: $input)
|
||||
}
|
||||
`;
|
||||
@ -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 };
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -41,7 +41,10 @@ export const WorkflowStepDecorator: Decorator = (Story) => {
|
||||
|
||||
return (
|
||||
<WorkflowStepContextProvider
|
||||
value={{ workflowVersionId: workflowVersion.id }}
|
||||
value={{
|
||||
workflowVersionId: workflowVersion.id,
|
||||
workflowRunId: '123',
|
||||
}}
|
||||
>
|
||||
{ready && <Story />}
|
||||
</WorkflowStepContextProvider>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user