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 if (this._openUrlSubscription != null) { 70 this._openUrlSubscription.remove(); 71 this._openUrlSubscription = null; 72 } 73 } 74 75 checkLinking = (incomingTests) => { 76 // TODO(Bacon): bare-expo should pass a space-separated string. 77 const tests = incomingTests.split(',').map((v) => v.trim()); 78 const query = createQueryString(tests); 79 this.props.navigation.navigate('run', { tests: query }); 80 }; 81 82 _handleOpenURL = ({ url }) => { 83 url = url || ''; 84 // TODO: Use Expo Linking library once parseURL is implemented for web 85 if (url.includes('/select/')) { 86 const selectedTests = url.split('/').pop(); 87 if (selectedTests) { 88 this.checkLinking(selectedTests); 89 return; 90 } 91 } 92 93 if (url.includes('/all')) { 94 // Test all available modules 95 const query = createQueryString(getTestModules().map((m) => m.name)); 96 97 this.props.navigation.navigate('run', { 98 tests: query, 99 }); 100 return; 101 } 102 103 // Application wasn't started from a deep link which we handle. So, we can load test modules. 104 this._loadTestModules(); 105 }; 106 107 _loadTestModules = () => { 108 this.setState({ 109 modules: getTestModules(), 110 }); 111 }; 112 113 componentDidMount() { 114 this._openUrlSubscription = Linking.addEventListener('url', this._handleOpenURL); 115 116 Linking.getInitialURL() 117 .then((url) => { 118 this._handleOpenURL({ url }); 119 }) 120 .catch((err) => console.error('Failed to load initial URL', err)); 121 } 122 123 _keyExtractor = ({ name }) => name; 124 125 _onPressItem = (id) => { 126 this.setState((state) => { 127 const selected = new Set(state.selected); 128 if (selected.has(id)) selected.delete(id); 129 else selected.add(id); 130 return { selected }; 131 }); 132 }; 133 134 _renderItem = ({ item: { name } }) => ( 135 <ListItem 136 id={name} 137 onPressItem={this._onPressItem} 138 selected={this.state.selected.has(name)} 139 title={name} 140 /> 141 ); 142 143 _selectAll = () => { 144 this.setState((prevState) => { 145 if (prevState.selected.size === prevState.modules.length) { 146 return { selected: new Set() }; 147 } 148 return { selected: new Set(prevState.modules.map((item) => item.name)) }; 149 }); 150 }; 151 152 _navigateToTests = () => { 153 const { selected } = this.state; 154 if (selected.length === 0) { 155 Alert.alert('Cannot Run Tests', 'You must select at least one test to run.'); 156 } else { 157 const query = createQueryString([...selected]); 158 159 this.props.navigation.navigate('run', { tests: query }); 160 } 161 }; 162 163 render() { 164 const { selected } = this.state; 165 const allSelected = selected.size === this.state.modules.length; 166 const buttonTitle = allSelected ? 'Deselect All' : 'Select All'; 167 168 return ( 169 // eslint-disable-next-line react/jsx-fragments 170 <> 171 <FlatList 172 data={this.state.modules} 173 extraData={this.state} 174 keyExtractor={this._keyExtractor} 175 renderItem={this._renderItem} 176 initialNumToRender={15} 177 /> 178 <Footer 179 buttonTitle={buttonTitle} 180 canRunTests={selected.size} 181 onRun={this._navigateToTests} 182 onToggle={this._selectAll} 183 /> 184 </> 185 ); 186 } 187} 188 189function Footer({ buttonTitle, canRunTests, onToggle, onRun }) { 190 const { bottom, left, right } = useSafeArea(); 191 192 const isRunningInDetox = Constants.manifest && Constants.manifest.slug === 'bare-expo'; 193 const paddingVertical = 16; 194 195 return ( 196 <View 197 style={[ 198 styles.buttonRow, 199 { paddingBottom: isRunningInDetox ? 0 : bottom, paddingLeft: left, paddingRight: right }, 200 ]}> 201 <FooterButton 202 style={{ paddingVertical, alignItems: 'flex-start' }} 203 title={buttonTitle} 204 onPress={onToggle} 205 /> 206 <FooterButton 207 style={{ paddingVertical, alignItems: 'flex-end' }} 208 title="Run Tests" 209 disabled={!canRunTests} 210 onPress={onRun} 211 /> 212 </View> 213 ); 214} 215 216function FooterButton({ title, style, ...props }) { 217 return ( 218 <TouchableOpacity 219 style={[styles.footerButton, { opacity: props.disabled ? 0.4 : 1 }, style]} 220 {...props}> 221 <Text style={styles.footerButtonTitle}>{title}</Text> 222 </TouchableOpacity> 223 ); 224} 225 226const HORIZONTAL_MARGIN = 24; 227 228const styles = StyleSheet.create({ 229 mainContainer: { 230 flex: 1, 231 }, 232 footerButtonTitle: { 233 fontSize: 18, 234 color: Colors.tintColor, 235 }, 236 footerButton: { 237 flex: 1, 238 justifyContent: 'center', 239 marginHorizontal: HORIZONTAL_MARGIN, 240 }, 241 listItem: { 242 alignItems: 'center', 243 justifyContent: 'space-between', 244 flexDirection: 'row', 245 paddingVertical: 14, 246 paddingHorizontal: HORIZONTAL_MARGIN, 247 borderBottomWidth: StyleSheet.hairlineWidth, 248 borderBottomColor: '#dddddd', 249 }, 250 label: { 251 color: 'black', 252 fontSize: 18, 253 }, 254 buttonRow: { 255 flexDirection: 'row', 256 alignItems: 'center', 257 justifyContent: 'center', 258 borderTopWidth: StyleSheet.hairlineWidth, 259 borderTopColor: '#dddddd', 260 backgroundColor: 'white', 261 }, 262 contentContainerStyle: { 263 paddingBottom: 128, 264 }, 265}); 266