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