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:
committed by
GitHub
parent
bd8eace0b1
commit
621a779526
@ -21,7 +21,8 @@ export const ArrayFieldMenuItem = ({
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
DisplayComponent={() => <ArrayDisplay value={[value]} />}
|
||||
hasPrimaryButton={false}
|
||||
showPrimaryIcon={false}
|
||||
showSetAsPrimaryButton={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user