Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions components/decision-summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ export function DecisionSummary({
</div>
)}

{decision.decisionNotes && !compact && (
<div className="space-y-2">
<h3 className="text-muted-foreground">Notes</h3>
<div className="prose prose-sm dark:prose-invert max-w-none">
<TipTapView content={decision.decisionNotes} />
</div>
</div>
)}

{!compact && (
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div className="space-y-2">
Expand Down
88 changes: 57 additions & 31 deletions components/tiptap-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from "@/lib/utils"

interface TipTapEditorProps {
content: string
onChange: (content: string) => void
className?: string
minimal?: boolean
}

export function TipTapEditor({ content, onChange, className = '' }: TipTapEditorProps) {
const getEditorClassNames = (minimal: boolean) => cn(
'prose prose-sm dark:prose-invert focus:outline-none max-w-none',
minimal ? 'p-2' : 'p-4 min-h-[200px]'
);

export function TipTapEditor({ content, onChange, className = '', minimal = false }: TipTapEditorProps) {
const [isFocused, setIsFocused] = React.useState(false);
const [isRawMode, setIsRawMode] = React.useState(false);
const [rawMarkdown, setRawMarkdown] = React.useState(content || '');
Expand All @@ -36,24 +43,37 @@ export function TipTapEditor({ content, onChange, className = '' }: TipTapEditor
},
heading: {
levels: [1, 2, 3]
},
hardBreak: {
keepMarks: true,
HTMLAttributes: {
class: 'my-2'
}
},
paragraph: {
HTMLAttributes: {
class: 'mb-2'
}
}
}),
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true
transformCopiedText: true,
breaks: true
})
],
content: rawMarkdown,
onUpdate: ({ editor }) => {
// Use the markdown extension to get markdown content
const markdown = editor.storage.markdown.getMarkdown();
console.log('markdown content:', markdown);

setRawMarkdown(markdown);
onChange(markdown);
},
editorProps: {
attributes: {
class: 'prose prose-sm dark:prose-invert focus:outline-none max-w-none p-4 min-h-[200px]'
class: getEditorClassNames(minimal)
}
},
onFocus: () => setIsFocused(true),
Expand Down Expand Up @@ -95,9 +115,8 @@ export function TipTapEditor({ content, onChange, className = '' }: TipTapEditor
// Toggle between rich text and raw markdown modes
const toggleEditMode = () => {
if (isRawMode && editor) {
// When switching from raw to rich, set the markdown content
editor.commands.clearContent();
editor.commands.insertContent(rawMarkdown);
editor.commands.setContent(rawMarkdown);
}
setIsRawMode(!isRawMode);
};
Expand Down Expand Up @@ -173,35 +192,42 @@ export function TipTapEditor({ content, onChange, className = '' }: TipTapEditor
];

return (
<Card className={`min-h-[300px] ${className}`}>
<div className="flex items-center gap-1 border-b p-2">
<TooltipProvider>
{tools
.filter(tool => isRawMode ? tool.showInRawMode : true)
.map((Tool) => (
<Tooltip key={Tool.title}>
<TooltipTrigger asChild>
<Button
variant={Tool.isActive() ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8"
onClick={Tool.action}
title={Tool.title}
>
<Tool.icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{Tool.title}</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
<Card className={cn(
minimal ? 'min-h-0 border-0 shadow-none bg-transparent' : 'min-h-[300px]',
className
)}>
{!minimal && (
<div className="flex items-center gap-1 border-b p-2">
<TooltipProvider>
{tools
.filter(tool => isRawMode ? tool.showInRawMode : true)
.map((Tool) => (
<Tooltip key={Tool.title}>
<TooltipTrigger asChild>
<Button
variant={Tool.isActive() ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8"
onClick={Tool.action}
title={Tool.title}
>
<Tool.icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{Tool.title}</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
)}

{isRawMode ? (
<Textarea
value={rawMarkdown}
value={rawMarkdown || ''}
onChange={handleRawMarkdownChange}
className="min-h-[200px] resize-none border-none rounded-none focus-visible:ring-0 p-4 font-mono text-sm"
className={`resize-none border-none rounded-none focus-visible:ring-0 p-4 font-mono text-sm ${
minimal ? 'min-h-0' : 'min-h-[200px]'
}`}
placeholder="Enter markdown here..."
/>
) : (
Expand Down
28 changes: 23 additions & 5 deletions components/tiptap-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,27 @@ export function TipTapView({ content, className = '' }: TipTapViewProps) {
},
heading: {
levels: [1, 2, 3]
},
hardBreak: {
keepMarks: true,
HTMLAttributes: {
class: 'my-2'
}
},
paragraph: {
HTMLAttributes: {
class: 'mb-2'
}
}
}),
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true
transformCopiedText: true,
breaks: true
})
],
content,
content: typeof content === 'string' ? (content.startsWith('"') ? JSON.parse(content) : content) : '',
editable: false,
editorProps: {
attributes: {
Expand All @@ -41,9 +53,15 @@ export function TipTapView({ content, className = '' }: TipTapViewProps) {
// Update editor content when content prop changes
React.useEffect(() => {
if (editor && content !== undefined) {
// Only update if content actually changed
if (editor.storage.markdown.getMarkdown() !== content) {
editor.commands.setContent(content);
try {
const parsedContent = content.startsWith('"') ? JSON.parse(content) : content;
// Only update if content actually changed
if (editor.storage.markdown.getMarkdown() !== parsedContent) {
editor.commands.setContent(parsedContent);
}
} catch (e) {
console.error('Error parsing content:', e);
editor.commands.setContent('');
}
}
}, [editor, content]);
Expand Down
36 changes: 27 additions & 9 deletions components/workflow/WorkflowAccordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default function WorkflowAccordion({
removeStakeholder,
updateStakeholders,
publishDecision,
updateDecisionNotes,
} = useDecision(decisionId, organisationId)

const {
Expand Down Expand Up @@ -457,25 +458,42 @@ export default function WorkflowAccordion({
if (step.key === 'choose' && decision) {
return (
<div className="space-y-8">
<div className="space-y-4">
<h2 className="text-xl text-muted-foreground">Notes</h2>
<TipTapEditor
content={decision.decisionNotes || ""}
onChange={(content) => updateDecisionNotes(content)}
/>
</div>

<DecisionRelationshipsList
relationshipType="blocked_by"
fromDecision={decision}
title="Blocked By Decision(s)"
/>

<div className="space-y-4">
<h2 className="text-xl text-muted-foreground">Decision</h2>
<TipTapEditor
content={decision.decision || ""}
onChange={(content) => updateDecisionContent(content)}
/>
</div>

<SupportingMaterialsList
<SupportingMaterialsList
materials={decision.supportingMaterials}
onAdd={addSupportingMaterial}
onRemove={removeSupportingMaterial}
/>

<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl text-muted-foreground">Decision</h2>
<span className="text-sm text-muted-foreground">
- state your decision concisely in 1-2 sentences
</span>
</div>
<div className="rounded-md border">
<TipTapEditor
content={decision.decision || ""}
onChange={(content) => updateDecisionContent(content)}
className="prose-sm min-h-[4rem] max-h-[8rem] overflow-y-auto"
minimal
/>
</div>
</div>
</div>
)
}
Expand Down
18 changes: 18 additions & 0 deletions hooks/useDecisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,23 @@ export function useDecision(decisionId: string, organisationId: string) {
}
};

/**
* Updates the notes associated with a decision
* @param decisionNotes - The new notes content in HTML format
* @throws {Error} If the update fails or if no decision is loaded
*/
const updateDecisionNotes = async (decisionNotes: string) => {
try {
if (!decision) return;
await decisionsRepository.update(
decision.with({ decisionNotes }),
);
} catch (error) {
setError(error as Error);
throw error;
}
};

return {
decision,
loading,
Expand All @@ -243,5 +260,6 @@ export function useDecision(decisionId: string, organisationId: string) {
addSupportingMaterial,
removeSupportingMaterial,
publishDecision,
updateDecisionNotes,
};
}
6 changes: 6 additions & 0 deletions lib/domain/Decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export type DecisionProps = {
teamIds: string[];
projectIds: string[];
relationships?: DecisionRelationshipMap;
decisionNotes?: string;
};

export class Decision {
Expand Down Expand Up @@ -175,6 +176,10 @@ export class Decision {
@IsOptional()
readonly relationships?: DecisionRelationshipMap;

@IsOptional()
@IsString()
readonly decisionNotes?: string;

toDocumentReference(): DocumentReference {
return {
id: this.id,
Expand Down Expand Up @@ -368,6 +373,7 @@ export class Decision {
this.teamIds = props.teamIds || [];
this.projectIds = props.projectIds || [];
this.relationships = props.relationships;
this.decisionNotes = props.decisionNotes;
this.validate();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,64 @@ describe('FirestoreDecisionsRepository Integration Tests', () => {
}, 20000);
});

describe('newline preservation', () => {
it('should preserve empty lines in markdown content', async () => {
const testName = 'newline-preservation';
const emptyDecision = Decision.createEmptyDecision();
const randomChars = Math.random().toString(36).substring(5, 9);
const projectId = `test-${testName}-${randomChars}`;
const teamId = `team-${testName}-${randomChars}`;

const project = await projectRepository.create(Project.create({
id: projectId,
name: `Test Project ${testName}`,
description: `Temporary project for integration tests ${testName}`,
organisationId: BASE_TEST_SCOPE.organisationId,
}));

projectsToCleanUp.push(project);

const decisionScope = {
organisationId: project.organisationId,
}

// Create a decision with markdown content containing multiple empty lines
const markdownWithEmptyLines = `# Title

This is a paragraph.


This is another paragraph after two empty lines.

* List item 1

* List item 2 after an empty line`;

const decision = await repository.create(
emptyDecision
.with({
title: 'Decision with empty lines',
description: markdownWithEmptyLines,
teamIds: [teamId],
projectIds: [project.id]
})
.withoutId(),
decisionScope
);

decisionsToCleanUp.push(decision);

// Retrieve the decision and check if empty lines are preserved
const retrievedDecision = await repository.getById(decision.id, decisionScope);

expect(retrievedDecision.description).toBe(markdownWithEmptyLines);

// Specifically check for the double newlines
expect(retrievedDecision.description).toContain('paragraph.\n\n\nThis');
expect(retrievedDecision.description).toContain('item 1\n\n* List');
});
});

describe('Relationship Management', () => {
it('should add and remove relationships correctly', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
Loading