Skip to content

Commit 8e29eec

Browse files
authored
Merge pull request #2396 from trycompai/main
[comp] Production Deploy
2 parents c55d754 + e4fb01a commit 8e29eec

8 files changed

Lines changed: 314 additions & 56 deletions

File tree

apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,12 +1117,7 @@ function PolicyEditorWrapper({
11171117
const formattedContent = Array.isArray(policyContent)
11181118
? policyContent
11191119
: [policyContent as JSONContent];
1120-
const sanitizedContent = formattedContent.map((node) => {
1121-
if (node.marks) node.marks = node.marks.filter((mark) => mark.type !== 'textStyle');
1122-
if (node.content) node.content = node.content.map((child) => child);
1123-
return node;
1124-
});
1125-
const validatedDoc = validateAndFixTipTapContent(sanitizedContent);
1120+
const validatedDoc = validateAndFixTipTapContent(formattedContent);
11261121
const normalizedContent = (validatedDoc.content || []) as Array<JSONContent>;
11271122

11281123
async function savePolicy(content: Array<JSONContent>): Promise<void> {

apps/framework-editor/app/components/editor/PolicyEditor.tsx

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { JSONContent } from '@tiptap/react';
44
import { useMemo } from 'react';
55
import AdvancedEditor from './AdvancedEditor'; // Use local AdvancedEditor
6+
import { validateAndFixTipTapContent } from '@trycompai/ui';
67

78
interface PolicyEditorProps {
89
// Accept raw JSONContent or array from DB
@@ -12,27 +13,12 @@ interface PolicyEditorProps {
1213
}
1314

1415
export function PolicyEditor({ initialDbContent, readOnly = false, onSave }: PolicyEditorProps) {
15-
// AdvancedEditor expects a single Tiptap document (JSONContent)
16-
// Convert the DB format (potentially null, array, or object) to the expected format.
17-
const initialEditorContent = useMemo(() => {
18-
if (!initialDbContent) {
19-
return { type: 'doc', content: [] }; // Default empty doc
20-
}
21-
if (Array.isArray(initialDbContent)) {
22-
// If DB stores array, wrap it in a doc node
23-
return { type: 'doc', content: initialDbContent };
24-
}
25-
if (typeof initialDbContent === 'object' && initialDbContent !== null) {
26-
// If DB stores a valid JSON object, use it directly
27-
// Add basic validation if needed
28-
if (initialDbContent.type === 'doc') {
29-
return initialDbContent as JSONContent;
30-
}
31-
}
32-
// Fallback for unexpected formats
33-
console.warn('Unexpected initialDbContent format, using default empty doc.', initialDbContent);
34-
return { type: 'doc', content: [] };
35-
}, [initialDbContent]);
16+
// Use the shared validation function for consistent content handling
17+
// across all editors (handles stringified JSON, invalid lists, etc.)
18+
const initialEditorContent = useMemo(
19+
() => validateAndFixTipTapContent(initialDbContent),
20+
[initialDbContent],
21+
);
3622

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

packages/integration-platform/src/dsl/interpreter.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,36 @@ async function executeFetch(
173173
let data: unknown;
174174
const method = step.method || 'GET';
175175

176+
// Resolve body with interpolation
177+
const resolveBody = (): unknown => {
178+
if (!step.body) return undefined;
179+
const interpolated = JSON.parse(interpolate(JSON.stringify(step.body), scope));
180+
181+
// Form-encode if bodyEncoding is 'form'
182+
if (step.bodyEncoding === 'form' && interpolated && typeof interpolated === 'object') {
183+
const formParams = new URLSearchParams();
184+
for (const [key, value] of Object.entries(interpolated)) {
185+
formParams.append(key, String(value ?? ''));
186+
}
187+
return formParams.toString();
188+
}
189+
190+
return interpolated;
191+
};
192+
193+
// Set Content-Type header for form encoding
194+
const bodyHeaders = step.bodyEncoding === 'form'
195+
? { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }
196+
: headers;
197+
176198
if (method === 'GET') {
177199
data = await ctx.fetch(path, { params, headers });
178200
} else if (method === 'POST') {
179-
const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined;
180-
data = await ctx.post(path, body, { headers });
201+
data = await ctx.post(path, resolveBody(), { headers: bodyHeaders });
181202
} else if (method === 'PUT') {
182-
const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined;
183-
data = await ctx.put(path, body, { headers });
203+
data = await ctx.put(path, resolveBody(), { headers: bodyHeaders });
184204
} else if (method === 'PATCH') {
185-
const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined;
186-
data = await ctx.patch(path, body, { headers });
205+
data = await ctx.patch(path, resolveBody(), { headers: bodyHeaders });
187206
} else if (method === 'DELETE') {
188207
data = await ctx.delete(path, { headers });
189208
}

packages/integration-platform/src/dsl/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export const FetchStepSchema = z.object({
120120
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
121121
params: z.record(z.string(), z.string()).optional(),
122122
body: z.unknown().optional(),
123+
bodyEncoding: z.enum(['json', 'form']).optional(),
123124
headers: z.record(z.string(), z.string()).optional(),
124125
dataPath: z.string().optional(),
125126
onError: z.enum(['fail', 'skip', 'empty']).optional(),

packages/integration-platform/src/runtime/check-context.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,18 @@ export function createCheckContext(options: CheckContextOptions): {
253253
opts?: { baseUrl?: string; headers?: Record<string, string> },
254254
): Promise<T> {
255255
const url = buildUrl(path, opts?.baseUrl);
256-
return executeRequest<T>(() =>
257-
fetch(url.toString(), {
256+
return executeRequest<T>(() => {
257+
// Build headers inside lambda so token refresh is picked up on 401 retry
258+
const merged = buildHeaders(opts?.headers);
259+
if (!merged['Content-Type'] && !merged['content-type']) {
260+
merged['Content-Type'] = 'application/json';
261+
}
262+
return fetch(url.toString(), {
258263
method: 'POST',
259-
headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' },
260-
body: body ? JSON.stringify(body) : undefined,
261-
}),
262-
);
264+
headers: merged,
265+
body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
266+
});
267+
});
263268
}
264269

265270
async function httpPut<T>(
@@ -268,13 +273,17 @@ export function createCheckContext(options: CheckContextOptions): {
268273
opts?: { baseUrl?: string; headers?: Record<string, string> },
269274
): Promise<T> {
270275
const url = buildUrl(path, opts?.baseUrl);
271-
return executeRequest<T>(() =>
272-
fetch(url.toString(), {
276+
return executeRequest<T>(() => {
277+
const merged = buildHeaders(opts?.headers);
278+
if (!merged['Content-Type'] && !merged['content-type']) {
279+
merged['Content-Type'] = 'application/json';
280+
}
281+
return fetch(url.toString(), {
273282
method: 'PUT',
274-
headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' },
275-
body: body ? JSON.stringify(body) : undefined,
276-
}),
277-
);
283+
headers: merged,
284+
body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
285+
});
286+
});
278287
}
279288

280289
async function httpPatch<T>(
@@ -283,13 +292,17 @@ export function createCheckContext(options: CheckContextOptions): {
283292
opts?: { baseUrl?: string; headers?: Record<string, string> },
284293
): Promise<T> {
285294
const url = buildUrl(path, opts?.baseUrl);
286-
return executeRequest<T>(() =>
287-
fetch(url.toString(), {
295+
return executeRequest<T>(() => {
296+
const merged = buildHeaders(opts?.headers);
297+
if (!merged['Content-Type'] && !merged['content-type']) {
298+
merged['Content-Type'] = 'application/json';
299+
}
300+
return fetch(url.toString(), {
288301
method: 'PATCH',
289-
headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' },
290-
body: body ? JSON.stringify(body) : undefined,
291-
}),
292-
);
302+
headers: merged,
303+
body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
304+
});
305+
});
293306
}
294307

295308
async function httpDelete<T>(

packages/ui/src/components/editor/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ export const Editor = ({
8989
},
9090
});
9191

92+
// Sync editable state with readOnly prop — TipTap v3's useEditor preserves
93+
// the current editable state on option updates, so we must set it explicitly.
94+
useEffect(() => {
95+
if (editor && !editor.isDestroyed) {
96+
editor.setEditable(!readOnly);
97+
}
98+
}, [editor, readOnly]);
99+
92100
useEffect(() => {
93101
setInitialLoadComplete(true);
94102
}, []);

packages/ui/src/components/editor/utils/validate-content.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,161 @@ describe('validateAndFixTipTapContent', () => {
182182
});
183183
});
184184

185+
describe('stringified JSON nodes', () => {
186+
it('should parse stringified JSON nodes in an array', () => {
187+
const content = [
188+
JSON.stringify({ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Purpose' }] }),
189+
JSON.stringify({ type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'text', text: 'Some policy text.' }] }),
190+
];
191+
192+
const fixed = validateAndFixTipTapContent(content);
193+
expect(fixed.type).toBe('doc');
194+
const nodes = fixed.content as any[];
195+
expect(nodes).toHaveLength(2);
196+
expect(nodes[0].type).toBe('heading');
197+
expect(nodes[0].content[0].text).toBe('Purpose');
198+
expect(nodes[1].type).toBe('paragraph');
199+
expect(nodes[1].content[0].text).toBe('Some policy text.');
200+
});
201+
202+
it('should handle mixed stringified and object nodes', () => {
203+
const content = [
204+
JSON.stringify({ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] }),
205+
{ type: 'paragraph', content: [{ type: 'text', text: 'Body text' }] },
206+
];
207+
208+
const fixed = validateAndFixTipTapContent(content);
209+
const nodes = fixed.content as any[];
210+
expect(nodes).toHaveLength(2);
211+
expect(nodes[0].type).toBe('heading');
212+
expect(nodes[1].type).toBe('paragraph');
213+
});
214+
215+
it('should skip invalid stringified JSON', () => {
216+
const content = [
217+
'not valid json',
218+
JSON.stringify({ type: 'paragraph', content: [{ type: 'text', text: 'Valid' }] }),
219+
];
220+
221+
const fixed = validateAndFixTipTapContent(content);
222+
const nodes = fixed.content as any[];
223+
expect(nodes).toHaveLength(1);
224+
expect(nodes[0].type).toBe('paragraph');
225+
});
226+
});
227+
228+
describe('orphaned listItem handling', () => {
229+
it('should wrap orphaned listItems in a bulletList', () => {
230+
const content = [
231+
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] },
232+
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }] },
233+
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }] },
234+
];
235+
236+
const fixed = validateAndFixTipTapContent(content);
237+
const nodes = fixed.content as any[];
238+
expect(nodes).toHaveLength(2);
239+
expect(nodes[0].type).toBe('heading');
240+
expect(nodes[1].type).toBe('bulletList');
241+
expect(nodes[1].content).toHaveLength(2);
242+
expect(nodes[1].content[0].type).toBe('listItem');
243+
expect(nodes[1].content[1].type).toBe('listItem');
244+
});
245+
246+
it('should append orphaned listItems to a preceding list', () => {
247+
const content = [
248+
{ type: 'bulletList', content: [
249+
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] },
250+
]},
251+
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] },
252+
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Third' }] }] },
253+
];
254+
255+
const fixed = validateAndFixTipTapContent(content);
256+
const nodes = fixed.content as any[];
257+
expect(nodes).toHaveLength(1);
258+
expect(nodes[0].type).toBe('bulletList');
259+
expect(nodes[0].content).toHaveLength(3);
260+
});
261+
});
262+
263+
describe('list with non-listItem children', () => {
264+
it('should wrap bare paragraphs inside a bulletList in listItems', () => {
265+
const content = [
266+
{ type: 'bulletList', content: [
267+
{ type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'text', text: 'Bare paragraph' }] },
268+
]},
269+
];
270+
271+
const fixed = validateAndFixTipTapContent(content);
272+
const nodes = fixed.content as any[];
273+
expect(nodes[0].type).toBe('bulletList');
274+
expect(nodes[0].content[0].type).toBe('listItem');
275+
expect(nodes[0].content[0].content[0].type).toBe('paragraph');
276+
expect(nodes[0].content[0].content[0].content[0].text).toBe('Bare paragraph');
277+
});
278+
});
279+
280+
describe('textStyle mark removal', () => {
281+
it('should strip textStyle marks from content', () => {
282+
const content = {
283+
type: 'doc',
284+
content: [
285+
{
286+
type: 'paragraph',
287+
content: [
288+
{
289+
type: 'text',
290+
text: 'Styled text',
291+
marks: [
292+
{ type: 'textStyle', attrs: { color: 'red' } },
293+
{ type: 'bold' },
294+
],
295+
},
296+
],
297+
},
298+
],
299+
};
300+
301+
const fixed = validateAndFixTipTapContent(content);
302+
const textNode = (fixed.content as any[])[0].content[0];
303+
expect(textNode.marks).toHaveLength(1);
304+
expect(textNode.marks[0].type).toBe('bold');
305+
});
306+
});
307+
308+
describe('real-world AI-generated malformed content', () => {
309+
it('should fix the exact content from ENG-197', () => {
310+
// This is the actual content from the bug report — each node is a
311+
// JSON string, the bulletList contains a bare paragraph, and
312+
// listItems are orphaned at the top level.
313+
const content = [
314+
JSON.stringify({ type: 'heading', attrs: { level: 2, textAlign: null }, content: [{ text: 'Purpose', type: 'text' }] }),
315+
JSON.stringify({ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Ensure all governance...', type: 'text' }] }),
316+
JSON.stringify({ type: 'heading', attrs: { level: 2, textAlign: null }, content: [{ text: 'Version Control & Distribution', type: 'text' }] }),
317+
JSON.stringify({ type: 'bulletList', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Keep policies under version control.', type: 'text' }] }] }),
318+
JSON.stringify({ type: 'listItem', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Include a version number.', type: 'text' }] }] }),
319+
JSON.stringify({ type: 'listItem', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Notify personnel.', type: 'text' }] }] }),
320+
];
321+
322+
const fixed = validateAndFixTipTapContent(content);
323+
expect(fixed.type).toBe('doc');
324+
const nodes = fixed.content as any[];
325+
326+
// heading, paragraph, heading, bulletList (merged)
327+
expect(nodes).toHaveLength(4);
328+
expect(nodes[0].type).toBe('heading');
329+
expect(nodes[1].type).toBe('paragraph');
330+
expect(nodes[2].type).toBe('heading');
331+
expect(nodes[3].type).toBe('bulletList');
332+
333+
// The bulletList should contain 3 listItems:
334+
// 1 from the bare paragraph wrapped in listItem + 2 orphaned listItems
335+
expect(nodes[3].content).toHaveLength(3);
336+
expect(nodes[3].content.every((n: any) => n.type === 'listItem')).toBe(true);
337+
});
338+
});
339+
185340
describe('empty text node handling', () => {
186341
const strip = (s: string) => s.replace(/[\u00A0\u200B\u202F]/g, '').trim();
187342

0 commit comments

Comments
 (0)