From a10aa2a6a9621a2d1fd6501098795e7052486d1e Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 9 Jun 2026 18:03:21 -0400 Subject: [PATCH] fix(policies): show a draft version's own content when editing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a new policy version, editing the text (which correctly saves to the draft version), switching tabs, then opening that draft via Versions > Edit showed the PUBLISHED version's content instead of the draft's saved edits — so the change appeared to vanish, and a follow-up edit could overwrite the draft. Root cause (PolicyContentManager): currentContent is seeded from the policyContent prop, which PolicyPageTabs always sets to the published/current version's content. The effect that swaps in the selected version's content early-returns on first mount because prevInitialVersionIdRef is initialized to initialVersionId. Switching tabs unmounts the Content tab (DS Tabs don't keep inactive panels mounted), so opening a draft from the Versions tab remounts it fresh with a versionId already in the URL and hits that skip-on-mount path. Fix: seed currentContent from the initialVersionId version when present, via a new pure, unit-tested helper (resolveInitialPolicyContent). viewingVersion was already correct; this aligns the displayed content with the selected version. No save-path change — edits were already persisting correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/components/PolicyDetails.tsx | 14 ++-- .../__tests__/resolve-initial-content.test.ts | 77 +++++++++++++++++++ .../editor/lib/resolve-initial-content.ts | 51 ++++++++++++ 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/__tests__/resolve-initial-content.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/resolve-initial-content.ts diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index 6c93e1eaa2..2e0feb3db3 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -57,6 +57,7 @@ import type { PolicyChatUIMessage } from '../types'; import { PolicyAiAssistant } from './ai/policy-ai-assistant'; import { useSuggestions } from '../hooks/use-suggestions'; import { buildPositionMap } from '../lib/build-position-map'; +import { resolveInitialPolicyContent } from '../lib/resolve-initial-content'; import { InlineEditBubble } from './ai/inline-edit-bubble'; import { markdownToTipTapJSON } from './ai/markdown-utils'; @@ -197,12 +198,13 @@ export function PolicyContentManager({ const [editorKey, setEditorKey] = useState(0); const [activeTab, setActiveTab] = useState(displayFormat); const previousTabRef = useRef(displayFormat); - const [currentContent, setCurrentContent] = useState>(() => { - const formattedContent = Array.isArray(policyContent) - ? policyContent - : [policyContent as JSONContent]; - return formattedContent; - }); + const [currentContent, setCurrentContent] = useState>(() => + // When opening directly on a specific version (e.g. "Edit" a draft from the + // Versions tab, which remounts this tab fresh with a versionId in the URL), + // seed from that version's content — not the published policyContent — so a + // draft's saved edits don't appear to vanish. See resolve-initial-content.ts. + resolveInitialPolicyContent({ initialVersionId, versions, policyContent }), + ); const [dismissedProposalKey, setDismissedProposalKey] = useState(null); const [editorInstance, setEditorInstance] = useState(null); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/__tests__/resolve-initial-content.test.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/__tests__/resolve-initial-content.test.ts new file mode 100644 index 0000000000..a85a560d43 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/__tests__/resolve-initial-content.test.ts @@ -0,0 +1,77 @@ +import type { JSONContent } from '@tiptap/react'; +import { describe, expect, it } from 'vitest'; +import { resolveInitialPolicyContent } from '../resolve-initial-content'; + +const publishedContent: JSONContent[] = [ + { type: 'paragraph', content: [{ type: 'text', text: 'published' }] }, +]; +const draftContent: JSONContent[] = [ + { type: 'paragraph', content: [{ type: 'text', text: 'draft edit' }] }, +]; + +describe('resolveInitialPolicyContent', () => { + it('seeds from the targeted version when initialVersionId matches a draft (the bug fix)', () => { + const result = resolveInitialPolicyContent({ + initialVersionId: 'pv_draft', + versions: [ + { id: 'pv_published', content: publishedContent }, + { id: 'pv_draft', content: draftContent }, + ], + policyContent: publishedContent, + }); + + expect(result).toEqual(draftContent); + }); + + it('falls back to policyContent when no initialVersionId is given', () => { + const result = resolveInitialPolicyContent({ + initialVersionId: undefined, + versions: [{ id: 'pv_draft', content: draftContent }], + policyContent: publishedContent, + }); + + expect(result).toEqual(publishedContent); + }); + + it('falls back to policyContent when initialVersionId matches no known version', () => { + const result = resolveInitialPolicyContent({ + initialVersionId: 'pv_missing', + versions: [{ id: 'pv_draft', content: draftContent }], + policyContent: publishedContent, + }); + + expect(result).toEqual(publishedContent); + }); + + it('wraps a single (non-array) version content into an array', () => { + const singleNode: JSONContent = { type: 'paragraph', content: [{ type: 'text', text: 'one' }] }; + const result = resolveInitialPolicyContent({ + initialVersionId: 'pv_draft', + versions: [{ id: 'pv_draft', content: singleNode }], + policyContent: publishedContent, + }); + + expect(result).toEqual([singleNode]); + }); + + it('wraps a single (non-array) policyContent fallback into an array', () => { + const singleNode: JSONContent = { type: 'paragraph', content: [{ type: 'text', text: 'pub' }] }; + const result = resolveInitialPolicyContent({ + initialVersionId: undefined, + versions: [], + policyContent: singleNode, + }); + + expect(result).toEqual([singleNode]); + }); + + it('returns an empty array for an empty draft version (does not fall back)', () => { + const result = resolveInitialPolicyContent({ + initialVersionId: 'pv_draft', + versions: [{ id: 'pv_draft', content: [] }], + policyContent: publishedContent, + }); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/resolve-initial-content.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/resolve-initial-content.ts new file mode 100644 index 0000000000..974cb9d5bf --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/lib/resolve-initial-content.ts @@ -0,0 +1,51 @@ +import type { JSONContent } from '@tiptap/react'; + +/** Minimal shape needed to resolve a version's editor content. */ +interface VersionContentSource { + id: string; + content: unknown; +} + +interface ResolveInitialPolicyContentArgs { + /** + * The version the editor is opening on — e.g. the `versionId` in the URL when + * a draft is opened via "Edit" from the Versions tab. Undefined when simply + * viewing the current/published content. + */ + initialVersionId?: string; + /** All known policy versions (each carrying its own content snapshot). */ + versions: VersionContentSource[]; + /** Fallback content: the published/current version's content. */ + policyContent: JSONContent | JSONContent[]; +} + +/** + * Decide which content the policy editor should display on mount. + * + * The Content tab is unmounted when you switch tabs (the design-system Tabs do + * not keep inactive panels mounted), so clicking "Edit" on a draft from the + * Versions tab remounts it fresh with a `versionId` already in the URL. In that + * case the editor must seed from THAT version's content. Falling back to + * `policyContent` (which is always the published/current version's content) is + * what made a draft's saved edits appear to vanish. + * + * Pure and side-effect free so it can be unit-tested without mounting the editor. + */ +export function resolveInitialPolicyContent({ + initialVersionId, + versions, + policyContent, +}: ResolveInitialPolicyContentArgs): JSONContent[] { + if (initialVersionId) { + const version = versions.find((v) => v.id === initialVersionId); + if (version) { + return toContentArray(version.content); + } + } + return toContentArray(policyContent); +} + +/** Normalize content to an array, matching the editor's existing handling. */ +function toContentArray(raw: unknown): JSONContent[] { + return Array.isArray(raw) ? (raw as JSONContent[]) : [raw as JSONContent]; +}