Skip to content

Commit f890a2e

Browse files
authored
Handle type checking when child wrapped in a fragment (47) (#724)
* Handle type checking when child wrapped in a fragment * Remove explicit cast
1 parent 769a1d6 commit f890a2e

File tree

13 files changed

+273
-47
lines changed

13 files changed

+273
-47
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from "react";
2+
import { View } from "react-native";
3+
import { render, screen } from "@testing-library/react-native";
4+
import { SectionList, SectionHeader } from "../components/SectionList";
5+
import { TabView, TabViewItem } from "../components/TabView";
6+
import {
7+
SwipeableItem,
8+
SwipeableItemButton,
9+
} from "../components/SwipeableItem";
10+
import { flattenReactFragments } from "../utilities";
11+
12+
describe("Type checked components wrapped in a fragment tests", () => {
13+
describe("Type checked components render when wrapped in a fragment tests", () => {
14+
test("should SectionHeader render when wrapped in fragment", () => {
15+
render(
16+
<SectionList
17+
data={[{ test: "data" }]}
18+
renderItem={() => (
19+
<>
20+
<SectionHeader>
21+
<View testID="header" />
22+
</SectionHeader>
23+
</>
24+
)}
25+
sectionKey="test"
26+
/>
27+
);
28+
29+
const headerView = screen.queryByTestId("header");
30+
expect(headerView).toBeTruthy();
31+
});
32+
33+
test("should TabViewItem render when wrapped in fragment", () => {
34+
render(
35+
<TabView Icon={() => <View />}>
36+
<>
37+
<TabViewItem title="Test">
38+
<View testID="tab-item" />
39+
</TabViewItem>
40+
</>
41+
</TabView>
42+
);
43+
44+
const tabItem = screen.queryByTestId("tab-item");
45+
expect(tabItem).toBeTruthy();
46+
});
47+
48+
test("should SwipeableItemButton render when wrapped in fragment", () => {
49+
render(
50+
<SwipeableItem Icon={() => <View />}>
51+
<>
52+
<SwipeableItemButton revealSwipeDirection="left" title="test" />
53+
</>
54+
</SwipeableItem>
55+
);
56+
57+
const swipeableButton = screen.queryByTestId("swipeable-behind-item");
58+
expect(swipeableButton).toBeTruthy();
59+
});
60+
});
61+
62+
describe("flattenReactFragments tests", () => {
63+
test("should extract components from react fragments", () => {
64+
const components = [
65+
<React.Fragment>
66+
<View testID="1" />
67+
<View testID="2" />
68+
<View testID="3" />
69+
</React.Fragment>,
70+
<>
71+
<View testID="4" />
72+
</>,
73+
<>
74+
<View testID="5" />
75+
<View testID="6" />
76+
</>,
77+
];
78+
79+
const result = flattenReactFragments(components);
80+
expect(result).toMatchInlineSnapshot(`
81+
[
82+
<View
83+
testID="1"
84+
/>,
85+
<View
86+
testID="2"
87+
/>,
88+
<View
89+
testID="3"
90+
/>,
91+
<View
92+
testID="4"
93+
/>,
94+
<View
95+
testID="5"
96+
/>,
97+
<View
98+
testID="6"
99+
/>,
100+
]
101+
`);
102+
});
103+
});
104+
});

packages/core/src/components/SectionList/SectionList.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from "react";
22
import { FlashListProps, FlashList } from "@shopify/flash-list";
33
import { FlatListProps, FlatList } from "react-native";
44
import SectionHeader, { DefaultSectionHeader } from "./SectionHeader";
5-
import { extractIfNestedInFragment } from "../../utilities";
5+
import { flattenReactFragments } from "../../utilities";
66

77
type ListComponentType = "FlatList" | "FlashList";
88

@@ -92,8 +92,8 @@ const SectionList = <T extends { [key: string]: any }>({
9292
}
9393

9494
const props = element.props || {};
95-
const children = React.Children.toArray(props.children).map((child) =>
96-
extractIfNestedInFragment(child as React.ReactElement)
95+
const children = flattenReactFragments(
96+
React.Children.toArray(props.children) as React.ReactElement[]
9797
);
9898

9999
if (element.type === SectionHeader) {
@@ -116,8 +116,8 @@ const SectionList = <T extends { [key: string]: any }>({
116116
}
117117

118118
const props = element.props || {};
119-
const children = React.Children.toArray(props.children).map(
120-
(child) => child as React.ReactElement
119+
const children = flattenReactFragments(
120+
React.Children.toArray(props.children) as React.ReactElement[]
121121
);
122122
if (element.type === SectionHeader) {
123123
return null;

packages/core/src/components/SwipeableItem/SwipeableItem.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import {
1212
extractBorderAndMarginStyles,
1313
extractEffectStyles,
1414
extractFlexItemStyles,
15-
extractIfNestedInFragment,
1615
extractPositionStyles,
1716
extractSizeStyles,
1817
extractStyles,
18+
flattenReactFragments,
1919
} from "../../utilities";
2020
import { SwipeRow } from "react-native-swipe-list-view";
2121
import { IconSlot } from "../../interfaces/Icon";
@@ -128,8 +128,8 @@ const SwipeableItem: React.FC<React.PropsWithChildren<Props>> = ({
128128

129129
const children: React.ReactNode[] = React.useMemo(
130130
() =>
131-
React.Children.toArray(childrenProp).map((child) =>
132-
extractIfNestedInFragment(child as React.ReactElement)
131+
flattenReactFragments(
132+
React.Children.toArray(childrenProp) as React.ReactElement[]
133133
),
134134
[childrenProp]
135135
);
@@ -187,6 +187,7 @@ const SwipeableItem: React.FC<React.PropsWithChildren<Props>> = ({
187187
//Renders a single 'behind' item. Used for both buttons and swipe handler
188188
const renderBehindItem = (item: SwipeableItemBehindItem, index: number) => (
189189
<Pressable
190+
testID="swipeable-behind-item"
190191
key={index.toString()}
191192
onPress={() => {
192193
item.onPress?.();

packages/core/src/components/TabView/TabView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import TabViewItem from "./TabViewItem";
1212
import type { IconSlot } from "../../interfaces/Icon";
1313
import { withTheme } from "../../theming";
1414
import type { Theme } from "../../styles/DefaultTheme";
15-
import { extractIfNestedInFragment, extractStyles } from "../../utilities";
15+
import { flattenReactFragments, extractStyles } from "../../utilities";
1616

1717
type SceneProps = SceneRendererProps & {
1818
route: Route;
@@ -66,8 +66,8 @@ const TabViewComponent: React.FC<React.PropsWithChildren<TabViewProps>> = ({
6666

6767
const children: React.ReactNode[] = React.useMemo(
6868
() =>
69-
React.Children.toArray(childrenProp).map((child) =>
70-
extractIfNestedInFragment(child as React.ReactElement)
69+
flattenReactFragments(
70+
React.Children.toArray(childrenProp) as React.ReactElement[]
7171
),
7272
[childrenProp]
7373
);

packages/core/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { flattenReactFragments } from "./utilities";
12
export { injectIcon } from "./interfaces/Icon";
23
export { withTheme, ThemeProvider } from "./theming";
34
export { default as Provider } from "./Provider";

packages/core/src/utilities.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -230,20 +230,25 @@ export function getValueForRadioButton(value: string | number) {
230230
}
231231
}
232232

233-
// This is done to ensure that operations that depend on a particular child type still work even when wrapped in a react fragment (ex: .type checks or prop extraction of child)
234-
// Wrapping in a fragment can happen in Draftbit when a component is wrapped in a conditional for example or inside a Fetch component
235-
export function extractIfNestedInFragment(
236-
component: React.ReactElement
237-
): React.ReactElement {
238-
if (component.type === React.Fragment) {
239-
const children = React.Children.toArray(
240-
(component.props as any)?.children
241-
) as React.ReactElement[];
242-
243-
if (children.length === 1) {
244-
return children[0];
233+
/**
234+
* Flattens array of components to remove any top level React.Fragment's (<> </>) and returns the fragment's children in its place
235+
* This is useful for operations that depend on a particular child type that would otherwise not match when wrapped in a fragment
236+
*/
237+
export function flattenReactFragments(
238+
components: React.ReactElement[]
239+
): React.ReactElement[] {
240+
const flattened = [];
241+
for (const component of components) {
242+
if (component.type === React.Fragment) {
243+
const children = React.Children.toArray(
244+
component.props?.children
245+
) as React.ReactElement[];
246+
247+
flattened.push(...children);
248+
} else {
249+
flattened.push(component);
245250
}
246251
}
247252

248-
return component;
253+
return flattened;
249254
}

packages/maps/src/__tests__/MapMarker.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,21 @@ describe("MapMarker tests", () => {
111111

112112
expect(markerPinImage).toBeTruthy();
113113
});
114+
115+
test("should MapCallout render when wrapped in fragment", () => {
116+
render(
117+
<MapView apiKey="">
118+
<MapMarker latitude={43.741895} longitude={-73.989308}>
119+
<>
120+
<MapCallout>
121+
<View testID="callout-view" />
122+
</MapCallout>
123+
</>
124+
</MapMarker>
125+
</MapView>
126+
);
127+
128+
const calloutView = screen.queryByTestId("callout-view");
129+
expect(calloutView).toBeTruthy();
130+
});
114131
});

packages/maps/src/__tests__/MapMarkerCluster.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,21 @@ describe("MapMarkerCluster tests", () => {
109109
}
110110
`);
111111
});
112+
113+
test("should MapMarker render when wrapped in fragment", () => {
114+
render(
115+
<MapMarkerCluster>
116+
<>
117+
<MapMarker latitude={41.741895} longitude={-73.989308} />
118+
</>
119+
<>
120+
<MapMarker latitude={42.741895} longitude={-73.989308} />
121+
</>
122+
</MapMarkerCluster>
123+
);
124+
125+
const renderedMarkers = screen.UNSAFE_queryAllByType(MapMarkerComponent);
126+
127+
expect(renderedMarkers.length).toBe(3); //3 markers, cluster itself + 2 markers
128+
});
112129
});

packages/maps/src/__tests__/MapView.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,69 @@ describe("MapView tests", () => {
279279
]
280280
`);
281281
});
282+
283+
test("should MapMarker render when wrapped in fragment", () => {
284+
render(
285+
<MapView apiKey="">
286+
<>
287+
<MapMarker latitude={40.741895} longitude={-73.989308} />
288+
</>
289+
</MapView>
290+
);
291+
292+
const renderedMarkers = screen
293+
.UNSAFE_queryAllByType(MapMarkerComponent)
294+
.map((marker) => marker.props.coordinate);
295+
296+
expect(renderedMarkers).toMatchInlineSnapshot(`
297+
[
298+
{
299+
"latitude": 40.741895,
300+
"longitude": -73.989308,
301+
},
302+
]
303+
`);
304+
});
305+
306+
test("should MapMarkerCluster render when wrapped in fragment", () => {
307+
render(
308+
<MapView apiKey="">
309+
<>
310+
<MapMarkerCluster>
311+
<MapMarker latitude={41.741895} longitude={-73.989308} />
312+
<MapMarker latitude={42.741895} longitude={-73.989308} />
313+
</MapMarkerCluster>
314+
</>
315+
</MapView>
316+
);
317+
318+
const renderedClusters = screen
319+
.UNSAFE_queryAllByType(MarkerClusterer)
320+
.map((marker) => marker.props.children);
321+
322+
expect(renderedClusters).toMatchInlineSnapshot(`
323+
[
324+
[
325+
<Marker
326+
coordinate={
327+
{
328+
"latitude": 41.741895,
329+
"longitude": -73.989308,
330+
}
331+
}
332+
onPress={[Function]}
333+
/>,
334+
<Marker
335+
coordinate={
336+
{
337+
"latitude": 42.741895,
338+
"longitude": -73.989308,
339+
}
340+
}
341+
onPress={[Function]}
342+
/>,
343+
],
344+
]
345+
`);
346+
});
282347
});

packages/maps/src/components/MapMarker.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { Marker as MapMarkerComponent } from "./react-native-maps";
1010
import type { MapMarkerProps as MapMarkerComponentProps } from "react-native-maps";
1111
import MapCallout, { renderCallout } from "./MapCallout";
12+
import { flattenReactFragments } from "@draftbit/ui";
1213

1314
export interface MapMarkerProps
1415
extends Omit<MapMarkerComponentProps, "onPress" | "coordinate"> {
@@ -44,14 +45,16 @@ export function renderMarker(
4445
}: MapMarkerProps,
4546
key?: React.Key
4647
) {
47-
const childrenArray = React.Children.toArray(children);
48+
const childrenArray = flattenReactFragments(
49+
React.Children.toArray(children) as React.ReactElement[]
50+
);
4851

4952
const calloutChildren = childrenArray.filter(
50-
(child) => (child as React.ReactElement).type === MapCallout
53+
(child) => child.type === MapCallout
5154
);
5255

5356
const nonCalloutChildren = childrenArray.filter(
54-
(child) => (child as React.ReactElement).type !== MapCallout
57+
(child) => child.type !== MapCallout
5558
);
5659

5760
// Add default callout for title/description

0 commit comments

Comments
 (0)