diff --git a/packages/twenty-front/src/modules/activities/emails/components/CampaignBodyField.tsx b/packages/twenty-front/src/modules/activities/emails/components/CampaignBodyField.tsx index 1b225055cc..607067c3e9 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/CampaignBodyField.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/CampaignBodyField.tsx @@ -28,7 +28,9 @@ type CampaignBodyFieldProps = { }; export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => { - const { body, setBody, flush } = useCampaignBodyState({ campaign }); + const { body, setBody, flush, draftResyncKey } = useCampaignBodyState({ + campaign, + }); const setActiveEmailEditor = useSetAtomState(activeEmailEditorState); const { uploadEmailImage } = useUploadEmailImage(); const { variables } = useCampaignEmailEditorVariables(); @@ -46,6 +48,7 @@ export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => { return ( flush()}> detailsState.flush()}> ({ + useUpdateOneRecord: () => ({ updateOneRecord: mockUpdateOneRecord }), +})); + +jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({ + useSnackBar: () => ({ enqueueErrorSnackBar: mockEnqueueErrorSnackBar }), +})); + +const campaignId = '20202020-0000-4000-8000-000000000001'; + +const renderDraftHook = (initialSubject: string) => + renderHook( + ({ subject }: { subject: string }) => + usePersistedCampaignDraft({ + campaignId, + initialDraft: () => ({ subject }), + toUpdateOneRecordInput: (draft) => draft, + }), + { initialProps: { subject: initialSubject } }, + ); + +describe('usePersistedCampaignDraft', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('should adopt a remote change when the draft is pristine', () => { + const { result, rerender } = renderDraftHook(''); + + const initialResyncKey = result.current.draftResyncKey; + + rerender({ subject: 'Written by the AI' }); + + expect(result.current.draft.subject).toBe('Written by the AI'); + expect(result.current.draftResyncKey).not.toBe(initialResyncKey); + }); + + it('should keep the local draft when its own persist echoes back', () => { + const { result, rerender } = renderDraftHook(''); + + act(() => { + result.current.updateDraft({ subject: 'Typed locally' }); + }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockUpdateOneRecord).toHaveBeenCalledWith({ + objectNameSingular: 'messageCampaign', + idToUpdate: campaignId, + updateOneRecordInput: { subject: 'Typed locally' }, + }); + + const resyncKeyBeforeEcho = result.current.draftResyncKey; + + rerender({ subject: 'Typed locally' }); + + expect(result.current.draft.subject).toBe('Typed locally'); + expect(result.current.draftResyncKey).toBe(resyncKeyBeforeEcho); + }); + + it('should let unsaved local edits win over a concurrent remote change', () => { + const { result, rerender } = renderDraftHook(''); + + act(() => { + result.current.updateDraft({ subject: 'Typed locally' }); + }); + + const resyncKeyBeforeRemoteChange = result.current.draftResyncKey; + + rerender({ subject: 'Concurrent remote change' }); + + expect(result.current.draft.subject).toBe('Typed locally'); + expect(result.current.draftResyncKey).toBe(resyncKeyBeforeRemoteChange); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockUpdateOneRecord).toHaveBeenCalledWith({ + objectNameSingular: 'messageCampaign', + idToUpdate: campaignId, + updateOneRecordInput: { subject: 'Typed locally' }, + }); + }); + + it('should adopt a remote change arriving after local edits were persisted and echoed', () => { + const { result, rerender } = renderDraftHook(''); + + act(() => { + result.current.updateDraft({ subject: 'Typed locally' }); + }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + rerender({ subject: 'Typed locally' }); + + const resyncKeyAfterEcho = result.current.draftResyncKey; + + rerender({ subject: 'Updated by the AI afterwards' }); + + expect(result.current.draft.subject).toBe('Updated by the AI afterwards'); + expect(result.current.draftResyncKey).not.toBe(resyncKeyAfterEcho); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignBodyState.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignBodyState.ts index 39031ca3c5..af796d17f0 100644 --- a/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignBodyState.ts +++ b/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignBodyState.ts @@ -6,15 +6,17 @@ export const useCampaignBodyState = ({ }: { campaign: MessageCampaign; }) => { - const { draft, updateDraft, flush } = usePersistedCampaignDraft({ - campaignId: campaign.id, - initialDraft: () => ({ bodyTemplate: campaign.bodyTemplate ?? '' }), - toUpdateOneRecordInput: ({ bodyTemplate }) => ({ bodyTemplate }), - }); + const { draft, updateDraft, flush, draftResyncKey } = + usePersistedCampaignDraft({ + campaignId: campaign.id, + initialDraft: () => ({ bodyTemplate: campaign.bodyTemplate ?? '' }), + toUpdateOneRecordInput: ({ bodyTemplate }) => ({ bodyTemplate }), + }); return { body: draft.bodyTemplate, setBody: (bodyTemplate: string) => updateDraft({ bodyTemplate }), flush, + draftResyncKey, }; }; diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignDetailsState.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignDetailsState.ts index 1f34d880eb..bc9c1779ef 100644 --- a/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignDetailsState.ts +++ b/packages/twenty-front/src/modules/activities/emails/hooks/useCampaignDetailsState.ts @@ -13,7 +13,7 @@ export const useCampaignDetailsState = ({ }: { campaign: MessageCampaign; }) => { - const { draft, updateDraft, flush } = + const { draft, updateDraft, flush, draftResyncKey } = usePersistedCampaignDraft({ campaignId: campaign.id, initialDraft: () => ({ @@ -36,6 +36,7 @@ export const useCampaignDetailsState = ({ return { ...draft, flush, + draftResyncKey, setListId: (listId: string | null) => updateDraft({ listId }), setUnsubscribeTopicId: (unsubscribeTopicId: string | null) => updateDraft({ unsubscribeTopicId }), diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/usePersistedCampaignDraft.ts b/packages/twenty-front/src/modules/activities/emails/hooks/usePersistedCampaignDraft.ts index cddad11e45..cf7c98d0ea 100644 --- a/packages/twenty-front/src/modules/activities/emails/hooks/usePersistedCampaignDraft.ts +++ b/packages/twenty-front/src/modules/activities/emails/hooks/usePersistedCampaignDraft.ts @@ -1,14 +1,11 @@ import { t } from '@lingui/core/macro'; -import { useState } from 'react'; import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { useDebouncedCallback } from 'use-debounce'; import { type MessageCampaign } from '@/activities/emails/types/MessageCampaign'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useRecordSeededDraft } from '@/object-record/record-seeded-draft/hooks/useRecordSeededDraft'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -const PERSIST_DEBOUNCE_MS = 500; - type UsePersistedCampaignDraftArgs = { campaignId: string; initialDraft: () => TDraft; @@ -22,27 +19,28 @@ export const usePersistedCampaignDraft = ({ initialDraft, toUpdateOneRecordInput, }: UsePersistedCampaignDraftArgs) => { - const [draft, setDraft] = useState(initialDraft); - const { updateOneRecord } = useUpdateOneRecord(); const { enqueueErrorSnackBar } = useSnackBar(); - const persistDebounced = useDebouncedCallback((nextDraft: TDraft) => { - updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.MessageCampaign, - idToUpdate: campaignId, - updateOneRecordInput: toUpdateOneRecordInput(nextDraft), - }).catch(() => - enqueueErrorSnackBar({ message: t`Failed to save the campaign` }), - ); - }, PERSIST_DEBOUNCE_MS); + const { draft, updateDraft, flush, draftResyncKey } = useRecordSeededDraft({ + upstreamDraft: initialDraft(), + onPersist: (nextDraft) => { + updateOneRecord({ + objectNameSingular: CoreObjectNameSingular.MessageCampaign, + idToUpdate: campaignId, + updateOneRecordInput: toUpdateOneRecordInput(nextDraft), + }).catch(() => + enqueueErrorSnackBar({ message: t`Failed to save the campaign` }), + ); + }, + }); - const updateDraft = (partialDraft: Partial) => { - const nextDraft = { ...draft, ...partialDraft }; - - setDraft(nextDraft); - persistDebounced(nextDraft); + return { + draft, + updateDraft, + flush, + // Inputs seeded through defaultValue (TipTap editors, record picker) read + // the draft on mount only; key them with this to remount on adoption. + draftResyncKey: `${campaignId}-${draftResyncKey}`, }; - - return { draft, updateDraft, flush: persistDebounced.flush }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor.tsx index f2bfa210e3..9cd8841a56 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAtom, useStore } from 'jotai'; import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; @@ -17,6 +17,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types'; import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useRecordSeededDraft } from '@/object-record/record-seeded-draft/hooks/useRecordSeededDraft'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; @@ -109,83 +110,6 @@ export const RichTextFieldEditor = ({ return attachmentAbsoluteURL; }; - const persistBodyDebounced = useDebouncedCallback((blocknote: string) => { - if (isRecordFieldReadOnly === true) return; - - if (onPersistBody) { - onPersistBody(blocknote); - return; - } - - updateOneRecord({ - idToUpdate: recordId, - objectNameSingular, - updateOneRecordInput: { - [fieldName]: { - blocknote, - markdown: null, - }, - }, - }); - }, 300); - - const handleBodyChange = useCallback( - async (newStringifiedBody: string) => { - const oldRecord = store.get(recordStoreFamilyState.atomFamily(recordId)); - - store.set( - recordStoreFamilyState.atomFamily(recordId), - (prev: typeof oldRecord) => ({ - ...prev, - id: recordId, - [fieldName]: { - blocknote: newStringifiedBody, - markdown: null, - }, - __typename: prev?.__typename ?? objectNameSingular, - }), - ); - - modifyRecordFromCache({ - recordId, - fieldModifiers: { - [fieldName]: () => ({ - blocknote: newStringifiedBody, - markdown: null, - }), - }, - cache, - objectMetadataItem, - }); - - persistBodyDebounced(prepareBodyWithSignedUrls(newStringifiedBody)); - - const oldFieldValue = oldRecord?.[fieldName] as - | { blocknote?: string | null } - | undefined; - - await syncAttachments(newStringifiedBody, oldFieldValue?.blocknote); - }, - [ - store, - recordId, - fieldName, - objectNameSingular, - cache, - objectMetadataItem, - persistBodyDebounced, - syncAttachments, - ], - ); - - const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500); - - const handleEditorChange = () => { - const newStringifiedBody = JSON.stringify(editor.document) ?? ''; - - handleBodyChangeDebounced(newStringifiedBody); - }; - const fieldValue = isDefined(recordInStore) ? (recordInStore as Record)?.[ fieldName @@ -223,14 +147,121 @@ export const RichTextFieldEditor = ({ fieldName, ); - const [currentRecordId, setCurrentRecordId] = useState(recordId); + const { updateDraft, markDirty, flush, draftResyncKey } = + useRecordSeededDraft({ + upstreamDraft: { blocknote: fieldValue?.blocknote ?? '' }, + persistDebounceMs: 300, + resetKey: recordId, + onPersist: ({ blocknote }) => { + if (isRecordFieldReadOnly === true) return; + + const preparedBlocknote = prepareBodyWithSignedUrls(blocknote); + + if (onPersistBody) { + onPersistBody(preparedBlocknote); + return; + } + + updateOneRecord({ + idToUpdate: recordId, + objectNameSingular, + updateOneRecordInput: { + [fieldName]: { + blocknote: preparedBlocknote, + markdown: null, + }, + }, + }); + }, + }); + + // The BlockNote editor is uncontrolled; when a remote value is adopted, + // replace its content in place instead of remounting to keep the instance. + const [lastAppliedResyncKey, setLastAppliedResyncKey] = + useState(draftResyncKey); + + // The editor reports programmatic replacements through the same change + // callback as typing, so latch around the adoption: without it the adopted + // body would be treated as a local edit, marked dirty and written straight + // back, blocking the next remote update from being adopted. + // oxlint-disable-next-line twenty/no-state-useref + const isApplyingUpstreamBodyRef = useRef(false); useEffect(() => { - if (currentRecordId !== recordId) { - replaceBlockEditorContent(recordId); - setCurrentRecordId(recordId); + if (draftResyncKey === lastAppliedResyncKey) { + return; } - }, [recordId, currentRecordId, replaceBlockEditorContent]); + + setLastAppliedResyncKey(draftResyncKey); + + isApplyingUpstreamBodyRef.current = true; + try { + replaceBlockEditorContent(recordId); + } finally { + isApplyingUpstreamBodyRef.current = false; + } + }, [ + draftResyncKey, + lastAppliedResyncKey, + replaceBlockEditorContent, + recordId, + ]); + + const handleBodyChange = async (newStringifiedBody: string) => { + const oldRecord = store.get(recordStoreFamilyState.atomFamily(recordId)); + + store.set( + recordStoreFamilyState.atomFamily(recordId), + (prev: typeof oldRecord) => ({ + ...prev, + id: recordId, + [fieldName]: { + blocknote: newStringifiedBody, + markdown: null, + }, + __typename: prev?.__typename ?? objectNameSingular, + }), + ); + + modifyRecordFromCache({ + recordId, + fieldModifiers: { + [fieldName]: () => ({ + blocknote: newStringifiedBody, + markdown: null, + }), + }, + cache, + objectMetadataItem, + }); + + const oldFieldValue = oldRecord?.[fieldName] as + | { blocknote?: string | null } + | undefined; + + // Only schedule the persist once the pre-edit body is captured above: + // persisting optimistically rewrites the record, and doing that earlier + // would make the attachment diff below compare the new body with itself, + // leaving attachments removed from the body undeleted. + updateDraft({ blocknote: newStringifiedBody }); + + await syncAttachments(newStringifiedBody, oldFieldValue?.blocknote); + }; + + const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500); + + const handleEditorChange = () => { + if (isApplyingUpstreamBodyRef.current) { + return; + } + + // Serialization is debounced, so mark the draft dirty synchronously: a + // remote adoption arriving in that window would otherwise replace content + // the user is actively typing. + markDirty(); + + handleBodyChangeDebounced(JSON.stringify(editor.document) ?? ''); + }; useHotkeysOnFocusedElement({ keys: Key.Escape, @@ -257,14 +288,17 @@ export const RichTextFieldEditor = ({ }); }, [focusId, pushFocusItemToFocusStack, onFocusOverride]); - const handleBlockEditorBlur = useCallback(() => { + const handleBlockEditorBlur = () => { + handleBodyChangeDebounced.flush(); + flush(); + if (onBlurOverride) { onBlurOverride(); return; } removeFocusItemFromFocusStackById({ focusId }); - }, [focusId, removeFocusItemFromFocusStackById, onBlurOverride]); + }; return ( + renderHook( + ({ subject, resetKey }: DraftProps) => + useRecordSeededDraft({ + upstreamDraft: { subject }, + onPersist: mockPersist, + resetKey, + }), + { initialProps }, + ); + +describe('useRecordSeededDraft', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('should adopt a remote change when the draft is pristine', () => { + const { result, rerender } = renderDraftHook({ subject: '' }); + + const initialResyncKey = result.current.draftResyncKey; + + rerender({ subject: 'Written remotely' }); + + expect(result.current.draft.subject).toBe('Written remotely'); + expect(result.current.draftResyncKey).not.toBe(initialResyncKey); + }); + + it('should keep the local draft when its own persist echoes back', () => { + const { result, rerender } = renderDraftHook({ subject: '' }); + + act(() => { + result.current.updateDraft({ subject: 'Typed locally' }); + }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockPersist).toHaveBeenCalledWith({ subject: 'Typed locally' }); + + const resyncKeyBeforeEcho = result.current.draftResyncKey; + + rerender({ subject: 'Typed locally' }); + + expect(result.current.draft.subject).toBe('Typed locally'); + expect(result.current.draftResyncKey).toBe(resyncKeyBeforeEcho); + }); + + it('should let unsaved local edits win over a concurrent remote change', () => { + const { result, rerender } = renderDraftHook({ subject: '' }); + + act(() => { + result.current.updateDraft({ subject: 'Typed locally' }); + }); + + rerender({ subject: 'Concurrent remote change' }); + + expect(result.current.draft.subject).toBe('Typed locally'); + expect(result.current.isDirty).toBe(true); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockPersist).toHaveBeenCalledWith({ subject: 'Typed locally' }); + }); + + it('should adopt a remote change arriving after local edits were persisted and echoed', () => { + const { result, rerender } = renderDraftHook({ subject: '' }); + + act(() => { + result.current.updateDraft({ subject: 'Typed locally' }); + }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + rerender({ subject: 'Typed locally' }); + + const resyncKeyAfterEcho = result.current.draftResyncKey; + + rerender({ subject: 'Updated remotely afterwards' }); + + expect(result.current.draft.subject).toBe('Updated remotely afterwards'); + expect(result.current.draftResyncKey).not.toBe(resyncKeyAfterEcho); + }); + + it('should flush the pending persist on unmount', () => { + const { result, unmount } = renderDraftHook({ subject: '' }); + + act(() => { + result.current.updateDraft({ subject: 'Typed then navigated away' }); + }); + + unmount(); + + expect(mockPersist).toHaveBeenCalledWith({ + subject: 'Typed then navigated away', + }); + }); + + it('should reseed and drop the pending persist when the reset key changes', () => { + const { result, rerender } = renderDraftHook({ + subject: 'First record body', + resetKey: 'record-1', + }); + + act(() => { + result.current.updateDraft({ subject: 'Edited on first record' }); + }); + + const resyncKeyBeforeReset = result.current.draftResyncKey; + + rerender({ subject: 'Second record body', resetKey: 'record-2' }); + + expect(result.current.draft.subject).toBe('Second record body'); + expect(result.current.draftResyncKey).not.toBe(resyncKeyBeforeReset); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockPersist).not.toHaveBeenCalled(); + }); + + it('should drop a pending persist when the reset key cycles back within the debounce', () => { + const { result, rerender } = renderDraftHook({ + subject: 'Record A body', + resetKey: 'record-a', + }); + + act(() => { + result.current.updateDraft({ subject: 'Edited on record A' }); + }); + + rerender({ subject: 'Record B body', resetKey: 'record-b' }); + rerender({ subject: 'Record A body', resetKey: 'record-a' }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockPersist).not.toHaveBeenCalled(); + expect(result.current.draft.subject).toBe('Record A body'); + }); + + it('should hold off adoption between markDirty and the serialized update', () => { + const { result, rerender } = renderDraftHook({ subject: 'Initial' }); + + act(() => { + result.current.markDirty(); + }); + + expect(result.current.isDirty).toBe(true); + + rerender({ subject: 'Remote change during serialization' }); + + expect(result.current.draft.subject).toBe('Initial'); + + act(() => { + result.current.updateDraft({ subject: 'Serialized local edit' }); + }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockPersist).toHaveBeenCalledWith({ + subject: 'Serialized local edit', + }); + }); + + it('should report a pristine draft as not dirty', () => { + const { result, rerender } = renderDraftHook({ subject: 'Initial' }); + + expect(result.current.isDirty).toBe(false); + + rerender({ subject: 'Remote change' }); + + expect(result.current.isDirty).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-seeded-draft/hooks/useRecordSeededDraft.ts b/packages/twenty-front/src/modules/object-record/record-seeded-draft/hooks/useRecordSeededDraft.ts new file mode 100644 index 0000000000..13de00fe6d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-seeded-draft/hooks/useRecordSeededDraft.ts @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; + +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +const PERSIST_DEBOUNCE_MS = 500; + +type UseRecordSeededDraftArgs = { + // Derived from the record on every render; the single source of remote truth. + upstreamDraft: TDraft; + onPersist: (draft: TDraft) => void; + persistDebounceMs?: number; + // Identity of the record being edited. When it changes, the draft is + // reseeded from upstream and any pending persist is dropped so it cannot + // write the previous record's content onto the new one. Consumers that + // remount on record change (key={recordId}) do not need it. + resetKey?: string; +}; + +type ScheduledPersist = { + draftToPersist: TDraft; + scheduledResetGeneration: number; +}; + +// Editing state seeded from a record, kept live against remote changes. +// +// Record data flows into local editing state exactly once per seed, so +// changes persisted by someone else (the AI chat, another user, another tab) +// arrive through the record without ever reaching the draft. This hook owns +// the policy for that seam, the same way on every surface: +// - a pristine draft adopts the remote value as soon as it arrives; +// - a dirty draft wins, and overwrites the remote value when its debounced +// persist flushes (last write wins); +// - our own persists come back as an upstream value equal to the draft and +// only mark the draft pristine again, never disrupting typing. +// +// Controlled inputs re-render from `draft`. Uncontrolled inputs remount via +// `draftResyncKey`. Imperative editors (BlockNote) watch `draftResyncKey` and +// replace their content when it changes; those that debounce their own +// serialization call `markDirty` on the raw change so the gap before the +// serialized value reaches `updateDraft` is never mistaken for pristine. +export const useRecordSeededDraft = ({ + upstreamDraft, + onPersist, + persistDebounceMs = PERSIST_DEBOUNCE_MS, + resetKey, +}: UseRecordSeededDraftArgs) => { + const [draft, setDraft] = useState(upstreamDraft); + const [lastUpstreamDraft, setLastUpstreamDraft] = + useState(upstreamDraft); + const [lastResetKey, setLastResetKey] = useState(resetKey); + const [resyncCount, setResyncCount] = useState(0); + const [resetGeneration, setResetGeneration] = useState(0); + const [hasUncommittedEdit, setHasUncommittedEdit] = useState(false); + + // The reset check below runs during render, where cancelling the timer + // would be an unsafe side effect; instead each scheduled persist remembers + // the reset generation it was scheduled under and is dropped once that + // generation is over. A counter rather than the key itself, so returning to + // a previously edited record (A to B back to A) still drops A's first + // pending persist instead of letting it land on the reseeded draft. + const persistDebounced = useDebouncedCallback( + ({ + draftToPersist, + scheduledResetGeneration, + }: ScheduledPersist) => { + if (scheduledResetGeneration !== resetGeneration) { + return; + } + + onPersist(draftToPersist); + }, + persistDebounceMs, + ); + + if (resetKey !== lastResetKey) { + setLastResetKey(resetKey); + setLastUpstreamDraft(upstreamDraft); + setDraft(upstreamDraft); + setResyncCount((count) => count + 1); + setResetGeneration((generation) => generation + 1); + setHasUncommittedEdit(false); + } else if (!isDeeplyEqual(upstreamDraft, lastUpstreamDraft)) { + const isDraftPristine = + !hasUncommittedEdit && + !persistDebounced.isPending() && + isDeeplyEqual(draft, lastUpstreamDraft); + + setLastUpstreamDraft(upstreamDraft); + + if (isDraftPristine) { + setDraft(upstreamDraft); + setResyncCount((count) => count + 1); + } + } + + // Trailing keystrokes must never be lost when the editor goes away. + useEffect(() => () => persistDebounced.flush(), [persistDebounced]); + + const updateDraft = (partialDraft: Partial) => { + const nextDraft = { ...draft, ...partialDraft }; + + setDraft(nextDraft); + setHasUncommittedEdit(false); + persistDebounced({ + draftToPersist: nextDraft, + scheduledResetGeneration: resetGeneration, + }); + }; + + // Holds off adoption for editors whose serialized value only arrives later, + // without scheduling a persist of a value we do not have yet. + const markDirty = () => { + setHasUncommittedEdit(true); + }; + + return { + draft, + updateDraft, + markDirty, + flush: persistDebounced.flush, + isDirty: + hasUncommittedEdit || + persistDebounced.isPending() || + !isDeeplyEqual(draft, lastUpstreamDraft), + draftResyncKey: resyncCount, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx index ddd59415cf..dbdfe015e9 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx @@ -2,14 +2,13 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; +import { useRecordSeededDraft } from '@/object-record/record-seeded-draft/hooks/useRecordSeededDraft'; import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { TextArea } from '@/ui/input/components/TextArea'; import { useAtomFamilySelectorState } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorState'; import { styled } from '@linaria/react'; -import { useCallback, useEffect, useState } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { useDebouncedCallback } from 'use-debounce'; const StyledContainer = styled.div` box-sizing: border-box; @@ -17,6 +16,8 @@ const StyledContainer = styled.div` width: 100%; `; +const PERSIST_DEBOUNCE_MS = 300; + type FieldWidgetTextEditorProps = { fieldMetadataItem: FieldMetadataItem; objectMetadataItem: EnrichedObjectMetadataItem; @@ -48,80 +49,44 @@ export const FieldWidgetTextEditor = ({ const textAreaId = `field-widget-text-editor-${recordId}-${fieldName}`; const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : ''; - const [draftText, setDraftText] = useState(fieldTextValue); - const [isFocused, setIsFocused] = useState(false); - const [isDraftDirty, setIsDraftDirty] = useState(false); - - const persistTextDebounced = useDebouncedCallback((text: string) => { - if (isRecordFieldReadOnly === true) { - return; - } - - updateOneRecord({ - objectNameSingular: objectMetadataItem.nameSingular, - idToUpdate: recordId, - updateOneRecordInput: { - [fieldName]: text, - }, - }); - }, 300); - - useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]); - - useEffect(() => { - if (isFocused) { - return; - } - - setDraftText(fieldTextValue); - setIsDraftDirty(false); - }, [fieldTextValue, isFocused]); - - const handleFocus = useCallback(() => { - setIsFocused(true); - setIsDraftDirty(false); - }, []); - - const handleChange = useCallback( - (text: string) => { + const { draft, updateDraft, flush } = useRecordSeededDraft({ + upstreamDraft: { text: fieldTextValue }, + persistDebounceMs: PERSIST_DEBOUNCE_MS, + resetKey: `${recordId}-${fieldMetadataItem.id}`, + onPersist: ({ text }) => { if (isRecordFieldReadOnly === true) { return; } - setDraftText(text); - setIsDraftDirty(true); setFieldValue(text); - persistTextDebounced(text); + updateOneRecord({ + objectNameSingular: objectMetadataItem.nameSingular, + idToUpdate: recordId, + updateOneRecordInput: { + [fieldName]: text, + }, + }); }, - [isRecordFieldReadOnly, persistTextDebounced, setFieldValue], - ); + }); - const handleBlur = useCallback(() => { - setIsFocused(false); - - if (isDraftDirty && isRecordFieldReadOnly !== true) { - setFieldValue(draftText); + const handleChange = (text: string) => { + if (isRecordFieldReadOnly === true) { + return; } - persistTextDebounced.flush(); - }, [ - draftText, - isDraftDirty, - isRecordFieldReadOnly, - persistTextDebounced, - setFieldValue, - ]); + updateDraft({ text }); + setFieldValue(text); + }; return (