Fix form record picker field (#11817)

- enrich response so the record is available in the step output. Today
this is available in the schema but only the id is set
- make the full record picker clickable instead of the arrow only

<img width="467" alt="Capture d’écran 2025-04-30 à 16 08 04"
src="https://github.com/user-attachments/assets/db74b9a6-7f1d-4e54-bf06-9be3d67ee398"
/>
This commit is contained in:
Thomas Trompette
2025-05-05 14:58:11 +02:00
committed by GitHub
parent c9eff401df
commit 6128d660c2
8 changed files with 270 additions and 54 deletions

View File

@ -30,7 +30,7 @@ type FormSingleRecordFieldChipProps = {
};
selectedRecord?: ObjectRecord;
objectNameSingular: string;
onRemove: () => void;
onRemove: (event?: React.MouseEvent<HTMLDivElement>) => void;
disabled?: boolean;
};

View File

@ -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<HTMLDivElement>) => {
// Prevents the dropdown to open when clicking on the chip
event?.stopPropagation();
onChange('');
};
@ -130,47 +147,57 @@ export const FormSingleRecordPicker = ({
<FormFieldInputContainer testId={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledFormSelectContainer
hasRightElement={isDefined(VariablePicker) && !disabled}
preventSetHotkeyScope={true}
>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
{!disabled && (
<DropdownScope dropdownScopeId={dropdownId}>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconChevronDown}
accent="tertiary"
{disabled ? (
<StyledFormSelectContainer hasRightElement={false} readonly>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
</StyledFormSelectContainer>
) : (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponentWidth={'100%'}
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenDropdown}
clickableComponent={
<StyledFormSelectContainer
hasRightElement={isDefined(VariablePicker) && !disabled}
preventSetHotkeyScope={true}
>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
<StyledIconButton>
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.light}
/>
}
dropdownComponents={
<SingleRecordPicker
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={'No ' + objectNameSingular}
onCancel={() => closeDropdown()}
onRecordSelected={handleRecordSelected}
objectNameSingular={objectNameSingular}
recordPickerInstanceId={dropdownId}
/>
}
dropdownHotkeyScope={{ scope: dropdownId }}
</StyledIconButton>
</StyledFormSelectContainer>
}
dropdownComponents={
<SingleRecordPicker
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={'No ' + objectNameSingular}
onCancel={() => closeDropdown()}
onRecordSelected={handleRecordSelected}
objectNameSingular={objectNameSingular}
recordPickerInstanceId={dropdownId}
/>
</DropdownScope>
)}
</StyledFormSelectContainer>
}
dropdownHotkeyScope={{ scope: dropdownId }}
/>
)}
{isDefined(VariablePicker) && !disabled && (
<VariablePicker
inputId={variablesDropdownId}

View File

@ -0,0 +1,98 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormSingleRecordPicker } from '../FormSingleRecordPicker';
const meta: Meta<typeof FormSingleRecordPicker> = {
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<typeof FormSingleRecordPicker>;
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: () => <div>VariablePicker</div>,
},
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: () => <div>VariablePicker</div>,
},
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();
},
};

View File

@ -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}
</StyledClickableComponent>

View File

@ -10,4 +10,5 @@ export enum WorkflowVersionStepExceptionCode {
NOT_FOUND = 'NOT_FOUND',
UNDEFINED = 'UNDEFINED',
FAILURE = 'FAILURE',
INVALID = 'INVALID',
}

View File

@ -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;
}, {});
}
}

View File

@ -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,
);

View File

@ -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,
);