Prevent setting primary link as primary link (#12266)

## Before

It was possible to set the primary link as the... primary link.


https://github.com/user-attachments/assets/a6ffefab-50c5-403e-9aa1-5acc08593168

## After


https://github.com/user-attachments/assets/494e45c4-de15-4b52-b71b-032a2ca77c35

- We display the bookmark icon for the first link if there is more than
one link to show. (`index === 0 && links.length > 1`)
- It's never possible to "Set as Primary" the first link (`index > 0`)
- I introduced abstractions to make it easy to solve a similar issue for
phones and emails fields (see
https://github.com/twentyhq/twenty/issues/12268)
- Wrote stories to document the current improper behavior of phones and
emails fields
This commit is contained in:
Baptiste Devessier
2025-05-23 18:55:26 +02:00
committed by GitHub
parent bd8eace0b1
commit 621a779526
12 changed files with 720 additions and 36 deletions

View File

@ -21,7 +21,8 @@ export const ArrayFieldMenuItem = ({
onEdit={onEdit}
onDelete={onDelete}
DisplayComponent={() => <ArrayDisplay value={[value]} />}
hasPrimaryButton={false}
showPrimaryIcon={false}
showSetAsPrimaryButton={false}
/>
);
};

View File

@ -77,7 +77,8 @@ export const EmailsFieldInput = ({
<EmailsFieldMenuItem
key={index}
dropdownId={`emails-${index}`}
isPrimary={isPrimaryEmail(index)}
showPrimaryIcon={isPrimaryEmail(index)}
showSetAsPrimaryButton={!isPrimaryEmail(index)}
email={email}
onEdit={handleEdit}
onSetAsPrimary={handleSetPrimary}

View File

@ -3,30 +3,33 @@ import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
type EmailsFieldMenuItemProps = {
dropdownId: string;
isPrimary?: boolean;
onEdit?: () => void;
onSetAsPrimary?: () => void;
onDelete?: () => void;
email: string;
showPrimaryIcon: boolean;
showSetAsPrimaryButton: boolean;
};
export const EmailsFieldMenuItem = ({
dropdownId,
isPrimary,
onEdit,
onSetAsPrimary,
onDelete,
email,
showPrimaryIcon,
showSetAsPrimaryButton,
}: EmailsFieldMenuItemProps) => {
return (
<MultiItemFieldMenuItem
dropdownId={dropdownId}
isPrimary={isPrimary}
value={email}
onEdit={onEdit}
onSetAsPrimary={onSetAsPrimary}
onDelete={onDelete}
DisplayComponent={EmailDisplay}
showPrimaryIcon={showPrimaryIcon}
showSetAsPrimaryButton={showSetAsPrimaryButton}
/>
);
};

View File

@ -38,7 +38,8 @@ export const LinksFieldInput = ({
});
};
const isPrimaryLink = (index: number) => index === 0 && links?.length > 1;
const getShowPrimaryIcon = (index: number) => index === 0 && links.length > 1;
const getShowSetAsPrimaryButton = (index: number) => index > 0;
const setIsFieldInError = useSetRecoilComponentStateV2(
recordFieldInputIsFieldInErrorComponentState,
@ -75,7 +76,8 @@ export const LinksFieldInput = ({
<LinksFieldMenuItem
key={index}
dropdownId={`links-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
isPrimary={isPrimaryLink(index)}
showPrimaryIcon={getShowPrimaryIcon(index)}
showSetAsPrimaryButton={getShowSetAsPrimaryButton(index)}
label={link.label}
onEdit={handleEdit}
onSetAsPrimary={handleSetPrimary}

View File

@ -3,27 +3,30 @@ import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
type LinksFieldMenuItemProps = {
dropdownId: string;
isPrimary?: boolean;
label: string | null;
url: string;
onEdit?: () => void;
onSetAsPrimary?: () => void;
onDelete?: () => void;
showPrimaryIcon: boolean;
showSetAsPrimaryButton: boolean;
};
export const LinksFieldMenuItem = ({
dropdownId,
isPrimary,
label,
onEdit,
onSetAsPrimary,
onDelete,
url,
showPrimaryIcon,
showSetAsPrimaryButton,
}: LinksFieldMenuItemProps) => {
return (
<MultiItemFieldMenuItem
dropdownId={dropdownId}
isPrimary={isPrimary}
showPrimaryIcon={showPrimaryIcon}
showSetAsPrimaryButton={showSetAsPrimaryButton}
value={{ label, url }}
onEdit={onEdit}
onSetAsPrimary={onSetAsPrimary}

View File

@ -12,24 +12,24 @@ import { MenuItem } from 'twenty-ui/navigation';
type MultiItemFieldMenuItemProps<T> = {
dropdownId: string;
isPrimary?: boolean;
value: T;
onEdit?: () => void;
onSetAsPrimary?: () => void;
onDelete?: () => void;
DisplayComponent: React.ComponentType<{ value: T }>;
hasPrimaryButton?: boolean;
showPrimaryIcon: boolean;
showSetAsPrimaryButton: boolean;
};
export const MultiItemFieldMenuItem = <T,>({
dropdownId,
isPrimary,
value,
onEdit,
onSetAsPrimary,
onDelete,
DisplayComponent,
hasPrimaryButton = true,
showPrimaryIcon,
showSetAsPrimaryButton,
}: MultiItemFieldMenuItemProps<T>) => {
const [isHovered, setIsHovered] = useState(false);
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
@ -69,12 +69,12 @@ export const MultiItemFieldMenuItem = <T,>({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
text={<DisplayComponent value={value} />}
isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen}
RightIcon={!isHovered && isPrimary ? IconBookmark : null}
isIconDisplayedOnHoverOnly={!showPrimaryIcon && !isDropdownOpen}
RightIcon={!isHovered && showPrimaryIcon ? IconBookmark : null}
dropdownId={dropdownId}
dropdownContent={
<DropdownMenuItemsContainer>
{hasPrimaryButton && !isPrimary && (
{showSetAsPrimaryButton && (
<MenuItem
LeftIcon={IconBookmarkPlus}
text="Set as Primary"

View File

@ -129,7 +129,8 @@ export const PhonesFieldInput = ({
<PhonesFieldMenuItem
key={index}
dropdownId={`phones-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
isPrimary={isPrimaryPhone(index)}
showPrimaryIcon={isPrimaryPhone(index)}
showSetAsPrimaryButton={!isPrimaryPhone(index)}
phone={phone}
onEdit={handleEdit}
onSetAsPrimary={handleSetPrimary}

View File

@ -3,30 +3,33 @@ import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
type PhonesFieldMenuItemProps = {
dropdownId: string;
isPrimary?: boolean;
onEdit?: () => void;
onSetAsPrimary?: () => void;
onDelete?: () => void;
phone: { number: string; callingCode: string };
showPrimaryIcon: boolean;
showSetAsPrimaryButton: boolean;
};
export const PhonesFieldMenuItem = ({
dropdownId,
isPrimary,
onEdit,
onSetAsPrimary,
onDelete,
phone,
showPrimaryIcon,
showSetAsPrimaryButton,
}: PhonesFieldMenuItemProps) => {
return (
<MultiItemFieldMenuItem
dropdownId={dropdownId}
isPrimary={isPrimary}
value={{ number: phone.number, callingCode: phone.callingCode }}
onEdit={onEdit}
onSetAsPrimary={onSetAsPrimary}
onDelete={onDelete}
DisplayComponent={PhoneDisplay}
showPrimaryIcon={showPrimaryIcon}
showSetAsPrimaryButton={showSetAsPrimaryButton}
/>
);
};

View File

@ -0,0 +1,127 @@
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, within } from '@storybook/test';
import { useEffect } from 'react';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useArrayField } from '@/object-record/record-field/meta-types/hooks/useArrayField';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ArrayFieldInput } from '../ArrayFieldInput';
const updateRecord = fn();
const ArrayValueSetterEffect = ({ value }: { value: string[] }) => {
const { setFieldValue } = useArrayField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
type ArrayFieldValueGaterProps = Pick<
ArrayInputWithContextProps,
'onCancel' | 'onClickOutside'
>;
const ArrayFieldValueGater = ({
onCancel,
onClickOutside,
}: ArrayFieldValueGaterProps) => {
const { fieldValue } = useArrayField();
return (
fieldValue && (
<ArrayFieldInput onCancel={onCancel} onClickOutside={onClickOutside} />
)
);
};
type ArrayInputWithContextProps = {
value: string[];
recordId?: string;
onCancel?: () => void;
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
};
const ArrayInputWithContext = ({
value,
recordId = 'record-id',
onCancel,
onClickOutside,
}: ArrayInputWithContextProps) => {
const setHotkeyScope = useSetHotkeyScope();
useEffect(() => {
setHotkeyScope(DEFAULT_CELL_SCOPE.scope);
}, [setHotkeyScope]);
return (
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputId(
recordId,
'tags',
'record-table-cell',
),
}}
>
<FieldContext.Provider
value={{
fieldDefinition: {
fieldMetadataId: 'tags',
label: 'Tags',
type: FieldMetadataType.ARRAY,
iconName: 'IconTags',
metadata: {
fieldName: 'tags',
placeHolder: 'Enter value',
objectMetadataNameSingular: 'company',
},
},
recordId,
isLabelIdentifier: false,
isReadOnly: false,
useUpdateRecord: () => [updateRecord, { loading: false }],
}}
>
<ArrayValueSetterEffect value={value} />
<ArrayFieldValueGater
onCancel={onCancel}
onClickOutside={onClickOutside}
/>
</FieldContext.Provider>
</RecordFieldComponentInstanceContext.Provider>
);
};
const meta: Meta<typeof ArrayInputWithContext> = {
title: 'UI/Input/ArrayFieldInput',
component: ArrayInputWithContext,
};
export default meta;
type Story = StoryObj<typeof ArrayInputWithContext>;
export const Default: Story = {
args: {
value: ['tag1', 'tag2'],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addButton = await canvas.findByText('Add Item');
await userEvent.click(addButton);
const input = await canvas.findByPlaceholderText('Enter value');
await userEvent.type(input, 'tag3{enter}');
const tag3Element = await canvas.findByText('tag3');
expect(tag3Element).toBeVisible();
},
};

View File

@ -0,0 +1,179 @@
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, waitFor, within } from '@storybook/test';
import { useEffect } from 'react';
import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { FieldEmailsValue } from '@/object-record/record-field/types/FieldMetadata';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { EmailsFieldInput } from '../EmailsFieldInput';
const updateRecord = fn();
const EmailValueSetterEffect = ({ value }: { value: FieldEmailsValue }) => {
const { setFieldValue } = useEmailsField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
type EmailFieldValueGaterProps = Pick<
EmailInputWithContextProps,
'onCancel' | 'onClickOutside'
>;
const EmailFieldValueGater = ({
onCancel,
onClickOutside,
}: EmailFieldValueGaterProps) => {
const { fieldValue } = useEmailsField();
return (
fieldValue && (
<EmailsFieldInput onCancel={onCancel} onClickOutside={onClickOutside} />
)
);
};
type EmailInputWithContextProps = {
value: FieldEmailsValue;
recordId?: string;
onCancel?: () => void;
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
};
const EmailInputWithContext = ({
value,
recordId = 'record-id',
onCancel,
onClickOutside,
}: EmailInputWithContextProps) => {
const setHotkeyScope = useSetHotkeyScope();
useEffect(() => {
setHotkeyScope(DEFAULT_CELL_SCOPE.scope);
}, [setHotkeyScope]);
return (
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputId(
recordId,
'emails',
'record-table-cell',
),
}}
>
<FieldContext.Provider
value={{
fieldDefinition: {
fieldMetadataId: 'emails',
label: 'Emails',
type: FieldMetadataType.EMAILS,
iconName: 'IconMail',
metadata: {
fieldName: 'emails',
placeHolder: 'Email',
objectMetadataNameSingular: 'company',
},
},
recordId,
isLabelIdentifier: false,
isReadOnly: false,
useUpdateRecord: () => [updateRecord, { loading: false }],
}}
>
<EmailValueSetterEffect value={value} />
<EmailFieldValueGater
onCancel={onCancel}
onClickOutside={onClickOutside}
/>
</FieldContext.Provider>
</RecordFieldComponentInstanceContext.Provider>
);
};
const meta: Meta<typeof EmailInputWithContext> = {
title: 'UI/Input/EmailsFieldInput',
component: EmailInputWithContext,
};
export default meta;
type Story = StoryObj<typeof EmailInputWithContext>;
export const Default: Story = {
args: {
value: {
primaryEmail: 'john@example.com',
additionalEmails: ['john.doe@example.com'],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addButton = await canvas.findByText('Add Email');
await userEvent.click(addButton);
const input = await canvas.findByPlaceholderText('Email');
await userEvent.type(input, 'new.email@example.com{enter}');
const newEmailElement = await canvas.findByText('new.email@example.com');
expect(newEmailElement).toBeVisible();
},
};
// FIXME: We will have to fix that behavior, we should only be able to set a secondary email as the primary email
export const CanSetPrimaryLinkAsPrimaryLink: Story = {
args: {
value: {
primaryEmail: 'primary@example.com',
additionalEmails: [],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const primaryEmail = await canvas.findByText('primary@example.com');
expect(primaryEmail).toBeVisible();
await userEvent.hover(primaryEmail);
const openDropdownButtons = await canvas.findAllByRole('button', {
expanded: false,
});
await userEvent.click(openDropdownButtons[0]);
const setPrimaryOption = await within(
getCanvasElementForDropdownTesting(),
).findByText('Set as Primary');
expect(setPrimaryOption).toBeVisible();
await userEvent.click(setPrimaryOption);
// Verify the update was called with swapped emails
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
variables: {
where: { id: 'record-id' },
updateOneRecordInput: {
emails: {
primaryEmail: 'primary@example.com',
additionalEmails: [],
},
},
},
});
});
expect(updateRecord).toHaveBeenCalledTimes(1);
},
};

View File

@ -115,6 +115,10 @@ const LinksInputWithContext = ({
);
};
const getPrimaryLinkBookmarkIcon = (canvasElement: HTMLElement) =>
// It would be better to use an aria-label on the icon, but we'll do this for now
canvasElement.querySelector('svg[class*="tabler-icon-bookmark"]');
const cancelJestFn = fn();
const clickOutsideJestFn = fn();
@ -172,10 +176,12 @@ export const PrimaryLinkOnly: Story = {
const canvas = within(canvasElement);
const primaryLink = await canvas.findByText('Twenty Website');
await expect(primaryLink).toBeVisible();
expect(primaryLink).toBeVisible();
const addButton = await canvas.findByText('Add URL');
await expect(addButton).toBeVisible();
expect(addButton).toBeVisible();
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
},
};
@ -200,16 +206,20 @@ export const WithSecondaryLinks: Story = {
const canvas = within(canvasElement);
const primaryLink = await canvas.findByText('Twenty Website');
await expect(primaryLink).toBeVisible();
expect(primaryLink).toBeVisible();
await waitFor(() => {
expect(getPrimaryLinkBookmarkIcon(canvasElement)).toBeVisible();
});
const documentationLink = await canvas.findByText('Documentation');
await expect(documentationLink).toBeVisible();
expect(documentationLink).toBeVisible();
const githubLink = await canvas.findByText('GitHub');
await expect(githubLink).toBeVisible();
expect(githubLink).toBeVisible();
const addButton = await canvas.findByText('Add URL');
await expect(addButton).toBeVisible();
expect(addButton).toBeVisible();
},
};
@ -221,7 +231,7 @@ export const CreatePrimaryLink: Story = {
await userEvent.type(input, 'https://www.twenty.com{enter}');
const linkDisplay = await canvas.findByText('twenty.com');
await expect(linkDisplay).toBeVisible();
expect(linkDisplay).toBeVisible();
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
@ -238,6 +248,8 @@ export const CreatePrimaryLink: Story = {
});
});
expect(updateRecord).toHaveBeenCalledTimes(1);
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
},
};
@ -252,16 +264,19 @@ export const AddSecondaryLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const primaryLink = await canvas.findByText('Twenty Website');
expect(primaryLink).toBeVisible();
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
const addButton = await canvas.findByText('Add URL');
await userEvent.click(addButton);
const input = await canvas.findByPlaceholderText('URL');
await userEvent.type(input, 'https://docs.twenty.com{enter}');
const primaryLink = await canvas.findByText('Twenty Website');
const secondaryLink = await canvas.findByText('docs.twenty.com');
await expect(primaryLink).toBeVisible();
await expect(secondaryLink).toBeVisible();
expect(secondaryLink).toBeVisible();
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
@ -298,7 +313,9 @@ export const DeletePrimaryLink: Story = {
const canvas = within(canvasElement);
const listItemToDelete = await canvas.findByText('Twenty Website');
await userEvent.hover(listItemToDelete);
expect(listItemToDelete).toBeVisible();
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
const openDropdownButton = await canvas.findByRole('button', {
expanded: false,
@ -311,8 +328,8 @@ export const DeletePrimaryLink: Story = {
await userEvent.click(deleteOption);
const input = await canvas.findByPlaceholderText('URL');
await expect(input).toBeVisible();
await expect(input).toHaveValue('');
expect(input).toBeVisible();
expect(input).toHaveValue('');
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
@ -332,6 +349,64 @@ export const DeletePrimaryLink: Story = {
},
};
export const DeletePrimaryLinkAndUseSecondaryLinkAsTheNewPrimaryLink: Story = {
args: {
value: {
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [
{
url: 'https://docs.twenty.com',
label: 'Documentation',
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const listItemToDelete = await canvas.findByText('Twenty Website');
expect(listItemToDelete).toBeVisible();
await waitFor(() => {
expect(getPrimaryLinkBookmarkIcon(canvasElement)).toBeVisible();
});
const openDropdownButtons = await canvas.findAllByRole('button', {
expanded: false,
});
await userEvent.click(openDropdownButtons[0]);
const deleteOption = await within(
getCanvasElementForDropdownTesting(),
).findByText('Delete');
await userEvent.click(deleteOption);
const newPrimaryLink = await canvas.findByText('Documentation');
expect(newPrimaryLink).toBeVisible();
const oldPrimaryLink = canvas.queryByText('Twenty Website');
expect(oldPrimaryLink).not.toBeInTheDocument();
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
variables: {
where: { id: '123' },
updateOneRecordInput: {
links: {
primaryLinkUrl: 'https://docs.twenty.com',
primaryLinkLabel: 'Documentation',
secondaryLinks: [],
},
},
},
});
});
expect(updateRecord).toHaveBeenCalledTimes(1);
},
};
export const DeleteSecondaryLink: Story = {
args: {
value: {
@ -348,6 +423,10 @@ export const DeleteSecondaryLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(getPrimaryLinkBookmarkIcon(canvasElement)).toBeVisible();
});
const listItemToDelete = await canvas.findByText('Documentation');
await userEvent.hover(listItemToDelete);
@ -362,9 +441,11 @@ export const DeleteSecondaryLink: Story = {
await userEvent.click(deleteOption);
const primaryLink = await canvas.findByText('Twenty Website');
await expect(primaryLink).toBeVisible();
expect(primaryLink).toBeVisible();
const secondaryLink = canvas.queryByText('Documentation');
await expect(secondaryLink).not.toBeInTheDocument();
expect(secondaryLink).not.toBeInTheDocument();
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
@ -444,3 +525,94 @@ export const InvalidUrls: Story = {
expect(canvas.queryByText('Invalid Characters')).not.toBeInTheDocument();
},
};
export const MakeSecondaryLinkPrimary: Story = {
args: {
value: {
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [
{
url: 'https://docs.twenty.com',
label: 'Documentation',
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const primaryLink = await canvas.findByText('Twenty Website');
expect(primaryLink).toBeVisible();
const secondaryLink = await canvas.findByText('Documentation');
expect(secondaryLink).toBeVisible();
await waitFor(() => {
expect(getPrimaryLinkBookmarkIcon(canvasElement)).toBeVisible();
});
await userEvent.hover(secondaryLink);
const openDropdownButtons = await canvas.findAllByRole('button', {
expanded: false,
});
await userEvent.click(openDropdownButtons[1]); // Click the secondary link's dropdown
const setPrimaryOption = await within(
getCanvasElementForDropdownTesting(),
).findByText('Set as Primary');
await userEvent.click(setPrimaryOption);
// Documentation should now be the primary link
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
variables: {
where: { id: '123' },
updateOneRecordInput: {
links: {
primaryLinkUrl: 'https://docs.twenty.com',
primaryLinkLabel: 'Documentation',
secondaryLinks: [
{
url: 'https://www.twenty.com',
label: 'Twenty Website',
},
],
},
},
},
});
});
expect(updateRecord).toHaveBeenCalledTimes(1);
},
};
export const CanNotSetPrimaryLinkAsPrimaryLink: Story = {
args: {
value: {
primaryLinkUrl: 'https://www.twenty.com',
primaryLinkLabel: 'Twenty Website',
secondaryLinks: [],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const primaryLink = await canvas.findByText('Twenty Website');
expect(primaryLink).toBeVisible();
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
const openDropdownButton = await canvas.findByRole('button', {
expanded: false,
});
await userEvent.click(openDropdownButton);
// Should not see "Set as Primary" option for primary link
const setPrimaryOption = within(
getCanvasElementForDropdownTesting(),
).queryByText('Set as Primary');
expect(setPrimaryOption).not.toBeInTheDocument();
},
};

View File

@ -0,0 +1,192 @@
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, waitFor, within } from '@storybook/test';
import { useEffect } from 'react';
import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { usePhonesField } from '@/object-record/record-field/meta-types/hooks/usePhonesField';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { PhonesFieldInput } from '../PhonesFieldInput';
const updateRecord = fn();
const PhoneValueSetterEffect = ({ value }: { value: FieldPhonesValue }) => {
const { setFieldValue } = usePhonesField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
type PhoneFieldValueGaterProps = Pick<
PhoneInputWithContextProps,
'onCancel' | 'onClickOutside'
>;
const PhoneFieldValueGater = ({
onCancel,
onClickOutside,
}: PhoneFieldValueGaterProps) => {
const { fieldValue } = usePhonesField();
return (
fieldValue && (
<PhonesFieldInput onCancel={onCancel} onClickOutside={onClickOutside} />
)
);
};
type PhoneInputWithContextProps = {
value: FieldPhonesValue;
recordId?: string;
onCancel?: () => void;
onClickOutside?: FieldInputClickOutsideEvent;
};
const PhoneInputWithContext = ({
value,
recordId = 'record-id',
onCancel,
onClickOutside,
}: PhoneInputWithContextProps) => {
const setHotkeyScope = useSetHotkeyScope();
useEffect(() => {
setHotkeyScope(DEFAULT_CELL_SCOPE.scope);
}, [setHotkeyScope]);
return (
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputId(
recordId,
'phones',
'record-table-cell',
),
}}
>
<FieldContext.Provider
value={{
fieldDefinition: {
fieldMetadataId: 'phones',
label: 'Phones',
type: FieldMetadataType.PHONES,
iconName: 'IconMail',
metadata: {
fieldName: 'phones',
placeHolder: 'Phone',
objectMetadataNameSingular: 'company',
},
},
recordId,
isLabelIdentifier: false,
isReadOnly: false,
useUpdateRecord: () => [updateRecord, { loading: false }],
}}
>
<PhoneValueSetterEffect value={value} />
<PhoneFieldValueGater
onCancel={onCancel}
onClickOutside={onClickOutside}
/>
</FieldContext.Provider>
</RecordFieldComponentInstanceContext.Provider>
);
};
const meta: Meta<typeof PhoneInputWithContext> = {
title: 'UI/Input/PhonesFieldInput',
component: PhoneInputWithContext,
};
export default meta;
type Story = StoryObj<typeof PhoneInputWithContext>;
export const Default: Story = {
args: {
value: {
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '642646272',
primaryPhoneCallingCode: '+33',
additionalPhones: [
{
countryCode: 'FR',
number: '642646273',
callingCode: '+33',
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addButton = await canvas.findByText('Add Phone');
await userEvent.click(addButton);
const input = await canvas.findByPlaceholderText('Phone');
await userEvent.type(input, '+33642646274{enter}');
const newPhoneElement = await canvas.findByText('+33 6 42 64 62 74');
expect(newPhoneElement).toBeVisible();
},
};
// FIXME: We will have to fix that behavior, we should only be able to set an additional phone as the primary phone
export const CanSetPrimaryLinkAsPrimaryLink: Story = {
args: {
value: {
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '642646272',
primaryPhoneCallingCode: '+33',
additionalPhones: [],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const primaryPhone = await canvas.findByText('+33 6 42 64 62 72');
expect(primaryPhone).toBeVisible();
await userEvent.hover(primaryPhone);
const openDropdownButtons = await canvas.findAllByRole('button', {
expanded: false,
});
await userEvent.click(openDropdownButtons[0]);
const setPrimaryOption = await within(
getCanvasElementForDropdownTesting(),
).findByText('Set as Primary');
expect(setPrimaryOption).toBeVisible();
await userEvent.click(setPrimaryOption);
// Verify the update was called with swapped phones
await waitFor(() => {
expect(updateRecord).toHaveBeenCalledWith({
variables: {
where: { id: 'record-id' },
updateOneRecordInput: {
phones: {
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
primaryPhoneNumber: '642646272',
additionalPhones: [],
},
},
},
});
});
expect(updateRecord).toHaveBeenCalledTimes(1);
},
};