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}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
DisplayComponent={() => <ArrayDisplay value={[value]} />}
|
DisplayComponent={() => <ArrayDisplay value={[value]} />}
|
||||||
hasPrimaryButton={false}
|
showPrimaryIcon={false}
|
||||||
|
showSetAsPrimaryButton={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -77,7 +77,8 @@ export const EmailsFieldInput = ({
|
|||||||
<EmailsFieldMenuItem
|
<EmailsFieldMenuItem
|
||||||
key={index}
|
key={index}
|
||||||
dropdownId={`emails-${index}`}
|
dropdownId={`emails-${index}`}
|
||||||
isPrimary={isPrimaryEmail(index)}
|
showPrimaryIcon={isPrimaryEmail(index)}
|
||||||
|
showSetAsPrimaryButton={!isPrimaryEmail(index)}
|
||||||
email={email}
|
email={email}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onSetAsPrimary={handleSetPrimary}
|
onSetAsPrimary={handleSetPrimary}
|
||||||
|
|||||||
@ -3,30 +3,33 @@ import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
|
|||||||
|
|
||||||
type EmailsFieldMenuItemProps = {
|
type EmailsFieldMenuItemProps = {
|
||||||
dropdownId: string;
|
dropdownId: string;
|
||||||
isPrimary?: boolean;
|
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onSetAsPrimary?: () => void;
|
onSetAsPrimary?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
email: string;
|
email: string;
|
||||||
|
showPrimaryIcon: boolean;
|
||||||
|
showSetAsPrimaryButton: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmailsFieldMenuItem = ({
|
export const EmailsFieldMenuItem = ({
|
||||||
dropdownId,
|
dropdownId,
|
||||||
isPrimary,
|
|
||||||
onEdit,
|
onEdit,
|
||||||
onSetAsPrimary,
|
onSetAsPrimary,
|
||||||
onDelete,
|
onDelete,
|
||||||
email,
|
email,
|
||||||
|
showPrimaryIcon,
|
||||||
|
showSetAsPrimaryButton,
|
||||||
}: EmailsFieldMenuItemProps) => {
|
}: EmailsFieldMenuItemProps) => {
|
||||||
return (
|
return (
|
||||||
<MultiItemFieldMenuItem
|
<MultiItemFieldMenuItem
|
||||||
dropdownId={dropdownId}
|
dropdownId={dropdownId}
|
||||||
isPrimary={isPrimary}
|
|
||||||
value={email}
|
value={email}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onSetAsPrimary={onSetAsPrimary}
|
onSetAsPrimary={onSetAsPrimary}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
DisplayComponent={EmailDisplay}
|
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(
|
const setIsFieldInError = useSetRecoilComponentStateV2(
|
||||||
recordFieldInputIsFieldInErrorComponentState,
|
recordFieldInputIsFieldInErrorComponentState,
|
||||||
@ -75,7 +76,8 @@ export const LinksFieldInput = ({
|
|||||||
<LinksFieldMenuItem
|
<LinksFieldMenuItem
|
||||||
key={index}
|
key={index}
|
||||||
dropdownId={`links-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
|
dropdownId={`links-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
|
||||||
isPrimary={isPrimaryLink(index)}
|
showPrimaryIcon={getShowPrimaryIcon(index)}
|
||||||
|
showSetAsPrimaryButton={getShowSetAsPrimaryButton(index)}
|
||||||
label={link.label}
|
label={link.label}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onSetAsPrimary={handleSetPrimary}
|
onSetAsPrimary={handleSetPrimary}
|
||||||
|
|||||||
@ -3,27 +3,30 @@ import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
|
|||||||
|
|
||||||
type LinksFieldMenuItemProps = {
|
type LinksFieldMenuItemProps = {
|
||||||
dropdownId: string;
|
dropdownId: string;
|
||||||
isPrimary?: boolean;
|
|
||||||
label: string | null;
|
label: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onSetAsPrimary?: () => void;
|
onSetAsPrimary?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
showPrimaryIcon: boolean;
|
||||||
|
showSetAsPrimaryButton: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinksFieldMenuItem = ({
|
export const LinksFieldMenuItem = ({
|
||||||
dropdownId,
|
dropdownId,
|
||||||
isPrimary,
|
|
||||||
label,
|
label,
|
||||||
onEdit,
|
onEdit,
|
||||||
onSetAsPrimary,
|
onSetAsPrimary,
|
||||||
onDelete,
|
onDelete,
|
||||||
url,
|
url,
|
||||||
|
showPrimaryIcon,
|
||||||
|
showSetAsPrimaryButton,
|
||||||
}: LinksFieldMenuItemProps) => {
|
}: LinksFieldMenuItemProps) => {
|
||||||
return (
|
return (
|
||||||
<MultiItemFieldMenuItem
|
<MultiItemFieldMenuItem
|
||||||
dropdownId={dropdownId}
|
dropdownId={dropdownId}
|
||||||
isPrimary={isPrimary}
|
showPrimaryIcon={showPrimaryIcon}
|
||||||
|
showSetAsPrimaryButton={showSetAsPrimaryButton}
|
||||||
value={{ label, url }}
|
value={{ label, url }}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onSetAsPrimary={onSetAsPrimary}
|
onSetAsPrimary={onSetAsPrimary}
|
||||||
|
|||||||
@ -12,24 +12,24 @@ import { MenuItem } from 'twenty-ui/navigation';
|
|||||||
|
|
||||||
type MultiItemFieldMenuItemProps<T> = {
|
type MultiItemFieldMenuItemProps<T> = {
|
||||||
dropdownId: string;
|
dropdownId: string;
|
||||||
isPrimary?: boolean;
|
|
||||||
value: T;
|
value: T;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onSetAsPrimary?: () => void;
|
onSetAsPrimary?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
DisplayComponent: React.ComponentType<{ value: T }>;
|
DisplayComponent: React.ComponentType<{ value: T }>;
|
||||||
hasPrimaryButton?: boolean;
|
showPrimaryIcon: boolean;
|
||||||
|
showSetAsPrimaryButton: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MultiItemFieldMenuItem = <T,>({
|
export const MultiItemFieldMenuItem = <T,>({
|
||||||
dropdownId,
|
dropdownId,
|
||||||
isPrimary,
|
|
||||||
value,
|
value,
|
||||||
onEdit,
|
onEdit,
|
||||||
onSetAsPrimary,
|
onSetAsPrimary,
|
||||||
onDelete,
|
onDelete,
|
||||||
DisplayComponent,
|
DisplayComponent,
|
||||||
hasPrimaryButton = true,
|
showPrimaryIcon,
|
||||||
|
showSetAsPrimaryButton,
|
||||||
}: MultiItemFieldMenuItemProps<T>) => {
|
}: MultiItemFieldMenuItemProps<T>) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
|
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
|
||||||
@ -69,12 +69,12 @@ export const MultiItemFieldMenuItem = <T,>({
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
text={<DisplayComponent value={value} />}
|
text={<DisplayComponent value={value} />}
|
||||||
isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen}
|
isIconDisplayedOnHoverOnly={!showPrimaryIcon && !isDropdownOpen}
|
||||||
RightIcon={!isHovered && isPrimary ? IconBookmark : null}
|
RightIcon={!isHovered && showPrimaryIcon ? IconBookmark : null}
|
||||||
dropdownId={dropdownId}
|
dropdownId={dropdownId}
|
||||||
dropdownContent={
|
dropdownContent={
|
||||||
<DropdownMenuItemsContainer>
|
<DropdownMenuItemsContainer>
|
||||||
{hasPrimaryButton && !isPrimary && (
|
{showSetAsPrimaryButton && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
LeftIcon={IconBookmarkPlus}
|
LeftIcon={IconBookmarkPlus}
|
||||||
text="Set as Primary"
|
text="Set as Primary"
|
||||||
|
|||||||
@ -129,7 +129,8 @@ export const PhonesFieldInput = ({
|
|||||||
<PhonesFieldMenuItem
|
<PhonesFieldMenuItem
|
||||||
key={index}
|
key={index}
|
||||||
dropdownId={`phones-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
|
dropdownId={`phones-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
|
||||||
isPrimary={isPrimaryPhone(index)}
|
showPrimaryIcon={isPrimaryPhone(index)}
|
||||||
|
showSetAsPrimaryButton={!isPrimaryPhone(index)}
|
||||||
phone={phone}
|
phone={phone}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onSetAsPrimary={handleSetPrimary}
|
onSetAsPrimary={handleSetPrimary}
|
||||||
|
|||||||
@ -3,30 +3,33 @@ import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
|
|||||||
|
|
||||||
type PhonesFieldMenuItemProps = {
|
type PhonesFieldMenuItemProps = {
|
||||||
dropdownId: string;
|
dropdownId: string;
|
||||||
isPrimary?: boolean;
|
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onSetAsPrimary?: () => void;
|
onSetAsPrimary?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
phone: { number: string; callingCode: string };
|
phone: { number: string; callingCode: string };
|
||||||
|
showPrimaryIcon: boolean;
|
||||||
|
showSetAsPrimaryButton: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PhonesFieldMenuItem = ({
|
export const PhonesFieldMenuItem = ({
|
||||||
dropdownId,
|
dropdownId,
|
||||||
isPrimary,
|
|
||||||
onEdit,
|
onEdit,
|
||||||
onSetAsPrimary,
|
onSetAsPrimary,
|
||||||
onDelete,
|
onDelete,
|
||||||
phone,
|
phone,
|
||||||
|
showPrimaryIcon,
|
||||||
|
showSetAsPrimaryButton,
|
||||||
}: PhonesFieldMenuItemProps) => {
|
}: PhonesFieldMenuItemProps) => {
|
||||||
return (
|
return (
|
||||||
<MultiItemFieldMenuItem
|
<MultiItemFieldMenuItem
|
||||||
dropdownId={dropdownId}
|
dropdownId={dropdownId}
|
||||||
isPrimary={isPrimary}
|
|
||||||
value={{ number: phone.number, callingCode: phone.callingCode }}
|
value={{ number: phone.number, callingCode: phone.callingCode }}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onSetAsPrimary={onSetAsPrimary}
|
onSetAsPrimary={onSetAsPrimary}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
DisplayComponent={PhoneDisplay}
|
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 cancelJestFn = fn();
|
||||||
const clickOutsideJestFn = fn();
|
const clickOutsideJestFn = fn();
|
||||||
|
|
||||||
@ -172,10 +176,12 @@ export const PrimaryLinkOnly: Story = {
|
|||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
const primaryLink = await canvas.findByText('Twenty Website');
|
const primaryLink = await canvas.findByText('Twenty Website');
|
||||||
await expect(primaryLink).toBeVisible();
|
expect(primaryLink).toBeVisible();
|
||||||
|
|
||||||
const addButton = await canvas.findByText('Add URL');
|
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 canvas = within(canvasElement);
|
||||||
|
|
||||||
const primaryLink = await canvas.findByText('Twenty Website');
|
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');
|
const documentationLink = await canvas.findByText('Documentation');
|
||||||
await expect(documentationLink).toBeVisible();
|
expect(documentationLink).toBeVisible();
|
||||||
|
|
||||||
const githubLink = await canvas.findByText('GitHub');
|
const githubLink = await canvas.findByText('GitHub');
|
||||||
await expect(githubLink).toBeVisible();
|
expect(githubLink).toBeVisible();
|
||||||
|
|
||||||
const addButton = await canvas.findByText('Add URL');
|
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}');
|
await userEvent.type(input, 'https://www.twenty.com{enter}');
|
||||||
|
|
||||||
const linkDisplay = await canvas.findByText('twenty.com');
|
const linkDisplay = await canvas.findByText('twenty.com');
|
||||||
await expect(linkDisplay).toBeVisible();
|
expect(linkDisplay).toBeVisible();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateRecord).toHaveBeenCalledWith({
|
expect(updateRecord).toHaveBeenCalledWith({
|
||||||
@ -238,6 +248,8 @@ export const CreatePrimaryLink: Story = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(updateRecord).toHaveBeenCalledTimes(1);
|
expect(updateRecord).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -252,16 +264,19 @@ export const AddSecondaryLink: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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');
|
const addButton = await canvas.findByText('Add URL');
|
||||||
await userEvent.click(addButton);
|
await userEvent.click(addButton);
|
||||||
|
|
||||||
const input = await canvas.findByPlaceholderText('URL');
|
const input = await canvas.findByPlaceholderText('URL');
|
||||||
await userEvent.type(input, 'https://docs.twenty.com{enter}');
|
await userEvent.type(input, 'https://docs.twenty.com{enter}');
|
||||||
|
|
||||||
const primaryLink = await canvas.findByText('Twenty Website');
|
|
||||||
const secondaryLink = await canvas.findByText('docs.twenty.com');
|
const secondaryLink = await canvas.findByText('docs.twenty.com');
|
||||||
await expect(primaryLink).toBeVisible();
|
expect(secondaryLink).toBeVisible();
|
||||||
await expect(secondaryLink).toBeVisible();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateRecord).toHaveBeenCalledWith({
|
expect(updateRecord).toHaveBeenCalledWith({
|
||||||
@ -298,7 +313,9 @@ export const DeletePrimaryLink: Story = {
|
|||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
const listItemToDelete = await canvas.findByText('Twenty Website');
|
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', {
|
const openDropdownButton = await canvas.findByRole('button', {
|
||||||
expanded: false,
|
expanded: false,
|
||||||
@ -311,8 +328,8 @@ export const DeletePrimaryLink: Story = {
|
|||||||
await userEvent.click(deleteOption);
|
await userEvent.click(deleteOption);
|
||||||
|
|
||||||
const input = await canvas.findByPlaceholderText('URL');
|
const input = await canvas.findByPlaceholderText('URL');
|
||||||
await expect(input).toBeVisible();
|
expect(input).toBeVisible();
|
||||||
await expect(input).toHaveValue('');
|
expect(input).toHaveValue('');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateRecord).toHaveBeenCalledWith({
|
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 = {
|
export const DeleteSecondaryLink: Story = {
|
||||||
args: {
|
args: {
|
||||||
value: {
|
value: {
|
||||||
@ -348,6 +423,10 @@ export const DeleteSecondaryLink: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getPrimaryLinkBookmarkIcon(canvasElement)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
const listItemToDelete = await canvas.findByText('Documentation');
|
const listItemToDelete = await canvas.findByText('Documentation');
|
||||||
await userEvent.hover(listItemToDelete);
|
await userEvent.hover(listItemToDelete);
|
||||||
|
|
||||||
@ -362,9 +441,11 @@ export const DeleteSecondaryLink: Story = {
|
|||||||
await userEvent.click(deleteOption);
|
await userEvent.click(deleteOption);
|
||||||
|
|
||||||
const primaryLink = await canvas.findByText('Twenty Website');
|
const primaryLink = await canvas.findByText('Twenty Website');
|
||||||
await expect(primaryLink).toBeVisible();
|
expect(primaryLink).toBeVisible();
|
||||||
const secondaryLink = canvas.queryByText('Documentation');
|
const secondaryLink = canvas.queryByText('Documentation');
|
||||||
await expect(secondaryLink).not.toBeInTheDocument();
|
expect(secondaryLink).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateRecord).toHaveBeenCalledWith({
|
expect(updateRecord).toHaveBeenCalledWith({
|
||||||
@ -444,3 +525,94 @@ export const InvalidUrls: Story = {
|
|||||||
expect(canvas.queryByText('Invalid Characters')).not.toBeInTheDocument();
|
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