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    Linking.removeEventListener('url', this._handleOpenURL);
70  }
71
72  checkLinking = (incomingTests) => {
73    // TODO(Bacon): bare-expo should pass a space-separated string.
74    const tests = incomingTests.split(',').map((v) => v.trim());
75    const query = createQueryString(tests);
76    this.props.navigation.navigate('run', { tests: query });
77  };
78
79  _handleOpenURL = ({ url }) => {
80    url = url || '';
81    // TODO: Use Expo Linking library once parseURL is implemented for web
82    if (url.includes('/select/')) {
83      const selectedTests = url.split('/').pop();
84      if (selectedTests) {
85        this.checkLinking(selectedTests);
86        return;
87      }
88    }
89
90    if (url.includes('/all')) {
91      // Test all available modules
92      const query = createQueryString(getTestModules().map((m) => m.name));
93
94      this.props.navigation.navigate('run', {
95        tests: query,
96      });
97      return;
98    }
99
100    // Application wasn't started from a deep link which we handle. So, we can load test modules.
101    this._loadTestModules();
102  };
103
104  _loadTestModules = () => {
105    this.setState({
106      modules: getTestModules(),
107    });
108  };
109
110  componentDidMount() {
111    Linking.addEventListener('url', this._handleOpenURL);
112
113    Linking.getInitialURL()
114      .then((url) => {
115        this._handleOpenURL({ url });
116      })
117      .catch((err) => console.error('Failed to load initial URL', err));
118  }
119
120  _keyExtractor = ({ name }) => name;
121
122  _onPressItem = (id) => {
123    this.setState((state) => {
124      const selected = new Set(state.selected);
125      if (selected.has(id)) selected.delete(id);
126      else selected.add(id);
127      return { selected };
128    });
129  };
130
131  _renderItem = ({ item: { name } }) => (
132    <ListItem
133      id={name}
134      onPressItem={this._onPressItem}
135      selected={this.state.selected.has(name)}
136      title={name}
137    />
138  );
139
140  _selectAll = () => {
141    this.setState((prevState) => {
142      if (prevState.selected.size === prevState.modules.length) {
143        return { selected: new Set() };
144      }
145      return { selected: new Set(prevState.modules.map((item) => item.name)) };
146    });
147  };
148
149  _navigateToTests = () => {
150    const { selected } = this.state;
151    if (selected.length === 0) {
152      Alert.alert('Cannot Run Tests', 'You must select at least one test to run.');
153    } else {
154      const query = createQueryString([...selected]);
155
156      this.props.navigation.navigate('run', { tests: query });
157    }
158  };
159
160  render() {
161    const { selected } = this.state;
162    const allSelected = selected.size === this.state.modules.length;
163    const buttonTitle = allSelected ? 'Deselect All' : 'Select All';
164
165    return (
166      // eslint-disable-next-line react/jsx-fragments
167      <>
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      </>
182    );
183  }
184}
185
186function Footer({ buttonTitle, canRunTests, onToggle, onRun }) {
187  const { bottom, left, right } = useSafeArea();
188
189  const isRunningInDetox = Constants.manifest && Constants.manifest.slug === 'bare-expo';
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