Add base form action without logic (#10811)

<img width="1298" alt="Capture d’écran 2025-03-12 à 15 32 27"
src="https://github.com/user-attachments/assets/8a3140e5-e165-445e-a718-748aa76b525c"
/>
This commit is contained in:
Thomas Trompette
2025-03-12 18:05:31 +01:00
committed by GitHub
parent a4ef820f13
commit f4a362b53a
26 changed files with 385 additions and 132 deletions

View File

@ -560,7 +560,6 @@ export enum FeatureFlagKey {
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',
IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled',
IsCopilotEnabled = 'IsCopilotEnabled',
IsCustomDomainEnabled = 'IsCustomDomainEnabled',
IsEventObjectEnabled = 'IsEventObjectEnabled',
@ -570,7 +569,8 @@ export enum FeatureFlagKey {
IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
IsWorkflowEnabled = 'IsWorkflowEnabled'
IsWorkflowEnabled = 'IsWorkflowEnabled',
IsWorkflowFormActionEnabled = 'IsWorkflowFormActionEnabled'
}
export type Field = {

View File

@ -491,7 +491,6 @@ export enum FeatureFlagKey {
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',
IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled',
IsCopilotEnabled = 'IsCopilotEnabled',
IsCustomDomainEnabled = 'IsCustomDomainEnabled',
IsEventObjectEnabled = 'IsEventObjectEnabled',
@ -501,7 +500,8 @@ export enum FeatureFlagKey {
IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
IsWorkflowEnabled = 'IsWorkflowEnabled'
IsWorkflowEnabled = 'IsWorkflowEnabled',
IsWorkflowFormActionEnabled = 'IsWorkflowFormActionEnabled'
}
export type Field = {
@ -1658,6 +1658,8 @@ export type ServerlessFunctionExecutionResult = {
duration: Scalars['Float'];
/** Execution error in JSON format */
error?: Maybe<Scalars['JSON']>;
/** Execution Logs */
logs: Scalars['String'];
/** Execution status */
status: ServerlessFunctionExecutionStatus;
};
@ -2220,11 +2222,6 @@ export type GetTimelineThreadsFromPersonIdQueryVariables = Exact<{
export type GetTimelineThreadsFromPersonIdQuery = { __typename?: 'Query', getTimelineThreadsFromPersonId: { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } };
export type EmptyQueryVariables = Exact<{ [key: string]: never; }>;
export type EmptyQuery = { __typename: 'Query' };
export type TrackMutationVariables = Exact<{
action: Scalars['String'];
payload: Scalars['JSON'];
@ -3065,38 +3062,6 @@ export function useGetTimelineThreadsFromPersonIdLazyQuery(baseOptions?: Apollo.
export type GetTimelineThreadsFromPersonIdQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdQuery>;
export type GetTimelineThreadsFromPersonIdLazyQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdLazyQuery>;
export type GetTimelineThreadsFromPersonIdQueryResult = Apollo.QueryResult<GetTimelineThreadsFromPersonIdQuery, GetTimelineThreadsFromPersonIdQueryVariables>;
export const EmptyDocument = gql`
query Empty {
__typename
}
`;
/**
* __useEmptyQuery__
*
* To run a query within a React component, call `useEmptyQuery` and pass it any options that fit your needs.
* When your component renders, `useEmptyQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useEmptyQuery({
* variables: {
* },
* });
*/
export function useEmptyQuery(baseOptions?: Apollo.QueryHookOptions<EmptyQuery, EmptyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<EmptyQuery, EmptyQueryVariables>(EmptyDocument, options);
}
export function useEmptyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<EmptyQuery, EmptyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<EmptyQuery, EmptyQueryVariables>(EmptyDocument, options);
}
export type EmptyQueryHookResult = ReturnType<typeof useEmptyQuery>;
export type EmptyLazyQueryHookResult = ReturnType<typeof useEmptyLazyQuery>;
export type EmptyQueryResult = Apollo.QueryResult<EmptyQuery, EmptyQueryVariables>;
export const TrackDocument = gql`
mutation Track($action: String!, $payload: JSON!) {
track(action: $action, payload: $payload) {

View File

@ -26,27 +26,12 @@ import { RecordFiltersComponentInstanceContext } from '@/object-record/record-fi
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { HttpResponse, graphql } from 'msw';
import { IconDotsVertical } from 'twenty-ui';
import { FeatureFlagKey } from '~/generated/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
import { CommandMenu } from '../CommandMenu';
const openTimeout = 50;
// Mock workspace with feature flag enabled
const mockWorkspaceWithFeatureFlag = {
...mockCurrentWorkspace,
featureFlags: [
...(mockCurrentWorkspace.featureFlags || []),
{
id: 'mock-id',
key: FeatureFlagKey.IsCommandMenuV2Enabled,
value: true,
workspaceId: mockCurrentWorkspace.id,
},
],
};
const ContextStoreDecorator: Decorator = (Story) => {
return (
<RecordFilterGroupsComponentInstanceContext.Provider
@ -92,7 +77,7 @@ const meta: Meta<typeof CommandMenu> = {
commandMenuNavigationStackState,
);
setCurrentWorkspace(mockWorkspaceWithFeatureFlag);
setCurrentWorkspace(mockCurrentWorkspace);
setCurrentWorkspaceMember(mockedWorkspaceMemberData);
setIsCommandMenuOpened(true);
setCommandMenuNavigationStack([

View File

@ -4,7 +4,9 @@ import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/co
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { OTHER_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/OtherActions';
import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { MenuItemCommand, useIcons } from 'twenty-ui';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const CommandMenuWorkflowSelectActionContent = ({
workflow,
@ -15,6 +17,9 @@ export const CommandMenuWorkflowSelectActionContent = ({
const { createStep } = useCreateStep({
workflow,
});
const isWorkflowFormActionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsWorkflowFormActionEnabled,
);
return (
<RightDrawerStepListContainer>
@ -32,7 +37,9 @@ export const CommandMenuWorkflowSelectActionContent = ({
<RightDrawerWorkflowSelectStepTitle>
Other
</RightDrawerWorkflowSelectStepTitle>
{OTHER_ACTIONS.map((action) => (
{OTHER_ACTIONS.filter(
(action) => isWorkflowFormActionEnabled || action.type !== 'FORM',
).map((action) => (
<MenuItemCommand
key={action.type}
LeftIcon={getIcon(action.icon)}

View File

@ -10,6 +10,8 @@ import {
workflowDeleteRecordActionSettingsSchema,
workflowFindRecordsActionSchema,
workflowFindRecordsActionSettingsSchema,
workflowFormActionSchema,
workflowFormActionSettingsSchema,
workflowManualTriggerSchema,
workflowRunContextSchema,
workflowRunOutputSchema,
@ -42,6 +44,9 @@ export type WorkflowDeleteRecordActionSettings = z.infer<
export type WorkflowFindRecordsActionSettings = z.infer<
typeof workflowFindRecordsActionSettingsSchema
>;
export type WorkflowFormActionSettings = z.infer<
typeof workflowFormActionSettingsSchema
>;
export type WorkflowCodeAction = z.infer<typeof workflowCodeActionSchema>;
export type WorkflowSendEmailAction = z.infer<
@ -59,6 +64,7 @@ export type WorkflowDeleteRecordAction = z.infer<
export type WorkflowFindRecordsAction = z.infer<
typeof workflowFindRecordsActionSchema
>;
export type WorkflowFormAction = z.infer<typeof workflowFormActionSchema>;
export type WorkflowAction = z.infer<typeof workflowActionSchema>;
export type WorkflowActionType = WorkflowAction['type'];

View File

@ -1,3 +1,4 @@
import { FieldMetadataType } from 'twenty-shared';
import { z } from 'zod';
// Base schemas
@ -81,6 +82,19 @@ export const workflowFindRecordsActionSettingsSchema =
}),
});
export const workflowFormActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.array(
z.object({
label: z.string(),
name: z.string(),
type: z.nativeEnum(FieldMetadataType),
placeholder: z.string().optional(),
settings: z.record(z.any()),
}),
),
});
// Action schemas
export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('CODE'),
@ -118,6 +132,11 @@ export const workflowFindRecordsActionSchema = baseWorkflowActionSchema.extend({
settings: workflowFindRecordsActionSettingsSchema,
});
export const workflowFormActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('FORM'),
settings: workflowFormActionSettingsSchema,
});
// Combined action schema
export const workflowActionSchema = z.discriminatedUnion('type', [
workflowCodeActionSchema,
@ -126,6 +145,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
workflowUpdateRecordActionSchema,
workflowDeleteRecordActionSchema,
workflowFindRecordsActionSchema,
workflowFormActionSchema,
]);
// Trigger schemas

View File

@ -1,11 +1,12 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionFormFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormFindRecords';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
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 { WorkflowEditActionForm } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionForm';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
@ -13,19 +14,19 @@ import { Suspense, lazy } from 'react';
import { isDefined } from 'twenty-shared';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
const WorkflowEditActionFormServerlessFunction = lazy(() =>
const WorkflowEditActionServerlessFunction = lazy(() =>
import(
'@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunction'
'@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionServerlessFunction'
).then((module) => ({
default: module.WorkflowEditActionFormServerlessFunction,
default: module.WorkflowEditActionServerlessFunction,
})),
);
const WorkflowReadonlyActionFormServerlessFunction = lazy(() =>
const WorkflowReadonlyActionServerlessFunction = lazy(() =>
import(
'@/workflow/workflow-steps/workflow-actions/components/WorkflowReadonlyActionFormServerlessFunction'
'@/workflow/workflow-steps/workflow-actions/components/WorkflowReadonlyActionServerlessFunction'
).then((module) => ({
default: module.WorkflowReadonlyActionFormServerlessFunction,
default: module.WorkflowReadonlyActionServerlessFunction,
})),
);
@ -106,12 +107,12 @@ export const WorkflowStepDetail = ({
return (
<Suspense fallback={<RightDrawerSkeletonLoader />}>
{props.readonly ? (
<WorkflowReadonlyActionFormServerlessFunction
<WorkflowReadonlyActionServerlessFunction
key={stepId}
action={stepDefinition.definition}
/>
) : (
<WorkflowEditActionFormServerlessFunction
<WorkflowEditActionServerlessFunction
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
@ -122,7 +123,7 @@ export const WorkflowStepDetail = ({
}
case 'SEND_EMAIL': {
return (
<WorkflowEditActionFormSendEmail
<WorkflowEditActionSendEmail
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
@ -131,7 +132,7 @@ export const WorkflowStepDetail = ({
}
case 'CREATE_RECORD': {
return (
<WorkflowEditActionFormCreateRecord
<WorkflowEditActionCreateRecord
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
@ -141,7 +142,7 @@ export const WorkflowStepDetail = ({
case 'UPDATE_RECORD': {
return (
<WorkflowEditActionFormUpdateRecord
<WorkflowEditActionUpdateRecord
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
@ -151,7 +152,7 @@ export const WorkflowStepDetail = ({
case 'DELETE_RECORD': {
return (
<WorkflowEditActionFormDeleteRecord
<WorkflowEditActionDeleteRecord
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
@ -161,7 +162,17 @@ export const WorkflowStepDetail = ({
case 'FIND_RECORDS': {
return (
<WorkflowEditActionFormFindRecords
<WorkflowEditActionFindRecords
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
case 'FORM': {
return (
<WorkflowEditActionForm
key={stepId}
action={stepDefinition.definition}
actionOptions={props}

View File

@ -17,7 +17,7 @@ import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
type WorkflowEditActionFormCreateRecordProps = {
type WorkflowEditActionCreateRecordProps = {
action: WorkflowCreateRecordAction;
actionOptions:
| {
@ -53,10 +53,10 @@ const sortByViewFieldPosition = (
return 0;
};
export const WorkflowEditActionFormCreateRecord = ({
export const WorkflowEditActionCreateRecord = ({
action,
actionOptions,
}: WorkflowEditActionFormCreateRecordProps) => {
}: WorkflowEditActionCreateRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();

View File

@ -13,7 +13,7 @@ import { HorizontalSeparator, useIcons } from 'twenty-ui';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormDeleteRecordProps = {
type WorkflowEditActionDeleteRecordProps = {
action: WorkflowDeleteRecordAction;
actionOptions:
| {
@ -30,10 +30,10 @@ type DeleteRecordFormData = {
objectRecordId: string;
};
export const WorkflowEditActionFormDeleteRecord = ({
export const WorkflowEditActionDeleteRecord = ({
action,
actionOptions,
}: WorkflowEditActionFormDeleteRecordProps) => {
}: WorkflowEditActionDeleteRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();

View File

@ -12,7 +12,7 @@ import { isDefined } from 'twenty-shared';
import { HorizontalSeparator, useIcons } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormFindRecordsProps = {
type WorkflowEditActionFindRecordsProps = {
action: WorkflowFindRecordsAction;
actionOptions:
| {
@ -29,10 +29,10 @@ type FindRecordsFormData = {
limit?: number;
};
export const WorkflowEditActionFormFindRecords = ({
export const WorkflowEditActionFindRecords = ({
action,
actionOptions,
}: WorkflowEditActionFormFindRecordsProps) => {
}: WorkflowEditActionFindRecordsProps) => {
const theme = useTheme();
const { getIcon } = useIcons();

View File

@ -0,0 +1,123 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared';
import { IconChevronDown, IconPlus, useIcons } from 'twenty-ui';
type WorkflowEditActionFormProps = {
action: WorkflowFormAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowFormAction) => void;
};
};
const StyledContainer = styled.div`
align-items: center;
background: transparent;
border: none;
display: flex;
font-family: inherit;
padding-inline: ${({ theme }) => theme.spacing(2)};
width: 100%;
cursor: pointer;
&:hover,
&[data-open='true'] {
background-color: ${({ theme }) => theme.background.transparent.lighter};
}
`;
const StyledPlaceholder = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
width: 100%;
`;
const StyledAddFieldContainer = styled.div`
display: flex;
color: ${({ theme }) => theme.font.color.secondary};
font-weight: ${({ theme }) => theme.font.weight.medium};
justify-content: center;
width: 100%;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
export const WorkflowEditActionForm = ({
action,
actionOptions,
}: WorkflowEditActionFormProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { t } = useLingui();
const headerTitle = isDefined(action.name) ? action.name : `Form`;
const headerIcon = getActionIcon(action.type);
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={actionOptions.readonly}
/>
<WorkflowStepBody>
{action.settings.input.map((field) => (
<FormFieldInputContainer key={field.name}>
{field.label ? <InputLabel>{field.label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInputContainer hasRightElement={false}>
<StyledContainer onClick={() => {}}>
<StyledPlaceholder>{field.placeholder}</StyledPlaceholder>
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</StyledContainer>
</FormFieldInputInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
))}
{!actionOptions.readonly && (
<FormFieldInputContainer>
<FormFieldInputRowContainer>
<FormFieldInputInputContainer hasRightElement={false}>
<StyledContainer onClick={() => {}}>
<StyledAddFieldContainer>
<IconPlus size={theme.icon.size.sm} />
{t`Add Field`}
</StyledAddFieldContainer>
</StyledContainer>
</FormFieldInputInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
)}
</WorkflowStepBody>
</>
);
};

View File

@ -27,7 +27,7 @@ import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type WorkflowEditActionFormSendEmailProps = {
type WorkflowEditActionSendEmailProps = {
action: WorkflowSendEmailAction;
actionOptions:
| {
@ -46,10 +46,10 @@ type SendEmailFormData = {
body: string;
};
export const WorkflowEditActionFormSendEmail = ({
export const WorkflowEditActionSendEmail = ({
action,
actionOptions,
}: WorkflowEditActionFormSendEmailProps) => {
}: WorkflowEditActionSendEmailProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);

View File

@ -22,7 +22,7 @@ import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
import { WorkflowEditActionServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionServerlessFunctionFields';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/utils/getWrongExportedFunctionMarkers';
@ -54,7 +54,7 @@ const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowEditActionFormServerlessFunctionProps = {
type WorkflowEditActionServerlessFunctionProps = {
action: WorkflowCodeAction;
actionOptions:
| {
@ -70,10 +70,10 @@ type ServerlessFunctionInputFormData = {
[field: string]: string | ServerlessFunctionInputFormData;
};
export const WorkflowEditActionFormServerlessFunction = ({
export const WorkflowEditActionServerlessFunction = ({
action,
actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => {
}: WorkflowEditActionServerlessFunctionProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
@ -303,7 +303,7 @@ export const WorkflowEditActionFormServerlessFunction = ({
<WorkflowStepBody>
{activeTabId === 'code' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
<WorkflowEditActionServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
@ -327,7 +327,7 @@ export const WorkflowEditActionFormServerlessFunction = ({
)}
{activeTabId === 'test' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
<WorkflowEditActionServerlessFunctionFields
functionInput={serverlessFunctionTestData.input}
onInputChange={handleTestInputChange}
readonly={actionOptions.readonly}

View File

@ -11,7 +11,7 @@ const StyledContainer = styled.div`
flex-direction: column;
`;
type WorkflowEditActionFormServerlessFunctionFieldsProps = {
type WorkflowEditActionServerlessFunctionFieldsProps = {
functionInput: FunctionInput;
path?: string[];
readonly?: boolean;
@ -19,13 +19,13 @@ type WorkflowEditActionFormServerlessFunctionFieldsProps = {
VariablePicker?: VariablePickerComponent;
};
export const WorkflowEditActionFormServerlessFunctionFields = ({
export const WorkflowEditActionServerlessFunctionFields = ({
functionInput,
path = [],
readonly,
onInputChange,
VariablePicker,
}: WorkflowEditActionFormServerlessFunctionFieldsProps) => {
}: WorkflowEditActionServerlessFunctionFieldsProps) => {
return (
<>
{Object.entries(functionInput).map(([inputKey, inputValue]) => {
@ -37,7 +37,7 @@ export const WorkflowEditActionFormServerlessFunctionFields = ({
<StyledContainer key={pathKey}>
<InputLabel>{inputKey}</InputLabel>
<FormNestedFieldInputContainer>
<WorkflowEditActionFormServerlessFunctionFields
<WorkflowEditActionServerlessFunctionFields
functionInput={inputValue}
path={currentPath}
readonly={readonly}

View File

@ -18,7 +18,7 @@ import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type WorkflowEditActionFormUpdateRecordProps = {
type WorkflowEditActionUpdateRecordProps = {
action: WorkflowUpdateRecordAction;
actionOptions:
| {
@ -55,10 +55,10 @@ const AVAILABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.UUID,
];
export const WorkflowEditActionFormUpdateRecord = ({
export const WorkflowEditActionUpdateRecord = ({
action,
actionOptions,
}: WorkflowEditActionFormUpdateRecordProps) => {
}: WorkflowEditActionUpdateRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();

View File

@ -5,7 +5,7 @@ import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/Workflo
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
import { WorkflowEditActionServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionServerlessFunctionFields';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/utils/getWrongExportedFunctionMarkers';
import { useTheme } from '@emotion/react';
@ -27,13 +27,13 @@ const StyledCodeEditorContainer = styled.div`
flex-direction: column;
`;
type WorkflowReadonlyActionFormServerlessFunctionProps = {
type WorkflowReadonlyActionServerlessFunctionProps = {
action: WorkflowCodeAction;
};
export const WorkflowReadonlyActionFormServerlessFunction = ({
export const WorkflowReadonlyActionServerlessFunction = ({
action,
}: WorkflowReadonlyActionFormServerlessFunctionProps) => {
}: WorkflowReadonlyActionServerlessFunctionProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
@ -81,7 +81,7 @@ export const WorkflowReadonlyActionFormServerlessFunction = ({
disabled
/>
<WorkflowStepBody>
<WorkflowEditActionFormServerlessFunctionFields
<WorkflowEditActionServerlessFunctionFields
functionInput={action.settings.input.serverlessFunctionInput}
readonly
/>

View File

@ -10,11 +10,11 @@ import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorato
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFormCreateRecord } from '../WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionCreateRecord } from '../WorkflowEditActionCreateRecord';
const meta: Meta<typeof WorkflowEditActionFormCreateRecord> = {
title: 'Modules/Workflow/WorkflowEditActionFormCreateRecord',
component: WorkflowEditActionFormCreateRecord,
const meta: Meta<typeof WorkflowEditActionCreateRecord> = {
title: 'Modules/Workflow/WorkflowEditActionCreateRecord',
component: WorkflowEditActionCreateRecord,
parameters: {
msw: graphqlMocks,
},
@ -54,7 +54,7 @@ const meta: Meta<typeof WorkflowEditActionFormCreateRecord> = {
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFormCreateRecord>;
type Story = StoryObj<typeof WorkflowEditActionCreateRecord>;
export const Default: Story = {
args: {

View File

@ -11,7 +11,7 @@ import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { allMockPersonRecords } from '~/testing/mock-data/people';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFormDeleteRecord } from '../WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionDeleteRecord } from '../WorkflowEditActionDeleteRecord';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
@ -35,9 +35,9 @@ const DEFAULT_ACTION = {
},
} satisfies WorkflowDeleteRecordAction;
const meta: Meta<typeof WorkflowEditActionFormDeleteRecord> = {
title: 'Modules/Workflow/WorkflowEditActionFormDeleteRecord',
component: WorkflowEditActionFormDeleteRecord,
const meta: Meta<typeof WorkflowEditActionDeleteRecord> = {
title: 'Modules/Workflow/WorkflowEditActionDeleteRecord',
component: WorkflowEditActionDeleteRecord,
parameters: {
msw: graphqlMocks,
},
@ -58,7 +58,7 @@ const meta: Meta<typeof WorkflowEditActionFormDeleteRecord> = {
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFormDeleteRecord>;
type Story = StoryObj<typeof WorkflowEditActionDeleteRecord>;
export const Default: Story = {
args: {

View File

@ -1,5 +1,5 @@
import { WorkflowFindRecordsAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionFormFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormFindRecords';
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFindRecords';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
@ -33,9 +33,9 @@ const DEFAULT_ACTION = {
},
} satisfies WorkflowFindRecordsAction;
const meta: Meta<typeof WorkflowEditActionFormFindRecords> = {
title: 'Modules/Workflow/WorkflowEditActionFormFindRecords',
component: WorkflowEditActionFormFindRecords,
const meta: Meta<typeof WorkflowEditActionFindRecords> = {
title: 'Modules/Workflow/WorkflowEditActionFindRecords',
component: WorkflowEditActionFindRecords,
parameters: {
msw: graphqlMocks,
},
@ -55,7 +55,7 @@ const meta: Meta<typeof WorkflowEditActionFormFindRecords> = {
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFormFindRecords>;
type Story = StoryObj<typeof WorkflowEditActionFindRecords>;
export const Default: Story = {
args: {

View File

@ -0,0 +1,99 @@
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionForm } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionForm';
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 { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
name: 'Form',
type: 'FORM',
valid: false,
settings: {
input: [
{
name: 'company',
type: FieldMetadataType.TEXT,
label: 'Company',
placeholder: 'Select a company',
settings: {},
},
{
name: 'number',
type: FieldMetadataType.NUMBER,
label: 'Number',
placeholder: '1000',
settings: {},
},
],
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
} satisfies WorkflowFormAction;
const meta: Meta<typeof WorkflowEditActionForm> = {
title: 'Modules/Workflow/WorkflowEditActionForm',
component: WorkflowEditActionForm,
parameters: {
msw: graphqlMocks,
},
args: {
action: DEFAULT_ACTION,
},
decorators: [
WorkflowStepActionDrawerDecorator,
ComponentDecorator,
RouterDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionForm>;
export const Default: Story = {
args: {
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Company');
await canvas.findByText('Add Field');
},
};
export const DisabledWithEmptyValues: Story = {
args: {
actionOptions: {
readonly: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Form');
expect(titleInput).toBeDisabled();
await canvas.findByText('Company');
const addFieldButton = canvas.queryByText('Add Field');
expect(addFieldButton).not.toBeInTheDocument();
},
};

View File

@ -11,7 +11,7 @@ import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { allMockPersonRecords } from '~/testing/mock-data/people';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFormUpdateRecord } from '../WorkflowEditActionFormUpdateRecord';
import { WorkflowEditActionUpdateRecord } from '../WorkflowEditActionUpdateRecord';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
@ -48,9 +48,9 @@ const DEFAULT_ACTION = {
valid: false,
} satisfies WorkflowUpdateRecordAction;
const meta: Meta<typeof WorkflowEditActionFormUpdateRecord> = {
title: 'Modules/Workflow/WorkflowEditActionFormUpdateRecord',
component: WorkflowEditActionFormUpdateRecord,
const meta: Meta<typeof WorkflowEditActionUpdateRecord> = {
title: 'Modules/Workflow/WorkflowEditActionUpdateRecord',
component: WorkflowEditActionUpdateRecord,
parameters: {
msw: graphqlMocks,
},
@ -71,7 +71,7 @@ const meta: Meta<typeof WorkflowEditActionFormUpdateRecord> = {
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFormUpdateRecord>;
type Story = StoryObj<typeof WorkflowEditActionUpdateRecord>;
export const Default: Story = {
args: {

View File

@ -15,4 +15,9 @@ export const OTHER_ACTIONS: Array<{
type: 'CODE',
icon: 'IconCode',
},
{
label: 'Form',
type: 'FORM',
icon: 'IconForms',
},
];

View File

@ -20,9 +20,10 @@ export const RECORD_ACTIONS: Array<{
type: 'DELETE_RECORD',
icon: 'IconTrash',
},
{
label: 'Search Records',
type: 'FIND_RECORDS',
icon: 'IconSearch',
},
// TODO: Add search records action
// {
// label: 'Search Records',
// type: 'FIND_RECORDS',
// icon: 'IconSearch',
// },
];

View File

@ -70,6 +70,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IsWorkflowFormActionEnabled,
workspaceId: workspaceId,
value: true,
},
])
.execute();
};

View File

@ -13,4 +13,5 @@ export enum FeatureFlagKey {
IsApprovedAccessDomainsEnabled = 'IS_APPROVED_ACCESS_DOMAINS_ENABLED',
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
IsPermissionsEnabled = 'IS_PERMISSIONS_ENABLED',
IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED',
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared';
import { FieldMetadataType, isDefined } from 'twenty-shared';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
@ -495,6 +495,31 @@ export class WorkflowVersionStepWorkspaceService {
},
};
}
case WorkflowActionType.FORM: {
return {
id: newStepId,
name: 'Form',
type: WorkflowActionType.FORM,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: [
{
label: 'Company',
name: 'company',
placeholder: 'Select a company',
type: FieldMetadataType.TEXT,
},
{
label: 'Number',
name: 'number',
placeholder: '1000',
type: FieldMetadataType.NUMBER,
},
],
},
};
}
default:
throw new WorkflowVersionStepException(
`WorkflowActionType '${type}' unknown`,