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 305c07b5f..6c93e1eaa 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 @@ -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; async function savePolicy(content: Array): Promise { diff --git a/apps/framework-editor/app/components/editor/PolicyEditor.tsx b/apps/framework-editor/app/components/editor/PolicyEditor.tsx index d926cd2d1..92a6cb2fc 100644 --- a/apps/framework-editor/app/components/editor/PolicyEditor.tsx +++ b/apps/framework-editor/app/components/editor/PolicyEditor.tsx @@ -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 @@ -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 diff --git a/packages/integration-platform/src/dsl/interpreter.ts b/packages/integration-platform/src/dsl/interpreter.ts index 0d65b0ad3..1e3ac0422 100644 --- a/packages/integration-platform/src/dsl/interpreter.ts +++ b/packages/integration-platform/src/dsl/interpreter.ts @@ -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 }); } diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index 15c0dc075..f45af9761 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -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(), diff --git a/packages/integration-platform/src/runtime/check-context.ts b/packages/integration-platform/src/runtime/check-context.ts index 56e559b06..16a773f68 100644 --- a/packages/integration-platform/src/runtime/check-context.ts +++ b/packages/integration-platform/src/runtime/check-context.ts @@ -253,13 +253,18 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); - return executeRequest(() => - fetch(url.toString(), { + return executeRequest(() => { + // 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( @@ -268,13 +273,17 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); - return executeRequest(() => - fetch(url.toString(), { + return executeRequest(() => { + 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( @@ -283,13 +292,17 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); - return executeRequest(() => - fetch(url.toString(), { + return executeRequest(() => { + 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( diff --git a/packages/ui/src/components/editor/index.tsx b/packages/ui/src/components/editor/index.tsx index fc29773d5..c28622444 100644 --- a/packages/ui/src/components/editor/index.tsx +++ b/packages/ui/src/components/editor/index.tsx @@ -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); }, []); diff --git a/packages/ui/src/components/editor/utils/validate-content.test.ts b/packages/ui/src/components/editor/utils/validate-content.test.ts index 6fdafe336..6c748418e 100644 --- a/packages/ui/src/components/editor/utils/validate-content.test.ts +++ b/packages/ui/src/components/editor/utils/validate-content.test.ts @@ -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(); diff --git a/packages/ui/src/components/editor/utils/validate-content.ts b/packages/ui/src/components/editor/utils/validate-content.ts index 04769b347..ae71c5b34 100644 --- a/packages/ui/src/components/editor/utils/validate-content.ts +++ b/packages/ui/src/components/editor/utils/validate-content.ts @@ -38,6 +38,23 @@ export function validateAndFixTipTapContent(content: any): JSONContent { return createEmptyDocument(); } +/** + * Tries to parse a stringified JSON node into a proper object. + * Returns the parsed object or null if parsing fails. + */ +function tryParseStringNode(node: unknown): Record | null { + if (typeof node !== 'string') return null; + try { + const parsed = JSON.parse(node); + if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') { + return parsed as Record; + } + } catch { + // Not valid JSON + } + return null; +} + /** * Fixes an array of content nodes */ @@ -46,16 +63,55 @@ function fixContentArray(contentArray: any[]): JSONContent[] { return [createEmptyParagraph()]; } - const fixedContent = contentArray + // First pass: parse any stringified JSON nodes + const parsed = contentArray.map((node) => tryParseStringNode(node) ?? node); + + // Second pass: fix each node + const fixedNodes = parsed .map(fixNode) .filter((node): node is JSONContent => node !== null) as JSONContent[]; + // Third pass: merge orphaned listItems into bulletLists + const merged = mergeOrphanedListItems(fixedNodes); + // Ensure we have at least one paragraph - if (fixedContent.length === 0) { + if (merged.length === 0) { return [createEmptyParagraph()]; } - return fixedContent; + return merged; +} + +/** + * Merges consecutive orphaned listItem nodes into a bulletList. + */ +function mergeOrphanedListItems(nodes: JSONContent[]): JSONContent[] { + const result: JSONContent[] = []; + let i = 0; + + while (i < nodes.length) { + const node = nodes[i]!; + if (node.type === 'listItem') { + // Collect consecutive listItems + const items: JSONContent[] = []; + while (i < nodes.length && nodes[i]!.type === 'listItem') { + items.push(nodes[i]!); + i++; + } + // Check if the previous node is a bulletList or orderedList — append to it + const prev = result[result.length - 1]; + if (prev && (prev.type === 'bulletList' || prev.type === 'orderedList') && prev.content) { + prev.content.push(...items); + } else { + result.push({ type: 'bulletList', content: items }); + } + } else { + result.push(node); + i++; + } + } + + return result; } function ensureNonEmptyText(value: unknown): string { @@ -174,7 +230,23 @@ function fixList(node: any): JSONContent { }; } - const fixedContent = content.map(fixNode).filter(Boolean) as JSONContent[]; + // Parse stringified children and fix each node, wrapping non-listItem + // children (e.g. bare paragraphs) in a listItem so the list is valid. + const fixedContent: JSONContent[] = []; + for (const child of content) { + const resolved = tryParseStringNode(child) ?? child; + const fixed = fixNode(resolved); + if (!fixed) continue; + if (fixed.type === 'listItem') { + fixedContent.push(fixed); + } else { + // Wrap non-listItem nodes (paragraph, etc.) in a listItem + fixedContent.push({ + type: 'listItem', + content: [fixed], + }); + } + } if (fixedContent.length === 0) { fixedContent.push(createEmptyListItem()); @@ -317,6 +389,12 @@ function fixTableCell(node: any): JSONContent { return { type: 'tableCell', content: blocks, ...(attrs && { attrs }), ...rest }; } +/** + * Marks to strip during validation (they cause rendering issues or are + * artefacts of AI generation / paste-from-rich-text). + */ +const STRIPPED_MARK_TYPES = new Set(['textStyle']); + /** * Fixes marks array */ @@ -326,7 +404,10 @@ function fixMarks(marks: any[]): any[] { } return marks - .filter((mark) => mark && typeof mark === 'object' && mark.type) + .filter( + (mark) => + mark && typeof mark === 'object' && mark.type && !STRIPPED_MARK_TYPES.has(mark.type), + ) .map((mark) => ({ type: mark.type, ...(mark.attrs && typeof mark.attrs === 'object' && { attrs: mark.attrs }),