Skip to content

Commit 635795d

Browse files
fix: avoid errors when an empty list item is rendered [CRNS-508] (#1088)
Co-authored-by: Vishal Narkhede <[email protected]>
1 parent cd69dbc commit 635795d

File tree

2 files changed

+147
-47
lines changed

2 files changed

+147
-47
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react';
2+
import { Text } from 'react-native';
3+
4+
import { render, waitFor } from '@testing-library/react-native';
5+
6+
// @ts-ignore
7+
import { ASTNode, SingleASTNode } from 'simple-markdown';
8+
9+
import { ListOutput, ListOutputProps } from './renderText';
10+
11+
describe('list', () => {
12+
const createNode = ({
13+
amount,
14+
ordered = false,
15+
start = 1,
16+
}: {
17+
amount: number;
18+
ordered?: boolean;
19+
start?: number;
20+
}): SingleASTNode => ({
21+
items: Array.from(Array(amount).keys()),
22+
ordered,
23+
start,
24+
type: 'text',
25+
});
26+
27+
const mockOutput = (node: ASTNode) => <Text>{node}</Text>;
28+
const MockText = ({ node, output, state }: ListOutputProps) => (
29+
<>
30+
<ListOutput node={node} output={output} state={state} styles={{}} />
31+
</>
32+
);
33+
34+
it('renders numbered items', async () => {
35+
const node = createNode({ amount: 3, ordered: true, start: 1 });
36+
const { getByText } = render(<MockText node={node} output={mockOutput} state={{}} />);
37+
38+
await waitFor(() => expect(getByText('1. ')).toBeTruthy());
39+
await waitFor(() => expect(getByText('2. ')).toBeTruthy());
40+
await waitFor(() => expect(getByText('3. ')).toBeTruthy());
41+
});
42+
43+
it('renders numbered items from a start index', async () => {
44+
const node = createNode({ amount: 3, ordered: true, start: 3 });
45+
const { getByText } = render(<MockText node={node} output={mockOutput} state={{}} />);
46+
47+
await waitFor(() => expect(getByText('3. ')).toBeTruthy());
48+
await waitFor(() => expect(getByText('4. ')).toBeTruthy());
49+
await waitFor(() => expect(getByText('5. ')).toBeTruthy());
50+
});
51+
52+
it('does not throw an error if an item is empty', async () => {
53+
const node = {
54+
...createNode({ amount: 3, ordered: true }),
55+
items: ['Not empty', null, 'Not empty'],
56+
};
57+
const { getByText } = render(<MockText node={node} output={mockOutput} state={{}} />);
58+
59+
await waitFor(() => expect(getByText('1. ')).toBeTruthy());
60+
await waitFor(() => expect(getByText('2. ')).toBeTruthy());
61+
await waitFor(() => expect(getByText('3. ')).toBeTruthy());
62+
});
63+
64+
it('renders an unordered list', async () => {
65+
const node = createNode({ amount: 3 });
66+
const { getAllByText } = render(<MockText node={node} output={mockOutput} state={{}} />);
67+
68+
await waitFor(() => expect(getAllByText('\u2022 ')).toHaveLength(3));
69+
});
70+
});

package/src/components/Message/MessageSimple/utils/renderText.tsx

Lines changed: 77 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React from 'react';
2-
import { GestureResponderEvent, Linking, Text, View } from 'react-native';
1+
import React, { PropsWithChildren } from 'react';
2+
import { GestureResponderEvent, Linking, Text, TextProps, View, ViewProps } from 'react-native';
33

44
// @ts-expect-error
55
import Markdown from 'react-native-markdown-package';
@@ -12,7 +12,9 @@ import {
1212
ParseFunction,
1313
parseInline,
1414
ReactNodeOutput,
15+
ReactOutput,
1516
SingleASTNode,
17+
State,
1618
} from 'simple-markdown';
1719

1820
import { parseLinksFromText } from './parseLinks';
@@ -171,7 +173,7 @@ export const renderText = <
171173
? onLinkParams(url)
172174
: Linking.canOpenURL(url).then((canOpenUrl) => canOpenUrl && Linking.openURL(url));
173175

174-
const react: ReactNodeOutput = (node, output, { ...state }) => {
176+
const link: ReactNodeOutput = (node, output, { ...state }) => {
175177
const onPress = (event: GestureResponderEvent) => {
176178
if (!preventPress && onPressParam) {
177179
onPressParam({
@@ -249,56 +251,18 @@ export const renderText = <
249251
);
250252
};
251253

252-
const listLevels = {
253-
sub: 'sub',
254-
top: 'top',
255-
};
256-
257-
/**
258-
* For lists and sublists, the default behavior of the markdown library we use is
259-
* to always renumber any list, so all ordered lists start from 1.
260-
*
261-
* This custom rule overrides this behavior both for top level lists and sublists,
262-
* in order to start the numbering from the number of the first list item provided.
263-
*/
264-
const customListAtLevel =
265-
(level: keyof typeof listLevels): ReactNodeOutput =>
266-
(node, output, { ...state }) => {
267-
const items = node.items.map((item: Array<SingleASTNode>, index: number) => {
268-
const withinList = item.length > 1 && item[1].type === 'list';
269-
const content = output(item, { ...state, withinList });
270-
271-
const isTopLevelText =
272-
['text', 'paragraph', 'strong'].includes(item[0].type) && withinList === false;
273-
274-
return (
275-
<View key={index} style={styles.listRow}>
276-
<Text style={styles.listItemNumber}>
277-
{node.ordered ? `${node.start + index}. ` : `\u2022`}
278-
</Text>
279-
<Text style={[styles.listItemText, isTopLevelText && { marginBottom: 0 }]}>
280-
{content}
281-
</Text>
282-
</View>
283-
);
284-
});
285-
286-
const isSublist = level === 'sub';
287-
return (
288-
<View key={state.key} style={[isSublist ? styles.list : styles.sublist]}>
289-
{items}
290-
</View>
291-
);
292-
};
254+
const list: ReactNodeOutput = (node, output, state) => (
255+
<ListOutput node={node} output={output} state={state} styles={styles} />
256+
);
293257

294258
const customRules = {
295-
link: { react },
296-
list: { react: customListAtLevel('top') },
259+
link: { link },
260+
list: { react: list },
297261
// Truncate long text content in the message overlay
298262
paragraph: messageTextNumberOfLines ? { react: paragraphText } : {},
299263
// we have no react rendering support for reflinks
300264
reflink: { match: () => null },
301-
sublist: { react: customListAtLevel('sub') },
265+
sublist: { react: list },
302266
...(mentionedUsers
303267
? {
304268
mentions: {
@@ -327,3 +291,69 @@ export const renderText = <
327291
</Markdown>
328292
);
329293
};
294+
295+
export interface ListOutputProps {
296+
node: SingleASTNode;
297+
output: ReactOutput;
298+
state: State;
299+
styles?: Partial<MarkdownStyle>;
300+
}
301+
302+
/**
303+
* For lists and sublists, the default behavior of the markdown library we use is
304+
* to always renumber any list, so all ordered lists start from 1.
305+
*
306+
* This custom rule overrides this behavior both for top level lists and sublists,
307+
* in order to start the numbering from the number of the first list item provided.
308+
*/
309+
export const ListOutput = ({ node, output, state, styles }: ListOutputProps) => {
310+
let isSublist = state.withinList;
311+
const parentTypes = ['text', 'paragraph', 'strong'];
312+
313+
return (
314+
<View key={state.key} style={isSublist ? styles?.sublist : styles?.list}>
315+
{node.items.map((item: SingleASTNode, index: number) => {
316+
const indexAfterStart = node.start + index;
317+
318+
if (item === null) {
319+
return (
320+
<ListRow key={index} style={styles?.listRow} testID='list-item'>
321+
<Bullet index={node.ordered && indexAfterStart} />
322+
</ListRow>
323+
);
324+
}
325+
326+
isSublist = item.length > 1 && item[1].type === 'list';
327+
const isSublistWithinText = parentTypes.includes((item[0] ?? {}).type) && isSublist;
328+
const style = isSublistWithinText ? { marginBottom: 0 } : {};
329+
330+
return (
331+
<ListRow key={index} style={styles?.listRow} testID='list-item'>
332+
<Bullet index={node.ordered && indexAfterStart} />
333+
<ListItem key={1} style={[styles?.listItemText, style]}>
334+
{output(item, state)}
335+
</ListItem>
336+
</ListRow>
337+
);
338+
})}
339+
</View>
340+
);
341+
};
342+
343+
interface BulletProps extends TextProps {
344+
index?: number;
345+
}
346+
347+
const Bullet = ({ index, style }: BulletProps) => (
348+
<Text key={0} style={[style, defaultMarkdownStyles.listItemNumber]}>
349+
{index ? `${index}. ` : '\u2022 '}
350+
</Text>
351+
);
352+
353+
const ListRow = (props: PropsWithChildren<ViewProps>) => (
354+
<Text style={[props.style, defaultMarkdownStyles.listRow]}>{props.children}</Text>
355+
);
356+
357+
const ListItem = ({ children, style }: PropsWithChildren<TextProps>) => (
358+
<Text style={style}>{children}</Text>
359+
);

0 commit comments

Comments
 (0)