Skip to content
Open
3 changes: 2 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 27 additions & 0 deletions apps/app/src/components/decisions/DocumentNotAvailable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import { cn } from '@op/ui/utils';
import { LuFileQuestion } from 'react-icons/lu';

import { useTranslations } from '@/lib/i18n';

/** Shown when document content failed to load from the collaboration server. */
export function DocumentNotAvailable({ className }: { className?: string }) {
const t = useTranslations();

return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 py-8 text-center',
className,
)}
>
<div className="flex size-8 items-center justify-center rounded-full bg-neutral-gray1">
<LuFileQuestion className="size-4 text-neutral-gray4" />
</div>
<p className="text-sm text-neutral-gray4">
{t('Content could not be loaded')}
</p>
</div>
);
}
4 changes: 2 additions & 2 deletions apps/app/src/components/decisions/MyBallot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -92,7 +92,7 @@ export const MyBallot = ({

<ProposalCardMeta proposal={proposal} />

<ProposalCardDescription proposal={proposal} />
<ProposalCardPreview proposal={proposal} />

<div className="border-neutral-silver h-0 w-full border-b" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 { Surface } from '@op/ui/Surface';
Expand All @@ -23,6 +23,8 @@ 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<typeof proposalEncoder>;

Expand Down Expand Up @@ -328,17 +330,21 @@ export function ProposalCardStatus({
}

/**
* 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 = getProposalContentPreview(proposal.documentContent);

if (!description) {
if (previewText === null) {
return <DocumentNotAvailable className="py-4" />;
}

if (!previewText) {
return null;
}

Expand All @@ -349,7 +355,7 @@ export function ProposalCardDescription({
className,
)}
>
{getTextPreview({ content: description })}
{previewText}
</p>
);
}
Expand Down
55 changes: 22 additions & 33 deletions apps/app/src/components/decisions/ProposalEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown>
)?.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()}`;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't end up in this state, but if we handle it by creating a new doc.

}, [existingProposal?.proposalData, existingProposal?.id, instance.id]);

// Editor extensions - memoized with collaborative flag
const editorExtensions = useMemo(
Expand Down Expand Up @@ -319,23 +310,21 @@ export function ProposalEditor({
setIsSubmitting(true);

try {
const proposalData: Record<string, unknown> = {
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,
Expand All @@ -361,7 +350,7 @@ export function ProposalEditor({
budget,
budgetCapAmount,
selectedCategory,
collabDocId,
collaborationDocId,
categories,
existingProposal,
isDraft,
Expand Down Expand Up @@ -445,7 +434,7 @@ export function ProposalEditor({

{/* Rich Text Editor with Collaboration */}
<CollaborativeEditor
docId={collabDocId}
docId={collaborationDocId}
extensions={editorExtensions}
onEditorReady={handleEditorReady}
placeholder={t('Write your proposal here...')}
Expand Down
20 changes: 13 additions & 7 deletions apps/app/src/components/decisions/ProposalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,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<typeof proposalEncoder>;

Expand Down Expand Up @@ -97,11 +99,11 @@ export function ProposalView({
}, []);

// Parse proposal data using shared utility
const { title, budget, category, description } = parseProposalData(
const { title, budget, category } = parseProposalData(
currentProposal.proposalData,
);

const proposalContent = description || `<p>${t('No content available')}</p>`;
const proposalContent = getProposalContent(currentProposal.documentContent);

return (
<ProposalViewLayout
Expand Down Expand Up @@ -213,11 +215,15 @@ export function ProposalView({
</div>

{/* Proposal Content */}
<RichTextViewer
extensions={getViewerExtensions()}
content={proposalContent}
editorClassName="p-0"
/>
{proposalContent ? (
<RichTextViewer
extensions={getViewerExtensions()}
content={proposalContent}
editorClassName="p-0"
/>
) : (
<DocumentNotAvailable />
)}

{/* Comments Section */}
<div className="mt-12" ref={commentsContainerRef}>
Expand Down
8 changes: 4 additions & 4 deletions apps/app/src/components/decisions/ProposalsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -261,7 +261,7 @@ const VotingProposalsList = ({
}
/>
<ProposalCardMeta withLink={false} proposal={proposal} />
<ProposalCardDescription proposal={proposal} />
<ProposalCardPreview proposal={proposal} />
</ProposalCardContent>
<ProposalCardFooter>
<ButtonLink
Expand Down Expand Up @@ -291,7 +291,7 @@ const VotingProposalsList = ({
}
/>
<ProposalCardMeta proposal={proposal} />
<ProposalCardDescription proposal={proposal} />
<ProposalCardPreview proposal={proposal} />
</ProposalCardContent>
</div>
<ProposalCardContent>
Expand Down Expand Up @@ -389,7 +389,7 @@ const ViewProposalsList = ({
}
/>
<ProposalCardMeta proposal={proposal} />
<ProposalCardDescription proposal={proposal} />
<ProposalCardPreview proposal={proposal} />
</ProposalCardContent>
</div>
<ProposalCardContent>
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/components/decisions/ResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { EmptyProposalsState } from './EmptyProposalsState';
import {
ProposalCard,
ProposalCardContent,
ProposalCardDescription,
ProposalCardFooter,
ProposalCardHeader,
ProposalCardMeta,
ProposalCardPreview,
} from './ProposalCard';

const NoProposalsFound = () => {
Expand Down Expand Up @@ -74,7 +74,7 @@ export const ResultsList = ({

<ProposalCardMeta proposal={proposal} />

<ProposalCardDescription proposal={proposal} />
<ProposalCardPreview proposal={proposal} />
</ProposalCardContent>
</div>
<ProposalCardContent>
Expand Down
56 changes: 56 additions & 0 deletions apps/app/src/components/decisions/proposalContentUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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<typeof proposalEncoder>;
type DocumentContent = NonNullable<Proposal['documentContent']>;

/**
* Extracts content from proposal documentContent for use with RichTextViewer.
* Returns Content for rendering, or null if no content available.
*/
export function getProposalContent(
documentContent?: DocumentContent,
): Content | null {
if (!documentContent) {
return null;
}

if (documentContent.type === 'json') {
return {
type: 'doc',
content: documentContent.content,
} as JSONContent;
}

return documentContent.content;
}

/** Extracts plain text preview from proposal content. */
export function getProposalContentPreview(
documentContent?: DocumentContent,
): string | null {
if (!documentContent) {
return null;
}

if (documentContent.type === 'json') {
try {
const doc = {
type: 'doc',
content: documentContent.content as JSONContent[],
};
const text = generateText(doc, defaultViewerExtensions);
return text.trim();
} catch {
return null;
}
}

return (
getTextPreview({ content: documentContent.content, maxLines: 3 }) ?? ''
);
}
2 changes: 1 addition & 1 deletion apps/app/src/lib/i18n/dictionaries/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "বিভাগ অনুসারে প্রস্তাবগুলি ফিল্টার করুন",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/lib/i18n/dictionaries/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading