diff --git a/package-lock.json b/package-lock.json index 74dd0cc5..5e6adcc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17594,6 +17594,12 @@ "is-typedarray": "^1.0.0" } }, + "typescript": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", + "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "dev": true + }, "typographic-apostrophes": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/typographic-apostrophes/-/typographic-apostrophes-1.1.1.tgz", diff --git a/package.json b/package.json index 44c666a0..343e991a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "husky": "^4.3.8", "npm-run-all": "^4.1.5", "prettier": "^2.2.1", - "react-scripts": "^4.0.1" + "react-scripts": "^4.0.1", + "typescript": "^4.1.3" }, "scripts": { "start": "react-scripts start", @@ -53,7 +54,14 @@ ] }, "eslintConfig": { - "extends": "react-app" + "extends": [ + "react-app", + "react-app/jest", + "plugin:jsx-a11y/recommended" + ], + "plugins": [ + "jsx-a11y" + ] }, "babel": { "presets": [ diff --git a/src/__tests__/01.js b/src/__tests__/01.tsx similarity index 83% rename from src/__tests__/01.js rename to src/__tests__/01.tsx index a64822db..3a57f027 100644 --- a/src/__tests__/01.js +++ b/src/__tests__/01.tsx @@ -4,13 +4,16 @@ import userEvent from '@testing-library/user-event' import * as userClient from '../user-client' import {AuthProvider} from '../auth-context' import App from '../final/01' +import type {User} from '../types' // import App from '../exercise/01' jest.mock('../user-client', () => { return {updateUser: jest.fn(() => Promise.resolve())} }) -const mockUser = {username: 'jakiechan', tagline: '', bio: ''} +const mockUserClient = userClient as jest.Mocked; + +const mockUser: User = {username: 'jakiechan', tagline: '', bio: ''} function renderApp() { const utils = render( @@ -24,14 +27,20 @@ function renderApp() { ...utils, submitButton: screen.getByText(/βœ”/), resetButton: screen.getByText(/reset/i), - taglineInput: screen.getByLabelText(/tagline/i), - bioInput: screen.getByLabelText(/bio/i), + taglineInput: screen.getByLabelText(/tagline/i) as HTMLInputElement, + bioInput: screen.getByLabelText(/bio/i) as HTMLInputElement, waitForLoading: () => waitForElementToBeRemoved(() => screen.getByText(/\.\.\./i)), userDisplayPre, - getDisplayData: () => JSON.parse(userDisplayPre.textContent), + getDisplayData: () => { + if (userDisplayPre && userDisplayPre.textContent) { + return JSON.parse(userDisplayPre.textContent) + } + throw new Error('userDisplayPre node is null') + }, } } +// type T0 = HTMLElement test('happy path works', async () => { const { @@ -57,7 +66,7 @@ test('happy path works', async () => { expect(resetButton).not.toHaveAttribute('disabled') const updatedUser = {...mockUser, ...testData} - userClient.updateUser.mockImplementationOnce(() => + mockUserClient.updateUser.mockImplementationOnce(() => Promise.resolve(updatedUser), ) @@ -70,7 +79,7 @@ test('happy path works', async () => { // submitting the form invokes userClient.updateUser expect(userClient.updateUser).toHaveBeenCalledTimes(1) expect(userClient.updateUser).toHaveBeenCalledWith(mockUser, testData) - userClient.updateUser.mockClear() + mockUserClient.updateUser.mockClear() // once the submit button changes from ... then we know the request is over await waitForLoading() @@ -109,7 +118,7 @@ test('failure works', async () => { const testData = {...mockUser, bio: 'test bio'} userEvent.type(bioInput, testData.bio) const testErrorMessage = 'test error message' - userClient.updateUser.mockImplementationOnce(() => + mockUserClient.updateUser.mockImplementationOnce(() => Promise.reject({message: testErrorMessage}), ) @@ -123,9 +132,9 @@ test('failure works', async () => { screen.getByText(testErrorMessage) expect(getDisplayData()).toEqual(mockUser) - userClient.updateUser.mockClear() + mockUserClient.updateUser.mockClear() - userClient.updateUser.mockImplementationOnce(() => + mockUserClient.updateUser.mockImplementationOnce(() => Promise.resolve(updatedUser), ) userEvent.click(submitButton) diff --git a/src/__tests__/02.js b/src/__tests__/02.tsx similarity index 95% rename from src/__tests__/02.js rename to src/__tests__/02.tsx index c2e315db..e561e40a 100644 --- a/src/__tests__/02.js +++ b/src/__tests__/02.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import {renderToggle} from '../../test/utils' import App from '../final/02' +// import App from '../final-ts/02' // import App from '../exercise/02' test('renders a toggle component', () => { diff --git a/src/__tests__/03.js b/src/__tests__/03.tsx similarity index 95% rename from src/__tests__/03.js rename to src/__tests__/03.tsx index 96a8d0cd..523c9d3d 100644 --- a/src/__tests__/03.js +++ b/src/__tests__/03.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import {renderToggle} from '../../test/utils' import App from '../final/03' +// import App from '../final-ts/03' // import App from '../exercise/03' test('renders a toggle component', () => { diff --git a/src/__tests__/04.js b/src/__tests__/04.tsx similarity index 96% rename from src/__tests__/04.js rename to src/__tests__/04.tsx index eeb1afc6..ba75feb3 100644 --- a/src/__tests__/04.js +++ b/src/__tests__/04.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import {renderToggle, screen, userEvent} from '../../test/utils' import App from '../final/04' +// import App from '../final-ts/04' // import App from '../exercise/04' test('renders a toggle component', () => { diff --git a/src/__tests__/05.js b/src/__tests__/05.tsx similarity index 89% rename from src/__tests__/05.js rename to src/__tests__/05.tsx index 735f644a..ecb99aa6 100644 --- a/src/__tests__/05.js +++ b/src/__tests__/05.tsx @@ -1,7 +1,12 @@ import * as React from 'react' import {renderToggle, screen, userEvent} from '../../test/utils' import App from '../final/05' +// import App from '../final-ts/05' +// import App from '../final-ts/05.extra-1' +// import App from '../final-ts/05.extra-2' + // import App from '../exercise/05' +// import App from '../exercise-ts/05' test('renders a toggle component', () => { const {toggleButton, toggle} = renderToggle() diff --git a/src/__tests__/06.extra-4.js b/src/__tests__/06.extra-4.tsx similarity index 96% rename from src/__tests__/06.extra-4.js rename to src/__tests__/06.extra-4.tsx index 2ca7fa51..61b7b6f6 100644 --- a/src/__tests__/06.extra-4.js +++ b/src/__tests__/06.extra-4.tsx @@ -3,7 +3,9 @@ import {alfredTip} from '@kentcdodds/react-workshop-app/test-utils' import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {Toggle} from '../final/06.extra-4' +// import {Toggle} from '../final-ts/06.extra-4' // import {Toggle} from '../exercise/06' +// import {Toggle} from '../exercise-ts/06' beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/__tests__/06.js b/src/__tests__/06.tsx similarity index 84% rename from src/__tests__/06.js rename to src/__tests__/06.tsx index 9de68ba7..73480af3 100644 --- a/src/__tests__/06.js +++ b/src/__tests__/06.tsx @@ -1,7 +1,9 @@ import * as React from 'react' import {renderToggle, screen, userEvent} from '../../test/utils' -import App, {Toggle} from '../final/06' +// import App, {Toggle} from '../final/06' +import App, {Toggle} from '../final-ts/06' // import App, {Toggle} from '../exercise/06' +// import App, {Toggle} from '../exercise-ts/06' test('toggling either toggle toggles both', () => { renderToggle() diff --git a/src/auth-context.js b/src/auth-context.tsx similarity index 59% rename from src/auth-context.js rename to src/auth-context.tsx index bc6c3944..bf138c44 100644 --- a/src/auth-context.js +++ b/src/auth-context.tsx @@ -1,17 +1,23 @@ import * as React from 'react' +import type {User} from './types' // normally this is going to implement a similar pattern // learn more here: https://kcd.im/auth -const AuthContext = React.createContext({ +interface IAuthContext { + user: User +} + +const AuthContext = React.createContext({ user: {username: 'jakiechan', tagline: '', bio: ''}, }) AuthContext.displayName = 'AuthContext' -const AuthProvider = ({user, ...props}) => ( + +const AuthProvider: React.FC<{user: IAuthContext}> = ({user, ...props}) => ( ) -function useAuth() { +function useAuth(): IAuthContext { return React.useContext(AuthContext) } diff --git a/src/exercise/01.tsx b/src/exercise/01.tsx new file mode 100644 index 00000000..7e9cf0ba --- /dev/null +++ b/src/exercise/01.tsx @@ -0,0 +1,220 @@ +// Context Module Functions +// http://localhost:3000/isolated/exercise/01.js + +import * as React from 'react' +import {dequal} from 'dequal' + +// ./context/user-context.js + +import * as userClient from '../user-client' +import {useAuth} from '../auth-context' +import type {User} from '../types' + +type State = { + status: null | 'pending' | 'resolved' | 'rejected' + error: null | Error + user: User + storedUser: null | User +} + +type Action = + | {type: 'start update'; updates: User} + | {type: 'finish update'; updatedUser: User} + | {type: 'fail update'; error: Error} + | {type: 'reset'} +function userReducer(state: State, action: Action): State { + switch (action.type) { + case 'start update': { + return { + ...state, + user: {...state.user, ...action.updates}, + status: 'pending', + storedUser: state.user, + } + } + case 'finish update': { + return { + ...state, + user: action.updatedUser, + status: 'resolved', + storedUser: null, + error: null, + } + } + case 'fail update': { + return { + ...state, + status: 'rejected', + error: action.error, + // @ts-expect-error FIXME: Type 'User | null' is not assignable to type 'User'. + user: state.storedUser, + storedUser: null, + } + } + case 'reset': { + return { + ...state, + status: null, + error: null, + } + } + default: { + // @ts-expect-error + throw new Error(`Unhandled action type: ${action.type}`) + } + } +} + +type UserContextType = readonly [state: State, dispatch: React.Dispatch] +const UserContext = React.createContext(undefined!) +UserContext.displayName = 'UserContext' + +type UserProviderProps = {children: React.ReactNode} +function UserProvider({children}: UserProviderProps): JSX.Element { + const {user} = useAuth() + const [state, dispatch] = React.useReducer(userReducer, { + status: null, + error: null, + storedUser: user, + user, + }) + const value = [state, dispatch] as const + return +} + +function useUser(): UserContextType { + const context = React.useContext(UserContext) + if (context === undefined) { + throw new Error(`useUser must be used within a UserProvider`) + } + return context +} + +// 🐨 add a function here called `updateUser` +// Then go down to the `handleSubmit` from `UserSettings` and put that logic in +// this function. It should accept: dispatch, user, and updates + +// export {UserProvider, useUser} + +// src/screens/user-profile.js +// import {UserProvider, useUser} from './context/user-context' +function UserSettings(): JSX.Element { + const [{user, status, error}, userDispatch] = useUser() + + const isPending: boolean = status === 'pending' + const isRejected: boolean = status === 'rejected' + + const [formState, setFormState] = React.useState(user) + + const isChanged: boolean = !dequal(user, formState) + + function handleChange( + e: React.ChangeEvent, + ): void { + setFormState({...formState, [e.target.name]: e.target.value}) + } + + function handleSubmit(event: React.FormEvent): void { + event.preventDefault() + // 🐨 move the following logic to the `updateUser` function you create above + userDispatch({type: 'start update', updates: formState}) + userClient.updateUser(user, formState).then( + updatedUser => userDispatch({type: 'finish update', updatedUser}), + error => userDispatch({type: 'fail update', error}), + ) + } + + return ( +
+
+ + +
+
+ + +
+
+ +