Skip to content

Commit 2ac86cf

Browse files
authored
feat: Add truncation to help text (#2637)
* Add truncation to help text * f * f * f
1 parent e9fc6a3 commit 2ac86cf

File tree

4 files changed

+254
-4
lines changed

4 files changed

+254
-4
lines changed

web/src/components/common/HelpText.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import ReactMarkdown from 'react-markdown';
33
import remarkGfm from 'remark-gfm';
4+
import { truncate } from '../../utils/textUtils';
45

56
interface HelpTextProps {
67
dataTestId?: string;
@@ -10,19 +11,31 @@ interface HelpTextProps {
1011
}
1112

1213
const HelpText: React.FC<HelpTextProps> = ({ dataTestId, helpText, defaultValue, error }) => {
14+
const [showFullText, setShowFullText] = useState(false);
15+
const maxTextLength = 80;
16+
17+
// The truncation threshold prevents cutting off text that's only slightly over the max length as
18+
// it would be preferable to display the full text than show/hide a small number of characters.
19+
const truncationThreshold = 40;
20+
1321
if ((!helpText && !defaultValue) || error) return null;
1422

15-
// Build the combined text with markdown formatting
23+
// Build the combined text
1624
let combinedText = helpText || '';
1725
if (defaultValue) {
1826
const defaultText = `(Default: \`${defaultValue}\`)`;
1927
combinedText = helpText ? `${helpText} ${defaultText}` : defaultText;
2028
}
2129

30+
const exceedsMaxLength = combinedText.length > maxTextLength;
31+
const withinThreshold = (combinedText.length - maxTextLength) <= truncationThreshold;
32+
const shouldTruncate = exceedsMaxLength && !withinThreshold;
33+
2234
return (
2335
<div data-testid={dataTestId ? `help-text-${dataTestId}` : "help-text"} className="mt-1 text-sm text-gray-500 [&_p]:inline [&_p]:mb-0">
2436
<ReactMarkdown
2537
remarkPlugins={[remarkGfm]}
38+
rehypePlugins={shouldTruncate && !showFullText ? [[truncate, maxTextLength]] : []}
2639
components={{
2740
a: ({ ...props }) => (
2841
<a
@@ -41,6 +54,15 @@ const HelpText: React.FC<HelpTextProps> = ({ dataTestId, helpText, defaultValue,
4154
>
4255
{combinedText}
4356
</ReactMarkdown>
57+
{shouldTruncate && (
58+
<button
59+
onClick={() => setShowFullText(!showFullText)}
60+
className="ml-1 text-blue-600 hover:text-blue-800 text-xs cursor-pointer"
61+
type="button"
62+
>
63+
{showFullText ? 'Show less' : 'Show more'}
64+
</button>
65+
)}
4466
</div>
4567
);
4668
};

web/src/components/common/Textarea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({
5555
required={required}
5656
className={`w-full px-3 py-2 border ${
5757
error ? 'border-red-500' : 'border-gray-300'
58-
} rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 ${
58+
} rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 min-h-[2.625rem] ${
5959
disabled ? 'bg-gray-100 text-gray-500' : 'bg-white'
6060
}`}
6161
style={{

web/src/components/common/tests/HelpText.test.tsx

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { render, screen } from '@testing-library/react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
33
import { describe, it, expect } from 'vitest';
44
import HelpText from '../HelpText';
55

@@ -78,4 +78,170 @@ describe('HelpText', () => {
7878
const helpDiv = container.querySelector('div');
7979
expect(helpDiv).toHaveClass('[&_p]:inline', '[&_p]:mb-0');
8080
});
81+
82+
// Truncation functionality tests
83+
describe('Text truncation', () => {
84+
it('does not truncate short text', () => {
85+
const shortText = 'This is a short help text';
86+
render(<HelpText helpText={shortText} />);
87+
88+
expect(screen.getByText(shortText)).toBeInTheDocument();
89+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
90+
});
91+
92+
it('truncates long text and shows "Show more" button', () => {
93+
const longText = 'This is a very long help text that exceeds the maximum length of 80 characters and should be truncated with an ellipsis and show more button because it is longer than the threshold';
94+
render(<HelpText helpText={longText} />);
95+
96+
// Should show truncated text with ellipsis
97+
expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
98+
// Should show "Show more" button
99+
expect(screen.getByText('Show more')).toBeInTheDocument();
100+
// Should not show the full text initially
101+
expect(screen.queryByText(longText)).not.toBeInTheDocument();
102+
});
103+
104+
it('expands text when "Show more" is clicked', () => {
105+
const longText = 'This is a very long help text that exceeds the maximum length of 80 characters and should be truncated with an ellipsis and show more button because it is longer than the threshold';
106+
render(<HelpText helpText={longText} />);
107+
108+
const showMoreButton = screen.getByText('Show more');
109+
fireEvent.click(showMoreButton);
110+
111+
// Should show full text after clicking
112+
expect(screen.getByText(longText)).toBeInTheDocument();
113+
// Button should change to "Show less"
114+
expect(screen.getByText('Show less')).toBeInTheDocument();
115+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
116+
});
117+
118+
it('collapses text when "Show less" is clicked', () => {
119+
const longText = 'This is a very long help text that exceeds the maximum length of 80 characters and should be truncated with an ellipsis and show more button because it is longer than the threshold';
120+
render(<HelpText helpText={longText} />);
121+
122+
const showMoreButton = screen.getByText('Show more');
123+
fireEvent.click(showMoreButton);
124+
125+
const showLessButton = screen.getByText('Show less');
126+
fireEvent.click(showLessButton);
127+
128+
// Should show truncated text again
129+
expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
130+
expect(screen.getByText('Show more')).toBeInTheDocument();
131+
expect(screen.queryByText('Show less')).not.toBeInTheDocument();
132+
});
133+
134+
it('truncates combined text with default value when total length exceeds limit', () => {
135+
const longHelpText = 'This is a moderately long help text that when combined with default value it exceeds the limit';
136+
const defaultValue = 'very-long-default-value-that-makes-total-exceed-limit';
137+
138+
render(<HelpText helpText={longHelpText} defaultValue={defaultValue} />);
139+
140+
// Should show "Show more" button when combined text is too long
141+
expect(screen.getByText('Show more')).toBeInTheDocument();
142+
expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
143+
});
144+
145+
it('does not truncate when only default value is present and under limit', () => {
146+
const shortDefaultValue = 'short-default';
147+
render(<HelpText defaultValue={shortDefaultValue} />);
148+
149+
expect(screen.getByText(shortDefaultValue)).toBeInTheDocument();
150+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
151+
});
152+
153+
it('truncates when only default value is present and exceeds limit', () => {
154+
const longDefaultValue = 'this-is-a-very-long-default-value-that-exceeds-the-maximum-character-limit-of-80-characters-and-the-threshold-so-it-should-be-truncated';
155+
render(<HelpText defaultValue={longDefaultValue} />);
156+
157+
expect(screen.getByText('Show more')).toBeInTheDocument();
158+
expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
159+
});
160+
161+
it('preserves markdown formatting in truncated text', () => {
162+
const longTextWithMarkdown = 'This is a very long help text with `code formatting` and **bold text** that exceeds the maximum length of 80 characters and the threshold so it should be truncated properly';
163+
render(<HelpText helpText={longTextWithMarkdown} />);
164+
165+
expect(screen.getByText('Show more')).toBeInTheDocument();
166+
167+
// Click to expand and check markdown is preserved
168+
fireEvent.click(screen.getByText('Show more'));
169+
expect(screen.getByText('code formatting')).toHaveClass('font-mono');
170+
expect(screen.getByText('bold text')).toBeInTheDocument();
171+
});
172+
173+
it('preserves markdown formatting in truncated text with code blocks', () => {
174+
// Create text with TLS certificate that will be truncated in the middle of the certificate
175+
const longTextWithCodeBlock = 'To configure TLS, provide your certificate:\n\n```\n-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTcwODI3MjM1NzU5WhcNMTgwODI3MjM1NzU5WjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAuuB5/8GvxwqhkzCQF22XA0cGRUbKCzlJHoFGELqoZKxSV8j9C7sW7A==\n-----END CERTIFICATE-----\n```\n\nEnsure the certificate is valid.';
176+
const { container } = render(<HelpText helpText={longTextWithCodeBlock} />);
177+
178+
expect(screen.getByText('Show more')).toBeInTheDocument();
179+
180+
// Click to expand and check code block markdown is preserved
181+
fireEvent.click(screen.getByText('Show more'));
182+
183+
// Check that certificate content is present (may be in different elements)
184+
expect(screen.getByText(/BEGIN CERTIFICATE/)).toBeInTheDocument();
185+
expect(screen.getByText(/END CERTIFICATE/)).toBeInTheDocument();
186+
187+
// Check that pre/code elements exist for code block
188+
const preElements = container.querySelectorAll('pre');
189+
const codeElements = container.querySelectorAll('code');
190+
expect(preElements.length).toBeGreaterThan(0);
191+
expect(codeElements.length).toBeGreaterThan(0);
192+
});
193+
194+
it('applies correct CSS classes to show more/less button', () => {
195+
const longText = 'This is a very long help text that exceeds the maximum length of 80 characters and should be truncated with an ellipsis and show more button because it is longer than the threshold';
196+
render(<HelpText helpText={longText} />);
197+
198+
const showMoreButton = screen.getByText('Show more');
199+
expect(showMoreButton).toHaveClass('ml-1', 'text-blue-600', 'hover:text-blue-800', 'text-xs', 'cursor-pointer');
200+
expect(showMoreButton).toHaveAttribute('type', 'button');
201+
});
202+
203+
it('handles text exactly at maxTextLength', () => {
204+
// Create text that's exactly 80 characters (the maxTextLength)
205+
const exactMaxText = 'a'.repeat(80);
206+
render(<HelpText helpText={exactMaxText} />);
207+
208+
// Should not show "Show more" button at exactly maxTextLength
209+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
210+
});
211+
212+
it('handles text within the truncation threshold', () => {
213+
// Create text that's 100 characters (over maxTextLength of 80 but within threshold of 40)
214+
const withinThresholdText = 'a'.repeat(100);
215+
render(<HelpText helpText={withinThresholdText} />);
216+
217+
// Should not show "Show more" button when within threshold (80 + 40 = 120)
218+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
219+
});
220+
221+
it('handles text at the edge of truncation threshold', () => {
222+
// Create text that's exactly 120 characters (maxTextLength + threshold = 80 + 40)
223+
const atThresholdEdgeText = 'a'.repeat(120);
224+
render(<HelpText helpText={atThresholdEdgeText} />);
225+
226+
// Should not show "Show more" button when exactly at threshold edge
227+
expect(screen.queryByText('Show more')).not.toBeInTheDocument();
228+
});
229+
230+
it('handles text just over the truncation threshold', () => {
231+
// Create text that's 121 characters (over maxTextLength + threshold = 80 + 40)
232+
const justOverThresholdText = 'a'.repeat(121);
233+
render(<HelpText helpText={justOverThresholdText} />);
234+
235+
// Should show "Show more" button when over threshold limit
236+
expect(screen.getByText('Show more')).toBeInTheDocument();
237+
});
238+
239+
it('maintains proper data-testid when truncated', () => {
240+
const longText = 'This is a very long help text that exceeds the maximum length of 80 characters and the threshold so it should be truncated';
241+
const { container } = render(<HelpText helpText={longText} dataTestId="custom-test-id" />);
242+
243+
const helpDiv = container.querySelector('[data-testid="help-text-custom-test-id"]');
244+
expect(helpDiv).toBeInTheDocument();
245+
});
246+
});
81247
});

web/src/utils/textUtils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Creates a rehype plugin that truncates markdown text content to a specified maximum length.
3+
*
4+
* Features:
5+
* - Truncates text at the AST node level for proper markdown handling
6+
* - Preserves markdown structure while limiting text content
7+
* - Adds ellipsis ("...") when text is truncated
8+
* - Removes child nodes that exceed the limit to prevent partial content
9+
*
10+
* @param maxTextLength - Maximum number of characters allowed before truncation
11+
* @returns A rehype plugin function that can be used with ReactMarkdown
12+
*
13+
* @example
14+
* ```typescript
15+
* // Use with ReactMarkdown
16+
* <ReactMarkdown
17+
* rehypePlugins={[[truncate, 100]]}
18+
* >
19+
* {longText}
20+
* </ReactMarkdown>
21+
* ```
22+
*/
23+
24+
interface ASTNode {
25+
type: string;
26+
value?: string;
27+
children?: ASTNode[];
28+
length?: number;
29+
}
30+
31+
export function truncate(maxTextLength: number): (tree: ASTNode) => void {
32+
const truncateNode = (node: ASTNode, textLength: number): number => {
33+
if (node.type === "text") {
34+
const newLength = textLength + (node.value?.length || 0);
35+
if (newLength > maxTextLength) {
36+
const excess = newLength - maxTextLength;
37+
if (node.value) {
38+
node.value = node.value.slice(0, -excess) + '...';
39+
}
40+
return maxTextLength;
41+
}
42+
return newLength;
43+
}
44+
45+
if ((node.type === "root" || node.type === "element") && node.children) {
46+
const children = node.children;
47+
for (let i = 0; i < children.length; i++) {
48+
if (textLength >= maxTextLength) {
49+
children.length = i;
50+
break;
51+
}
52+
textLength = truncateNode(children[i], textLength);
53+
}
54+
}
55+
56+
return textLength;
57+
};
58+
59+
return (tree: ASTNode) => {
60+
truncateNode(tree, 0);
61+
};
62+
}

0 commit comments

Comments
 (0)