Skip to content

Fix form record picker field #11817

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type FormSingleRecordFieldChipProps = {
};
selectedRecord?: ObjectRecord;
objectNameSingular: string;
onRemove: () => void;
onRemove: (event?: React.MouseEvent<HTMLDivElement>) => void;
disabled?: boolean;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +75,7 @@ export const FormSingleRecordPicker = ({
testId,
VariablePicker,
}: FormSingleRecordPickerProps) => {
const theme = useTheme();
const draftValue: FormSingleRecordPickerValue = isStandaloneVariableString(
defaultValue,
)
Expand Down Expand Up @@ -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('');
};

Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -70,6 +75,7 @@ export const Dropdown = ({
onClose,
onOpen,
avoidPortal,
clickableComponentWidth = 'auto',
}: DropdownProps) => {
const { isDropdownOpen, toggleDropdown } = useDropdown(dropdownId);

Expand Down Expand Up @@ -159,6 +165,7 @@ export const Dropdown = ({
aria-expanded={isDropdownOpen}
aria-haspopup={true}
role="button"
width={clickableComponentWidth}
>
{clickableComponent}
</StyledClickableComponent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export enum WorkflowVersionStepExceptionCode {
NOT_FOUND = 'NOT_FOUND',
UNDEFINED = 'UNDEFINED',
FAILURE = 'FAILURE',
INVALID = 'INVALID',
}
Loading
Loading