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); }