Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6425a6a
chore(deps): install slate and rehype-raw
Alejandro-Vega Jun 1, 2026
13392f9
add custom editor types and utilities for list formatting
Alejandro-Vega Jun 1, 2026
86b5c56
feat: create editor toolbar
Alejandro-Vega Jun 1, 2026
0be3e48
add keyboard handlers and utils
Alejandro-Vega Jun 1, 2026
a8cdc42
add markdown handlers and utils
Alejandro-Vega Jun 1, 2026
72b705b
add: EditorLeaf to render Slate leaf nodes
Alejandro-Vega Jun 1, 2026
a85562f
add: EditorElement for for rendering element types
Alejandro-Vega Jun 1, 2026
c7b9ae1
added hook for handling editor keydown
Alejandro-Vega Jun 1, 2026
f0f2bd3
add: document utilities for Slate editor and update markdown parser
Alejandro-Vega Jun 1, 2026
d7f3536
fix: documentation wording
Alejandro-Vega Jun 1, 2026
ed6e0bf
add: useRichTextEditor hook for Slate editor handling
Alejandro-Vega Jun 1, 2026
773ffa1
add: RichTextEditor component entry
Alejandro-Vega Jun 1, 2026
bf58a3f
created rich text viewer component
Alejandro-Vega Jun 1, 2026
9fe4bfe
update review form dialog to include rich text editor
Alejandro-Vega Jun 1, 2026
61187e0
update review comments dialog to include rich text viewer
Alejandro-Vega Jun 1, 2026
66fb3ff
fix: incorrect import path
Alejandro-Vega Jun 1, 2026
b9f879d
Merge branch '3.7.0' of https://github.com/CBIIT/crdc-datahub-codebas…
Alejandro-Vega Jun 1, 2026
80a60ee
add storybook support for rich text editor and viewer
Alejandro-Vega Jun 4, 2026
29ed575
refactor: toolbar components and add test coverage
Alejandro-Vega Jun 8, 2026
6b76941
fix: whitespace causing issues when formatting
Alejandro-Vega Jun 8, 2026
dcbb6a0
refactor: lazy load component and add test coverage for rich text editor
Alejandro-Vega Jun 8, 2026
2a60235
refactor: clean up component and add test coverage to EditorLeaf
Alejandro-Vega Jun 8, 2026
d56010e
refactor: clean up EditorElement component and add test coverage
Alejandro-Vega Jun 8, 2026
549701d
refactor: document utils and add test coverage
Alejandro-Vega Jun 8, 2026
d0d14fb
add test coverage for useRichTextEditor hook
Alejandro-Vega Jun 8, 2026
7f995d7
refactor: cleanup documentation for utils
Alejandro-Vega Jun 8, 2026
42eec14
add test coverage for editor transformation utils
Alejandro-Vega Jun 8, 2026
603d7fe
refactor: cleanup docs for editor guards and add test coverage
Alejandro-Vega Jun 8, 2026
0e4c24e
refactor: move functions to relevant utils file and add test coverage
Alejandro-Vega Jun 8, 2026
8a361b2
refactor: centralize mark definitions
Alejandro-Vega Jun 8, 2026
56d0a5f
refactor: combine util files, update docs, and add test coverage for …
Alejandro-Vega Jun 8, 2026
d85156a
add pattern to config and adjust type definition
Alejandro-Vega Jun 8, 2026
b954614
refactor: update documentation and add test coverage for keyboard hot…
Alejandro-Vega Jun 10, 2026
d4cfde4
refactor: keyboard list handlers and add test coverage
Alejandro-Vega Jun 10, 2026
d9e5ffc
refactor: add documentation to keyboard selection utils and test cove…
Alejandro-Vega Jun 10, 2026
54fe78e
refactor: delete extra file and move keydown logic to hook
Alejandro-Vega Jun 10, 2026
d63a5dc
refactor: markdown inline parser utils and add test coverage
Alejandro-Vega Jun 10, 2026
daeaa89
refactor: markdown deserializer and add test coverage
Alejandro-Vega Jun 10, 2026
31f1347
fix: use editor config instead of repeating patterns
Alejandro-Vega Jun 10, 2026
52d6603
fix: delete line when backspacing and return previous list item
Alejandro-Vega Jun 10, 2026
5019514
feat: add ability to enable/disable toolbar actions
Alejandro-Vega Jun 10, 2026
b707028
fix: border styling to be consistent with other fields
Alejandro-Vega Jun 10, 2026
d27cd2e
fix: failing tests due to mock
Alejandro-Vega Jun 10, 2026
d434f9d
fix: new lines not being preserved in rich text viewer
Alejandro-Vega Jun 10, 2026
b66d6ca
fix: clearing all in editor resulting in invalid cursor state
Alejandro-Vega Jun 10, 2026
e866a49
Merge branch '3.7.0' into CRDCDH-3760
Alejandro-Vega Jun 10, 2026
07cf640
fix: type error
Alejandro-Vega Jun 10, 2026
8eaee7c
Merge branch 'CRDCDH-3760' of https://github.com/CBIIT/crdc-datahub-c…
Alejandro-Vega Jun 10, 2026
f59cffd
refactor: general cleanup and reduce regex usage
Alejandro-Vega Jun 11, 2026
e226be1
add basic test coverage for rich text viewer
Alejandro-Vega Jun 11, 2026
29ec4ab
update rich text viewer story
Alejandro-Vega Jun 11, 2026
7142fdf
fix: storybook titles
Alejandro-Vega Jun 11, 2026
c7bf022
fix: remove export causing bundle size increase
Alejandro-Vega Jun 11, 2026
37cea9f
fix: lazy load pdf generation lib
Alejandro-Vega Jun 11, 2026
0c2ac62
fix: optimize bundle to avoid loading unecessary deps
Alejandro-Vega Jun 11, 2026
f34db61
fix: strip attributes from components in rich text viewer for security
Alejandro-Vega Jun 11, 2026
68bc482
fix: incorrect text length calculation
Alejandro-Vega Jun 11, 2026
42213a6
revert: lazy load causing failing tests
Alejandro-Vega Jun 11, 2026
1097c00
Merge branch '3.7.0' into CRDCDH-3760
Alejandro-Vega Jun 11, 2026
826e8da
fix: hide placeholder when list item and update background color
Alejandro-Vega Jun 11, 2026
3a9ea21
fix: new lines in between causing incorrect character count
Alejandro-Vega Jun 11, 2026
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
326 changes: 310 additions & 16 deletions apps/frontend/package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@
"react-transition-group": "^4.4.5",
"recharts": "^2.12.0",
"redux": "^4.2.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"slate": "^0.124.1",
"slate-history": "^0.113.1",
"slate-react": "^0.124.2",
"uuid": "^11.1.0",
"vite": "^6.3.5",
"vite-plugin-svgr": "^4.3.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,42 @@ import { render, waitFor, within } from "../../test-utils";

import ReviewFormDialog from "./ReviewFormDialog";

vi.mock("../RichTextEditor", () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
const { forwardRef } = require("react");
return {
default: forwardRef(
({
value,
onChange,
onTextLengthChange,
"data-testid": dataTestId,
placeholder,
disabled,
}: {
value: string;
onChange: (v: string) => void;
onTextLengthChange?: (n: number) => void;
"data-testid"?: string;
placeholder?: string;
disabled?: boolean;
}) => (
<div data-testid={dataTestId}>
<textarea
placeholder={placeholder}
disabled={disabled}
value={value}
onChange={(e) => {
onChange(e.target.value);
onTextLengthChange?.(e.target.value.length);
}}
/>
</div>
)
),
};
});

beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -129,7 +165,7 @@ describe("Implementation Requirements", () => {
expect(getByTestId("review-form-dialog-confirm-button")).not.toBeDisabled();
});

it("should not allow typing more than 10,000 characters in the review comment input field", async () => {
it("should show a validation error when the review comment exceeds 10,000 characters", async () => {
const mockOnSubmit = vi.fn();

const { getByTestId } = render(
Expand All @@ -139,15 +175,13 @@ describe("Implementation Requirements", () => {
const input = within(getByTestId("review-comment")).getByRole("textbox");
userEvent.paste(input, "X".repeat(10_050));

expect(input).toHaveValue("X".repeat(10_000));

userEvent.click(getByTestId("review-form-dialog-confirm-button"));

await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
expect(getByTestId("review-comment-dialog-error")).toBeInTheDocument();
});

expect(mockOnSubmit).toHaveBeenCalledWith(expect.stringMatching(/^X{10000}$/));
expect(mockOnSubmit).not.toHaveBeenCalled();
});

it("should display a character counter that updates as the user types", () => {
Expand Down
71 changes: 27 additions & 44 deletions apps/frontend/src/components/Questionnaire/ReviewFormDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,14 @@
import { LoadingButton } from "@mui/lab";
import { Box, Button, ButtonProps, DialogProps, Typography, styled } from "@mui/material";
import { isEqual } from "lodash";
import { FC, ReactNode, memo, useMemo } from "react";
import { FC, ReactNode, memo, useCallback, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";

import Dialog from "../GenericDialog";
import RichTextEditor from "../RichTextEditor";
import type { RichTextEditorHandle } from "../RichTextEditor";
import { getPlainTextLength } from "../RichTextEditor/utils/markdown/markdownSerializer";
import StyledHelperText from "../StyledFormComponents/StyledHelperText";
import BaseOutlinedInput from "../StyledFormComponents/StyledOutlinedInput";

const StyledOutlinedInput = styled(BaseOutlinedInput)({
marginTop: "24px",
width: "fit-content",
maxWidth: "100%",
"&.MuiInputBase-multiline": {
padding: "12px",
alignItems: "flex-start",
},
"& textarea.MuiInputBase-inputMultiline": {
resize: "both",
overflow: "auto !important",
padding: 0,
lineHeight: "25px",
width: "min(600px, calc(100vw - 150px))",
minWidth: "min(750px, calc(100vw - 150px))",
maxWidth: "min(1440px, 80vw)",
height: "min(375px, calc(100vh - 340px))",
minHeight: "clamp(100px, calc(100vh - 340px), 375px)",
maxHeight: "min(500px, calc(100vh - 340px))",
boxSizing: "border-box",
},
});

const StyledCharacterCount = styled(Box)({
display: "flex",
Expand Down Expand Up @@ -96,7 +75,6 @@ const ReviewFormDialog: FC<Props> = ({
}) => {
const {
handleSubmit,
watch,
control,
reset,
formState: { errors },
Expand All @@ -108,11 +86,13 @@ const ReviewFormDialog: FC<Props> = ({
},
});

const reviewComment = watch("reviewComment");
const [plainTextLength, setPlainTextLength] = useState(0);

const editorRef = useRef<RichTextEditorHandle>(null);

const reviewCommentLengthLabel = useMemo(
() =>
Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(reviewComment?.length || 0),
[reviewComment]
() => Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(plainTextLength),
[plainTextLength]
);
const reviewCommentLimitLabel = Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
Expand All @@ -126,11 +106,17 @@ const ReviewFormDialog: FC<Props> = ({
onCancel?.();
};

const handleExited = useCallback(() => {
reset();
setPlainTextLength(0);
editorRef.current?.reset();
}, [reset]);

return (
<StyledDialog
open={open}
onClose={handleOnCancel}
TransitionProps={{ onExited: () => reset() }}
TransitionProps={{ onExited: handleExited }}
title={header}
scroll="body"
actions={
Expand All @@ -145,7 +131,7 @@ const ReviewFormDialog: FC<Props> = ({
<LoadingButton
data-testid="review-form-dialog-confirm-button"
onClick={handleSubmit(handleOnSubmit)}
disabled={!reviewComment?.trim()?.length || loading}
disabled={!plainTextLength || loading}
loading={loading}
{...confirmButtonProps}
>
Expand All @@ -160,25 +146,22 @@ const ReviewFormDialog: FC<Props> = ({
control={control}
rules={{
validate: {
required: (v: string) => v.trim() !== "" || "This field is required",
required: (v: string) => getPlainTextLength(v) > 0 || "This field is required",
maxLength: (v: string) =>
v.trim().length <= MAX_REVIEW_COMMENT_LIMIT ||
getPlainTextLength(v) <= MAX_REVIEW_COMMENT_LIMIT ||
`Maximum of ${reviewCommentLimitLabel} characters allowed`,
},
}}
render={({ field }) => (
<StyledOutlinedInput
{...field}
inputProps={{
maxLength: MAX_REVIEW_COMMENT_LIMIT,
"aria-label": "Review comment input",
}}
name="reviewComment"
<RichTextEditor
ref={editorRef}
value={field.value}
onChange={field.onChange}
onTextLengthChange={setPlainTextLength}
placeholder={`${reviewCommentLimitLabel} characters allowed`}
Comment thread
Alejandro-Vega marked this conversation as resolved.
disabled={loading}
aria-label="Review comment input"
data-testid="review-comment"
sx={{ paddingY: "16px" }}
required
multiline
/>
)}
/>
Expand Down
17 changes: 15 additions & 2 deletions apps/frontend/src/components/ReviewCommentsDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { CSSProperties } from "react";

import CloseIconSvg from "../../assets/icons/close_icon.svg?react";
import { FormatDate } from "../../utils";
import RichTextViewer from "../RichTextViewer";

const StyledDialog = styled(Dialog, {
shouldForwardProp: (prop) => prop !== "status" && prop !== "getColorScheme",
Expand Down Expand Up @@ -72,8 +73,18 @@ const StyledDialogContent = styled(DialogContent)({
marginBottom: "11px",
minHeight: "44px",
overflowX: "hidden",
whiteSpace: "pre-line",
overflowWrap: "break-word",
"& p": {
margin: "0 0 4px 0",
"&:last-child": { marginBottom: 0 },
},
"& ul, & ol": {
margin: "0 0 4px 0",
paddingLeft: "24px",
},
"& li": {
lineHeight: "1.6",
},
});

const StyledSubTitle = styled("p")({
Expand Down Expand Up @@ -177,7 +188,9 @@ const ReviewCommentsDialog = <T, H>({
{`Based on submission from ${FormatDate(lastReview?.dateTime, "M/D/YYYY", "N/A")}:`}
</StyledSubTitle>
</StyledDialogTitle>
<StyledDialogContent>{lastReview?.reviewComment}</StyledDialogContent>
<StyledDialogContent>
<RichTextViewer content={lastReview?.reviewComment ?? ""} />
</StyledDialogContent>
<DialogActions>
<StyledCloseButton
id="close-review-comments-button"
Expand Down
63 changes: 63 additions & 0 deletions apps/frontend/src/components/RichTextEditor/Controller.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createEditor } from "slate";
import { withHistory } from "slate-history";
import { withReact } from "slate-react";

import { render } from "../../test-utils";

import Controller from "./Controller";

const mockReset = vi.fn();

vi.mock("./hooks/useRichTextEditor", () => ({
useRichTextEditor: () => ({
editor: withHistory(withReact(createEditor())),
initialValue: [{ type: "paragraph", children: [{ text: "" }] }],
handleChange: vi.fn(),
handleKeyDown: vi.fn(),
renderElement: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
renderLeaf: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
reset: mockReset,
}),
}));

vi.mock("./Toolbar", () => ({
default: () => <div data-testid="toolbar" />,
}));

describe("RichTextEditor Controller", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should render without crashing", () => {
expect(() => render(<Controller value="" onChange={vi.fn()} />)).not.toThrow();
});

it("should render the toolbar when not disabled", () => {
const { getByTestId } = render(<Controller value="" onChange={vi.fn()} />);

expect(getByTestId("toolbar")).toBeInTheDocument();
});

it("should not render the toolbar when disabled", () => {
const { queryByTestId } = render(<Controller value="" onChange={vi.fn()} disabled />);

expect(queryByTestId("toolbar")).not.toBeInTheDocument();
});

it("should apply aria-label to the editable area", () => {
const { getByRole } = render(
<Controller value="" onChange={vi.fn()} aria-label="Comment input" />
);

expect(getByRole("textbox", { name: "Comment input" })).toBeInTheDocument();
});

it("should set the editable to read-only when disabled", () => {
const { getByLabelText } = render(
<Controller value="" onChange={vi.fn()} disabled aria-label="Comment input" />
);

expect(getByLabelText("Comment input")).toHaveAttribute("contenteditable", "false");
});
});
Loading
Loading