Skip to content

Commit

Permalink
Add React Query command
Browse files Browse the repository at this point in the history
This follows the template that [we previously coded in
react-native-templates](https://github.com/thoughtbot/react-native-templates/pull/1/files#diff-fc130cd2eebfc7cbeb229f509cb090c6fb5837a169b9270709cfa73ff46c9a56).

* Installs React Query
* Installs testing/mocking utility, MSW
* Creates a mocking and testing strategy
* Adds an example API call and mock and test

For now, the "create" command automatically uses React Query, but we
will likely decide to prompt the user if they'd like to use this or
Apollo (for GraphQL) in the future.

Co-authored-by: Frida Casas <frida@thoughtbot.com>
  • Loading branch information
Stephen Hanson and fridaland committed Dec 12, 2023
1 parent f536334 commit b135a6e
Show file tree
Hide file tree
Showing 20 changed files with 448 additions and 61 deletions.
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export default function runCli() {
.description('Install and configure Jest and Testing Library')
.action(buildAction(import('./commands/testingLibrary')));

program
.command('react-query')
.description('Add React Query')
.action(buildAction(import('./commands/reactQuery')));

program
.command('navigation')
.description('Install and configure React Navigation')
Expand Down
8 changes: 8 additions & 0 deletions src/commands/__tests__/createApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,12 @@ test('creates app', async () => {
);

expect(homeScreen).toMatch('expo-status-bar');

// from React Query sub-command
expect(homeScreen).toMatch('<ExampleCoffees />');

expect(fs.readFileSync('App.tsx', 'utf8')).toMatch('<QueryClientProvider>');
expect(fs.readFileSync('src/test/render.tsx', 'utf8')).toMatch(
'<QueryClientProvider',
);
});
66 changes: 26 additions & 40 deletions src/commands/__tests__/reactQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { fs, vol } from 'memfs';
import { Mock, afterEach, expect, test, vi } from 'vitest';
import addDependency from '../../util/addDependency';
import print from '../../util/print';
import addTypescript from '../typescript';
import addReactQuery from '../reactQuery';

vi.mock('../../util/addDependency');
vi.mock('../../util/print', () => ({ default: vi.fn() }));
Expand All @@ -12,65 +11,52 @@ afterEach(() => {
(print as Mock).mockReset();
});

test('exits with message if tsconfig.json already exists', async () => {
test('installs React Query and copies templates', async () => {
const json = {
'package.json': JSON.stringify({
scripts: {},
dependencies: {},
}),
'tsconfig.json': '1',
'jest.setup.js': 'import React from "react";\n\n// stuff',
};
vol.fromJSON(json, './');

await addTypescript();
expect(print).toHaveBeenCalledWith(
expect.stringMatching(/tsconfig\.json already exists/),
);
vol.fromJSON(json, './');

// doesn't modify
expect(fs.readFileSync('tsconfig.json', 'utf8')).toEqual('1');
});
await addReactQuery();

test('writes new tsconfig.json, adds dependencies', async () => {
vol.fromJSON({
'package.json': JSON.stringify({
scripts: {},
dependencies: {
expo: '1.0.0',
},
}),
});
expect(fs.existsSync('src/util/api/api.ts')).toBe(true);

await addTypescript();
expect(fs.readFileSync('jest.setup.js', 'utf8')).toMatchInlineSnapshot(`
"import server from 'src/test/server';
import React from \\"react\\";
expect(addDependency).toHaveBeenCalledWith('typescript @types/react', {
dev: true,
});
// stuff
expect(fs.readFileSync('tsconfig.json', 'utf8')).toMatch(
'"extends": "expo/tsconfig.base"',
);
// listen with MSW server. Individual tests can pass mocks to 'render' function
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
expect(print).not.toHaveBeenCalledWith(
expect.stringMatching(/already exists/),
);
beforeEach(() => {
server.resetHandlers()
});
"
`);
});

test("doesn't extend expo/tsconfig.base if not an Expo project", async () => {
vol.fromJSON({
test('creates jest.setup.js if doesnt exist', async () => {
const json = {
'package.json': JSON.stringify({
scripts: {},
dependencies: {},
}),
});
'tsconfig.json': '1',
'jest.setup.js': 'import React from "react";\n\n// stuff',
};

await addTypescript();
vol.fromJSON(json, './');

expect(addDependency).toHaveBeenCalledWith('typescript @types/react', {
dev: true,
});
await addReactQuery();

expect(fs.readFileSync('tsconfig.json', 'utf8')).not.toMatch(
'expo/tsconfig.base',
);
expect(fs.existsSync('jest.setup.js')).toBe(true);
});
9 changes: 9 additions & 0 deletions src/commands/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import print from '../util/print';
import addEslint from './eslint';
import addNavigation from './navigation';
import addPrettier from './prettier';
import addReactQuery from './reactQuery';
import createScaffold from './scaffold';
import addTestingLibrary from './testingLibrary';
import addTypescript from './typescript';
Expand Down Expand Up @@ -89,9 +90,16 @@ export async function createApp(name: string | undefined, options: Options) {
await addNavigation();
await commit('Add navigation');

await addReactQuery();
await commit('Add React Query');

await copyTemplateDirectory({ templateDir: 'createApp' });
await commit('Add scaffold');

spinner.start('Formatting codebase');
await exec('npm run fix:prettier');
spinner.succeed('Formatted codebase');

print(chalk.green(`\n\n👖 ${appName} successfully configured!`));

print(`
Expand Down Expand Up @@ -134,6 +142,7 @@ async function printIntro() {
- Create the project directory structure
- Install and configure Jest and Testing Library
- Install and configure React Navigation
- Install and configure React Query
`);

if (!globals.interactive) {
Expand Down
22 changes: 22 additions & 0 deletions src/commands/reactQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import addDependency from '../util/addDependency';
import appendToFile from '../util/appendToFile';
import copyTemplateDirectory from '../util/copyTemplateDirectory';
import prependToFile from '../util/prependToFile';

export default async function addReactQuery() {
await addDependency('@tanstack/react-query axios msw');
await copyTemplateDirectory({ templateDir: 'reactQuery' });
await prependToFile('jest.setup.js', "import server from 'src/test/server';");
await appendToFile(
'jest.setup.js',
`
// listen with MSW server. Individual tests can pass mocks to 'render' function
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
beforeEach(() => {
server.resetHandlers()
});
`,
);
}
17 changes: 17 additions & 0 deletions src/util/__tests__/prependToFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { fs, vol } from 'memfs';
import { expect, test } from 'vitest';
import prependToFile from '../prependToFile';

test('prepends contents', async () => {
const json = {
'package.json': '1',
'src/myFile.txt': 'hello world',
};

vol.fromJSON(json, './');

await prependToFile('src/myFile.txt', 'prepended\ntwo');
expect(fs.readFileSync('src/myFile.txt', 'utf8')).toMatch(
'prepended\ntwo\nhello world',
);
});
10 changes: 3 additions & 7 deletions src/util/addToGitignore.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import fs from 'fs-extra';
import path from 'path';
import getProjectDir from './getProjectDir';
import appendToFile from './appendToFile';

/**
* lines should be separated by newlines
*/
export default async function addToGitignore(lines: string) {
return fs.appendFile(
path.join(await getProjectDir(), '.gitignore'),
`\n${lines}`,
);
return appendToFile('.gitignore', lines);
}
12 changes: 12 additions & 0 deletions src/util/appendToFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import fs from 'fs-extra';
import path from 'path';
import getProjectDir from './getProjectDir';
/**
* lines should be separated by newlines
*/
export default async function appendToFile(filename: string, lines: string) {
return fs.appendFile(
path.join(await getProjectDir(), filename),
`\n${lines}`,
);
}
4 changes: 0 additions & 4 deletions src/util/copyTemplateDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ async function renderTemplates(
await writeFile(
path.join(destinationFilename.replace(/\.eta$/, '')),
rendered,
{
// don't try to format if unsupported file type (Prettier will error)
format: /\.(js|ts|jsx|tsx|json|md)\.eta$/.test(destinationFilename),
},
);

// remove .eta file
Expand Down
12 changes: 12 additions & 0 deletions src/util/prependToFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import fs from 'fs-extra';
import path from 'path';
import getProjectDir from './getProjectDir';

/**
* lines should be separated by newlines
*/
export default async function prependToFile(filename: string, lines: string) {
const fullFilename = path.join(await getProjectDir(), filename);
const contents = await fs.readFile(fullFilename, 'utf8');
return fs.writeFile(fullFilename, `${lines}\n${contents}`);
}
10 changes: 8 additions & 2 deletions templates/createApp/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { NavigationContainer } from '@react-navigation/native';
import RootNavigator from 'src/navigators/RootNavigator';
import { QueryClientProvider } from '@tanstack/react-query';
import Providers, { Provider } from 'src/components/Providers';
import RootNavigator from 'src/navigators/RootNavigator';
import queryClient from '../reactQuery/src/util/api/queryClient';

// Add providers to this array
// Add providers to this array. They will be wrapped around the app, with the
// first items in the array wrapping the last items in the array.
const providers: Provider[] = [
(children) => <NavigationContainer>{children}</NavigationContainer>,
(children) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
// CODEGEN:BELT:PROVIDERS - do not remove
];

Expand Down
23 changes: 19 additions & 4 deletions templates/createApp/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { screen } from '@testing-library/react-native';
import RootNavigator from 'src/navigators/RootNavigator';
import render from 'src/test/render';
import mock from 'src/test/mock';
import { renderApplication } from 'src/test/render';

test('renders', async () => {
render(<RootNavigator />);
expect(await screen.findByText(/Open up App.tsx/)).toBeDefined();
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',
},
],
});
}
2 changes: 2 additions & 0 deletions templates/createApp/src/screens/HomeScreen/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
import ExampleCoffees from 'src/components/ExampleCoffees';

export default function HomeScreen() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
<ExampleCoffees />
</View>
);
}
Expand Down
44 changes: 44 additions & 0 deletions templates/createApp/src/test/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NavigationContainer } from '@react-navigation/native';
import { QueryClientProvider } from '@tanstack/react-query';
import {
RenderAPI,
// eslint-disable-next-line no-restricted-imports
render as TestingLibraryRender,
} from '@testing-library/react-native';
import { RequestHandler } from 'msw';
import { ReactElement } from 'react';
import Providers, { Provider } from 'src/components/Providers';
import RootNavigator from 'src/navigators/RootNavigator';
import queryClient from 'src/util/api/queryClient';
import server from './server';

export type RenderOptions = {
mocks?: Array<RequestHandler>;
};

// TODO: this will become customized as the codebase progresses, so our
// tests can be wrapped with appropriate providers, mocks can be supplied, etc
export default function render(
element: ReactElement,
{ mocks }: RenderOptions = {},
): RenderAPI {
if (mocks) {
server.use(...mocks);
}

const providers: Provider[] = [
(children) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
(children) => <NavigationContainer>{children}</NavigationContainer>,
// CODEGEN:BELT:PROVIDERS - do not remove
];

return TestingLibraryRender(
<Providers providers={providers}>{element}</Providers>,
);
}

export function renderApplication(options: RenderOptions = {}) {
return render(<RootNavigator />, options);
}
Loading

0 comments on commit b135a6e

Please sign in to comment.