diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7097390..f8177aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,11 +53,14 @@ One way to build new features is to generate a new Belt app locally using the co > cd builds/MyApp # now make some changes -# now copy changes back into Belt, dry-run first (-n flag): -> rsync -avpn . ../../templates/boilerplate/ --exclude node_modules --exclude .cache --exclude .expo --exclude .vscode --exclude assets --exclude .git --exclude .gitignore +# now copy changes back into Belt. Go back to Belt project: +> cd ../.. + +# run sync script +> node bin/sync-from-app.js MyApp --dry-run # now run without the dry-run flag: -> rsync -avp . ../../templates/boilerplate/ --exclude node_modules --exclude .cache --exclude .expo --exclude .vscode --exclude assets --exclude .git --exclude .gitignore +> node bin/sync-from-app.js MyApp ``` ## Creating a pull request diff --git a/bin/sync-from-app.js b/bin/sync-from-app.js new file mode 100755 index 0000000..230dd33 --- /dev/null +++ b/bin/sync-from-app.js @@ -0,0 +1,40 @@ +import { execSync } from 'child_process'; + +// copies files over from a sample app generated with `bin/belt.js` +// See CONTRIBUTING.md for more info +function run() { + const appDir = process.argv[2]; + + if (!appDir || appDir.includes('builds')) { + console.error('Please provide an app directory, relative to `builds`'); + console.error('Usage: node bin/sync-from-app.js MyApp --dry-run'); + process.exit(1); + } + + const excludes = [ + 'node_modules', + '.cache', + '.expo', + '.vscode', + 'assets', + '.git', + '.gitignore', + ]; + + const excludesStr = excludes.map((e) => `--exclude ${e}`).join(' '); + + // provide additional flags, eg. --dry-run + const flags = `-avp ${process.argv[3] || ''}`; + + const command = `rsync ${flags} ${excludesStr} builds/${appDir}/ templates/boilerplate/`; + console.log(command); + execSync(command, { + stdio: 'inherit', + }); + + console.log( + "\n\n🎉 Success! Ensure that all files have copied over correctly, remove any unwanted modifications (eg. app.json, package.json, etc), and manually remove any files that need to be deleted (these don't sync)", + ); +} + +run(); diff --git a/templates/boilerplate/package.json b/templates/boilerplate/package.json index 24e5461..5b9eed9 100644 --- a/templates/boilerplate/package.json +++ b/templates/boilerplate/package.json @@ -31,7 +31,6 @@ "react": "18.2.0", "react-native": "0.74.5", "react-native-keyboard-aware-scroll-view": "^0.9.5", - "react-native-keyboard-aware-scrollview": "^2.1.0", "react-native-safe-area-context": "4.10.5", "react-native-screens": "3.31.1" }, diff --git a/templates/boilerplate/src/__tests__/App.integration.test.tsx b/templates/boilerplate/src/__tests__/App.integration.test.tsx new file mode 100644 index 0000000..c332480 --- /dev/null +++ b/templates/boilerplate/src/__tests__/App.integration.test.tsx @@ -0,0 +1,58 @@ +import { screen, userEvent } from '@testing-library/react-native'; + +import mock from 'src/test/mock'; +import { renderApplication } from 'src/test/render'; +import { GithubProjectsResponse } from 'src/util/api/api'; + +// Testing philosophy: +// - Tests that render the entire application with `renderApplication` go in the +// top level `src/__tests__` directory and are named with `mytest.integration.test.tsx`. +// These are ideal for when you need to test flows that include navigation between screens +// - Tests that render a single screen or component are colocated in +// `__tests__/MyComponent.test.tsx`. These call `render` and are not able to +// navigate between screens, since the Navigator is not mounted +test('renders app, can navigate between screens', async () => { + jest.useFakeTimers(); + + const mocks = [mockGitHubProjects()]; + + // load the app on the Home screen + renderApplication({ mocks }); + expect( + await screen.findByRole('header', { name: /Welcome to your new app/ }), + ).toBeDefined(); + + // go to About tab + await userEvent.press(screen.getByRole('button', { name: /About/ })); + expect( + await screen.findByRole('header', { name: 'Open Source' }), + ).toBeDefined(); + + // expect GitHub project loaded via API + expect(await screen.findByText(/Belt is a CLI/)).toBeDefined(); +}); + +// TODO: sample data, remove +// creates a mock for a GET request to the GitHub projects API. +// Pass this mock to `render` or `renderApplication` to register it with MSW. +// Recommended to place these mocks in a central location like `src/test/mocks` +function mockGitHubProjects() { + return mock.get( + 'https://github-projects-api.vercel.app/api/projects', + { + response: { + projects: [ + { + id: 635980144, + name: 'belt', + description: + 'Belt is a CLI for starting a new React Native Expo app and will even keep your pants secure as you continue development.', + url: 'https://github.com/thoughtbot/belt', + stars: 8, + forks: 0, + }, + ], + }, + }, + ); +} diff --git a/templates/boilerplate/src/__tests__/App.test.tsx b/templates/boilerplate/src/__tests__/App.test.tsx deleted file mode 100644 index 61ebeca..0000000 --- a/templates/boilerplate/src/__tests__/App.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { screen } from '@testing-library/react-native'; - -import mock from 'src/test/mock'; -import { renderApplication } from 'src/test/render'; - -test('renders', async () => { - // We would not normally recommend fake timers, but the tests are currently - // throwing a "not wrapped in act" warning after this test finishes. One - // option is to put a `await waitForUpdates()` at the end of the test, but - // fake timers also work here until we find a better solution. The stack trace - // seems to point to React Navigation bottom tabs. - jest.useFakeTimers(); - - const mocks = [mockCoffees()]; - - renderApplication({ mocks }); - - expect(await screen.findByRole('header', { name: 'Mocha' })).toBeDefined(); -}); - -function mockCoffees() { - return mock.get('coffee/hot', { - response: [ - { - id: 1, - title: 'Mocha', - image: 'htps://placehold.it/200x200', - }, - ], - }); -} diff --git a/templates/boilerplate/src/components/HeaderShadow.tsx b/templates/boilerplate/src/components/HeaderShadow.tsx deleted file mode 100644 index 55e0c90..0000000 --- a/templates/boilerplate/src/components/HeaderShadow.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Animated, StyleSheet } from 'react-native'; - -type Props = { - animatedScrollOffset: Animated.Value; - fixedTop?: boolean; - animationScrollDistance?: number; -}; - -const SHADOW_HEIGHT = 12; // Height of the element that creates the shadow. Will never be seen. - -export default function HeaderShadow({ - animatedScrollOffset = new Animated.Value(0), - fixedTop = false, - animationScrollDistance = 32, -}: Props) { - const shadowOpacity = animatedScrollOffset.interpolate({ - inputRange: [0, animationScrollDistance], - outputRange: [0, 0.4], // Change of opacity of the shadow by changing the opacity of the entire component - extrapolate: 'clamp', - }); - - return ( - - ); -} - -const styles = StyleSheet.create({ - shadow: { - // Shadow is applied to its own element so it can be faded in and out - // with opacity which is more performant to animate - position: 'absolute', - left: 0, - right: 0, - elevation: 10, - shadowColor: '#000', - shadowRadius: 5.46, - }, -}); diff --git a/templates/boilerplate/src/components/Screen.tsx b/templates/boilerplate/src/components/Screen.tsx index db32dcf..db4e300 100644 --- a/templates/boilerplate/src/components/Screen.tsx +++ b/templates/boilerplate/src/components/Screen.tsx @@ -1,7 +1,6 @@ import { useNavigation } from '@react-navigation/native'; -import { StatusBarStyle } from 'expo-status-bar'; import { ReactNode } from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { KeyboardAwareScrollView, KeyboardAwareScrollViewProps, @@ -12,7 +11,14 @@ import { } from 'react-native-safe-area-context'; type Props = KeyboardAwareScrollViewProps & { + /** + * If true (default), horizontal padding is added to the screen content + */ padHorizontal?: boolean; + /** + * If true, the screen will be scrollable. If false, the screen will not scroll. + * Set to false if screen includes a scrollable component like a FlatList + */ scroll?: boolean; /** * If true, a safe area view is not added for the top of the screen, since it is @@ -25,19 +31,13 @@ type Props = KeyboardAwareScrollViewProps & { * is used on the bottom */ FixedBottomComponent?: ReactNode; fixedBottomAddSafeArea?: boolean; - statusBarStyle?: StatusBarStyle; }; -const AnimatedKeyboardAwareScrollView = Animated.createAnimatedComponent( - KeyboardAwareScrollView, -); - export default function Screen({ style, padHorizontal = true, scroll = true, testID, - /** if screen has a navigation header, safe area view is not needed, since header takes into account */ hasHeader = false, children, FixedBottomComponent, @@ -64,20 +64,17 @@ export default function Screen({ ]} > {scroll ? ( - navigation.goBack()} - testID={`${testID || 'ScreenContainer'}ScrollView`} + testID={`${testID || 'Screen'}ScrollView`} showsVerticalScrollIndicator={false} - contentContainerStyle={{ - paddingTop: 0, - }} {...props} > {children} - + ) : ( children )} diff --git a/templates/boilerplate/src/navigators/DashboardStack.tsx b/templates/boilerplate/src/navigators/DashboardStack.tsx index 5de7e34..1ef4f67 100644 --- a/templates/boilerplate/src/navigators/DashboardStack.tsx +++ b/templates/boilerplate/src/navigators/DashboardStack.tsx @@ -9,7 +9,11 @@ const Dashboard = createNativeStackNavigator(); export default function DashboardStack() { return ( - + ); diff --git a/templates/boilerplate/src/navigators/TabNavigator.tsx b/templates/boilerplate/src/navigators/TabNavigator.tsx index 09c3f20..9311422 100644 --- a/templates/boilerplate/src/navigators/TabNavigator.tsx +++ b/templates/boilerplate/src/navigators/TabNavigator.tsx @@ -19,6 +19,11 @@ function AccountIcon({ focused = false, color = 'gray' }) { return ; } +// To add a new bottom tab: +// 1. Create a new stack navigator for the tab's screens +// 2. Add a new screen to the stack navigator +// 3. Add a new Tab.Screen to the TabNavigator +// 4. Update navigatorTypes with the TypeScript types for the tab export default function TabNavigator() { return ( ; +} + +function Header() { + return ( + <> + + Open Source + + + Here are a few projects that we maintain. + + + ); +} + +// TODO: sample data, remove +function GitHubProjects() { const { data, isLoading } = useQuery({ queryKey: ['githubRepos'], queryFn: api.githubRepos, @@ -25,7 +43,7 @@ export default function AboutScreen() { } - keyExtractor={(item) => item.id} + keyExtractor={(item) => item.id.toString()} ListHeaderComponent={Header} stickyHeaderHiddenOnScroll ListEmptyComponent={ @@ -37,17 +55,6 @@ export default function AboutScreen() { ); } -function Header() { - return ( - <> - Open Source - - Here are a few projects that we maintain. - - - ); -} - // TODO: sample data, remove function Project({ project }: { project: GithubProject }) { const { name, description, stars } = project; diff --git a/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx b/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx index c899a32..7c99ef3 100644 --- a/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx +++ b/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx @@ -1,30 +1,12 @@ -import { useNavigation } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; -import { Button, StyleSheet, Text, View } from 'react-native'; -import { HomeScreenProp } from 'src/navigators/navigatorTypes'; +import Screen from 'src/components/Screen'; +import HomeScreenContent from './HomeScreenContent'; export default function HomeScreen() { - const navigation = useNavigation(); - return ( - - Open up App.tsx to start working on your app! -