diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 257337ceb..1afe345a6 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -13,6 +13,7 @@ import { workflowFindRecordsActionSettingsSchema, workflowFormActionSchema, workflowFormActionSettingsSchema, + workflowHttpRequestActionSchema, workflowManualTriggerSchema, workflowRunContextSchema, workflowRunOutputSchema, @@ -67,6 +68,9 @@ export type WorkflowFindRecordsAction = z.infer< typeof workflowFindRecordsActionSchema >; export type WorkflowFormAction = z.infer; +export type WorkflowHttpRequestAction = z.infer< + typeof workflowHttpRequestActionSchema +>; export type WorkflowAction = z.infer; export type WorkflowActionType = WorkflowAction['type']; 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 6210c5956..a941e0272 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -110,6 +110,26 @@ export const workflowFormActionSettingsSchema = ), }); +export const workflowHttpRequestActionSettingsSchema = + baseWorkflowActionSettingsSchema.extend({ + input: z.object({ + url: z.string().url(), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + headers: z.record(z.string()).optional(), + body: z + .record( + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), + ]), + ) + .optional(), + }), + }); + // Action schemas export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({ type: z.literal('CODE'), @@ -152,6 +172,11 @@ export const workflowFormActionSchema = baseWorkflowActionSchema.extend({ settings: workflowFormActionSettingsSchema, }); +export const workflowHttpRequestActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('HTTP_REQUEST'), + settings: workflowHttpRequestActionSettingsSchema, +}); + // Combined action schema export const workflowActionSchema = z.discriminatedUnion('type', [ workflowCodeActionSchema, @@ -161,6 +186,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [ workflowDeleteRecordActionSchema, workflowFindRecordsActionSchema, workflowFormActionSchema, + workflowHttpRequestActionSchema, ]); // Trigger schemas diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx index 002534df1..782e1099b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey'; @@ -46,7 +45,8 @@ export const WorkflowDiagramStepNodeIcon = ({ } case 'action': { switch (data.actionType) { - case 'CODE': { + case 'CODE': + case 'HTTP_REQUEST': { return ( = { 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 693a6ca03..32fb5fb37 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 @@ -9,6 +9,7 @@ import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow- import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord'; import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords'; import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller'; +import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest'; import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm'; import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm'; @@ -175,6 +176,18 @@ export const WorkflowRunStepNodeDetail = ({ /> ); } + + case 'HTTP_REQUEST': { + return ( + + ); + } } } } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx index 37326175b..8c6514dce 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx @@ -8,6 +8,7 @@ import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow- import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord'; import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords'; import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder'; +import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest'; import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm'; import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm'; @@ -163,6 +164,16 @@ export const WorkflowStepDetail = ({ /> ); } + + case 'HTTP_REQUEST': { + return ( + + ); + } } } } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx index 8d2dc4cbf..3da95a415 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx @@ -7,9 +7,8 @@ import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOr import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; -import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow'; -import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow'; -import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon'; +import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; +import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -17,7 +16,6 @@ import { HorizontalSeparator, useIcons } from 'twenty-ui/display'; import { SelectOption } from 'twenty-ui/input'; import { JsonValue } from 'type-fest'; import { useDebouncedCallback } from 'use-debounce'; -import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField'; type WorkflowEditActionCreateRecordProps = { action: WorkflowCreateRecordAction; @@ -159,10 +157,11 @@ export const WorkflowEditActionCreateRecord = ({ }; }, [saveAction]); - const headerTitle = isDefined(action.name) ? action.name : `Create Record`; - const headerIcon = getActionIcon(action.type); - const headerIconColor = useActionIconColorOrThrow(action.type); - const headerType = useActionHeaderTypeOrThrow(action.type); + const { headerTitle, headerIcon, headerIconColor, headerType } = + useWorkflowActionHeader({ + action, + defaultTitle: 'Create Record', + }); return ( <> diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx index ca2ad1213..635c72a92 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx @@ -6,9 +6,7 @@ import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/Workflo import { useEffect, useState } from 'react'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; -import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow'; -import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow'; -import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon'; +import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { isDefined } from 'twenty-shared/utils'; import { HorizontalSeparator, useIcons } from 'twenty-ui/display'; @@ -104,10 +102,11 @@ export const WorkflowEditActionDeleteRecord = ({ }; }, [saveAction]); - const headerTitle = isDefined(action.name) ? action.name : `Delete Record`; - const headerIcon = getActionIcon(action.type); - const headerIconColor = useActionIconColorOrThrow(action.type); - const headerType = useActionHeaderTypeOrThrow(action.type); + const { headerTitle, headerIcon, headerIconColor, headerType } = + useWorkflowActionHeader({ + action, + defaultTitle: 'Delete Record', + }); return ( <> diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx index d003ff379..1b5dd6ed0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx @@ -13,9 +13,7 @@ import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/wo import { WorkflowSendEmailAction } from '@/workflow/types/Workflow'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; -import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow'; -import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow'; -import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon'; +import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; @@ -197,10 +195,11 @@ export const WorkflowEditActionSendEmail = ({ } }); - const headerTitle = isDefined(action.name) ? action.name : 'Send Email'; - const headerIcon = getActionIcon(action.type); - const headerIconColor = useActionIconColorOrThrow(action.type); - const headerType = useActionHeaderTypeOrThrow(action.type); + const { headerTitle, headerIcon, headerIconColor, headerType } = + useWorkflowActionHeader({ + action, + defaultTitle: 'Send Email', + }); const navigate = useNavigateSettings(); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx index 65eac4693..c166c86e8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx @@ -10,9 +10,7 @@ import { isFieldRelation } from '@/object-record/record-field/types/guards/isFie import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; -import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow'; -import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow'; -import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon'; +import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { isDefined } from 'twenty-shared/utils'; @@ -139,10 +137,11 @@ export const WorkflowEditActionUpdateRecord = ({ }; }, [saveAction]); - const headerTitle = isDefined(action.name) ? action.name : `Update Record`; - const headerIcon = getActionIcon(action.type); - const headerIconColor = useActionIconColorOrThrow(action.type); - const headerType = useActionHeaderTypeOrThrow(action.type); + const { headerTitle, headerIcon, headerIconColor, headerType } = + useWorkflowActionHeader({ + action, + defaultTitle: 'Update Record', + }); return ( <> diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts index 909c7b8fc..b2c738ecf 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts @@ -23,4 +23,9 @@ export const OTHER_ACTIONS: Array<{ type: 'FORM', icon: 'IconForms', }, + { + label: 'HTTP Request', + type: 'HTTP_REQUEST', + icon: 'IconWorld', + }, ]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx index 421b22257..7e73a31cf 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx @@ -15,9 +15,7 @@ import { InputLabel } from '@/ui/input/components/InputLabel'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFilters'; import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect'; -import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow'; -import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow'; -import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon'; +import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; import { useLingui } from '@lingui/react/macro'; import { isDefined } from 'twenty-shared/utils'; import { HorizontalSeparator, useIcons } from 'twenty-ui/display'; @@ -115,10 +113,11 @@ export const WorkflowEditActionFindRecords = ({ }; }, [saveAction]); - const headerTitle = isDefined(action.name) ? action.name : `Search Records`; - const headerIcon = getActionIcon(action.type); - const headerIconColor = useActionIconColorOrThrow(action.type); - const headerType = useActionHeaderTypeOrThrow(action.type); + const { headerTitle, headerIcon, headerIconColor, headerType } = + useWorkflowActionHeader({ + action, + defaultTitle: 'Search Records', + }); const instanceId = `workflow-edit-action-record-find-records-${action.id}-${formData.objectName}`; return ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx new file mode 100644 index 000000000..8626b3118 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx @@ -0,0 +1,219 @@ +import { + workflowActionSchema, + workflowFormActionSettingsSchema, + workflowHttpRequestActionSettingsSchema, + workflowSendEmailActionSettingsSchema, +} from '@/workflow/validation-schemas/workflowSchema'; +import { renderHook } from '@testing-library/react'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { useWorkflowActionHeader } from '../useWorkflowActionHeader'; + +jest.mock('../useActionIconColorOrThrow', () => ({ + useActionIconColorOrThrow: jest.fn().mockReturnValue('blue'), +})); + +jest.mock('../useActionHeaderTypeOrThrow', () => ({ + useActionHeaderTypeOrThrow: jest.fn().mockReturnValue('Action'), +})); + +jest.mock('../../utils/getActionIcon', () => ({ + getActionIcon: jest.fn().mockReturnValue('IconHttp'), +})); + +const mockGetIcon = jest.fn().mockReturnValue('IconComponent'); +jest.mock('twenty-ui/display', () => ({ + useIcons: () => ({ + getIcon: mockGetIcon, + }), +})); + +describe('useWorkflowActionHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when action name is not defined', () => { + it('should return default title', () => { + const action = workflowActionSchema.parse({ + id: '1', + name: '', + type: 'HTTP_REQUEST', + settings: workflowHttpRequestActionSettingsSchema.parse({ + input: { + url: 'https://example.com', + method: 'GET', + headers: {}, + body: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }), + valid: true, + }); + + const { result } = renderHook(() => + useWorkflowActionHeader({ + action, + defaultTitle: 'HTTP Request', + }), + ); + + expect(result.current.headerTitle).toBe('HTTP Request'); + expect(result.current.headerIcon).toBe('IconHttp'); + expect(result.current.headerIconColor).toBe('blue'); + expect(result.current.headerType).toBe('Action'); + expect(result.current.getIcon).toBe(mockGetIcon); + }); + }); + + describe('when action name is defined', () => { + it('should return the action name', () => { + const action = workflowActionSchema.parse({ + id: '1', + name: 'Test Action', + type: 'HTTP_REQUEST', + settings: workflowHttpRequestActionSettingsSchema.parse({ + input: { + url: 'https://example.com', + method: 'GET', + headers: {}, + body: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }), + valid: true, + }); + + const { result } = renderHook(() => + useWorkflowActionHeader({ + action, + defaultTitle: 'HTTP Request', + }), + ); + + expect(result.current.headerTitle).toBe('Test Action'); + expect(result.current.headerIcon).toBe('IconHttp'); + expect(result.current.headerIconColor).toBe('blue'); + expect(result.current.headerType).toBe('Action'); + expect(result.current.getIcon).toBe(mockGetIcon); + }); + }); + + describe('when action type is defined', () => { + it('should return default title for HTTP request action', () => { + const action = workflowActionSchema.parse({ + id: '1', + name: '', + type: 'HTTP_REQUEST', + settings: workflowHttpRequestActionSettingsSchema.parse({ + input: { + url: 'https://example.com', + method: 'GET', + headers: {}, + body: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }), + valid: true, + }); + + const { result } = renderHook(() => + useWorkflowActionHeader({ + action, + defaultTitle: 'HTTP Request', + }), + ); + + expect(result.current.headerTitle).toBe('HTTP Request'); + expect(result.current.headerIcon).toBe('IconHttp'); + expect(result.current.headerIconColor).toBe('blue'); + expect(result.current.headerType).toBe('Action'); + expect(result.current.getIcon).toBe(mockGetIcon); + }); + + it('should return default title for form action', () => { + const action = workflowActionSchema.parse({ + id: '1', + name: '', + type: 'FORM', + settings: workflowFormActionSettingsSchema.parse({ + input: [ + { + id: '1', + name: 'field1', + label: 'Field 1', + type: FieldMetadataType.TEXT, + required: false, + settings: {}, + }, + ], + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }), + valid: true, + }); + + const { result } = renderHook(() => + useWorkflowActionHeader({ + action, + defaultTitle: 'Form', + }), + ); + + expect(result.current.headerTitle).toBe('Form'); + expect(result.current.headerIcon).toBe('IconHttp'); + expect(result.current.headerIconColor).toBe('blue'); + expect(result.current.headerType).toBe('Action'); + expect(result.current.getIcon).toBe(mockGetIcon); + }); + + it('should return default title for email action', () => { + const action = workflowActionSchema.parse({ + id: '1', + name: '', + type: 'SEND_EMAIL', + settings: workflowSendEmailActionSettingsSchema.parse({ + input: { + connectedAccountId: '1', + email: 'test@example.com', + subject: 'Test Subject', + body: 'Test Body', + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }), + valid: true, + }); + + const { result } = renderHook(() => + useWorkflowActionHeader({ + action, + defaultTitle: 'Email', + }), + ); + + expect(result.current.headerTitle).toBe('Email'); + expect(result.current.headerIcon).toBe('IconHttp'); + expect(result.current.headerIconColor).toBe('blue'); + expect(result.current.headerType).toBe('Action'); + expect(result.current.getIcon).toBe(mockGetIcon); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader.ts new file mode 100644 index 000000000..30cb2e195 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader.ts @@ -0,0 +1,38 @@ +import { WorkflowAction } from '@/workflow/types/Workflow'; +import { IconComponent, useIcons } from 'twenty-ui/display'; +import { getActionIcon } from '../utils/getActionIcon'; +import { useActionHeaderTypeOrThrow } from './useActionHeaderTypeOrThrow'; +import { useActionIconColorOrThrow } from './useActionIconColorOrThrow'; + +type UseWorkflowActionHeaderProps = { + action: WorkflowAction; + defaultTitle: string; +}; + +type UseWorkflowActionHeaderReturn = { + headerTitle: string; + headerIcon: string | undefined; + headerIconColor: string; + headerType: string; + getIcon: (iconName: string) => IconComponent; +}; + +export const useWorkflowActionHeader = ({ + action, + defaultTitle, +}: UseWorkflowActionHeaderProps): UseWorkflowActionHeaderReturn => { + const { getIcon } = useIcons(); + + const headerTitle = action.name ? action.name : defaultTitle; + const headerIcon = getActionIcon(action.type); + const headerIconColor = useActionIconColorOrThrow(action.type); + const headerType = useActionHeaderTypeOrThrow(action.type); + + return { + headerTitle, + headerIcon, + headerIconColor, + headerType, + getIcon, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx new file mode 100644 index 000000000..35255eebb --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx @@ -0,0 +1,134 @@ +import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer'; +import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput'; + +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { Select } from '@/ui/input/components/Select'; +import { + DEFAULT_JSON_BODY_PLACEHOLDER, + HttpRequestBody, +} from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; +import { hasNonStringValues } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues'; +import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { IconFileText, IconKey } from 'twenty-ui/display'; +import { KeyValuePairInput } from './KeyValuePairInput'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledSelectDropdown = styled(Select)` + margin-bottom: ${({ theme }) => theme.spacing(2)}; +`; + +type BodyInputProps = { + label?: string; + defaultValue?: HttpRequestBody; + onChange: (value?: HttpRequestBody) => void; + readonly?: boolean; +}; + +export const BodyInput = ({ + defaultValue, + onChange, + readonly, +}: BodyInputProps) => { + const [isRawJson, setIsRawJson] = useState(() => + hasNonStringValues(defaultValue), + ); + const [jsonString, setJsonString] = useState( + JSON.stringify(defaultValue, null, 2), + ); + const [errorMessage, setErrorMessage] = useState(); + + const validateJson = (value: string | null): boolean => { + if (!value?.trim()) { + setErrorMessage(undefined); + return true; + } + + try { + JSON.parse(value); + setErrorMessage(undefined); + return true; + } catch (e) { + setErrorMessage(String(e)); + return false; + } + }; + + const handleKeyValueChange = (value: Record) => { + onChange(value); + setErrorMessage(undefined); + }; + + const handleJsonChange = (value: string | null) => { + setJsonString(value); + + if (!value?.trim()) { + onChange(); + setErrorMessage(undefined); + return; + } + + try { + const parsed = JSON.parse(value); + onChange(parsed); + } catch { + // Do nothing, validation will happen on blur + } + }; + + const handleModeChange = (isRawJson: boolean) => { + setIsRawJson(isRawJson); + onChange(); + setJsonString(null); + }; + + const handleBlur = () => { + if (isRawJson && Boolean(jsonString)) { + validateJson(jsonString); + } + }; + + return ( + + Body Input + handleModeChange(value === 'rawJson')} + disabled={readonly} + /> + + + {isRawJson ? ( + + ) : ( + } + onChange={handleKeyValueChange} + readonly={readonly} + keyPlaceholder="Property name" + valuePlaceholder="Property value" + /> + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx new file mode 100644 index 000000000..537ab3b40 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx @@ -0,0 +1,159 @@ +import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer'; +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { IconTrash } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { v4 } from 'uuid'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledRow = styled.div` + align-items: flex-start; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledKeyValueContainer = styled.div` + display: flex; + flex: 1; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledPlaceholder = styled.div` + aspect-ratio: 1; + height: ${({ theme }) => theme.spacing(8)}; +`; + +export type KeyValuePair = { + id: string; + key: string; + value: string; +}; + +export type KeyValuePairInputProps = { + label?: string; + defaultValue?: Record; + onChange: (value: Record) => void; + readonly?: boolean; + keyPlaceholder?: string; + valuePlaceholder?: string; +}; + +export const KeyValuePairInput = ({ + label, + defaultValue = {}, + onChange, + readonly, + keyPlaceholder = 'Key', + valuePlaceholder = 'Value', +}: KeyValuePairInputProps) => { + const [pairs, setPairs] = useState(() => { + const initialPairs = Object.entries(defaultValue).map(([key, value]) => ({ + id: v4(), + key, + value, + })); + return initialPairs.length > 0 + ? [...initialPairs, { id: v4(), key: '', value: '' }] + : [{ id: v4(), key: '', value: '' }]; + }); + + const handlePairChange = ( + pairId: string, + field: 'key' | 'value', + newValue: string, + ) => { + const index = pairs.findIndex((p) => p.id === pairId); + const newPairs = [...pairs]; + newPairs[index] = { ...newPairs[index], [field]: newValue }; + + if ( + index === pairs.length - 1 && + (field === 'key' || field === 'value') && + Boolean(newValue.trim()) + ) { + newPairs.push({ id: v4(), key: '', value: '' }); + } + + setPairs(newPairs); + + const record = newPairs.reduce( + (acc, { key, value }) => { + if (key.trim().length > 0) { + acc[key] = value; + } + return acc; + }, + {} as Record, + ); + + onChange(record); + }; + + const handleRemovePair = (pairId: string) => { + const newPairs = pairs.filter((pair) => pair.id !== pairId); + if (newPairs.length === 0) { + newPairs.push({ id: v4(), key: '', value: '' }); + } + setPairs(newPairs); + + const record = newPairs.reduce( + (acc, { key, value }) => { + if (key.trim().length > 0) { + acc[key] = value; + } + return acc; + }, + {} as Record, + ); + + onChange(record); + }; + + return ( + + {label && {label}} + + {pairs.map((pair) => ( + + + + handlePairChange(pair.id, 'key', value ?? '') + } + VariablePicker={WorkflowVariablePicker} + /> + + handlePairChange(pair.id, 'value', value ?? '') + } + VariablePicker={WorkflowVariablePicker} + /> + {!readonly && pair.id !== pairs[pairs.length - 1].id ? ( +