1import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; 2import React from 'react'; 3import { Alert, FlatList, Linking, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; 4import { useSafeArea } from 'react-native-safe-area-context'; 5 6import { getTestModules } from '../TestModules'; 7import PlatformTouchable from '../components/PlatformTouchable'; 8import Colors from '../constants/Colors'; 9 10function ListItem({ title, onPressItem, selected, id }) { 11 function onPress() { 12 onPressItem(id); 13 } 14 15 return ( 16 <PlatformTouchable onPress={onPress}> 17 <View style={styles.listItem}> 18 <Text style={styles.label}>{title}</Text> 19 <MaterialCommunityIcons 20 color={selected ? Colors.tintColor : 'black'} 21 name={selected ? 'checkbox-marked' : 'checkbox-blank-outline'} 22 size={24} 23 /> 24 </View> 25 </PlatformTouchable> 26 ); 27} 28 29function createQueryString(tests) { 30 if (!Array.isArray(tests) || !tests.every(v => typeof v === 'string')) { 31 throw new Error( 32 `test-suite: Cannot create query string for runner. Expected array of strings, instead got: ${tests}` 33 ); 34 } 35 const uniqueTests = [...new Set(tests)]; 36 // Skip encoding or React Navigation will encode twice 37 return uniqueTests.join(' '); 38} 39 40export default class SelectScreen extends React.PureComponent { 41 state = { 42 selected: new Set(), 43 modules: [], 44 }; 45 46 constructor(props) { 47 super(props); 48 49 if (global.ErrorUtils) { 50 const originalErrorHandler = global.ErrorUtils.getGlobalHandler(); 51 52 global.ErrorUtils.setGlobalHandler((error, isFatal) => { 53 // Prevent optionalRequire from failing 54 if ( 55 isFatal && 56 (error.message.includes('Native module cannot be null') || 57 error.message.includes( 58 `from NativeViewManagerAdapter isn't exported by @unimodules/react-native-adapter. Views of this type may not render correctly. Exported view managers: ` 59 )) 60 ) { 61 console.log('Caught require error'); 62 } else { 63 originalErrorHandler(error, isFatal); 64 } 65 }); 66 } 67 } 68 69 componentWillUnmount() { 70 Linking.removeEventListener('url', this._handleOpenURL); 71 } 72 73 checkLinking = incomingTests => { 74 // TODO(Bacon): bare-expo should pass a space-separated string. 75 const tests = incomingTests.split(',').map(v => v.trim()); 76 const query = createQueryString(tests); 77 this.props.navigation.navigate('run', { tests: query }); 78 }; 79 80 _handleOpenURL = ({ url }) => { 81 url = url || ''; 82 // TODO: Use Expo Linking library once parseURL is implemented for web 83 if (url.includes('/select/')) { 84 const selectedTests = url.split('/').pop(); 85 if (selectedTests) { 86 this.checkLinking(selectedTests); 87 return; 88 } 89 } 90 91 if (url.includes('/all')) { 92 // Test all available modules 93 const query = createQueryString(getTestModules().map(m => m.name)); 94 95 this.props.navigation.navigate('run', { 96 tests: query, 97 }); 98 return; 99 } 100 101 // Application wasn't started from a deep link which we handle. So, we can load test modules. 102 this._loadTestModules(); 103 }; 104 105 _loadTestModules = () => { 106 this.setState({ 107 modules: getTestModules(), 108 }); 109 }; 110 111 componentDidMount() { 112 Linking.addEventListener('url', this._handleOpenURL); 113 114 Linking.getInitialURL() 115 .then(url => { 116 this._handleOpenURL({ url }); 117 }) 118 .catch(err => console.error('Failed to load initial URL', err)); 119 } 120 121 _keyExtractor = ({ name }) => name; 122 123 _onPressItem = id => { 124 this.setState(state => { 125 const selected = new Set(state.selected); 126 if (selected.has(id)) selected.delete(id); 127 else selected.add(id); 128 return { selected }; 129 }); 130 }; 131 132 _renderItem = ({ item: { name } }) => ( 133 <ListItem 134 id={name} 135 onPressItem={this._onPressItem} 136 selected={this.state.selected.has(name)} 137 title={name} 138 /> 139 ); 140 141 _selectAll = () => { 142 this.setState(state => { 143 if (state.selected.size === this.state.modules.length) { 144 return { selected: new Set() }; 145 } 146 return { selected: new Set(this.state.modules.map(item => item.name)) }; 147 }); 148 }; 149 150 _navigateToTests = () => { 151 const { selected } = this.state; 152 if (selected.length === 0) { 153 Alert.alert('Cannot Run Tests', 'You must select at least one test to run.'); 154 } else { 155 const query = createQueryString([...selected]); 156 157 this.props.navigation.navigate('run', { tests: query }); 158 this.setState({ selected: new Set() }); 159 } 160 }; 161 162 render() { 163 const { selected } = this.state; 164 const allSelected = selected.size === this.state.modules.length; 165 const buttonTitle = allSelected ? 'Deselect All' : 'Select All'; 166 return ( 167 <React.Fragment> 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 </React.Fragment> 182 ); 183 } 184} 185 186function Footer({ buttonTitle, canRunTests, onToggle, onRun }) { 187 const { bottom, left, right } = useSafeArea(); 188 189 const isRunningInDetox = !!global.DETOX; 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