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