From 815c7eaec1b05cfca65650747dfe33ac083777bf Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Thu, 22 Jan 2026 15:47:00 +0100 Subject: [PATCH 01/12] Hook up view proposal with tiptap docs --- .env.local.example | 3 +- .../ProposalCard/ProposalCardComponents.tsx | 60 ++++++++++++++++++- .../src/components/decisions/ProposalView.tsx | 16 ++++- packages/common/package.json | 1 + .../RichTextEditor/RichTextViewer.tsx | 7 ++- .../RichTextEditor/useRichTextEditor.ts | 8 ++- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/.env.local.example b/.env.local.example index af2cb17ac..dcc07c130 100644 --- a/.env.local.example +++ b/.env.local.example @@ -27,8 +27,9 @@ OTEL_SERVICE_NAME=common OTEL_RESOURCE_ATTRIBUTES="deployment.environment=local" ## TipTap Cloud (Real-time collaborative editing) -# Get your App ID from https://cloud.tiptap.dev/ +# Get your App ID and Secret from https://cloud.tiptap.dev/ NEXT_PUBLIC_TIPTAP_APP_ID=your-tiptap-app-id +TIPTAP_SECRET=your-tiptap-secret # TIPTAP_PRO_TOKEN is needed for pnpm to install @tiptap-pro packages. # Run before install: export TIPTAP_PRO_TOKEN="your-token" && pnpm install # Or add to your shell profile for persistence. diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index f062493d7..8d20b76d0 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -327,6 +327,58 @@ export function ProposalCardStatus({ }); } +/** + * Recursively extracts plain text from TipTap JSON nodes + */ +function extractTextFromTipTapNodes(nodes: unknown[]): string { + const textParts: string[] = []; + + for (const node of nodes) { + if (typeof node !== 'object' || node === null) { + continue; + } + + const n = node as Record; + + // Direct text node + if (n.type === 'text' && typeof n.text === 'string') { + textParts.push(n.text); + } + + // Recurse into content + if (Array.isArray(n.content)) { + textParts.push(extractTextFromTipTapNodes(n.content)); + } + } + + return textParts.join(' '); +} + +/** + * Gets preview text from document content (TipTap JSON or HTML) + */ +function getDescriptionPreview( + documentContent: Proposal['documentContent'], + fallbackDescription?: string, +): string | null { + if (documentContent) { + if (documentContent.type === 'json') { + const text = extractTextFromTipTapNodes(documentContent.content); + return text.trim() || null; + } + if (documentContent.type === 'html') { + return getTextPreview({ content: documentContent.content, maxLines: 3 }); + } + } + + // Fallback to legacy description from proposalData + if (fallbackDescription) { + return getTextPreview({ content: fallbackDescription, maxLines: 3 }); + } + + return null; +} + /** * Description text component */ @@ -337,8 +389,12 @@ export function ProposalCardDescription({ className?: string; }) { const { description } = parseProposalData(proposal.proposalData); + const previewText = getDescriptionPreview( + proposal.documentContent, + description, + ); - if (!description) { + if (!previewText) { return null; } @@ -349,7 +405,7 @@ export function ProposalCardDescription({ className, )} > - {getTextPreview({ content: description })} + {previewText}

); } diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index 25c5fc550..e994a266a 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -101,7 +101,21 @@ export function ProposalView({ currentProposal.proposalData, ); - const proposalContent = description || `

${t('No content available')}

`; + // Use documentContent when available, fall back to legacy description + const proposalContent = (() => { + if (currentProposal.documentContent) { + if (currentProposal.documentContent.type === 'json') { + // Return TipTap JSON format for RichTextViewer + // Cast to JSONContent since the API returns validated TipTap document structure + return { + type: 'doc', + content: currentProposal.documentContent.content, + } as import('@tiptap/react').JSONContent; + } + return currentProposal.documentContent.content; + } + return description || `

${t('No content available')}

`; + })(); return ( void; onChange?: (content: string) => void; @@ -50,7 +51,8 @@ export function useRichTextEditor({ editor.commands.setContent(content); } } - }, [editor]); // Only depend on editor, not content + // biome-ignore lint/correctness/useExhaustiveDependencies: Only run on editor creation, not content changes + }, [editor]); // Notify parent when editor is ready useEffect(() => { From 2dff17d3180e8a456aa2ce6eb4b778fb8c8bcad9 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 11:54:00 +0100 Subject: [PATCH 02/12] Use TipTap generateText for proposal content preview --- .../app/src/components/decisions/MyBallot.tsx | 4 +- .../ProposalCard/ProposalCardComponents.tsx | 46 ++++++------------- .../components/decisions/ProposalsList.tsx | 8 ++-- .../src/components/decisions/ResultsList.tsx | 4 +- 4 files changed, 21 insertions(+), 41 deletions(-) diff --git a/apps/app/src/components/decisions/MyBallot.tsx b/apps/app/src/components/decisions/MyBallot.tsx index ca704b060..7be8f1877 100644 --- a/apps/app/src/components/decisions/MyBallot.tsx +++ b/apps/app/src/components/decisions/MyBallot.tsx @@ -10,10 +10,10 @@ import { useTranslations } from '@/lib/i18n'; import { EmptyProposalsState } from './EmptyProposalsState'; import { ProposalCardContent, - ProposalCardDescription, ProposalCardFooter, ProposalCardHeader, ProposalCardMeta, + ProposalCardPreview, } from './ProposalCard'; import { VotingProposalCard } from './VotingProposalCard'; @@ -92,7 +92,7 @@ export const MyBallot = ({ - +
diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 8d20b76d0..297f2bc08 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -11,8 +11,10 @@ import { parseProposalData } from '@op/common/client'; import { getTextPreview, isNullish, match } from '@op/core'; import { Avatar } from '@op/ui/Avatar'; import { Chip } from '@op/ui/Chip'; +import { defaultViewerExtensions } from '@op/ui/RichTextEditor'; import { Surface } from '@op/ui/Surface'; import { cn } from '@op/ui/utils'; +import { generateText } from '@tiptap/core'; import { Heart, MessageCircle } from 'lucide-react'; import Image from 'next/image'; import type { HTMLAttributes, ReactNode } from 'react'; @@ -328,42 +330,20 @@ export function ProposalCardStatus({ } /** - * Recursively extracts plain text from TipTap JSON nodes + * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML) */ -function extractTextFromTipTapNodes(nodes: unknown[]): string { - const textParts: string[] = []; - - for (const node of nodes) { - if (typeof node !== 'object' || node === null) { - continue; - } - - const n = node as Record; - - // Direct text node - if (n.type === 'text' && typeof n.text === 'string') { - textParts.push(n.text); - } - - // Recurse into content - if (Array.isArray(n.content)) { - textParts.push(extractTextFromTipTapNodes(n.content)); - } - } - - return textParts.join(' '); -} - -/** - * Gets preview text from document content (TipTap JSON or HTML) - */ -function getDescriptionPreview( +function getProposalContentPreview( documentContent: Proposal['documentContent'], fallbackDescription?: string, ): string | null { if (documentContent) { if (documentContent.type === 'json') { - const text = extractTextFromTipTapNodes(documentContent.content); + const doc = { + type: 'doc', + content: + documentContent.content as import('@tiptap/core').JSONContent[], + }; + const text = generateText(doc, defaultViewerExtensions); return text.trim() || null; } if (documentContent.type === 'html') { @@ -380,16 +360,16 @@ function getDescriptionPreview( } /** - * Description text component + * Content preview/excerpt component */ -export function ProposalCardDescription({ +export function ProposalCardPreview({ proposal, className, }: BaseProposalCardProps & { className?: string; }) { const { description } = parseProposalData(proposal.proposalData); - const previewText = getDescriptionPreview( + const previewText = getProposalContentPreview( proposal.documentContent, description, ); diff --git a/apps/app/src/components/decisions/ProposalsList.tsx b/apps/app/src/components/decisions/ProposalsList.tsx index 6059e32ab..34169dd03 100644 --- a/apps/app/src/components/decisions/ProposalsList.tsx +++ b/apps/app/src/components/decisions/ProposalsList.tsx @@ -25,13 +25,13 @@ import { ProposalCard, ProposalCardActions, ProposalCardContent, - ProposalCardDescription, ProposalCardFooter, ProposalCardHeader, ProposalCardMenu, ProposalCardMeta, ProposalCardMetrics, ProposalCardOwnerActions, + ProposalCardPreview, } from './ProposalCard'; import { VoteSubmissionModal } from './VoteSubmissionModal'; import { VoteSuccessModal } from './VoteSuccessModal'; @@ -261,7 +261,7 @@ const VotingProposalsList = ({ } /> - + - +
@@ -389,7 +389,7 @@ const ViewProposalsList = ({ } /> - + diff --git a/apps/app/src/components/decisions/ResultsList.tsx b/apps/app/src/components/decisions/ResultsList.tsx index ac3704988..e74b71114 100644 --- a/apps/app/src/components/decisions/ResultsList.tsx +++ b/apps/app/src/components/decisions/ResultsList.tsx @@ -9,10 +9,10 @@ import { EmptyProposalsState } from './EmptyProposalsState'; import { ProposalCard, ProposalCardContent, - ProposalCardDescription, ProposalCardFooter, ProposalCardHeader, ProposalCardMeta, + ProposalCardPreview, } from './ProposalCard'; const NoProposalsFound = () => { @@ -74,7 +74,7 @@ export const ResultsList = ({ - + From 0572e9f6d410cb33a765da60964c37df9ba0716f Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 11:56:13 +0100 Subject: [PATCH 03/12] Use TipTap generateText for proposal content preview --- .../ProposalCard/ProposalCardComponents.tsx | 61 +++++++++---------- .../src/components/decisions/ProposalView.tsx | 3 +- packages/common/package.json | 1 - .../RichTextEditor/useRichTextEditor.ts | 1 - 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 297f2bc08..507e702d1 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -14,7 +14,7 @@ import { Chip } from '@op/ui/Chip'; import { defaultViewerExtensions } from '@op/ui/RichTextEditor'; import { Surface } from '@op/ui/Surface'; import { cn } from '@op/ui/utils'; -import { generateText } from '@tiptap/core'; +import { type JSONContent, generateText } from '@tiptap/core'; import { Heart, MessageCircle } from 'lucide-react'; import Image from 'next/image'; import type { HTMLAttributes, ReactNode } from 'react'; @@ -329,36 +329,6 @@ export function ProposalCardStatus({ }); } -/** - * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML) - */ -function getProposalContentPreview( - documentContent: Proposal['documentContent'], - fallbackDescription?: string, -): string | null { - if (documentContent) { - if (documentContent.type === 'json') { - const doc = { - type: 'doc', - content: - documentContent.content as import('@tiptap/core').JSONContent[], - }; - const text = generateText(doc, defaultViewerExtensions); - return text.trim() || null; - } - if (documentContent.type === 'html') { - return getTextPreview({ content: documentContent.content, maxLines: 3 }); - } - } - - // Fallback to legacy description from proposalData - if (fallbackDescription) { - return getTextPreview({ content: fallbackDescription, maxLines: 3 }); - } - - return null; -} - /** * Content preview/excerpt component */ @@ -390,6 +360,35 @@ export function ProposalCardPreview({ ); } +/** + * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML) + */ +function getProposalContentPreview( + documentContent: Proposal['documentContent'], + fallbackDescription?: string, +): string | null { + if (documentContent) { + if (documentContent.type === 'json') { + const doc = { + type: 'doc', + content: documentContent.content as JSONContent[], + }; + const text = generateText(doc, defaultViewerExtensions); + return text.trim() || null; + } + if (documentContent.type === 'html') { + return getTextPreview({ content: documentContent.content, maxLines: 3 }); + } + } + + // Fallback to legacy description from proposalData + if (fallbackDescription) { + return getTextPreview({ content: fallbackDescription, maxLines: 3 }); + } + + return null; +} + /** * Engagement metrics component (likes, comments, followers) */ diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index e994a266a..bec163f3b 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -12,6 +12,7 @@ import { Header1 } from '@op/ui/Header'; import { RichTextViewer } from '@op/ui/RichTextEditor'; import { Surface } from '@op/ui/Surface'; import { Tag, TagGroup } from '@op/ui/TagGroup'; +import type { JSONContent } from '@tiptap/react'; import { Heart, MessageCircle } from 'lucide-react'; import Image from 'next/image'; import { useCallback, useRef } from 'react'; @@ -110,7 +111,7 @@ export function ProposalView({ return { type: 'doc', content: currentProposal.documentContent.content, - } as import('@tiptap/react').JSONContent; + } as JSONContent; } return currentProposal.documentContent.content; } diff --git a/packages/common/package.json b/packages/common/package.json index fa4d95c4e..17b72ec32 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -11,7 +11,6 @@ "./realtime": "./src/realtime/index.ts", "./client": "./src/client.ts", "./src/services/access": "./src/services/access/index.ts", - "./proposalDataSchema": "./src/services/decision/proposalDataSchema.ts", "./src/*": "./src/*" }, "main": "src/index.ts", diff --git a/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts b/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts index e801c6e7a..74e3b66a0 100644 --- a/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts +++ b/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts @@ -51,7 +51,6 @@ export function useRichTextEditor({ editor.commands.setContent(content); } } - // biome-ignore lint/correctness/useExhaustiveDependencies: Only run on editor creation, not content changes }, [editor]); // Notify parent when editor is ready From 0b6090fa929a9321cb2748d5793b7358637cdc90 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 12:13:03 +0100 Subject: [PATCH 04/12] extract proposal content utils and add DocumentNotAvailable component --- .../decisions/DocumentNotAvailable.tsx | 28 +++++++ .../ProposalCard/ProposalCardComponents.tsx | 34 +------- .../src/components/decisions/ProposalView.tsx | 35 ++++----- .../decisions/proposalContentUtils.ts | 77 +++++++++++++++++++ 4 files changed, 122 insertions(+), 52 deletions(-) create mode 100644 apps/app/src/components/decisions/DocumentNotAvailable.tsx create mode 100644 apps/app/src/components/decisions/proposalContentUtils.ts diff --git a/apps/app/src/components/decisions/DocumentNotAvailable.tsx b/apps/app/src/components/decisions/DocumentNotAvailable.tsx new file mode 100644 index 000000000..c523f07ac --- /dev/null +++ b/apps/app/src/components/decisions/DocumentNotAvailable.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { cn } from '@op/ui/utils'; +import { LuFileQuestion } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +/** + * Simple empty state component for when proposal/document content is not available. + * Reusable across different contexts where document content might be missing. + */ +export function DocumentNotAvailable({ className }: { className?: string }) { + const t = useTranslations(); + + return ( +
+
+ +
+

{t('No content available')}

+
+ ); +} diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 507e702d1..0478d2724 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -8,13 +8,11 @@ import { type proposalEncoder, } from '@op/api/encoders'; import { parseProposalData } from '@op/common/client'; -import { getTextPreview, isNullish, match } from '@op/core'; +import { isNullish, match } from '@op/core'; import { Avatar } from '@op/ui/Avatar'; import { Chip } from '@op/ui/Chip'; -import { defaultViewerExtensions } from '@op/ui/RichTextEditor'; import { Surface } from '@op/ui/Surface'; import { cn } from '@op/ui/utils'; -import { type JSONContent, generateText } from '@tiptap/core'; import { Heart, MessageCircle } from 'lucide-react'; import Image from 'next/image'; import type { HTMLAttributes, ReactNode } from 'react'; @@ -25,6 +23,7 @@ import { useTranslations } from '@/lib/i18n'; import { Link } from '@/lib/i18n/routing'; import { Bullet } from '../../Bullet'; +import { getProposalContentPreview } from '../proposalContentUtils'; export type Proposal = z.infer; @@ -360,35 +359,6 @@ export function ProposalCardPreview({ ); } -/** - * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML) - */ -function getProposalContentPreview( - documentContent: Proposal['documentContent'], - fallbackDescription?: string, -): string | null { - if (documentContent) { - if (documentContent.type === 'json') { - const doc = { - type: 'doc', - content: documentContent.content as JSONContent[], - }; - const text = generateText(doc, defaultViewerExtensions); - return text.trim() || null; - } - if (documentContent.type === 'html') { - return getTextPreview({ content: documentContent.content, maxLines: 3 }); - } - } - - // Fallback to legacy description from proposalData - if (fallbackDescription) { - return getTextPreview({ content: fallbackDescription, maxLines: 3 }); - } - - return null; -} - /** * Engagement metrics component (likes, comments, followers) */ diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index bec163f3b..5fc7d5090 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -12,7 +12,6 @@ import { Header1 } from '@op/ui/Header'; import { RichTextViewer } from '@op/ui/RichTextEditor'; import { Surface } from '@op/ui/Surface'; import { Tag, TagGroup } from '@op/ui/TagGroup'; -import type { JSONContent } from '@tiptap/react'; import { Heart, MessageCircle } from 'lucide-react'; import Image from 'next/image'; import { useCallback, useRef } from 'react'; @@ -24,7 +23,9 @@ import { useTranslations } from '@/lib/i18n'; import { PostFeed, PostItem, usePostFeedActions } from '../PostFeed'; import { PostUpdate } from '../PostUpdate'; import { getViewerExtensions } from '../RichTextEditor/editorConfig'; +import { DocumentNotAvailable } from './DocumentNotAvailable'; import { ProposalViewLayout } from './ProposalViewLayout'; +import { getProposalContent } from './proposalContentUtils'; type Proposal = z.infer; @@ -103,20 +104,10 @@ export function ProposalView({ ); // Use documentContent when available, fall back to legacy description - const proposalContent = (() => { - if (currentProposal.documentContent) { - if (currentProposal.documentContent.type === 'json') { - // Return TipTap JSON format for RichTextViewer - // Cast to JSONContent since the API returns validated TipTap document structure - return { - type: 'doc', - content: currentProposal.documentContent.content, - } as JSONContent; - } - return currentProposal.documentContent.content; - } - return description || `

${t('No content available')}

`; - })(); + const proposalContent = getProposalContent( + currentProposal.documentContent, + description, + ); return ( {/* Proposal Content */} - + {proposalContent ? ( + + ) : ( + + )} {/* Comments Section */}
diff --git a/apps/app/src/components/decisions/proposalContentUtils.ts b/apps/app/src/components/decisions/proposalContentUtils.ts new file mode 100644 index 000000000..e2f096018 --- /dev/null +++ b/apps/app/src/components/decisions/proposalContentUtils.ts @@ -0,0 +1,77 @@ +import type { proposalEncoder } from '@op/api/encoders'; +import { getTextPreview } from '@op/core'; +import { defaultViewerExtensions } from '@op/ui/RichTextEditor'; +import { type JSONContent, generateText } from '@tiptap/core'; +import type { Content } from '@tiptap/react'; +import type { z } from 'zod'; + +type Proposal = z.infer; +type DocumentContent = NonNullable; + +/** + * Extracts content from proposal documentContent for use with RichTextViewer. + * Returns the appropriate format (JSONContent for TipTap, string for HTML) or null if unavailable. + * + * @param documentContent - The proposal's documentContent field + * @param fallbackDescription - Legacy description from proposalData to use as fallback + * @returns Content suitable for RichTextViewer, or null if no content available + */ +export function getProposalContent( + documentContent: DocumentContent | undefined | null, + fallbackDescription?: string | null, +): Content | null { + if (documentContent) { + if (documentContent.type === 'json') { + return { + type: 'doc', + content: documentContent.content, + } as JSONContent; + } + if (documentContent.type === 'html') { + return documentContent.content; + } + } + + // Fallback to legacy description + if (fallbackDescription) { + return fallbackDescription; + } + + return null; +} + +/** + * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML). + * Useful for card previews and summaries. + * + * @param documentContent - The proposal's documentContent field + * @param fallbackDescription - Legacy description from proposalData to use as fallback + * @param maxLines - Maximum number of lines for HTML preview (default: 3) + * @returns Plain text preview or null if no content available + */ +export function getProposalContentPreview( + documentContent: DocumentContent | undefined | null, + fallbackDescription?: string | null, + maxLines = 3, +): string | null { + if (documentContent) { + if (documentContent.type === 'json') { + const doc = { + type: 'doc', + content: documentContent.content as JSONContent[], + }; + const text = generateText(doc, defaultViewerExtensions); + return text.trim() || null; + } + if (documentContent.type === 'html') { + return getTextPreview({ content: documentContent.content, maxLines }); + } + } + + // Fallback to legacy description + if (fallbackDescription) { + return getTextPreview({ content: fallbackDescription, maxLines }); + } + + return null; +} From 8d5c1d3ea5585dbfd577b56d12ab970ab7c619e6 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 12:23:45 +0100 Subject: [PATCH 05/12] no document state --- .../app/src/components/decisions/DocumentNotAvailable.tsx | 8 +++++--- .../decisions/ProposalCard/ProposalCardComponents.tsx | 3 ++- apps/app/src/components/decisions/proposalContentUtils.ts | 5 +---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/decisions/DocumentNotAvailable.tsx b/apps/app/src/components/decisions/DocumentNotAvailable.tsx index c523f07ac..ae0e1f159 100644 --- a/apps/app/src/components/decisions/DocumentNotAvailable.tsx +++ b/apps/app/src/components/decisions/DocumentNotAvailable.tsx @@ -19,10 +19,12 @@ export function DocumentNotAvailable({ className }: { className?: string }) { className, )} > -
- +
+
-

{t('No content available')}

+

+ {t('Content could not be loaded')} +

); } diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 0478d2724..16bd3065b 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -23,6 +23,7 @@ import { useTranslations } from '@/lib/i18n'; import { Link } from '@/lib/i18n/routing'; import { Bullet } from '../../Bullet'; +import { DocumentNotAvailable } from '../DocumentNotAvailable'; import { getProposalContentPreview } from '../proposalContentUtils'; export type Proposal = z.infer; @@ -344,7 +345,7 @@ export function ProposalCardPreview({ ); if (!previewText) { - return null; + return ; } return ( diff --git a/apps/app/src/components/decisions/proposalContentUtils.ts b/apps/app/src/components/decisions/proposalContentUtils.ts index e2f096018..93378a889 100644 --- a/apps/app/src/components/decisions/proposalContentUtils.ts +++ b/apps/app/src/components/decisions/proposalContentUtils.ts @@ -10,11 +10,10 @@ type DocumentContent = NonNullable; /** * Extracts content from proposal documentContent for use with RichTextViewer. - * Returns the appropriate format (JSONContent for TipTap, string for HTML) or null if unavailable. + * Returns Content for rendering, or null if no content available. * * @param documentContent - The proposal's documentContent field * @param fallbackDescription - Legacy description from proposalData to use as fallback - * @returns Content suitable for RichTextViewer, or null if no content available */ export function getProposalContent( documentContent: DocumentContent | undefined | null, @@ -42,12 +41,10 @@ export function getProposalContent( /** * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML). - * Useful for card previews and summaries. * * @param documentContent - The proposal's documentContent field * @param fallbackDescription - Legacy description from proposalData to use as fallback * @param maxLines - Maximum number of lines for HTML preview (default: 3) - * @returns Plain text preview or null if no content available */ export function getProposalContentPreview( documentContent: DocumentContent | undefined | null, From 721082d818aafc17caa3d66e37ba05b2b673c5bb Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 12:28:43 +0100 Subject: [PATCH 06/12] simplify proposal content utils and update translations --- .../decisions/DocumentNotAvailable.tsx | 1 - .../ProposalCard/ProposalCardComponents.tsx | 6 +- .../src/components/decisions/ProposalView.tsx | 8 +-- .../decisions/proposalContentUtils.ts | 57 ++++++------------- apps/app/src/lib/i18n/dictionaries/bn.json | 2 +- apps/app/src/lib/i18n/dictionaries/en.json | 2 +- apps/app/src/lib/i18n/dictionaries/es.json | 2 +- apps/app/src/lib/i18n/dictionaries/fr.json | 2 +- apps/app/src/lib/i18n/dictionaries/pt.json | 2 +- 9 files changed, 26 insertions(+), 56 deletions(-) diff --git a/apps/app/src/components/decisions/DocumentNotAvailable.tsx b/apps/app/src/components/decisions/DocumentNotAvailable.tsx index ae0e1f159..25d34ec23 100644 --- a/apps/app/src/components/decisions/DocumentNotAvailable.tsx +++ b/apps/app/src/components/decisions/DocumentNotAvailable.tsx @@ -7,7 +7,6 @@ import { useTranslations } from '@/lib/i18n'; /** * Simple empty state component for when proposal/document content is not available. - * Reusable across different contexts where document content might be missing. */ export function DocumentNotAvailable({ className }: { className?: string }) { const t = useTranslations(); diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 16bd3065b..6fb91f9c8 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -338,11 +338,7 @@ export function ProposalCardPreview({ }: BaseProposalCardProps & { className?: string; }) { - const { description } = parseProposalData(proposal.proposalData); - const previewText = getProposalContentPreview( - proposal.documentContent, - description, - ); + const previewText = getProposalContentPreview(proposal.documentContent); if (!previewText) { return ; diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index 5fc7d5090..445ce0d4d 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -99,15 +99,11 @@ export function ProposalView({ }, []); // Parse proposal data using shared utility - const { title, budget, category, description } = parseProposalData( + const { title, budget, category } = parseProposalData( currentProposal.proposalData, ); - // Use documentContent when available, fall back to legacy description - const proposalContent = getProposalContent( - currentProposal.documentContent, - description, - ); + const proposalContent = getProposalContent(currentProposal.documentContent); return ( ; /** * Extracts content from proposal documentContent for use with RichTextViewer. * Returns Content for rendering, or null if no content available. - * - * @param documentContent - The proposal's documentContent field - * @param fallbackDescription - Legacy description from proposalData to use as fallback */ export function getProposalContent( documentContent: DocumentContent | undefined | null, - fallbackDescription?: string | null, ): Content | null { - if (documentContent) { - if (documentContent.type === 'json') { - return { - type: 'doc', - content: documentContent.content, - } as JSONContent; - } - if (documentContent.type === 'html') { - return documentContent.content; - } + if (!documentContent) { + return null; } - // Fallback to legacy description - if (fallbackDescription) { - return fallbackDescription; + if (documentContent.type === 'json') { + return { + type: 'doc', + content: documentContent.content, + } as JSONContent; } - return null; + return documentContent.content; } /** * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML). - * - * @param documentContent - The proposal's documentContent field - * @param fallbackDescription - Legacy description from proposalData to use as fallback - * @param maxLines - Maximum number of lines for HTML preview (default: 3) */ export function getProposalContentPreview( documentContent: DocumentContent | undefined | null, - fallbackDescription?: string | null, maxLines = 3, ): string | null { - if (documentContent) { - if (documentContent.type === 'json') { - const doc = { - type: 'doc', - content: documentContent.content as JSONContent[], - }; - const text = generateText(doc, defaultViewerExtensions); - return text.trim() || null; - } - if (documentContent.type === 'html') { - return getTextPreview({ content: documentContent.content, maxLines }); - } + if (!documentContent) { + return null; } - // Fallback to legacy description - if (fallbackDescription) { - return getTextPreview({ content: fallbackDescription, maxLines }); + if (documentContent.type === 'json') { + const doc = { + type: 'doc', + content: documentContent.content as JSONContent[], + }; + const text = generateText(doc, defaultViewerExtensions); + return text.trim() || null; } - return null; + return getTextPreview({ content: documentContent.content, maxLines }); } diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 724983faf..86de5362f 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -279,7 +279,7 @@ "Embed Link Preview": "লিঙ্ক প্রিভিউ এমবেড করুন", "Add Image": "ছবি যোগ করুন", "Add Horizontal Rule": "অনুভূমিক রুল যোগ করুন", - "No content available": "কোন বিষয়বস্তু উপলব্ধ নেই", + "Content could not be loaded": "বিষয়বস্তু লোড করা যায়নি", "Loading comments": "মন্তব্য লোড করা", "No comments": "কোনো মন্তব্য নেই", "Filter proposals by category": "বিভাগ অনুসারে প্রস্তাবগুলি ফিল্টার করুন", diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 19a13bfa7..983570612 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -287,7 +287,7 @@ "Embed Link Preview": "Embed Link Preview", "Add Image": "Add Image", "Add Horizontal Rule": "Add Horizontal Rule", - "No content available": "No content available", + "Content could not be loaded": "Content could not be loaded", "Loading comments": "Loading comments", "No comments": "No comments", "Filter proposals by category": "Filter proposals by category", diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index a7ad5cc75..b447b2241 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -286,7 +286,7 @@ "Embed Link Preview": "Insertar vista previa de enlace", "Add Image": "Agregar imagen", "Add Horizontal Rule": "Agregar línea horizontal", - "No content available": "No hay contenido disponible", + "Content could not be loaded": "No se pudo cargar el contenido", "Loading comments": "Cargando comentarios", "No comments": "Sin comentarios", "Filter proposals by category": "Filtrar propuestas por categoría", diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 4e064c56c..36be8c23f 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -287,7 +287,7 @@ "Embed Link Preview": "Intégrer un aperçu de lien", "Add Image": "Ajouter une image", "Add Horizontal Rule": "Ajouter une ligne horizontale", - "No content available": "Aucun contenu disponible", + "Content could not be loaded": "Le contenu n'a pas pu être chargé", "Loading comments": "Chargement des commentaires", "No comments": "Aucun commentaire", "Filter proposals by category": "Filtrer les propositions par catégorie", diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index fd1cd55a4..75603f293 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -287,7 +287,7 @@ "Embed Link Preview": "Incorporar visualização de link", "Add Image": "Adicionar imagem", "Add Horizontal Rule": "Adicionar linha horizontal", - "No content available": "Nenhum conteúdo disponível", + "Content could not be loaded": "Não foi possível carregar o conteúdo", "Loading comments": "Carregando comentários", "No comments": "Sem comentários", "Filter proposals by category": "Filtrar propostas por categoria", From eca8c7a68f46918cfc2403f8319c5fed728e4d76 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 12:31:38 +0100 Subject: [PATCH 07/12] simplify --- apps/app/src/components/decisions/proposalContentUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/decisions/proposalContentUtils.ts b/apps/app/src/components/decisions/proposalContentUtils.ts index ee886ca80..10e37b6f7 100644 --- a/apps/app/src/components/decisions/proposalContentUtils.ts +++ b/apps/app/src/components/decisions/proposalContentUtils.ts @@ -13,7 +13,7 @@ type DocumentContent = NonNullable; * Returns Content for rendering, or null if no content available. */ export function getProposalContent( - documentContent: DocumentContent | undefined | null, + documentContent?: DocumentContent, ): Content | null { if (!documentContent) { return null; @@ -33,7 +33,7 @@ export function getProposalContent( * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML). */ export function getProposalContentPreview( - documentContent: DocumentContent | undefined | null, + documentContent?: DocumentContent, maxLines = 3, ): string | null { if (!documentContent) { From a5a164fcfd0700513d0e470f325ed7eb114389c4 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 12:35:59 +0100 Subject: [PATCH 08/12] handle malformed tiptap json gracefully --- .../decisions/proposalContentUtils.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/decisions/proposalContentUtils.ts b/apps/app/src/components/decisions/proposalContentUtils.ts index 10e37b6f7..33edc3066 100644 --- a/apps/app/src/components/decisions/proposalContentUtils.ts +++ b/apps/app/src/components/decisions/proposalContentUtils.ts @@ -31,23 +31,28 @@ export function getProposalContent( /** * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML). + * For HTML content, maxLines controls truncation. For JSON content, truncation + * is handled via CSS line-clamp in the rendering component. */ export function getProposalContentPreview( documentContent?: DocumentContent, - maxLines = 3, ): string | null { if (!documentContent) { return null; } if (documentContent.type === 'json') { - const doc = { - type: 'doc', - content: documentContent.content as JSONContent[], - }; - const text = generateText(doc, defaultViewerExtensions); - return text.trim() || null; + try { + const doc = { + type: 'doc', + content: documentContent.content as JSONContent[], + }; + const text = generateText(doc, defaultViewerExtensions); + return text.trim() || null; + } catch { + return null; + } } - return getTextPreview({ content: documentContent.content, maxLines }); + return getTextPreview({ content: documentContent.content, maxLines: 3 }); } From 20deddddfaa34053d83cbb75a23c643e4f302347 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 12:38:53 +0100 Subject: [PATCH 09/12] update jsdocs --- apps/app/src/components/decisions/DocumentNotAvailable.tsx | 4 +--- apps/app/src/components/decisions/proposalContentUtils.ts | 6 +----- .../ui/src/components/RichTextEditor/RichTextViewer.tsx | 1 - .../ui/src/components/RichTextEditor/useRichTextEditor.ts | 3 +-- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/app/src/components/decisions/DocumentNotAvailable.tsx b/apps/app/src/components/decisions/DocumentNotAvailable.tsx index 25d34ec23..b5060702a 100644 --- a/apps/app/src/components/decisions/DocumentNotAvailable.tsx +++ b/apps/app/src/components/decisions/DocumentNotAvailable.tsx @@ -5,9 +5,7 @@ import { LuFileQuestion } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; -/** - * Simple empty state component for when proposal/document content is not available. - */ +/** Shown when document content failed to load from the collaboration server. */ export function DocumentNotAvailable({ className }: { className?: string }) { const t = useTranslations(); diff --git a/apps/app/src/components/decisions/proposalContentUtils.ts b/apps/app/src/components/decisions/proposalContentUtils.ts index 33edc3066..bbd204748 100644 --- a/apps/app/src/components/decisions/proposalContentUtils.ts +++ b/apps/app/src/components/decisions/proposalContentUtils.ts @@ -29,11 +29,7 @@ export function getProposalContent( return documentContent.content; } -/** - * Extracts plain text preview from proposal content (TipTap JSON or legacy HTML). - * For HTML content, maxLines controls truncation. For JSON content, truncation - * is handled via CSS line-clamp in the rendering component. - */ +/** Extracts plain text preview from proposal content. */ export function getProposalContentPreview( documentContent?: DocumentContent, ): string | null { diff --git a/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx b/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx index bbb5fbf7a..87e375292 100644 --- a/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx +++ b/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx @@ -23,7 +23,6 @@ export function RichTextViewer({ editorClassName = '', }: { extensions?: Extensions; - /** HTML string or TipTap JSON document */ content: Content; className?: string; editorClassName?: string; diff --git a/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts b/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts index 74e3b66a0..00ed99da2 100644 --- a/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts +++ b/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts @@ -15,7 +15,6 @@ export function useRichTextEditor({ editable = true, }: { extensions?: Extensions; - /** HTML string or TipTap JSON document */ content?: Content; editorClassName?: string; onUpdate?: (content: string) => void; @@ -51,7 +50,7 @@ export function useRichTextEditor({ editor.commands.setContent(content); } } - }, [editor]); + }, [editor]); // Only run when editor is ready, not on content changes // Notify parent when editor is ready useEffect(() => { From 03b41c6484fabf589d75c9fc42348a1483d524b9 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Fri, 23 Jan 2026 13:07:48 +0100 Subject: [PATCH 10/12] empty vs not loaded --- .../decisions/ProposalCard/ProposalCardComponents.tsx | 6 +++++- .../src/components/decisions/proposalContentUtils.ts | 6 ++++-- .../common/src/services/decision/createProposal.ts | 10 +++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 6fb91f9c8..f41e81016 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -340,10 +340,14 @@ export function ProposalCardPreview({ }) { const previewText = getProposalContentPreview(proposal.documentContent); - if (!previewText) { + if (previewText === null) { return ; } + if (!previewText) { + return null; + } + return (

Date: Fri, 23 Jan 2026 13:19:53 +0100 Subject: [PATCH 11/12] always use existing collab id --- .../components/decisions/ProposalEditor.tsx | 55 ++++++++----------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/apps/app/src/components/decisions/ProposalEditor.tsx b/apps/app/src/components/decisions/ProposalEditor.tsx index cb1d408be..2dbb1c8d8 100644 --- a/apps/app/src/components/decisions/ProposalEditor.tsx +++ b/apps/app/src/components/decisions/ProposalEditor.tsx @@ -1,13 +1,12 @@ 'use client'; -import { generateProposalCollabDocId } from '@/utils/proposalUtils'; import { trpc } from '@op/api/client'; import { type ProcessInstance, ProposalStatus, type proposalEncoder, } from '@op/api/encoders'; -import { parseProposalData } from '@op/common/client'; +import { type ProposalDataInput, parseProposalData } from '@op/common/client'; import { Button } from '@op/ui/Button'; import { NumberField } from '@op/ui/NumberField'; import { Select, SelectItem } from '@op/ui/Select'; @@ -96,23 +95,15 @@ export function ProposalEditor({ const isDraft = isEditMode && existingProposal?.status === ProposalStatus.DRAFT; - // Generate or use existing collaboration document ID - const collabDocId = useMemo(() => { - const existingDocId = ( - existingProposal?.proposalData as Record - )?.collaborationDocId as string | undefined; - - if (isEditMode && existingDocId) { - return existingDocId; + const collaborationDocId = useMemo(() => { + const { collaborationDocId: existingId } = parseProposalData( + existingProposal?.proposalData, + ); + if (existingId) { + return existingId; } - // Generate new docId for new proposals or legacy proposals without one - return generateProposalCollabDocId(instance.id, existingProposal?.id); - }, [ - instance.id, - isEditMode, - existingProposal?.id, - existingProposal?.proposalData, - ]); + return `proposal-${instance.id}-${existingProposal?.id ?? crypto.randomUUID()}`; + }, [existingProposal?.proposalData, existingProposal?.id, instance.id]); // Editor extensions - memoized with collaborative flag const editorExtensions = useMemo( @@ -319,23 +310,21 @@ export function ProposalEditor({ setIsSubmitting(true); try { - const proposalData: Record = { - title, - collaborationDocId: collabDocId, - }; - - if (categories && categories.length > 0) { - proposalData.category = selectedCategory; - } - - if (budget !== null) { - proposalData.budget = budget; - } - if (!existingProposal) { throw new Error('No proposal to update'); } + const proposalData: ProposalDataInput = { + ...parseProposalData(existingProposal.proposalData), + collaborationDocId, + title, + category: + categories && categories.length > 0 + ? (selectedCategory ?? undefined) + : undefined, + budget: budget ?? undefined, + }; + // Update existing proposal await updateProposalMutation.mutateAsync({ proposalId: existingProposal.id, @@ -361,7 +350,7 @@ export function ProposalEditor({ budget, budgetCapAmount, selectedCategory, - collabDocId, + collaborationDocId, categories, existingProposal, isDraft, @@ -445,7 +434,7 @@ export function ProposalEditor({ {/* Rich Text Editor with Collaboration */} Date: Fri, 23 Jan 2026 13:52:24 +0100 Subject: [PATCH 12/12] fix tests --- .../src/services/decision/createProposal.ts | 1 - .../routers/decision/proposals/get.test.ts | 54 ++++---- .../routers/decision/proposals/list.test.ts | 120 ++++++++++-------- .../test/helpers/TestDecisionsDataManager.ts | 14 ++ 4 files changed, 106 insertions(+), 83 deletions(-) diff --git a/packages/common/src/services/decision/createProposal.ts b/packages/common/src/services/decision/createProposal.ts index 42df7f8f1..43e2a1877 100644 --- a/packages/common/src/services/decision/createProposal.ts +++ b/packages/common/src/services/decision/createProposal.ts @@ -145,7 +145,6 @@ export const createProposal = async ({ throw new CommonError('Failed to create proposal profile'); } - // Generate proposal ID upfront so we can create a deterministic collaborationDocId const proposalId = crypto.randomUUID(); const collaborationDocId = `proposal-${data.processInstanceId}-${proposalId}`; diff --git a/services/api/src/routers/decision/proposals/get.test.ts b/services/api/src/routers/decision/proposals/get.test.ts index 09965aee6..30bf16a39 100644 --- a/services/api/src/routers/decision/proposals/get.test.ts +++ b/services/api/src/routers/decision/proposals/get.test.ts @@ -297,7 +297,19 @@ describe.concurrent('getProposal', () => { throw new Error('No instance created'); } - const collaborationDocId = `proposal-${instance.instance.id}-test-doc-123`; + // Create proposal first to get the API-generated collaborationDocId + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { + title: 'TipTap Test Proposal', + }, + }); + + const { collaborationDocId } = proposal.proposalData as { + collaborationDocId: string; + }; + const mockTipTapContent = { type: 'doc', content: [ @@ -309,16 +321,6 @@ describe.concurrent('getProposal', () => { }; mockCollab.setDocResponse(collaborationDocId, mockTipTapContent); - const proposal = await testData.createProposal({ - callerEmail: setup.userEmail, - processInstanceId: instance.instance.id, - proposalData: { - title: 'TipTap Test Proposal', - description: 'Fallback description', - collaborationDocId, - } as { title: string; description: string }, - }); - const caller = await createAuthenticatedCaller(setup.userEmail); const result = await caller.decision.getProposal({ profileId: proposal.profileId, @@ -326,7 +328,7 @@ describe.concurrent('getProposal', () => { expect(result.proposalData).toMatchObject({ title: 'TipTap Test Proposal', - collaborationDocId, + collaborationDocId: expect.any(String), }); expect(result.documentContent).toEqual({ type: 'json', @@ -350,7 +352,6 @@ describe.concurrent('getProposal', () => { throw new Error('No instance created'); } - const collaborationDocId = `proposal-${instance.instance.id}-nonexistent`; // 404 is the default behavior when docId not in docResponses const proposal = await testData.createProposal({ @@ -358,9 +359,7 @@ describe.concurrent('getProposal', () => { processInstanceId: instance.instance.id, proposalData: { title: 'Missing Doc Proposal', - description: 'Has docId but doc does not exist', - collaborationDocId, - } as { title: string; description: string }, + }, }); const caller = await createAuthenticatedCaller(setup.userEmail); @@ -370,7 +369,7 @@ describe.concurrent('getProposal', () => { expect(result.proposalData).toMatchObject({ title: 'Missing Doc Proposal', - collaborationDocId, + collaborationDocId: expect.any(String), }); // When TipTap fetch fails, documentContent is undefined (UI handles error state) expect(result.documentContent).toBeUndefined(); @@ -392,22 +391,23 @@ describe.concurrent('getProposal', () => { throw new Error('No instance created'); } - const collaborationDocId = `proposal-${instance.instance.id}-timeout-doc`; - - const timeoutError = new Error('Request timed out'); - timeoutError.name = 'TimeoutError'; - mockCollab.setDocError(collaborationDocId, timeoutError); - + // Create proposal - API generates collaborationDocId const proposal = await testData.createProposal({ callerEmail: setup.userEmail, processInstanceId: instance.instance.id, proposalData: { title: 'Timeout Test Proposal', - description: 'Doc fetch will timeout', - collaborationDocId, - } as { title: string; description: string }, + }, }); + const { collaborationDocId } = proposal.proposalData as { + collaborationDocId: string; + }; + + const timeoutError = new Error('Request timed out'); + timeoutError.name = 'TimeoutError'; + mockCollab.setDocError(collaborationDocId, timeoutError); + const caller = await createAuthenticatedCaller(setup.userEmail); const result = await caller.decision.getProposal({ profileId: proposal.profileId, @@ -415,7 +415,7 @@ describe.concurrent('getProposal', () => { expect(result.proposalData).toMatchObject({ title: 'Timeout Test Proposal', - collaborationDocId, + collaborationDocId: expect.any(String), }); expect(result.documentContent).toBeUndefined(); }); diff --git a/services/api/src/routers/decision/proposals/list.test.ts b/services/api/src/routers/decision/proposals/list.test.ts index e57c11d50..9a5eadf26 100644 --- a/services/api/src/routers/decision/proposals/list.test.ts +++ b/services/api/src/routers/decision/proposals/list.test.ts @@ -411,13 +411,12 @@ describe.concurrent('listProposals', () => { // Create proposals with different data formats in parallel const [newFormatProposal, legacyProposal, caller] = await Promise.all([ - // New format: uses collaborationDocId + // New format: API generates collaborationDocId testData.createProposal({ callerEmail: setup.userEmail, processInstanceId: instance.instance.id, proposalData: { title: 'New Format Proposal', - collaborationDocId: 'doc-123', }, }), // Legacy format: uses description field (HTML content) @@ -443,10 +442,10 @@ describe.concurrent('listProposals', () => { ); const legacy = result.proposals.find((p) => p.id === legacyProposal.id); - // New format proposal should have collaborationDocId + // New format proposal should have title and API-generated collaborationDocId expect(newFormat?.proposalData).toMatchObject({ title: 'New Format Proposal', - collaborationDocId: 'doc-123', + collaborationDocId: expect.any(String), }); // Legacy proposal should have description (HTML content) @@ -580,7 +579,19 @@ describe.concurrent('listProposals', () => { throw new Error('No instance created'); } - const collaborationDocId = `proposal-${instance.instance.id}-test-doc`; + // Create proposal first to get the API-generated collaborationDocId + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { + title: 'Collab Proposal', + }, + }); + + const { collaborationDocId } = proposal.proposalData as { + collaborationDocId: string; + }; + const mockTipTapContent = { type: 'doc', content: [ @@ -591,20 +602,10 @@ describe.concurrent('listProposals', () => { ], }; - // Configure mock to return TipTap content + // Configure mock to return TipTap content for the generated docId mockCollab.setDocResponse(collaborationDocId, mockTipTapContent); - const [proposal, caller] = await Promise.all([ - testData.createProposal({ - callerEmail: setup.userEmail, - processInstanceId: instance.instance.id, - proposalData: { - title: 'Collab Proposal', - collaborationDocId, - }, - }), - createAuthenticatedCaller(setup.userEmail), - ]); + const caller = await createAuthenticatedCaller(setup.userEmail); const result = await caller.decision.listProposals({ processInstanceId: instance.instance.id, @@ -633,8 +634,6 @@ describe.concurrent('listProposals', () => { throw new Error('No instance created'); } - const collaborationDocId = `proposal-${instance.instance.id}-nonexistent`; - // Mock returns 404 by default for unknown docIds (no explicit setup needed) const [proposal, caller] = await Promise.all([ @@ -643,7 +642,6 @@ describe.concurrent('listProposals', () => { processInstanceId: instance.instance.id, proposalData: { title: 'Failed Fetch Proposal', - collaborationDocId, }, }), createAuthenticatedCaller(setup.userEmail), @@ -674,8 +672,26 @@ describe.concurrent('listProposals', () => { throw new Error('No instance created'); } - const docId1 = `proposal-${instance.instance.id}-doc1`; - const docId2 = `proposal-${instance.instance.id}-doc2`; + // Create proposals first to get API-generated collaborationDocIds + const [proposal1, proposal2] = await Promise.all([ + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Proposal 1' }, + }), + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Proposal 2' }, + }), + ]); + + const { collaborationDocId: docId1 } = proposal1.proposalData as { + collaborationDocId: string; + }; + const { collaborationDocId: docId2 } = proposal2.proposalData as { + collaborationDocId: string; + }; const mockContent1 = { type: 'doc', @@ -693,19 +709,7 @@ describe.concurrent('listProposals', () => { mockCollab.setDocResponse(docId1, mockContent1); mockCollab.setDocResponse(docId2, mockContent2); - const [proposal1, proposal2, caller] = await Promise.all([ - testData.createProposal({ - callerEmail: setup.userEmail, - processInstanceId: instance.instance.id, - proposalData: { title: 'Proposal 1', collaborationDocId: docId1 }, - }), - testData.createProposal({ - callerEmail: setup.userEmail, - processInstanceId: instance.instance.id, - proposalData: { title: 'Proposal 2', collaborationDocId: docId2 }, - }), - createAuthenticatedCaller(setup.userEmail), - ]); + const caller = await createAuthenticatedCaller(setup.userEmail); const result = await caller.decision.listProposals({ processInstanceId: instance.instance.id, @@ -740,34 +744,39 @@ describe.concurrent('listProposals', () => { throw new Error('No instance created'); } - const collaborationDocId = `proposal-${instance.instance.id}-mixed`; + // Create proposals first (collab and empty both get API-generated docIds) + const [collabProposal, legacyProposal, emptyProposal] = await Promise.all([ + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Collab' }, + }), + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Legacy', description: '

HTML

' }, + }), + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Empty' }, + }), + ]); + + const { collaborationDocId } = collabProposal.proposalData as { + collaborationDocId: string; + }; + const mockTipTapContent = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'TipTap' }] }, ], }; + // Only set up mock for the collab proposal (empty and legacy won't have valid responses) mockCollab.setDocResponse(collaborationDocId, mockTipTapContent); - const [collabProposal, legacyProposal, emptyProposal, caller] = - await Promise.all([ - testData.createProposal({ - callerEmail: setup.userEmail, - processInstanceId: instance.instance.id, - proposalData: { title: 'Collab', collaborationDocId }, - }), - testData.createProposal({ - callerEmail: setup.userEmail, - processInstanceId: instance.instance.id, - proposalData: { title: 'Legacy', description: '

HTML

' }, - }), - testData.createProposal({ - callerEmail: setup.userEmail, - processInstanceId: instance.instance.id, - proposalData: { title: 'Empty' }, // No content - }), - createAuthenticatedCaller(setup.userEmail), - ]); + const caller = await createAuthenticatedCaller(setup.userEmail); const result = await caller.decision.listProposals({ processInstanceId: instance.instance.id, @@ -789,6 +798,7 @@ describe.concurrent('listProposals', () => { type: 'html', content: '

HTML

', }); + // Empty proposal has a collaborationDocId but no mock response, so documentContent is undefined expect(foundEmpty?.documentContent).toBeUndefined(); }); }); diff --git a/services/api/src/test/helpers/TestDecisionsDataManager.ts b/services/api/src/test/helpers/TestDecisionsDataManager.ts index 2bb81a99e..74cabd83b 100644 --- a/services/api/src/test/helpers/TestDecisionsDataManager.ts +++ b/services/api/src/test/helpers/TestDecisionsDataManager.ts @@ -8,6 +8,7 @@ import { profileUserToAccessRoles, profileUsers, profiles, + proposals, users, } from '@op/db/schema'; import { ROLES } from '@op/db/seedData/accessControl'; @@ -530,6 +531,7 @@ export class TestDecisionsDataManager { /** * Creates a proposal via the tRPC router and tracks its profile for cleanup. + * If `description` is provided, removes `collaborationDocId` to simulate legacy proposals. */ async createProposal({ callerEmail, @@ -557,6 +559,18 @@ export class TestDecisionsDataManager { this.createdProfileIds.push(proposal.profileId); } + // Simulate legacy proposal by removing collaborationDocId when description is provided + if (proposalData.description) { + const { collaborationDocId: _, ...legacyProposalData } = + proposal.proposalData as Record; + const updatedProposalData = { ...legacyProposalData, ...proposalData }; + await db + .update(proposals) + .set({ proposalData: updatedProposalData }) + .where(eq(proposals.id, proposal.id)); + return { ...proposal, proposalData: updatedProposalData }; + } + return proposal; }