Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -197,12 +198,13 @@ export function PolicyContentManager({
const [editorKey, setEditorKey] = useState(0);
const [activeTab, setActiveTab] = useState<string>(displayFormat);
const previousTabRef = useRef<string>(displayFormat);
const [currentContent, setCurrentContent] = useState<Array<JSONContent>>(() => {
const formattedContent = Array.isArray(policyContent)
? policyContent
: [policyContent as JSONContent];
return formattedContent;
});
const [currentContent, setCurrentContent] = useState<Array<JSONContent>>(() =>
// 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<string | null>(null);
const [editorInstance, setEditorInstance] = useState<TipTapEditor | null>(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Original file line number Diff line number Diff line change
@@ -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];
}
Loading