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