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 62515819a..33306d54a 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 @@ -1,6 +1,7 @@ 'use client'; import { PolicyEditor } from '@/components/editor/policy-editor'; +import { validateAndFixTipTapContent } from '@comp/ui/editor'; import '@comp/ui/editor.css'; import type { JSONContent } from '@tiptap/react'; import { updatePolicy } from '../actions/update-policy'; @@ -34,6 +35,9 @@ export function PolicyPageEditor({ ? [policyContent as JSONContent] : []; const sanitizedContent = formattedContent.map(removeUnsupportedMarks); + // Normalize via validator so editor always receives a clean array + const validatedDoc = validateAndFixTipTapContent(sanitizedContent); + const normalizedContent = (validatedDoc.content || []) as JSONContent[]; const handleSavePolicy = async (policyContent: JSONContent[]): Promise => { if (!policyId) return; @@ -48,7 +52,7 @@ export function PolicyPageEditor({ return (
diff --git a/apps/app/src/components/editor/policy-editor.tsx b/apps/app/src/components/editor/policy-editor.tsx index 80f42490d..95d631a99 100644 --- a/apps/app/src/components/editor/policy-editor.tsx +++ b/apps/app/src/components/editor/policy-editor.tsx @@ -1,5 +1,6 @@ 'use client'; +import { validateAndFixTipTapContent } from '@comp/ui/editor'; import type { JSONContent } from '@tiptap/react'; import { useState } from 'react'; import AdvancedEditor from './advanced-editor'; @@ -13,18 +14,10 @@ interface PolicyEditorProps { export function PolicyEditor({ content, readOnly = false, onSave }: PolicyEditorProps) { const [editorContent, setEditorContent] = useState(null); - const documentContent = { + const documentContent = validateAndFixTipTapContent({ type: 'doc', - content: - Array.isArray(content) && content.length > 0 - ? content - : [ - { - type: 'paragraph', - content: [{ type: 'text', text: '' }], - }, - ], - }; + content: Array.isArray(content) && content.length > 0 ? content : [], + }); const handleUpdate = (updatedContent: JSONContent) => { setEditorContent(updatedContent); @@ -34,7 +27,8 @@ export function PolicyEditor({ content, readOnly = false, onSave }: PolicyEditor if (!contentToSave || !onSave) return; try { - const contentArray = contentToSave.content as JSONContent[]; + const fixed = validateAndFixTipTapContent(contentToSave); + const contentArray = (fixed.content || []) as JSONContent[]; await onSave(contentArray); } catch (error) { console.error('Error saving policy:', error); diff --git a/packages/ui/src/components/editor/utils/validate-content.ts b/packages/ui/src/components/editor/utils/validate-content.ts index 1e38f9cf8..88dc9109f 100644 --- a/packages/ui/src/components/editor/utils/validate-content.ts +++ b/packages/ui/src/components/editor/utils/validate-content.ts @@ -46,14 +46,9 @@ function fixContentArray(contentArray: any[]): JSONContent[] { return [createEmptyParagraph()]; } - const fixedContent = contentArray.map(fixNode).filter((node): node is JSONContent => { - if (!node) return false; - if (node.type === 'text') { - const value = typeof (node as any).text === 'string' ? (node as any).text : ''; - return isNonEmptyText(value); - } - return true; - }) as JSONContent[]; + const fixedContent = contentArray + .map(fixNode) + .filter((node): node is JSONContent => node !== null) as JSONContent[]; // Ensure we have at least one paragraph if (fixedContent.length === 0) { @@ -63,11 +58,13 @@ function fixContentArray(contentArray: any[]): JSONContent[] { return fixedContent; } -function isNonEmptyText(value: string): boolean { - // Consider normal whitespace, NBSP, zero-width space and narrow no-break space - if (typeof value !== 'string') return false; - const normalized = value.replace(/[\u00A0\u200B\u202F]/g, ''); - return normalized.trim().length > 0; +function ensureNonEmptyText(value: unknown): string { + const text = typeof value === 'string' ? value : ''; + // Normalize NBSP and narrow no-break space for emptiness checks + const normalized = text.replace(/[\u00A0\u202F]/g, ''); + if (normalized.trim().length > 0) return text; + // Return zero-width space to ensure non-empty text node without visual change + return '\u200B'; } /** @@ -94,6 +91,12 @@ function fixNode(node: any): JSONContent | null { return fixList(node); case 'listItem': return fixListItem(node); + case 'table': + return fixTable(node); + case 'tableRow': + return fixTableRow(node); + case 'tableCell': + return fixTableCell(node); case 'text': return fixTextNode(node); case 'heading': @@ -102,6 +105,8 @@ function fixNode(node: any): JSONContent | null { return fixBlockquote(node); case 'codeBlock': return fixCodeBlock(node); + case 'hardBreak': + return { type: 'hardBreak' }; default: // For other valid nodes, just fix their content if they have any return { @@ -130,21 +135,13 @@ function fixParagraph(node: any): JSONContent { if (item.text && !item.type) { return { type: 'text', - text: item.text, + text: ensureNonEmptyText(item.text), ...(item.marks && { marks: fixMarks(item.marks) }), }; } return fixNode(item); }) - .filter((n): n is JSONContent => { - if (!n) return false; - // Drop empty text nodes entirely - if (n.type === 'text') { - const txt = typeof (n as any).text === 'string' ? (n as any).text : ''; - return txt.trim().length > 0; - } - return true; - }); + .filter((n): n is JSONContent => Boolean(n)); // If no valid content, keep an empty paragraph (no empty text nodes) @@ -216,8 +213,7 @@ function fixListItem(node: any): JSONContent { function fixTextNode(node: any): JSONContent { const { text, marks, ...rest } = node; - const value = typeof text === 'string' ? text : ''; - // If the resulting text is empty, return a text node with non-empty check handled by callers. + const value = ensureNonEmptyText(text); return { type: 'text', text: value, @@ -272,6 +268,49 @@ function fixCodeBlock(node: any): JSONContent { }; } +/** + * Fixes table structures + */ +function fixTable(node: any): JSONContent { + const { content, attrs, ...rest } = node; + let rows: JSONContent[] = []; + if (Array.isArray(content)) { + rows = content + .map((child: any) => (child?.type === 'tableRow' ? fixTableRow(child) : null)) + .filter(Boolean) as JSONContent[]; + } + if (rows.length === 0) { + rows = [createEmptyTableRow()]; + } + return { type: 'table', content: rows, ...(attrs && { attrs }), ...rest }; +} + +function fixTableRow(node: any): JSONContent { + const { content, attrs, ...rest } = node; + let cells: JSONContent[] = []; + if (Array.isArray(content)) { + cells = content + .map((child: any) => (child?.type === 'tableCell' ? fixTableCell(child) : null)) + .filter(Boolean) as JSONContent[]; + } + if (cells.length === 0) { + cells = [createEmptyTableCell()]; + } + return { type: 'tableRow', content: cells, ...(attrs && { attrs }), ...rest }; +} + +function fixTableCell(node: any): JSONContent { + const { content, attrs, ...rest } = node; + let blocks: JSONContent[] = []; + if (Array.isArray(content)) { + blocks = fixContentArray(content); + } + if (blocks.length === 0) { + blocks = [createEmptyParagraph()]; + } + return { type: 'tableCell', content: blocks, ...(attrs && { attrs }), ...rest }; +} + /** * Fixes marks array */ @@ -318,6 +357,20 @@ function createEmptyListItem(): JSONContent { }; } +function createEmptyTableCell(): JSONContent { + return { + type: 'tableCell', + content: [createEmptyParagraph()], + }; +} + +function createEmptyTableRow(): JSONContent { + return { + type: 'tableRow', + content: [createEmptyTableCell()], + }; +} + /** * Validates if content is a valid TipTap document structure */