Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1117,12 +1117,7 @@ function PolicyEditorWrapper({
const formattedContent = Array.isArray(policyContent)
? policyContent
: [policyContent as JSONContent];
const sanitizedContent = formattedContent.map((node) => {
if (node.marks) node.marks = node.marks.filter((mark) => mark.type !== 'textStyle');
if (node.content) node.content = node.content.map((child) => child);
return node;
});
const validatedDoc = validateAndFixTipTapContent(sanitizedContent);
const validatedDoc = validateAndFixTipTapContent(formattedContent);
const normalizedContent = (validatedDoc.content || []) as Array<JSONContent>;

async function savePolicy(content: Array<JSONContent>): Promise<void> {
Expand Down
28 changes: 7 additions & 21 deletions apps/framework-editor/app/components/editor/PolicyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { JSONContent } from '@tiptap/react';
import { useMemo } from 'react';
import AdvancedEditor from './AdvancedEditor'; // Use local AdvancedEditor
import { validateAndFixTipTapContent } from '@trycompai/ui';

interface PolicyEditorProps {
// Accept raw JSONContent or array from DB
Expand All @@ -12,27 +13,12 @@ interface PolicyEditorProps {
}

export function PolicyEditor({ initialDbContent, readOnly = false, onSave }: PolicyEditorProps) {
// AdvancedEditor expects a single Tiptap document (JSONContent)
// Convert the DB format (potentially null, array, or object) to the expected format.
const initialEditorContent = useMemo(() => {
if (!initialDbContent) {
return { type: 'doc', content: [] }; // Default empty doc
}
if (Array.isArray(initialDbContent)) {
// If DB stores array, wrap it in a doc node
return { type: 'doc', content: initialDbContent };
}
if (typeof initialDbContent === 'object' && initialDbContent !== null) {
// If DB stores a valid JSON object, use it directly
// Add basic validation if needed
if (initialDbContent.type === 'doc') {
return initialDbContent as JSONContent;
}
}
// Fallback for unexpected formats
console.warn('Unexpected initialDbContent format, using default empty doc.', initialDbContent);
return { type: 'doc', content: [] };
}, [initialDbContent]);
// Use the shared validation function for consistent content handling
// across all editors (handles stringified JSON, invalid lists, etc.)
const initialEditorContent = useMemo(
() => validateAndFixTipTapContent(initialDbContent),
[initialDbContent],
);

// No internal state needed for content, pass directly to AdvancedEditor

Expand Down
31 changes: 25 additions & 6 deletions packages/integration-platform/src/dsl/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,36 @@ async function executeFetch(
let data: unknown;
const method = step.method || 'GET';

// Resolve body with interpolation
const resolveBody = (): unknown => {
if (!step.body) return undefined;
const interpolated = JSON.parse(interpolate(JSON.stringify(step.body), scope));

// Form-encode if bodyEncoding is 'form'
if (step.bodyEncoding === 'form' && interpolated && typeof interpolated === 'object') {
const formParams = new URLSearchParams();
for (const [key, value] of Object.entries(interpolated)) {
formParams.append(key, String(value ?? ''));
}
return formParams.toString();
}

return interpolated;
};

// Set Content-Type header for form encoding
const bodyHeaders = step.bodyEncoding === 'form'
? { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }
: headers;

if (method === 'GET') {
data = await ctx.fetch(path, { params, headers });
} else if (method === 'POST') {
const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined;
data = await ctx.post(path, body, { headers });
data = await ctx.post(path, resolveBody(), { headers: bodyHeaders });
} else if (method === 'PUT') {
const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined;
data = await ctx.put(path, body, { headers });
data = await ctx.put(path, resolveBody(), { headers: bodyHeaders });
} else if (method === 'PATCH') {
const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined;
data = await ctx.patch(path, body, { headers });
data = await ctx.patch(path, resolveBody(), { headers: bodyHeaders });
} else if (method === 'DELETE') {
data = await ctx.delete(path, { headers });
}
Expand Down
1 change: 1 addition & 0 deletions packages/integration-platform/src/dsl/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const FetchStepSchema = z.object({
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
params: z.record(z.string(), z.string()).optional(),
body: z.unknown().optional(),
bodyEncoding: z.enum(['json', 'form']).optional(),
headers: z.record(z.string(), z.string()).optional(),
dataPath: z.string().optional(),
onError: z.enum(['fail', 'skip', 'empty']).optional(),
Expand Down
49 changes: 31 additions & 18 deletions packages/integration-platform/src/runtime/check-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,18 @@ export function createCheckContext(options: CheckContextOptions): {
opts?: { baseUrl?: string; headers?: Record<string, string> },
): Promise<T> {
const url = buildUrl(path, opts?.baseUrl);
return executeRequest<T>(() =>
fetch(url.toString(), {
return executeRequest<T>(() => {
// Build headers inside lambda so token refresh is picked up on 401 retry
const merged = buildHeaders(opts?.headers);
if (!merged['Content-Type'] && !merged['content-type']) {
merged['Content-Type'] = 'application/json';
}
return fetch(url.toString(), {
method: 'POST',
headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
}),
);
headers: merged,
body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
});
});
}

async function httpPut<T>(
Expand All @@ -268,13 +273,17 @@ export function createCheckContext(options: CheckContextOptions): {
opts?: { baseUrl?: string; headers?: Record<string, string> },
): Promise<T> {
const url = buildUrl(path, opts?.baseUrl);
return executeRequest<T>(() =>
fetch(url.toString(), {
return executeRequest<T>(() => {
const merged = buildHeaders(opts?.headers);
if (!merged['Content-Type'] && !merged['content-type']) {
merged['Content-Type'] = 'application/json';
}
return fetch(url.toString(), {
method: 'PUT',
headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
}),
);
headers: merged,
body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
});
});
}

async function httpPatch<T>(
Expand All @@ -283,13 +292,17 @@ export function createCheckContext(options: CheckContextOptions): {
opts?: { baseUrl?: string; headers?: Record<string, string> },
): Promise<T> {
const url = buildUrl(path, opts?.baseUrl);
return executeRequest<T>(() =>
fetch(url.toString(), {
return executeRequest<T>(() => {
const merged = buildHeaders(opts?.headers);
if (!merged['Content-Type'] && !merged['content-type']) {
merged['Content-Type'] = 'application/json';
}
return fetch(url.toString(), {
method: 'PATCH',
headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
}),
);
headers: merged,
body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
});
});
}

async function httpDelete<T>(
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ export const Editor = ({
},
});

// Sync editable state with readOnly prop — TipTap v3's useEditor preserves
// the current editable state on option updates, so we must set it explicitly.
useEffect(() => {
if (editor && !editor.isDestroyed) {
editor.setEditable(!readOnly);
}
}, [editor, readOnly]);

useEffect(() => {
setInitialLoadComplete(true);
}, []);
Expand Down
155 changes: 155 additions & 0 deletions packages/ui/src/components/editor/utils/validate-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,161 @@ describe('validateAndFixTipTapContent', () => {
});
});

describe('stringified JSON nodes', () => {
it('should parse stringified JSON nodes in an array', () => {
const content = [
JSON.stringify({ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Purpose' }] }),
JSON.stringify({ type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'text', text: 'Some policy text.' }] }),
];

const fixed = validateAndFixTipTapContent(content);
expect(fixed.type).toBe('doc');
const nodes = fixed.content as any[];
expect(nodes).toHaveLength(2);
expect(nodes[0].type).toBe('heading');
expect(nodes[0].content[0].text).toBe('Purpose');
expect(nodes[1].type).toBe('paragraph');
expect(nodes[1].content[0].text).toBe('Some policy text.');
});

it('should handle mixed stringified and object nodes', () => {
const content = [
JSON.stringify({ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] }),
{ type: 'paragraph', content: [{ type: 'text', text: 'Body text' }] },
];

const fixed = validateAndFixTipTapContent(content);
const nodes = fixed.content as any[];
expect(nodes).toHaveLength(2);
expect(nodes[0].type).toBe('heading');
expect(nodes[1].type).toBe('paragraph');
});

it('should skip invalid stringified JSON', () => {
const content = [
'not valid json',
JSON.stringify({ type: 'paragraph', content: [{ type: 'text', text: 'Valid' }] }),
];

const fixed = validateAndFixTipTapContent(content);
const nodes = fixed.content as any[];
expect(nodes).toHaveLength(1);
expect(nodes[0].type).toBe('paragraph');
});
});

describe('orphaned listItem handling', () => {
it('should wrap orphaned listItems in a bulletList', () => {
const content = [
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] },
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }] },
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }] },
];

const fixed = validateAndFixTipTapContent(content);
const nodes = fixed.content as any[];
expect(nodes).toHaveLength(2);
expect(nodes[0].type).toBe('heading');
expect(nodes[1].type).toBe('bulletList');
expect(nodes[1].content).toHaveLength(2);
expect(nodes[1].content[0].type).toBe('listItem');
expect(nodes[1].content[1].type).toBe('listItem');
});

it('should append orphaned listItems to a preceding list', () => {
const content = [
{ type: 'bulletList', content: [
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] },
]},
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] },
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Third' }] }] },
];

const fixed = validateAndFixTipTapContent(content);
const nodes = fixed.content as any[];
expect(nodes).toHaveLength(1);
expect(nodes[0].type).toBe('bulletList');
expect(nodes[0].content).toHaveLength(3);
});
});

describe('list with non-listItem children', () => {
it('should wrap bare paragraphs inside a bulletList in listItems', () => {
const content = [
{ type: 'bulletList', content: [
{ type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'text', text: 'Bare paragraph' }] },
]},
];

const fixed = validateAndFixTipTapContent(content);
const nodes = fixed.content as any[];
expect(nodes[0].type).toBe('bulletList');
expect(nodes[0].content[0].type).toBe('listItem');
expect(nodes[0].content[0].content[0].type).toBe('paragraph');
expect(nodes[0].content[0].content[0].content[0].text).toBe('Bare paragraph');
});
});

describe('textStyle mark removal', () => {
it('should strip textStyle marks from content', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Styled text',
marks: [
{ type: 'textStyle', attrs: { color: 'red' } },
{ type: 'bold' },
],
},
],
},
],
};

const fixed = validateAndFixTipTapContent(content);
const textNode = (fixed.content as any[])[0].content[0];
expect(textNode.marks).toHaveLength(1);
expect(textNode.marks[0].type).toBe('bold');
});
});

describe('real-world AI-generated malformed content', () => {
it('should fix the exact content from ENG-197', () => {
// This is the actual content from the bug report — each node is a
// JSON string, the bulletList contains a bare paragraph, and
// listItems are orphaned at the top level.
const content = [
JSON.stringify({ type: 'heading', attrs: { level: 2, textAlign: null }, content: [{ text: 'Purpose', type: 'text' }] }),
JSON.stringify({ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Ensure all governance...', type: 'text' }] }),
JSON.stringify({ type: 'heading', attrs: { level: 2, textAlign: null }, content: [{ text: 'Version Control & Distribution', type: 'text' }] }),
JSON.stringify({ type: 'bulletList', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Keep policies under version control.', type: 'text' }] }] }),
JSON.stringify({ type: 'listItem', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Include a version number.', type: 'text' }] }] }),
JSON.stringify({ type: 'listItem', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Notify personnel.', type: 'text' }] }] }),
];

const fixed = validateAndFixTipTapContent(content);
expect(fixed.type).toBe('doc');
const nodes = fixed.content as any[];

// heading, paragraph, heading, bulletList (merged)
expect(nodes).toHaveLength(4);
expect(nodes[0].type).toBe('heading');
expect(nodes[1].type).toBe('paragraph');
expect(nodes[2].type).toBe('heading');
expect(nodes[3].type).toBe('bulletList');

// The bulletList should contain 3 listItems:
// 1 from the bare paragraph wrapped in listItem + 2 orphaned listItems
expect(nodes[3].content).toHaveLength(3);
expect(nodes[3].content.every((n: any) => n.type === 'listItem')).toBe(true);
});
});

describe('empty text node handling', () => {
const strip = (s: string) => s.replace(/[\u00A0\u200B\u202F]/g, '').trim();

Expand Down
Loading
Loading