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