diff --git a/src/cli.ts b/src/cli.ts
index 1dd7a5b..4f42edc 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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')
diff --git a/src/commands/__tests__/createApp.test.ts b/src/commands/__tests__/createApp.test.ts
index f883a07..e87a791 100644
--- a/src/commands/__tests__/createApp.test.ts
+++ b/src/commands/__tests__/createApp.test.ts
@@ -38,4 +38,12 @@ test('creates app', async () => {
);
expect(homeScreen).toMatch('expo-status-bar');
+
+ // from React Query sub-command
+ expect(homeScreen).toMatch('');
+
+ expect(fs.readFileSync('App.tsx', 'utf8')).toMatch('');
+ expect(fs.readFileSync('src/test/render.tsx', 'utf8')).toMatch(
+ ' ({ default: vi.fn() }));
@@ -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);
});
diff --git a/src/commands/createApp.ts b/src/commands/createApp.ts
index 8c7fa4a..c2eaaca 100644
--- a/src/commands/createApp.ts
+++ b/src/commands/createApp.ts
@@ -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';
@@ -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(`
@@ -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) {
diff --git a/src/commands/reactQuery.ts b/src/commands/reactQuery.ts
new file mode 100644
index 0000000..56b288e
--- /dev/null
+++ b/src/commands/reactQuery.ts
@@ -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()
+});
+`,
+ );
+}
diff --git a/src/util/__tests__/prependToFile.test.ts b/src/util/__tests__/prependToFile.test.ts
new file mode 100644
index 0000000..bdcb0a7
--- /dev/null
+++ b/src/util/__tests__/prependToFile.test.ts
@@ -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',
+ );
+});
diff --git a/src/util/addToGitignore.ts b/src/util/addToGitignore.ts
index fe11220..7fb16cf 100644
--- a/src/util/addToGitignore.ts
+++ b/src/util/addToGitignore.ts
@@ -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);
}
diff --git a/src/util/appendToFile.ts b/src/util/appendToFile.ts
new file mode 100644
index 0000000..0ee864f
--- /dev/null
+++ b/src/util/appendToFile.ts
@@ -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}`,
+ );
+}
diff --git a/src/util/copyTemplateDirectory.ts b/src/util/copyTemplateDirectory.ts
index c8fd2b5..6f5dcae 100644
--- a/src/util/copyTemplateDirectory.ts
+++ b/src/util/copyTemplateDirectory.ts
@@ -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
diff --git a/src/util/prependToFile.ts b/src/util/prependToFile.ts
new file mode 100644
index 0000000..f570419
--- /dev/null
+++ b/src/util/prependToFile.ts
@@ -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}`);
+}
diff --git a/templates/createApp/App.tsx b/templates/createApp/App.tsx
index 3a484a5..01ad486 100644
--- a/templates/createApp/App.tsx
+++ b/templates/createApp/App.tsx
@@ -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) => {children},
+ (children) => (
+ {children}
+ ),
// CODEGEN:BELT:PROVIDERS - do not remove
];
diff --git a/templates/createApp/src/__tests__/App.test.tsx b/templates/createApp/src/__tests__/App.test.tsx
index 7fc6b2d..49f78f6 100644
--- a/templates/createApp/src/__tests__/App.test.tsx
+++ b/templates/createApp/src/__tests__/App.test.tsx
@@ -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();
- 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',
+ },
+ ],
+ });
+}
diff --git a/templates/createApp/src/screens/HomeScreen/HomeScreen.tsx b/templates/createApp/src/screens/HomeScreen/HomeScreen.tsx
index 8fde09e..07222a2 100644
--- a/templates/createApp/src/screens/HomeScreen/HomeScreen.tsx
+++ b/templates/createApp/src/screens/HomeScreen/HomeScreen.tsx
@@ -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 (
Open up App.tsx to start working on your app!
+
);
}
diff --git a/templates/createApp/src/test/render.tsx b/templates/createApp/src/test/render.tsx
new file mode 100644
index 0000000..0a346f7
--- /dev/null
+++ b/templates/createApp/src/test/render.tsx
@@ -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;
+};
+
+// 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) => (
+ {children}
+ ),
+ (children) => {children},
+ // CODEGEN:BELT:PROVIDERS - do not remove
+ ];
+
+ return TestingLibraryRender(
+ {element},
+ );
+}
+
+export function renderApplication(options: RenderOptions = {}) {
+ return render(, options);
+}
diff --git a/templates/reactQuery/src/components/ExampleCoffees.tsx b/templates/reactQuery/src/components/ExampleCoffees.tsx
new file mode 100644
index 0000000..46da8b4
--- /dev/null
+++ b/templates/reactQuery/src/components/ExampleCoffees.tsx
@@ -0,0 +1,49 @@
+import { useQuery } from '@tanstack/react-query';
+import { FlatList, Image, Text, View } from 'react-native';
+import api, { Coffee as CoffeeType } from 'src/util/api/api';
+
+// TODO: sample component, remove
+export default function ExampleCoffees() {
+ const { data } = useQuery({ queryKey: ['coffee'], queryFn: api.coffee });
+
+ return (
+ <>
+ Coffees
+ }
+ keyExtractor={(item) => item.id.toString()}
+ style={{ flexGrow: 0 }}
+ />
+ >
+ );
+}
+
+function Coffee({ coffee }: { coffee: CoffeeType }) {
+ const { title, image } = coffee;
+
+ return (
+
+
+ {title}
+
+
+
+ );
+}
diff --git a/templates/reactQuery/src/test/mock.ts b/templates/reactQuery/src/test/mock.ts
new file mode 100644
index 0000000..f424238
--- /dev/null
+++ b/templates/reactQuery/src/test/mock.ts
@@ -0,0 +1,148 @@
+import {
+ DefaultBodyType,
+ HttpResponse,
+ PathParams,
+ StrictRequest,
+ http,
+} from 'msw';
+import { BASE_URL, RequestMethod } from 'src/util/api/api';
+
+type RequestParams = Record<
+ string,
+ string | number | undefined | (string | number)[]
+>;
+type MockRequestParams = {
+ method: RequestMethod;
+ response?: TData;
+ headers?: Record;
+ params?: Partial;
+ status?: number;
+ delay?: number;
+ baseUrl?: string;
+};
+
+function mockRequest(
+ path: string,
+ {
+ method,
+ status = 200,
+ response,
+ headers,
+ params,
+ baseUrl = BASE_URL,
+ }: MockRequestParams,
+) {
+ const methodName = method.toLowerCase() as Lowercase;
+ return http[methodName](
+ `${baseUrl}/${path}`,
+ async (info) => {
+ const { request, params: actualParams } = info;
+ validateHeaders(headers, request);
+ await validateParams(params, actualParams, request);
+
+ const responseString =
+ typeof response === 'string' ? response : JSON.stringify(response);
+ return new HttpResponse(responseString, { status });
+ },
+ { once: true },
+ );
+}
+
+function validateHeaders(
+ expectedHeaders: Record | undefined,
+ req: StrictRequest,
+) {
+ if (!expectedHeaders) {
+ return;
+ }
+
+ Object.entries(expectedHeaders).forEach(([key, value]) => {
+ try {
+ expect(req.headers.get(key)).toEqual(value);
+ } catch (e) {
+ handleAndThrowError(req, e, 'the headers did not match expectation');
+ }
+ });
+}
+
+async function validateParams(
+ expectedParams: TParams | undefined,
+ actualParams: PathParams,
+ req: StrictRequest,
+) {
+ if (!expectedParams) {
+ return;
+ }
+
+ const searchParams = Object.fromEntries(new URL(req.url).searchParams);
+ const params = Object.keys(searchParams).length ? searchParams : actualParams;
+
+ try {
+ expect(params).toMatchObject(expectedParams);
+ } catch (e) {
+ handleAndThrowError(req, e, 'the params did not match expectation');
+ }
+}
+
+function handleAndThrowError(
+ request: StrictRequest,
+ e: unknown,
+ message: string,
+) {
+ const error = e as Error;
+ if (error.message) {
+ error.message = `Mock for ${request.method} ${
+ request.url
+ } was called, but ${message}. Verify that the mocks provided to the test are correct.\n\n${
+ error.message
+ }.\n\nThis error occurred in test: ${
+ expect.getState().testPath || ''
+ }. Test name: '${expect.getState().currentTestName || 'unknown'}'`;
+ }
+ // eslint-disable-next-line no-console
+ console.error(error.stack);
+ throw error;
+}
+
+export type MockParams = Omit<
+ MockRequestParams,
+ 'method'
+>;
+
+/**
+ * mock requests for tests
+ * Eg. usage:
+ * const mocks = [
+ * mock.post('/user/login', { response: { firstName: 'Debra' }})
+ * ])
+ *
+ * render(\, { mocks })
+ */
+const mock = {
+ /**
+ * mock a GET request to the specified path.
+ * If params are passed, will throw an error if request params do not match
+ * */
+ get: (
+ path: string,
+ params: MockParams = {},
+ ) => mockRequest(path, { ...params, method: 'GET' }),
+ /**
+ * mock a POST request to the specified path
+ * if params are passed, will throw an error if request params do not match
+ * */
+ post: (
+ path: string,
+ params: MockParams = {},
+ ) => mockRequest(path, { ...params, method: 'POST' }),
+ /**
+ * mock a DELETE request to the specified path
+ * if params are passed, will throw an error if request params do not match
+ * */
+ delete: (
+ path: string,
+ params: MockParams = {},
+ ) => mockRequest(path, { ...params, method: 'DELETE' }),
+};
+
+export default mock;
diff --git a/templates/reactQuery/src/test/server.ts b/templates/reactQuery/src/test/server.ts
new file mode 100644
index 0000000..dbe8f29
--- /dev/null
+++ b/templates/reactQuery/src/test/server.ts
@@ -0,0 +1,9 @@
+import { setupServer } from 'msw/node';
+
+/**
+ * MSW server for mocking network requests
+ * server is started in jest.setup.js
+ * individual tests can pass mocks to 'render' function
+ */
+const server = setupServer();
+export default server;
diff --git a/templates/reactQuery/src/util/api/api.ts b/templates/reactQuery/src/util/api/api.ts
new file mode 100644
index 0000000..b25ce11
--- /dev/null
+++ b/templates/reactQuery/src/util/api/api.ts
@@ -0,0 +1,44 @@
+import axios from 'axios';
+
+export type RequestMethod = 'GET' | 'POST' | 'DELETE';
+
+// Consider moving this out into environment configuration
+// to support multiple environments
+export const BASE_URL = 'https://api.sampleapis.com';
+
+type Params = {
+ path: string;
+ method: RequestMethod;
+ params?: unknown;
+ parseJson?: boolean;
+ baseUrl?: string;
+};
+
+async function makeRequest(options: Params): Promise {
+ const { path, method, params, baseUrl = BASE_URL } = options;
+
+ const response = await axios(`${baseUrl}/${path}`, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ [method === 'GET' ? 'params' : 'data']: params,
+ });
+
+ return response.data;
+}
+
+const api = {
+ coffee: () => makeRequest({ path: 'coffee/hot', method: 'GET' }),
+};
+
+// TODO: sample data, remove
+export type Coffee = {
+ title: string;
+ description: string;
+ /** url */
+ image: string;
+ id: number;
+};
+
+export default api;
diff --git a/templates/reactQuery/src/util/api/queryClient.ts b/templates/reactQuery/src/util/api/queryClient.ts
new file mode 100644
index 0000000..e358b6f
--- /dev/null
+++ b/templates/reactQuery/src/util/api/queryClient.ts
@@ -0,0 +1,11 @@
+import { QueryClient } from '@tanstack/react-query';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+});
+
+export default queryClient;
diff --git a/templates/testingLibrary/src/test/render.tsx b/templates/testingLibrary/src/test/render.tsx
index 7c904eb..f7b4a93 100644
--- a/templates/testingLibrary/src/test/render.tsx
+++ b/templates/testingLibrary/src/test/render.tsx
@@ -4,12 +4,9 @@ import {
render as TestingLibraryRender,
} from '@testing-library/react-native';
import { ReactElement } from 'react';
-import { NavigationContainer } from '@react-navigation/native';
// 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): RenderAPI {
- return TestingLibraryRender(
- {element},
- );
+ return TestingLibraryRender(element);
}