Use null as the default value for link fields when persisting (#12173)
Follow up for https://github.com/twentyhq/twenty/pull/12113 I created stories for the LinksFieldInput component.
This commit is contained in:
committed by
GitHub
parent
fdde7651a4
commit
4f4d216a21
@ -28,10 +28,12 @@ export const LinksFieldInput = ({
|
|||||||
const handlePersistLinks = (
|
const handlePersistLinks = (
|
||||||
updatedLinks: { url: string | null; label: string | null }[],
|
updatedLinks: { url: string | null; label: string | null }[],
|
||||||
) => {
|
) => {
|
||||||
const [nextPrimaryLink, ...nextSecondaryLinks] = updatedLinks;
|
const nextPrimaryLink = updatedLinks.at(0);
|
||||||
|
const nextSecondaryLinks = updatedLinks.slice(1);
|
||||||
|
|
||||||
persistLinksField({
|
persistLinksField({
|
||||||
primaryLinkUrl: nextPrimaryLink?.url ?? '',
|
primaryLinkUrl: nextPrimaryLink?.url ?? null,
|
||||||
primaryLinkLabel: nextPrimaryLink?.label ?? '',
|
primaryLinkLabel: nextPrimaryLink?.label ?? null,
|
||||||
secondaryLinks: nextSecondaryLinks,
|
secondaryLinks: nextSecondaryLinks,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -62,7 +64,7 @@ export const LinksFieldInput = ({
|
|||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
})}
|
})}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
formatInput={(input) => ({ url: input, label: '' })}
|
formatInput={(input) => ({ url: input, label: null })}
|
||||||
renderItem={({
|
renderItem={({
|
||||||
value: link,
|
value: link,
|
||||||
index,
|
index,
|
||||||
|
|||||||
@ -0,0 +1,418 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
|
||||||
|
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
|
||||||
|
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 { getCanvasElementForDropdownTesting } from 'twenty-ui/testing';
|
||||||
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
import { LinksFieldInput } from '../LinksFieldInput';
|
||||||
|
|
||||||
|
const updateRecord = fn();
|
||||||
|
|
||||||
|
const LinksValueSetterEffect = ({
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: string | null;
|
||||||
|
primaryLinkLabel: string | null;
|
||||||
|
secondaryLinks: Array<{ url: string | null; label: string | null }> | null;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const { setFieldValue } = useLinksField();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFieldValue(value);
|
||||||
|
}, [setFieldValue, value]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinksInputWithContextProps = {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: string | null;
|
||||||
|
primaryLinkLabel: string | null;
|
||||||
|
secondaryLinks: Array<{ url: string | null; label: string | null }> | null;
|
||||||
|
};
|
||||||
|
recordId?: string;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinksFieldValueGaterProps = Pick<
|
||||||
|
LinksInputWithContextProps,
|
||||||
|
'onCancel' | 'onClickOutside'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const LinksFieldValueGater = ({
|
||||||
|
onCancel,
|
||||||
|
onClickOutside,
|
||||||
|
}: LinksFieldValueGaterProps) => {
|
||||||
|
const { fieldValue } = useLinksField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
fieldValue && (
|
||||||
|
<LinksFieldInput onCancel={onCancel} onClickOutside={onClickOutside} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinksInputWithContext = ({
|
||||||
|
value,
|
||||||
|
recordId,
|
||||||
|
onCancel,
|
||||||
|
onClickOutside,
|
||||||
|
}: LinksInputWithContextProps) => {
|
||||||
|
const setHotkeyScope = useSetHotkeyScope();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHotkeyScope(DEFAULT_CELL_SCOPE.scope);
|
||||||
|
}, [setHotkeyScope]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<RecordFieldComponentInstanceContext.Provider
|
||||||
|
value={{
|
||||||
|
instanceId: getRecordFieldInputId(
|
||||||
|
recordId ?? '',
|
||||||
|
'Links',
|
||||||
|
'record-table-cell',
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldContext.Provider
|
||||||
|
value={{
|
||||||
|
fieldDefinition: {
|
||||||
|
fieldMetadataId: 'links',
|
||||||
|
label: 'Links',
|
||||||
|
type: FieldMetadataType.LINKS,
|
||||||
|
iconName: 'IconLink',
|
||||||
|
metadata: {
|
||||||
|
fieldName: 'links',
|
||||||
|
placeHolder: 'Enter URL',
|
||||||
|
objectMetadataNameSingular: 'company',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recordId: recordId ?? '123',
|
||||||
|
isLabelIdentifier: false,
|
||||||
|
isReadOnly: false,
|
||||||
|
useUpdateRecord: () => [updateRecord, { loading: false }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LinksValueSetterEffect value={value} />
|
||||||
|
<LinksFieldValueGater
|
||||||
|
onCancel={onCancel}
|
||||||
|
onClickOutside={onClickOutside}
|
||||||
|
/>
|
||||||
|
</FieldContext.Provider>
|
||||||
|
</RecordFieldComponentInstanceContext.Provider>
|
||||||
|
<div data-testid="links-field-input-click-outside-div" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelJestFn = fn();
|
||||||
|
const clickOutsideJestFn = fn();
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'UI/Data/Field/Input/LinksFieldInput',
|
||||||
|
component: LinksInputWithContext,
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: null,
|
||||||
|
primaryLinkLabel: null,
|
||||||
|
secondaryLinks: null,
|
||||||
|
},
|
||||||
|
onCancel: cancelJestFn,
|
||||||
|
onClickOutside: clickOutsideJestFn,
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
onCancel: { control: false },
|
||||||
|
onClickOutside: { control: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof LinksInputWithContext>;
|
||||||
|
|
||||||
|
export const EmptyState: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: null,
|
||||||
|
primaryLinkLabel: null,
|
||||||
|
secondaryLinks: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const input = await canvas.findByPlaceholderText('URL');
|
||||||
|
expect(input).toBeVisible();
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
|
||||||
|
const addButton = canvas.queryByText('Add URL');
|
||||||
|
expect(addButton).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PrimaryLinkOnly: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const primaryLink = await canvas.findByText('Twenty Website');
|
||||||
|
await expect(primaryLink).toBeVisible();
|
||||||
|
|
||||||
|
const addButton = await canvas.findByText('Add URL');
|
||||||
|
await expect(addButton).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSecondaryLinks: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: [
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: 'Documentation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://github.com/twentyhq/twenty',
|
||||||
|
label: 'GitHub',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const primaryLink = await canvas.findByText('Twenty Website');
|
||||||
|
await expect(primaryLink).toBeVisible();
|
||||||
|
|
||||||
|
const documentationLink = await canvas.findByText('Documentation');
|
||||||
|
await expect(documentationLink).toBeVisible();
|
||||||
|
|
||||||
|
const githubLink = await canvas.findByText('GitHub');
|
||||||
|
await expect(githubLink).toBeVisible();
|
||||||
|
|
||||||
|
const addButton = await canvas.findByText('Add URL');
|
||||||
|
await expect(addButton).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreatePrimaryLink: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const input = await canvas.findByPlaceholderText('URL');
|
||||||
|
await userEvent.type(input, 'https://www.twenty.com{enter}');
|
||||||
|
|
||||||
|
const linkDisplay = await canvas.findByText('twenty.com');
|
||||||
|
await expect(linkDisplay).toBeVisible();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateRecord).toHaveBeenCalledWith({
|
||||||
|
variables: {
|
||||||
|
where: { id: '123' },
|
||||||
|
updateOneRecordInput: {
|
||||||
|
links: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: null,
|
||||||
|
secondaryLinks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(updateRecord).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddSecondaryLink: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateRecord).toHaveBeenCalledWith({
|
||||||
|
variables: {
|
||||||
|
where: { id: '123' },
|
||||||
|
updateOneRecordInput: {
|
||||||
|
links: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: [
|
||||||
|
{
|
||||||
|
url: 'https://docs.twenty.com',
|
||||||
|
label: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(updateRecord).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeletePrimaryLink: Story = {
|
||||||
|
args: {
|
||||||
|
value: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const listItemToDelete = await canvas.findByText('Twenty Website');
|
||||||
|
await userEvent.hover(listItemToDelete);
|
||||||
|
|
||||||
|
const openDropdownButton = await canvas.findByRole('button', {
|
||||||
|
expanded: false,
|
||||||
|
});
|
||||||
|
await userEvent.click(openDropdownButton);
|
||||||
|
|
||||||
|
const deleteOption = await within(
|
||||||
|
getCanvasElementForDropdownTesting(),
|
||||||
|
).findByText('Delete');
|
||||||
|
await userEvent.click(deleteOption);
|
||||||
|
|
||||||
|
const input = await canvas.findByPlaceholderText('URL');
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
await expect(input).toHaveValue('');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateRecord).toHaveBeenCalledWith({
|
||||||
|
variables: {
|
||||||
|
where: { id: '123' },
|
||||||
|
updateOneRecordInput: {
|
||||||
|
links: {
|
||||||
|
primaryLinkUrl: null,
|
||||||
|
primaryLinkLabel: null,
|
||||||
|
secondaryLinks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(updateRecord).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteSecondaryLink: 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('Documentation');
|
||||||
|
await userEvent.hover(listItemToDelete);
|
||||||
|
|
||||||
|
const openDropdownButtons = await canvas.findAllByRole('button', {
|
||||||
|
expanded: false,
|
||||||
|
});
|
||||||
|
await userEvent.click(openDropdownButtons[1]);
|
||||||
|
|
||||||
|
const deleteOption = await within(
|
||||||
|
getCanvasElementForDropdownTesting(),
|
||||||
|
).findByText('Delete');
|
||||||
|
await userEvent.click(deleteOption);
|
||||||
|
|
||||||
|
const primaryLink = await canvas.findByText('Twenty Website');
|
||||||
|
await expect(primaryLink).toBeVisible();
|
||||||
|
const secondaryLink = canvas.queryByText('Documentation');
|
||||||
|
await expect(secondaryLink).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateRecord).toHaveBeenCalledWith({
|
||||||
|
variables: {
|
||||||
|
where: { id: '123' },
|
||||||
|
updateOneRecordInput: {
|
||||||
|
links: {
|
||||||
|
primaryLinkUrl: 'https://www.twenty.com',
|
||||||
|
primaryLinkLabel: 'Twenty Website',
|
||||||
|
secondaryLinks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(updateRecord).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClickOutside: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const input = await canvas.findByPlaceholderText('URL');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
expect(clickOutsideJestFn).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
const outsideDiv = await canvas.findByTestId(
|
||||||
|
'links-field-input-click-outside-div',
|
||||||
|
);
|
||||||
|
await userEvent.click(outsideDiv);
|
||||||
|
|
||||||
|
expect(clickOutsideJestFn).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Cancel: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
expect(cancelJestFn).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
const input = await canvas.findByPlaceholderText('URL');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
await userEvent.keyboard('{escape}');
|
||||||
|
|
||||||
|
expect(cancelJestFn).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user