diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordFieldChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordFieldChip.tsx index 5d290c4b5..cb763dce8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordFieldChip.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordFieldChip.tsx @@ -30,7 +30,7 @@ type FormSingleRecordFieldChipProps = { }; selectedRecord?: ObjectRecord; objectNameSingular: string; - onRemove: () => void; + onRemove: (event?: React.MouseEvent) => void; disabled?: boolean; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordPicker.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordPicker.tsx index 20a4f27bb..458288b41 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordPicker.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormSingleRecordPicker.tsx @@ -11,19 +11,36 @@ import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-r import { InputLabel } from '@/ui/input/components/InputLabel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; +import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useCallback } from 'react'; import { isDefined, isValidUuid } from 'twenty-shared/utils'; import { IconChevronDown, IconForbid } from 'twenty-ui/display'; -import { LightIconButton } from 'twenty-ui/input'; -const StyledFormSelectContainer = styled(FormFieldInputInnerContainer)` - justify-content: space-between; +const StyledFormSelectContainer = styled(FormFieldInputInnerContainer)<{ + readonly?: boolean; +}>` align-items: center; - padding-right: ${({ theme }) => theme.spacing(1)}; + height: 32px; + justify-content: space-between; + padding-right: ${({ theme }) => theme.spacing(2)}; + + ${({ readonly, theme }) => + !readonly && + css` + &:hover, + &[data-open='true'] { + background-color: ${theme.background.transparent.light}; + } + + cursor: pointer; + `} +`; + +const StyledIconButton = styled.div` + display: flex; `; export type RecordId = string; @@ -58,6 +75,7 @@ export const FormSingleRecordPicker = ({ testId, VariablePicker, }: FormSingleRecordPickerProps) => { + const theme = useTheme(); const draftValue: FormSingleRecordPickerValue = isStandaloneVariableString( defaultValue, ) @@ -103,12 +121,11 @@ export const FormSingleRecordPicker = ({ const handleVariableTagInsert = (variable: string) => { onChange?.(variable); - closeDropdown(); }; - const handleUnlinkVariable = () => { - closeDropdown(); - + const handleUnlinkVariable = (event?: React.MouseEvent) => { + // Prevents the dropdown to open when clicking on the chip + event?.stopPropagation(); onChange(''); }; @@ -130,47 +147,57 @@ export const FormSingleRecordPicker = ({ {label ? {label} : null} - - - {!disabled && ( - - + + + ) : ( + + + + - } - dropdownComponents={ - closeDropdown()} - onRecordSelected={handleRecordSelected} - objectNameSingular={objectNameSingular} - recordPickerInstanceId={dropdownId} - /> - } - dropdownHotkeyScope={{ scope: dropdownId }} + + + } + dropdownComponents={ + closeDropdown()} + onRecordSelected={handleRecordSelected} + objectNameSingular={objectNameSingular} + recordPickerInstanceId={dropdownId} /> - - )} - + } + dropdownHotkeyScope={{ scope: dropdownId }} + /> + )} {isDefined(VariablePicker) && !disabled && ( = { + title: 'UI/Data/Field/Form/Input/FormSingleRecordPicker', + component: FormSingleRecordPicker, + parameters: { + msw: graphqlMocks, + }, + args: {}, + argTypes: {}, + decorators: [ + I18nFrontDecorator, + ObjectMetadataItemsDecorator, + ComponentDecorator, + WorkspaceDecorator, + SnackBarDecorator, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Company', + defaultValue: '123e4567-e89b-12d3-a456-426614174000', + objectNameSingular: 'company', + onChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Company'); + const dropdown = await canvas.findByRole('button'); + expect(dropdown).toBeVisible(); + }, +}; + +export const WithVariables: Story = { + args: { + label: 'Company', + defaultValue: `{{${MOCKED_STEP_ID}.company.id}}`, + objectNameSingular: 'company', + onChange: fn(), + VariablePicker: () =>
VariablePicker
, + }, + decorators: [ + WorkflowStepDecorator, + ComponentDecorator, + ObjectMetadataItemsDecorator, + SnackBarDecorator, + RouterDecorator, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Company'); + const variablePicker = await canvas.findByText('VariablePicker'); + expect(variablePicker).toBeVisible(); + }, +}; + +export const Disabled: Story = { + args: { + label: 'Company', + defaultValue: '123e4567-e89b-12d3-a456-426614174000', + objectNameSingular: 'company', + onChange: fn(), + disabled: true, + VariablePicker: () =>
VariablePicker
, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Company'); + const dropdown = canvas.queryByRole('button'); + expect(dropdown).not.toBeInTheDocument(); + + // Variable picker should not be visible when disabled + const variablePicker = canvas.queryByText('VariablePicker'); + expect(variablePicker).not.toBeInTheDocument(); + + // Clicking should not trigger onChange + await userEvent.click(dropdown); + expect(args.onChange).not.toHaveBeenCalled(); + }, +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx index 60ede3ed6..13ede6817 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx @@ -25,19 +25,24 @@ import { isDefined } from 'twenty-shared/utils'; import { useIsMobile } from 'twenty-ui/utilities'; import { useDropdown } from '../hooks/useDropdown'; +type Width = `${string}px` | `${number}%` | 'auto' | number; const StyledDropdownFallbackAnchor = styled.div` left: 0; position: fixed; top: 0; `; -const StyledClickableComponent = styled.div` +const StyledClickableComponent = styled.div<{ + width?: Width; +}>` height: fit-content; + width: ${({ width }) => width ?? 'auto'}; `; export type DropdownProps = { className?: string; clickableComponent?: ReactNode; + clickableComponentWidth?: Width; dropdownComponents: ReactNode; hotkey?: { key: Keys; @@ -46,7 +51,7 @@ export type DropdownProps = { dropdownHotkeyScope: HotkeyScope; dropdownId: string; dropdownPlacement?: Placement; - dropdownWidth?: `${string}px` | `${number}%` | 'auto' | number; + dropdownWidth?: Width; dropdownOffset?: DropdownOffset; dropdownStrategy?: 'fixed' | 'absolute'; onClickOutside?: () => void; @@ -70,6 +75,7 @@ export const Dropdown = ({ onClose, onOpen, avoidPortal, + clickableComponentWidth = 'auto', }: DropdownProps) => { const { isDropdownOpen, toggleDropdown } = useDropdown(dropdownId); @@ -159,6 +165,7 @@ export const Dropdown = ({ aria-expanded={isDropdownOpen} aria-haspopup={true} role="button" + width={clickableComponentWidth} > {clickableComponent} diff --git a/packages/twenty-server/src/modules/workflow/common/exceptions/workflow-version-step.exception.ts b/packages/twenty-server/src/modules/workflow/common/exceptions/workflow-version-step.exception.ts index 483338b12..0e414bfd1 100644 --- a/packages/twenty-server/src/modules/workflow/common/exceptions/workflow-version-step.exception.ts +++ b/packages/twenty-server/src/modules/workflow/common/exceptions/workflow-version-step.exception.ts @@ -10,4 +10,5 @@ export enum WorkflowVersionStepExceptionCode { NOT_FOUND = 'NOT_FOUND', UNDEFINED = 'UNDEFINED', FAILURE = 'FAILURE', + INVALID = 'INVALID', } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index 666b89c05..04f3bcca3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FieldMetadataType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isValidUuid } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; @@ -25,6 +25,7 @@ import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-execut import { WorkflowAction, WorkflowActionType, + WorkflowFormAction, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; import { WorkflowRunnerWorkspaceService } from 'src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service'; @@ -287,16 +288,28 @@ export class WorkflowVersionStepWorkspaceService { ); } + if (step.type !== WorkflowActionType.FORM) { + throw new WorkflowVersionStepException( + 'Step is not a form', + WorkflowVersionStepExceptionCode.INVALID, + ); + } + + const enrichedResponse = await this.enrichFormStepResponse({ + step, + response, + }); + const newStepOutput: StepOutput = { id: stepId, output: { - result: response, + result: enrichedResponse, }, }; const updatedContext = { ...workflowRun.context, - [stepId]: response, + [stepId]: enrichedResponse, }; await this.workflowRunWorkspaceService.saveWorkflowRunState({ @@ -547,4 +560,49 @@ export class WorkflowVersionStepWorkspaceService { ); } } + + private async enrichFormStepResponse({ + step, + response, + }: { + step: WorkflowFormAction; + response: object; + }) { + const responseKeys = Object.keys(response); + + const enrichedResponses = await Promise.all( + responseKeys.map(async (key) => { + if (!isDefined(response[key])) { + return { key, value: response[key] }; + } + + const field = step.settings.input.find((field) => field.name === key); + + if ( + field?.type === 'RECORD' && + field?.settings?.objectName && + isDefined(response[key].id) && + isValidUuid(response[key].id) + ) { + const repository = await this.twentyORMManager.getRepository( + field.settings.objectName, + ); + + const record = await repository.findOne({ + where: { id: response[key].id }, + }); + + return { key, value: record }; + } else { + return { key, value: response[key] }; + } + }), + ); + + return enrichedResponses.reduce((acc, { key, value }) => { + acc[key] = value; + + return acc; + }, {}); + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts index a1ef48461..b8b6fcfc7 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { isDefined } from 'class-validator'; +import { isValidUuid } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; @@ -59,6 +61,17 @@ export class DeleteRecordWorkflowAction implements WorkflowExecutor { context, ) as WorkflowDeleteRecordActionInput; + if ( + !isDefined(workflowActionInput.objectRecordId) || + !isValidUuid(workflowActionInput.objectRecordId) || + !isDefined(workflowActionInput.objectName) + ) { + throw new RecordCRUDActionException( + 'Failed to update: Object record ID and name are required', + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + const repository = await this.twentyORMManager.getRepository( workflowActionInput.objectName, ); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts index ed5d57f0e..3615aa189 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import deepEqual from 'deep-equal'; +import { isDefined, isValidUuid } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; @@ -67,6 +68,17 @@ export class UpdateRecordWorkflowAction implements WorkflowExecutor { context, ) as WorkflowUpdateRecordActionInput; + if ( + !isDefined(workflowActionInput.objectRecordId) || + !isValidUuid(workflowActionInput.objectRecordId) || + !isDefined(workflowActionInput.objectName) + ) { + throw new RecordCRUDActionException( + 'Failed to update: Object record ID and name are required', + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + const repository = await this.twentyORMManager.getRepository( workflowActionInput.objectName, );