{/* Split the data into two columns: non-media fields and media fields*/}
diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx
index 347011a..ef1bd1f 100644
--- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx
+++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx
@@ -14,6 +14,7 @@ import ReactAudioPlayer from 'react-audio-player';
import { Link } from 'react-router-dom';
import ComponentLoading from '../../../../../components/common/ComponentLoading';
+import CollapsibleText from '@/webpages/dashboard/mrt/manual_review_job/v2/components/CollapsibleText';
import {
GQLContentItem,
@@ -190,12 +191,23 @@ function TableRowComponent(props: {
);
}
+ case 'STRING': {
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+ );
+ }
case 'BOOLEAN':
case 'GEOHASH':
case 'ID':
case 'NUMBER':
case 'POLICY_ID':
- case 'STRING':
case 'EMAIL_ADDRESS': {
// EMAIL_ADDRESS renders as plain text for now; a follow-up could make
// it a mailto/pivot link the way IP_ADDRESS pivots on the IP.
@@ -528,7 +540,7 @@ function FieldComponent(props: {
case 'EMAIL_ADDRESS':
case 'DATETIME':
return (
-
,
+ );
+ expect(clampedDiv.style.webkitLineClamp).toBe('3');
+ });
+
+ it('does not collapse text at exactly maxGraphemes', () => {
+ render();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('resets to collapsed when the text prop changes', () => {
+ // Simulates navigating between review jobs that reuse the same field name
+ // (React reuses the CollapsibleText instance). Expanding one long value,
+ //then rendering a different long value, must restore the collapsed state.
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /read more/i }));
+ expect(
+ screen.getByRole('button', { name: /read less/i }),
+ ).toBeInTheDocument();
+
+ rerender();
+ expect(
+ screen.getByRole('button', { name: /read more/i }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/components/CollapsibleText.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/components/CollapsibleText.tsx
new file mode 100644
index 0000000..4046528
--- /dev/null
+++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/components/CollapsibleText.tsx
@@ -0,0 +1,89 @@
+import { memo, useEffect, useMemo, useState } from 'react';
+
+type CollapsibleTextProps = {
+ text: string;
+ maxLines?: number;
+ maxGraphemes?: number;
+};
+
+/**
+ * Count graphemes using Intl.Segmenter (handles all scripts correctly),
+ * stopping early once the count is known to exceed `threshold`. This avoids
+ * segmenting an entire pathological paste (e.g. a 1MB string) when we only
+ * need to know whether it crosses the collapse threshold.
+ */
+function exceedsGraphemeThreshold(text: string, threshold: number): boolean {
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
+ let count = 0;
+ for (const _ of segmenter.segment(text)) {
+ count += 1;
+ if (count > threshold) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Renders text with wrapping. When the text exceeds `maxGraphemes`, it is
+ * collapsed to `maxLines` visible lines with a "Read more" / "Read less"
+ * toggle. Short text renders in full with no toggle.
+ *
+ * Memoized: props are primitives (string, number), so React.memo's shallow
+ * comparison prevents re-renders (and re-segmentation) when a parent re-renders
+ * but the text hasn't changed — important for thread histories with many copies
+ * of the same message.
+ */
+function CollapsibleTextImpl({
+ text,
+ maxLines = 12,
+ maxGraphemes = 2000,
+}: CollapsibleTextProps) {
+ const isCollapsible = useMemo(
+ () => exceedsGraphemeThreshold(text, maxGraphemes),
+ [text, maxGraphemes],
+ );
+ const [expanded, setExpanded] = useState(false);
+
+ // When the text prop changes (e.g. navigating between review jobs that reuse
+ // the same field name → React reuses this component instance), reset to the
+ // collapsed state so newly loaded long content doesn't inherit the previous
+ // job's expanded view.
+ useEffect(() => {
+ setExpanded(false);
+ }, [text]);
+
+ if (!isCollapsible) {
+ return (
+