1import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; 2import Constants from 'expo-constants'; 3import React from 'react'; 4import { Alert, FlatList, Linking, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; 5import { useSafeArea } from 'react-native-safe-area-context'; 6 7import { getTestModules } from '../TestModules'; 8import PlatformTouchable from '../components/PlatformTouchable'; 9import Colors from '../constants/Colors'; 10 11function ListItem({ title, onPressItem, selected, id }) { 12 const onPress = () => onPressItem(id); 13 14 return ( 15 <PlatformTouchable onPress={onPress}> 16 <View style={styles.listItem}> 17 <Text style={styles.label}>{title}</Text> 18 <MaterialCommunityIcons 19 color={selected ? Colors.tintColor : 'black'} 20 name={selected ? 'checkbox-marked' : 'checkbox-blank-outline'} 21 size={24} 22 /> 23 </View> 24 </PlatformTouchable> 25 ); 26} 27 28function createQueryString(tests) { 29 if (!Array.isArray(tests) || !tests.every((v) => typeof v === 'string')) { 30 throw new Error( 31 `test-suite: Cannot create query string for runner. Expected array of strings, instead got: ${tests}` 32 ); 33 } 34 const uniqueTests = [...new Set(tests)]; 35 // Skip encoding or React Navigation will encode twice 36 return uniqueTests.join(' '); 37} 38 39export default class SelectScreen extends React.PureComponent { 40 state = { 41 selected: new Set(), 42 modules: [], 43 }; 44 45 constructor(props) { 46 super(props); 47 48 if (global.ErrorUtils) { 49 const originalErrorHandler = global.ErrorUtils.getGlobalHandler(); 50 51 global.ErrorUtils.setGlobalHandler((error, isFatal) => { 52 // Prevent optionalRequire from failing 53 if ( 54 isFatal && 55 (error.message.includes('Native module cannot be null') || 56 error.message.includes( 57 `from NativeViewManagerAdapter isn't exported by @unimodules/react-native-adapter. Views of this type may not render correctly. Exported view managers: ` 58 )) 59 ) { 60 console.log('Caught require error'); 61 } else { 62 originalErrorHandler(error, isFatal); 63 } 64 }); 65 } 66 } 67 68 componentWillUnmount() { 69 Linking.removeEventListener('url', this._handleOpenURL); 70 } 71 72 checkLinking = (incomingTests) => { 73 // TODO(Bacon): bare-expo should pass a space-separated string. 74 const tests = incomingTests.split(',').map((v) => v.trim()); 75 const query = createQueryString(tests); 76 this.props.navigation.navigate('run', { tests: query }); 77 }; 78 79 _handleOpenURL = ({ url }) => { 80 url = url || ''; 81 // TODO: Use Expo Linking library once parseURL is implemented for web 82 if (url.includes('/select/')) { 83 const selectedTests = url.split('/').pop(); 84 if (selectedTests) { 85 this.checkLinking(selectedTests); 86 return; 87 } 88 } 89 90 if (url.includes('/all')) { 91 // Test all available modules 92 const query = createQueryString(getTestModules().map((m) => m.name)); 93 94 this.props.navigation.navigate('run', { 95 tests: query, 96 }); 97 return; 98 } 99 100 // Application wasn't started from a deep link which we handle. So, we can load test modules. 101 this._loadTestModules(); 102 }; 103 104 _loadTestModules = () => { 105 this.setState({ 106 modules: getTestModules(), 107 }); 108 }; 109 110 componentDidMount() { 111 Linking.addEventListener('url', this._handleOpenURL); 112 113 Linking.getInitialURL() 114 .then((url) => { 115 this._handleOpenURL({ url }); 116 }) 117 .catch((err) => console.error('Failed to load initial URL', err)); 118 } 119 120 _keyExtractor = ({ name }) => name; 121 122 _onPressItem = (id) => { 123 this.setState((state) => { 124 const selected = new Set(state.selected); 125 if (selected.has(id)) selected.delete(id); 126 else selected.add(id); 127 return { selected }; 128 }); 129 }; 130 131 _renderItem = ({ item: { name } }) => ( 132 <ListItem 133 id={name} 134 onPressItem={this._onPressItem} 135 selected={this.state.selected.has(name)} 136 title={name} 137 /> 138 ); 139 140 _selectAll = () => { 141 this.setState((prevState) => { 142 if (prevState.selected.size === prevState.modules.length) { 143 return { selected: new Set() }; 144 } 145 return { selected: new Set(prevState.modules.map((item) => item.name)) }; 146 }); 147 }; 148 149 _navigateToTests = () => { 150 const { selected } = this.state; 151 if (selected.length === 0) { 152 Alert.alert('Cannot Run Tests', 'You must select at least one test to run.'); 153 } else { 154 const query = createQueryString([...selected]); 155 156 this.props.navigation.navigate('run', { tests: query }); 157 } 158 }; 159 160 render() { 161 const { selected } = this.state; 162 const allSelected = selected.size === this.state.modules.length; 163 const buttonTitle = allSelected ? 'Deselect All' : 'Select All'; 164 165 return ( 166 // eslint-disable-next-line react/jsx-fragments 167 <> 168 <FlatList 169 data={this.state.modules} 170 extraData={this.state} 171 keyExtractor={this._keyExtractor} 172 renderItem={this._renderItem} 173 initialNumToRender={15} 174 /> 175 <Footer 176 buttonTitle={buttonTitle} 177 canRunTests={selected.size} 178 onRun={this._navigateToTests} 179 onToggle={this._selectAll} 180 /> 181 </> 182 ); 183 } 184} 185 186function Footer({ buttonTitle, canRunTests, onToggle, onRun }) { 187 const { bottom, left, right } = useSafeArea(); 188 189 const isRunningInDetox = Constants.manifest && Constants.manifest.slug === 'bare-expo'; 190 const paddingVertical = 16; 191 192 return ( 193 <View 194 style={[ 195 styles.buttonRow, 196 { paddingBottom: isRunningInDetox ? 0 : bottom, paddingLeft: left, paddingRight: right }, 197 ]}> 198 <FooterButton 199 style={{ paddingVertical, alignItems: 'flex-start' }} 200 title={buttonTitle} 201 onPress={onToggle} 202 /> 203 <FooterButton 204 style={{ paddingVertical, alignItems: 'flex-end' }} 205 title="Run Tests" 206 disabled={!canRunTests} 207 onPress={onRun} 208 /> 209 </View> 210 ); 211} 212 213function FooterButton({ title, style, ...props }) { 214 return ( 215 <TouchableOpacity 216 style={[styles.footerButton, { opacity: props.disabled ? 0.4 : 1 }, style]} 217 {...props}> 218 <Text style={styles.footerButtonTitle}>{title}</Text> 219 </TouchableOpacity> 220 ); 221} 222 223const HORIZONTAL_MARGIN = 24; 224 225const styles = StyleSheet.create({ 226 mainContainer: { 227 flex: 1, 228 }, 229 footerButtonTitle: { 230 fontSize: 18, 231 color: Colors.tintColor, 232 }, 233 footerButton: { 234 flex: 1, 235 justifyContent: 'center', 236 marginHorizontal: HORIZONTAL_MARGIN, 237 }, 238 listItem: { 239 alignItems: 'center', 240 justifyContent: 'space-between', 241 flexDirection: 'row', 242 paddingVertical: 14, 243 paddingHorizontal: HORIZONTAL_MARGIN, 244 borderBottomWidth: StyleSheet.hairlineWidth, 245 borderBottomColor: '#dddddd', 246 }, 247 label: { 248 color: 'black', 249 fontSize: 18, 250 }, 251 buttonRow: { 252 flexDirection: 'row', 253 alignItems: 'center', 254 justifyContent: 'center', 255 borderTopWidth: StyleSheet.hairlineWidth, 256 borderTopColor: '#dddddd', 257 backgroundColor: 'white', 258 }, 259 contentContainerStyle: { 260 paddingBottom: 128, 261 }, 262}); 263