From 4f4d216a218e91cf78efb745ed100629a6690f59 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Wed, 21 May 2025 14:11:38 +0200 Subject: [PATCH] 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. --- .../input/components/LinksFieldInput.tsx | 10 +- .../__stories__/LinksFieldInput.stories.tsx | 418 ++++++++++++++++++ 2 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx index a5dfbde75..e38906ed5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx @@ -28,10 +28,12 @@ export const LinksFieldInput = ({ const handlePersistLinks = ( updatedLinks: { url: string | null; label: string | null }[], ) => { - const [nextPrimaryLink, ...nextSecondaryLinks] = updatedLinks; + const nextPrimaryLink = updatedLinks.at(0); + const nextSecondaryLinks = updatedLinks.slice(1); + persistLinksField({ - primaryLinkUrl: nextPrimaryLink?.url ?? '', - primaryLinkLabel: nextPrimaryLink?.label ?? '', + primaryLinkUrl: nextPrimaryLink?.url ?? null, + primaryLinkLabel: nextPrimaryLink?.label ?? null, secondaryLinks: nextSecondaryLinks, }); }; @@ -62,7 +64,7 @@ export const LinksFieldInput = ({ errorMessage: '', })} onError={handleError} - formatInput={(input) => ({ url: input, label: '' })} + formatInput={(input) => ({ url: input, label: null })} renderItem={({ value: link, index, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx new file mode 100644 index 000000000..59b763b2b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx @@ -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 && ( + + ) + ); +}; + +const LinksInputWithContext = ({ + value, + recordId, + onCancel, + onClickOutside, +}: LinksInputWithContextProps) => { + const setHotkeyScope = useSetHotkeyScope(); + + useEffect(() => { + setHotkeyScope(DEFAULT_CELL_SCOPE.scope); + }, [setHotkeyScope]); + + return ( +
+ + [updateRecord, { loading: false }], + }} + > + + + + +
+
+ ); +}; + +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; + +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); + }, +};