diff --git a/.env.example b/.env.example
index c316a3eea..62e7a5d09 100644
--- a/.env.example
+++ b/.env.example
@@ -51,4 +51,4 @@ MONGO_MIGRATION_URL=
OPENAI_API_KEY=
ZENVIA_API_URL=
-ZENVIA_API_TOKEN=
\ No newline at end of file
+ZENVIA_API_TOKEN=
diff --git a/cypress/e2e/tests/review.cy.ts b/cypress/e2e/tests/review.cy.ts
index a80e22c49..cc7bdb5cd 100644
--- a/cypress/e2e/tests/review.cy.ts
+++ b/cypress/e2e/tests/review.cy.ts
@@ -28,7 +28,7 @@ const assignUser = () => {
cy.get(locators.claimReview.BTN_START_CLAIM_REVIEW).should("exist").click();
cy.get(locators.claimReview.INPUT_USER)
.should("exist")
- .type(`${review.username}{downarrow}{enter}`, { delay: 200 })
+ .type(`${review.username}{downarrow}{enter}`, { delay: 200 });
cy.get('[title="reCAPTCHA"]').should("exist");
cy.get(locators.claimReview.BTN_ASSIGN_USER).should("be.disabled");
cy.checkRecaptcha();
@@ -37,11 +37,21 @@ const assignUser = () => {
};
const blockAssignedUserReview = () => {
+ // Wait for the form to be fully loaded with review data (including classification)
+ // before interacting. The classification field has a validation rule, and if its value
+ // hasn't been restored via react-hook-form's reset(reviewData) before the submit button
+ // is clicked, validation fails silently and the state transition never happens.
+ cy.get(locators.claimReview.INPUT_CLASSIFICATION).should("exist");
cy.checkRecaptcha();
- cy.get(locators.claimReview.BTN_SELECTED_REVIEW).should("exist").click();
+ cy.get(locators.claimReview.BTN_SELECTED_REVIEW)
+ .should("be.visible")
+ .and("be.enabled")
+ .click();
cy.get(locators.claimReview.INPUT_REVIEWER)
.should("exist")
- .type(`${review.username}{downarrow}{downarrow}{enter}`, { delay: 200 })
+ .type(`${review.username}{downarrow}{downarrow}{enter}`, {
+ delay: 200,
+ });
cy.checkRecaptcha();
cy.get(locators.claimReview.BTN_SUBMIT).should("be.enabled").click();
cy.get(locators.claimReview.TEXT_REVIEWER_ERROR).should("exist");
@@ -113,8 +123,10 @@ describe("Test claim review", () => {
});
it("should not be able submit after choosing assigned user as reviewer", () => {
+ cy.intercept("GET", "/api/reviewtask/hash/*").as("getReviewTask");
cy.login();
goToClaimReviewPage();
+ cy.wait("@getReviewTask");
blockAssignedUserReview();
});
});
diff --git a/lib/editor-parser.ts b/lib/editor-parser.ts
index 197636432..5ace1fdc2 100644
--- a/lib/editor-parser.ts
+++ b/lib/editor-parser.ts
@@ -96,10 +96,10 @@ export class EditorParser {
*/
private convertToParagraphElements(content: MultiParagraphContent): string {
const lines = content.split('\n');
-
+
// Check if there are any empty lines (for visual spacing)
const hasEmptyLines = lines.some(line => line.trim() === '');
-
+
if (hasEmptyLines) {
// Use
approach for content with intentional empty lines/spacing
return this.convertLineBreaksToHtml(content);
@@ -157,7 +157,7 @@ export class EditorParser {
if (this.isMultiParagraphContent(contentStr)) {
const lines = contentStr.split('\n');
const hasEmptyLines = lines.some(line => line.trim() === '');
-
+
if (hasEmptyLines) {
// Use
approach for content with intentional empty lines/spacing
const htmlContent = this.convertLineBreaksToHtml(contentStr);
@@ -215,7 +215,7 @@ export class EditorParser {
if (this.isMultiParagraphContent(joinedContent)) {
const lines = joinedContent.split('\n');
const hasEmptyLines = lines.some(line => line.trim() === '');
-
+
if (hasEmptyLines) {
// Use
approach for content with intentional empty lines/spacing
const finalContent = this.convertLineBreaksToHtml(joinedContent);
@@ -355,10 +355,10 @@ export class EditorParser {
{ type, content: cardContent }: RemirrorJSON
): MultiParagraphContent {
const paragraphContents: ParagraphContent[] = [];
-
+
for (const { content } of cardContent) {
const textFragments: TextFragment[] = [];
-
+
if (content) {
for (const { text, marks } of content) {
if (marks) {
@@ -376,12 +376,12 @@ export class EditorParser {
}
}
}
-
+
// Combine all fragments within a paragraph into a single paragraph content
const paragraphContent: ParagraphContent = textFragments.join("");
paragraphContents.push(paragraphContent);
}
-
+
// Join paragraphs with newlines to create multi-paragraph content
return paragraphContents.join("\n") as MultiParagraphContent;
}
@@ -684,4 +684,22 @@ export class EditorParser {
const match = fragmentText.match(MarkupCleanerRegex);
return match ? match[1] : "";
}
+
+ removeTrailingParagraph(editorJSON: RemirrorJSON): RemirrorJSON {
+ if (!editorJSON?.content || !Array.isArray(editorJSON.content)) {
+ return editorJSON;
+ }
+
+ const content = [...editorJSON.content];
+ const lastItem = content[content.length - 1];
+
+ if (lastItem?.type === "paragraph") {
+ content.pop();
+ }
+
+ return {
+ ...editorJSON,
+ content,
+ };
+ }
}
diff --git a/server/editor-parse/editor-parse.service.spec.ts b/server/editor-parse/editor-parse.service.spec.ts
index 5cd5638a1..9530becd5 100644
--- a/server/editor-parse/editor-parse.service.spec.ts
+++ b/server/editor-parse/editor-parse.service.spec.ts
@@ -6,22 +6,22 @@ import { RemirrorJSON } from "remirror";
/**
* EditorParseService Unit Test Suite
- *
+ *
* Tests the bidirectional transformation service that converts between different
* representation formats for the collaborative fact-checking editor system.
- *
+ *
* Business Context:
* The editor parse service handles content transformation between three key formats:
* 1. Schema format - Internal data structure with source references
* 2. Editor format - Remirror JSON for rich text editing
* 3. HTML format - Final rendered output for display
- *
+ *
* Core Functionality:
* - Schema ↔ Editor: Bidirectional conversion for editing workflows
* - Schema → HTML: Rendering for final display with source citations
* - Source link processing: Converting markup references to interactive citations
* - Content validation: Ensuring data integrity across transformations
- *
+ *
* Data Flow:
* 1. User creates content in editor (Remirror JSON)
* 2. Editor → Schema conversion for storage and processing
@@ -158,19 +158,19 @@ describe("EditorParseService", () => {
describe("parse()", () => {
/**
* Test: Schema to Editor Conversion - Rich Text Structure Generation
- *
+ *
* Purpose: Validates conversion from internal schema format to Remirror editor format
* Business Logic:
* - Transforms schema content structure to Remirror JSON nodes
* - Converts source references to link marks with metadata
* - Organizes content by section types (questions, summary, report, verification)
* - Maintains source attribution and link relationships
- *
+ *
* Test Data:
* - Schema with questions, summary, report (with source), verification
* - Source reference: {{uniqueId|duplicated}} → link mark with href
* - Expected Remirror JSON with proper node structure
- *
+ *
* Validates:
* - Correct Remirror document structure (doc → sections → paragraphs → text)
* - Source markup conversion to link marks
@@ -187,19 +187,19 @@ describe("EditorParseService", () => {
/**
* Test: Editor to Schema Conversion - Content Structure Extraction
- *
+ *
* Purpose: Validates conversion from Remirror editor format to internal schema
* Business Logic:
* - Extracts content from Remirror JSON nodes by section type
* - Converts link marks back to source reference markup
* - Organizes content into schema structure with source metadata
* - Preserves source relationships and attribution data
- *
+ *
* Test Data:
* - Remirror JSON with structured content sections
* - Link marks with id, href, and target attributes
* - Expected schema format with {{id|text}} source references
- *
+ *
* Validates:
* - Correct schema content extraction from editor nodes
* - Link mark conversion to source markup format
@@ -216,19 +216,19 @@ describe("EditorParseService", () => {
/**
* Test: Schema to HTML Conversion - Rendering with Citations
- *
+ *
* Purpose: Validates conversion from schema format to final HTML display format
* Business Logic:
* - Renders schema content as HTML with proper markup
* - Converts source references to interactive citation links
* - Generates superscript numbering for source citations
* - Creates accessible link structure with proper attributes
- *
+ *
* Test Data:
* - Schema content with embedded source references
* - Source metadata with href, textRange, targetText, and sup numbering
* - Expected HTML with
, , and elements
- *
+ *
* Validates:
* - Correct HTML structure generation (div containers, paragraphs)
* - Source reference conversion to citation links
@@ -244,19 +244,19 @@ describe("EditorParseService", () => {
/**
* Test: Source Position Accuracy - Citation Placement Validation
- *
+ *
* Purpose: Validates accurate positioning of source citations in HTML output
* Business Logic:
* - Ensures source citations appear at correct text positions
* - Maintains source reference integrity during HTML conversion
* - Preserves citation context and readability
* - Validates superscript numbering and link formatting
- *
+ *
* Test Data:
* - Report text: "duplicated word {{uniqueId|duplicated}}"
* - Expected HTML: "duplicated word duplicated1"
* - Source citation with proper attributes and superscript
- *
+ *
* Validates:
* - Exact text position of source citations
* - Correct HTML link generation with href and rel attributes
@@ -272,19 +272,19 @@ describe("EditorParseService", () => {
/**
* Test: Bidirectional Source Processing - Editor Link Mark Accuracy
- *
+ *
* Purpose: Validates accurate source processing in editor-to-schema conversion
* Business Logic:
* - Converts Remirror link marks back to schema markup format
* - Maintains source reference position and metadata accuracy
* - Ensures bidirectional conversion consistency
* - Preserves source attribution and text relationships
- *
+ *
* Test Data:
* - Remirror editor content with link marks containing source metadata
* - Link marks with id, href, target attributes on specific text ranges
* - Expected schema markup: "duplicated word {{uniqueId|duplicated}}"
- *
+ *
* Validates:
* - Accurate conversion from link marks to schema markup
* - Source position preservation in text content
@@ -340,12 +340,14 @@ describe("EditorParseService", () => {
};
const expectedSchemaWithLineBreaks = {
- summary: "First paragraph with important information.\nSecond paragraph after line break.\nThird paragraph with conclusion.",
+ summary:
+ "First paragraph with important information.\nSecond paragraph after line break.\nThird paragraph with conclusion.",
sources: [],
};
const expectedHtmlWithLineBreaks = {
- summary: " First paragraph with important information. Second paragraph after line break. Third paragraph with conclusion. First paragraph with important information. Second paragraph after line break. Third paragraph with conclusion. elements in HTML output for semantic HTML", async () => {
const schemaWithLineBreaks = {
- summary: "First paragraph with important information.\nSecond paragraph after line break.\nThird paragraph with conclusion.",
+ summary:
+ "First paragraph with important information.\nSecond paragraph after line break.\nThird paragraph with conclusion.",
sources: [],
};
@@ -367,14 +372,17 @@ describe("EditorParseService", () => {
schemaWithLineBreaks
);
- expect(htmlResult.summary).toEqual(expectedHtmlWithLineBreaks.summary);
- expect(htmlResult.summary.includes(' ')).toBe(true);
- expect(htmlResult.summary.split(' ').length - 1).toBe(3); // Should have 3 paragraphs
+ expect(htmlResult.summary).toEqual(
+ expectedHtmlWithLineBreaks.summary
+ );
+ expect(htmlResult.summary.includes(" ")).toBe(true);
+ expect(htmlResult.summary.split(" ").length - 1).toBe(3); // Should have 3 paragraphs
});
it("Should convert schema with line breaks back to multiple paragraphs in editor", async () => {
const schemaWithLineBreaks = {
- summary: "First paragraph with important information.\nSecond paragraph after line break.\nThird paragraph with conclusion.",
+ summary:
+ "First paragraph with important information.\nSecond paragraph after line break.\nThird paragraph with conclusion.",
sources: [],
};
@@ -385,16 +393,23 @@ describe("EditorParseService", () => {
expect(editorResult.content).toHaveLength(1);
expect(editorResult.content[0].type).toBe("summary");
expect(editorResult.content[0].content).toHaveLength(3); // Should have 3 paragraphs
-
+
// Verify each paragraph content
- expect(editorResult.content[0].content[0].content[0].text).toBe("First paragraph with important information.");
- expect(editorResult.content[0].content[1].content[0].text).toBe("Second paragraph after line break.");
- expect(editorResult.content[0].content[2].content[0].text).toBe("Third paragraph with conclusion.");
+ expect(editorResult.content[0].content[0].content[0].text).toBe(
+ "First paragraph with important information."
+ );
+ expect(editorResult.content[0].content[1].content[0].text).toBe(
+ "Second paragraph after line break."
+ );
+ expect(editorResult.content[0].content[2].content[0].text).toBe(
+ "Third paragraph with conclusion."
+ );
});
it("Should handle empty paragraphs (multiple consecutive line breaks)", async () => {
const schemaWithEmptyLines = {
- summary: "First paragraph.\n\nThird paragraph after empty line.",
+ summary:
+ "First paragraph.\n\nThird paragraph after empty line.",
sources: [],
};
@@ -403,9 +418,13 @@ describe("EditorParseService", () => {
);
expect(editorResult.content[0].content).toHaveLength(3);
- expect(editorResult.content[0].content[0].content[0].text).toBe("First paragraph.");
+ expect(editorResult.content[0].content[0].content[0].text).toBe(
+ "First paragraph."
+ );
expect(editorResult.content[0].content[1].content).toHaveLength(0); // Empty paragraph
- expect(editorResult.content[0].content[2].content[0].text).toBe("Third paragraph after empty line.");
+ expect(editorResult.content[0].content[2].content[0].text).toBe(
+ "Third paragraph after empty line."
+ );
});
it("Should maintain backward compatibility with single paragraph content", async () => {
@@ -417,9 +436,11 @@ describe("EditorParseService", () => {
const editorResult = await editorParseService.schema2editor(
singleParagraphSchema
);
-
+
expect(editorResult.content[0].content).toHaveLength(1);
- expect(editorResult.content[0].content[0].content[0].text).toBe("Single paragraph without line breaks.");
+ expect(editorResult.content[0].content[0].content[0].text).toBe(
+ "Single paragraph without line breaks."
+ );
});
it("Should preserve line breaks with sources (marked content)", async () => {
@@ -471,8 +492,10 @@ describe("EditorParseService", () => {
multiParagraphWithSource
);
- expect(schemaResult.report).toContain('\n');
- expect(schemaResult.report).toEqual("First paragraph with {{sourceId|source}}\nSecond paragraph continues here.");
+ expect(schemaResult.report).toContain("\n");
+ expect(schemaResult.report).toEqual(
+ "First paragraph with {{sourceId|source}}\nSecond paragraph continues here."
+ );
});
});
@@ -550,11 +573,13 @@ describe("EditorParseService", () => {
);
// Verify content structure
- expect(schemaResult.report).toEqual("First paragraph with {{source1|first source}} and more text.\nSecond paragraph has {{source2|second source}} here.");
-
+ expect(schemaResult.report).toEqual(
+ "First paragraph with {{source1|first source}} and more text.\nSecond paragraph has {{source2|second source}} here."
+ );
+
// Verify both sources are captured
expect(schemaResult.sources).toHaveLength(2);
-
+
// Verify first source
expect(schemaResult.sources[0]).toMatchObject({
href: "https://source1.com",
@@ -562,9 +587,9 @@ describe("EditorParseService", () => {
field: "report",
targetText: "first source",
id: "source1",
- })
+ }),
});
-
+
// Verify second source
expect(schemaResult.sources[1]).toMatchObject({
href: "https://source2.com",
@@ -572,7 +597,7 @@ describe("EditorParseService", () => {
field: "report",
targetText: "second source",
id: "source2",
- })
+ }),
});
});
@@ -586,8 +611,10 @@ describe("EditorParseService", () => {
schemaWithLineBreaks
);
- expect(htmlResult.report).toContain(' ');
- expect(htmlResult.report).toBe(' First paragraph with some text. Second paragraph with more text. ");
+ expect(htmlResult.report).toBe(
+ " First paragraph with some text. Second paragraph with more text. First paragraph with content. First paragraph with content. ) → schema2editor → removeTrailingParagraph → frontend
+ */
+
+ it("Should produce clean editor content from schema round-trip", async () => {
+ const schema: ReviewTaskMachineContextReviewData = {
+ sources: [],
+ questions: ["What is the claim?"],
+ summary: "Summary of the report",
+ report: "Report content here",
+ verification: "Verification details",
+ };
+
+ const editorResult = await editorParseService.schema2editor(schema);
+ const cleaned =
+ editorParseService.removeTrailingParagraph(editorResult);
+
+ cleaned.content.forEach((node) => {
+ expect([
+ "questions",
+ "summary",
+ "report",
+ "verification",
+ ]).toContain(node.type);
+ });
+ });
+
+ it("Should preserve content integrity after trailing paragraph removal", async () => {
+ const schema: ReviewTaskMachineContextReviewData = {
+ sources: [],
+ questions: ["Question A", "Question B"],
+ summary: "A summary",
+ report: "Report content here",
+ verification: "Verified",
+ };
+
+ const editorResult = await editorParseService.schema2editor(schema);
+ const cleaned =
+ editorParseService.removeTrailingParagraph(editorResult);
+ const roundTripSchema = await editorParseService.editor2schema(
+ cleaned
+ );
+
+ expect(roundTripSchema.questions).toEqual(schema.questions);
+ expect(roundTripSchema.summary).toEqual(schema.summary);
+ expect(roundTripSchema.verification).toEqual(schema.verification);
+ expect(roundTripSchema.report).toEqual(schema.report);
});
});
});
diff --git a/server/review-task/review-task.service.ts b/server/review-task/review-task.service.ts
index b6825d3d4..da3cc699f 100644
--- a/server/review-task/review-task.service.ts
+++ b/server/review-task/review-task.service.ts
@@ -757,12 +757,13 @@ export class ReviewTaskService {
}
}
- getEditorContentObject(schema, reportModel, reviewTaskType) {
- return this.editorParseService.schema2editor(
+ async getEditorContentObject(schema, reportModel, reviewTaskType) {
+ const editorContent = await this.editorParseService.schema2editor(
schema,
reportModel,
reviewTaskType
);
+ return this.editorParseService.removeTrailingParagraph(editorContent);
}
async addComment(data_hash, comment) {
diff --git a/src/components/Collaborative/VisualEditorProvider.tsx b/src/components/Collaborative/VisualEditorProvider.tsx
index 70fdea2c1..c9b3d383e 100644
--- a/src/components/Collaborative/VisualEditorProvider.tsx
+++ b/src/components/Collaborative/VisualEditorProvider.tsx
@@ -13,7 +13,6 @@ import { RemirrorContentType } from "remirror";
import { SourceType } from "../../types/Source";
import { ReviewTaskMachineContext } from "../../machines/reviewTask/ReviewTaskMachineProvider";
import { EditorConfig } from "./utils/getEditorConfig";
-import { removeTrailingParagraph } from "./utils/removeTrailingParagraph";
import {
crossCheckingSelector,
addCommentCrossCheckingSelector,
@@ -89,7 +88,7 @@ export const VisualEditorProvider = (props: VisualEditorProviderProps) => {
if (reportModel) {
fetchEditorContentObject(props.data_hash).then((content) => {
- setEditorContentObject(removeTrailingParagraph(content));
+ setEditorContentObject(content);
setIsFetchingEditor(false);
});
}
diff --git a/src/components/Collaborative/utils/removeTrailingParagraph.ts b/src/components/Collaborative/utils/removeTrailingParagraph.ts
deleted file mode 100644
index c3190a0e6..000000000
--- a/src/components/Collaborative/utils/removeTrailingParagraph.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { RemirrorJSON } from "remirror";
-
-/**
- * Removes the last paragraph from a Remirror document if it is of type "paragraph"
- * @param doc - Remirror document in JSON format
- * @returns Document without the last paragraph, if it exists
- */
-export function removeTrailingParagraph(
- doc: RemirrorJSON | null | undefined
-): RemirrorJSON | null | undefined {
- if (!doc || !Array.isArray(doc.content)) return doc;
-
- const items = [...doc.content];
- const last = items[items.length - 1];
-
- if (last?.type === "paragraph") {
- items.pop();
- }
-
- return { ...doc, content: items };
-}
diff --git a/src/machines/reviewTask/actions.ts b/src/machines/reviewTask/actions.ts
index 4056f0a5a..0a51e6c59 100644
--- a/src/machines/reviewTask/actions.ts
+++ b/src/machines/reviewTask/actions.ts
@@ -18,9 +18,13 @@ const saveContext = assign
tags for content with empty lines (visual spacing)", async () => {
@@ -600,13 +627,16 @@ describe("EditorParseService", () => {
schemaWithEmptyLines
);
- expect(htmlResult.report).toContain('
');
- expect(htmlResult.report).toBe('
Third paragraph after empty line.
");
+ expect(htmlResult.report).toBe(
+ "
Third paragraph after empty line.