├── src ├── pages │ ├── Error │ │ ├── index.js │ │ ├── Error.model.js │ │ ├── Error.jsx │ │ └── Error.test.jsx │ ├── Success │ │ ├── index.js │ │ ├── Success.model.js │ │ ├── Success.jsx │ │ └── Success.test.jsx │ ├── Confirmation │ │ ├── index.js │ │ ├── Confirmation.model.js │ │ ├── Confirmation.jsx │ │ └── Confirmation.test.jsx │ ├── MoreInfo │ │ ├── index.js │ │ ├── MoreInfo.mock.js │ │ ├── MoreInfo.model.js │ │ ├── MoreInfo.jsx │ │ └── MoreInfo.test.jsx │ └── UserData │ │ ├── index.js │ │ ├── UserData.mock.js │ │ ├── UserData.model.js │ │ ├── UserData.jsx │ │ └── UserData.test.jsx ├── constants │ ├── index.js │ └── SignupStep.js ├── components │ └── PageWrapper │ │ ├── index.js │ │ ├── PageWrapper.jsx │ │ └── PageWrapper.style.jsx ├── providers │ ├── index.js │ └── SignupFormProvider.jsx ├── contexts │ ├── index.js │ └── SignupFormContext.jsx ├── App.test.jsx ├── store │ └── store.js ├── index.jsx ├── apis │ └── upgradeApi.js ├── App.jsx ├── hooks │ ├── useFormStorage.js │ └── useFormStorage.test.js └── assets │ └── upgradeLogo.svg ├── __mocks__ └── fileMock.js ├── public └── favicon.ico ├── 20180829-wireframe001.png ├── .prettierrc ├── babel.config.json ├── .gitignore ├── index.html ├── vite.config.js ├── server ├── index.js └── index.test.js ├── eslint.config.js ├── setup-tests.js ├── package.json ├── INTRODUCTION.md └── README.md /src/pages/Error/index.js: -------------------------------------------------------------------------------- 1 | export * from './Error'; -------------------------------------------------------------------------------- /src/pages/Success/index.js: -------------------------------------------------------------------------------- 1 | export * from './Success'; -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | export * from './SignupStep'; 2 | -------------------------------------------------------------------------------- /src/pages/Confirmation/index.js: -------------------------------------------------------------------------------- 1 | export * from './Confirmation'; -------------------------------------------------------------------------------- /src/pages/MoreInfo/index.js: -------------------------------------------------------------------------------- 1 | export * from './MoreInfo'; 2 | -------------------------------------------------------------------------------- /src/pages/UserData/index.js: -------------------------------------------------------------------------------- 1 | export * from './UserData'; 2 | -------------------------------------------------------------------------------- /src/components/PageWrapper/index.js: -------------------------------------------------------------------------------- 1 | export * from './PageWrapper'; -------------------------------------------------------------------------------- /src/providers/index.js: -------------------------------------------------------------------------------- 1 | export { SignupFormProvider } from './SignupFormProvider'; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-br/upgrade/master/public/favicon.ico -------------------------------------------------------------------------------- /src/pages/MoreInfo/MoreInfo.mock.js: -------------------------------------------------------------------------------- 1 | export const mockColors = ['red', 'blue', 'green', 'yellow']; -------------------------------------------------------------------------------- /20180829-wireframe001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-br/upgrade/master/20180829-wireframe001.png -------------------------------------------------------------------------------- /src/contexts/index.js: -------------------------------------------------------------------------------- 1 | export { SignupFormContext, useSignupFormContext } from './SignupFormContext'; -------------------------------------------------------------------------------- /src/pages/Error/Error.model.js: -------------------------------------------------------------------------------- 1 | export const testIds = { 2 | restartButton: 'error-restart-button', 3 | }; -------------------------------------------------------------------------------- /src/pages/Success/Success.model.js: -------------------------------------------------------------------------------- 1 | export const testIds = { 2 | restartButton: 'restart-button', 3 | }; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/UserData/UserData.mock.js: -------------------------------------------------------------------------------- 1 | export const mockFormData = { 2 | firstName: 'Upgrade', 3 | email: 'test@upgrade.com', 4 | password: 'password123', 5 | color: 'red', 6 | terms: true, 7 | }; -------------------------------------------------------------------------------- /src/constants/SignupStep.js: -------------------------------------------------------------------------------- 1 | export const SignupStep = { 2 | USER_DATA: '/', 3 | MORE_INFO: '/more-info', 4 | CONFIRMATION: '/confirmation', 5 | SUCCESS: '/success', 6 | ERROR: '/error', 7 | }; 8 | -------------------------------------------------------------------------------- /src/pages/MoreInfo/MoreInfo.model.js: -------------------------------------------------------------------------------- 1 | export const testIds = { 2 | colorSelect: 'color-select', 3 | termsCheckbox: 'terms-checkbox', 4 | backButton: 'back-button', 5 | nextButton: 'next-button', 6 | }; -------------------------------------------------------------------------------- /src/pages/UserData/UserData.model.js: -------------------------------------------------------------------------------- 1 | export const testIds = { 2 | firstNameInput: 'first-name-input', 3 | emailInput: 'email-input', 4 | passwordInput: 'password-input', 5 | submitButton: 'submit-button', 6 | }; -------------------------------------------------------------------------------- /src/App.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { App } from './App'; 4 | 5 | it('renders without crashing', () => { 6 | render(); 7 | expect(document.body).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "esmodules": "true" 8 | } 9 | } 10 | ], 11 | ["@babel/preset-react", { "runtime": "automatic" }] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/Confirmation/Confirmation.model.js: -------------------------------------------------------------------------------- 1 | export const testIds = { 2 | backButton: 'confirmation-back-button', 3 | submitButton: 'confirmation-submit-button', 4 | firstNameField: 'confirmation-first-name', 5 | emailField: 'confirmation-email', 6 | passwordField: 'confirmation-password', 7 | colorField: 'confirmation-color', 8 | termsField: 'confirmation-terms', 9 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /src/contexts/SignupFormContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | const SignupFormContext = createContext(); 4 | 5 | export const useSignupFormContext = () => { 6 | const context = useContext(SignupFormContext); 7 | if (!context) { 8 | throw new Error( 9 | 'useSignupFormContext must be used within a SignupFormProvider' 10 | ); 11 | } 12 | return context; 13 | }; 14 | 15 | export { SignupFormContext }; 16 | -------------------------------------------------------------------------------- /src/store/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { setupListeners } from '@reduxjs/toolkit/query'; 3 | 4 | import { upgradeApi } from '@/apis/upgradeApi'; 5 | 6 | export const store = configureStore({ 7 | reducer: { 8 | [upgradeApi.reducerPath]: upgradeApi.reducer, 9 | }, 10 | 11 | middleware: (getDefaultMiddleware) => 12 | getDefaultMiddleware().concat(upgradeApi.middleware), 13 | }); 14 | 15 | setupListeners(store.dispatch); 16 | -------------------------------------------------------------------------------- /src/components/PageWrapper/PageWrapper.jsx: -------------------------------------------------------------------------------- 1 | import upgradeLogo from '@/assets/upgradeLogo.svg'; 2 | 3 | import { SignupFormProvider } from '@/providers'; 4 | 5 | import { SignupContainer, Logo } from './PageWrapper.style'; 6 | 7 | export const PageWrapper = ({ children }) => { 8 | return ( 9 | 10 | 11 | 12 | {children} 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | Upgrade challenge 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | import { ConfigProvider } from 'antd'; 5 | 6 | import { store } from '@/store/store'; 7 | 8 | import { App } from './App'; 9 | 10 | ReactDOM.createRoot(document.getElementById('root')).render( 11 | 12 | 13 | 20 | 21 | 22 | 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /src/apis/upgradeApi.js: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; 2 | 3 | export const upgradeApi = createApi({ 4 | reducerPath: 'upgradeApi', 5 | baseQuery: fetchBaseQuery({ baseUrl: '/api' }), 6 | endpoints: (builder) => ({ 7 | getColors: builder.query({ 8 | query: () => ({ 9 | url: `/colors`, 10 | }), 11 | }), 12 | submitForm: builder.mutation({ 13 | query: (formData) => ({ 14 | url: '/submit', 15 | method: 'POST', 16 | body: formData, 17 | }), 18 | }), 19 | }), 20 | }); 21 | 22 | export const { useGetColorsQuery, useSubmitFormMutation } = upgradeApi; 23 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import dns from "dns"; 4 | 5 | // Display localhost instead of 127.0.0.1 for CORS purposes. 6 | // See [this](https://vitejs.dev/config/server-options.html#server-host). 7 | dns.setDefaultResultOrder("verbatim"); 8 | 9 | export default defineConfig(() => { 10 | return { 11 | build: { 12 | outDir: "build", 13 | }, 14 | server: { 15 | port: 3000, 16 | proxy: { 17 | '/api': { 18 | target: 'http://localhost:3001/api', 19 | changeOrigin: true, 20 | rewrite: (path) => path.replace(/^\/api/, ''), 21 | }, 22 | }, 23 | }, 24 | resolve: { 25 | alias: { 26 | '@': '/src', 27 | }, 28 | }, 29 | plugins: [react()], 30 | }; 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/PageWrapper/PageWrapper.style.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Form, Card } from 'antd'; 3 | 4 | export const SignupContainer = styled.div` 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | min-height: 100vh; 10 | `; 11 | 12 | export const Logo = styled.img` 13 | width: 180px; 14 | margin-bottom: 16px; 15 | `; 16 | 17 | export const StyledForm = styled(Form)` 18 | display: flex; 19 | flex-direction: column; 20 | gap: 5px; 21 | `; 22 | 23 | export const StyledFormItem = styled(Form.Item).withConfig({ 24 | shouldForwardProp: (prop) => prop !== 'noMargin', 25 | })` 26 | ${({ noMargin }) => noMargin && 'margin-bottom: 0;'} 27 | `; 28 | 29 | export const StyledCard = styled(Card)` 30 | min-width: 350px; 31 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); 32 | `; 33 | 34 | export const CTAWrapper = styled.div.withConfig({ 35 | shouldForwardProp: (prop) => prop !== 'marginTop', 36 | })` 37 | display: flex; 38 | gap: 8px; 39 | 40 | ${({ marginTop }) => marginTop && 'margin-top: 24px;'} 41 | `; 42 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | const app = express(); 3 | 4 | app.use((req, res, next) => { 5 | res.header('Access-Control-Allow-Origin', 'http://localhost:3000'); 6 | res.header( 7 | 'Access-Control-Allow-Headers', 8 | 'Origin, X-Requested-With, Content-Type, Accept' 9 | ); 10 | next(); 11 | }); 12 | 13 | app.get('/api/colors', (req, res) => 14 | setTimeout( 15 | () => res.json(['black', 'blue', 'green', 'red', 'white']), 16 | 3000 * Math.random() 17 | ) 18 | ); 19 | 20 | app.post('/api/submit', express.json(), (req, res) => 21 | setTimeout(() => { 22 | const { name, email, password, color, terms } = req.body; 23 | if (name && email && password && color && terms && name !== 'Error') { 24 | res.type('json'); 25 | res.status(200).send({ data: { message: 'OK' } }); 26 | } else { 27 | res.type('json'); 28 | res.status(400).send({ 29 | error: 'All fields are mandatory and the agreement must be accepted', 30 | }); 31 | } 32 | }, 3000 * Math.random()) 33 | ); 34 | 35 | app.listen(3001, () => console.log('Mock server running')); 36 | 37 | export default app; 38 | -------------------------------------------------------------------------------- /src/providers/SignupFormProvider.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { Form } from 'antd'; 3 | 4 | import { useFormStorage } from '@/hooks/useFormStorage'; 5 | import { SignupFormContext } from '@/contexts'; 6 | 7 | export const SignupFormProvider = ({ children }) => { 8 | const [form] = Form.useForm(); 9 | const { saveFormData, clearFormData, getFormData } = useFormStorage(form); 10 | 11 | const handleFieldChange = useCallback(() => { 12 | saveFormData(); 13 | }, [saveFormData]); 14 | 15 | const validateAndProceed = useCallback( 16 | async (fields) => { 17 | try { 18 | await form.validateFields(fields); 19 | saveFormData(); 20 | return { success: true }; 21 | } catch (error) { 22 | return { success: false, error }; 23 | } 24 | }, 25 | [form, saveFormData] 26 | ); 27 | 28 | const contextValue = { 29 | form, 30 | formData: getFormData(), 31 | handleFieldChange, 32 | validateAndProceed, 33 | clearFormData, 34 | }; 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; -------------------------------------------------------------------------------- /src/pages/Error/Error.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { Button, Result } from 'antd'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { useSignupFormContext } from '@/contexts'; 6 | import { SignupStep } from '@/constants'; 7 | import { StyledCard } from '@/components/PageWrapper/PageWrapper.style'; 8 | 9 | import { testIds } from './Error.model'; 10 | 11 | export const Error = () => { 12 | const navigate = useNavigate(); 13 | 14 | const { form } = useSignupFormContext(); 15 | 16 | const handleRestart = useCallback(() => { 17 | form.resetFields(); 18 | navigate(SignupStep.USER_DATA); 19 | }, [form, navigate]); 20 | 21 | return ( 22 | 23 | 35 | Restart 36 | , 37 | ]} 38 | /> 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/pages/Success/Success.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { Button, Result } from 'antd'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { useSignupFormContext } from '@/contexts'; 6 | import { SignupStep } from '@/constants'; 7 | import { StyledCard } from '@/components/PageWrapper/PageWrapper.style'; 8 | 9 | import { testIds } from './Success.model'; 10 | 11 | export const Success = () => { 12 | const navigate = useNavigate(); 13 | 14 | const { form } = useSignupFormContext(); 15 | 16 | const handleRestart = useCallback(() => { 17 | form.resetFields(); 18 | navigate(SignupStep.USER_DATA); 19 | }, [form, navigate]); 20 | 21 | return ( 22 | <> 23 | 24 | 36 | Restart 37 | , 38 | ]} 39 | /> 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/pages/Error/Error.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | 5 | import { SignupStep } from '@/constants'; 6 | import { SignupFormProvider } from '@/providers/SignupFormProvider'; 7 | 8 | import { Error } from './Error'; 9 | import { testIds } from './Error.model'; 10 | 11 | const TestWrapper = ({ children }) => ( 12 | 13 | {children} 14 | 15 | ); 16 | 17 | describe('Error page', () => { 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | global.localStorageMock.getItem.mockReturnValue(null); 21 | }); 22 | 23 | const renderError = () => { 24 | return render( 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | it('should navigate to user data page when restart button is clicked', async () => { 32 | renderError(); 33 | 34 | const restartButton = screen.getByTestId(testIds.restartButton); 35 | await userEvent.click(restartButton); 36 | 37 | await waitFor(() => { 38 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.USER_DATA); 39 | }); 40 | }); 41 | }); -------------------------------------------------------------------------------- /src/pages/Success/Success.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | 5 | import { SignupStep } from '@/constants'; 6 | import { SignupFormProvider } from '@/providers/SignupFormProvider'; 7 | 8 | import { Success } from './Success'; 9 | import { testIds } from './Success.model'; 10 | 11 | const TestWrapper = ({ children }) => ( 12 | 13 | {children} 14 | 15 | ); 16 | 17 | describe('Success page', () => { 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | global.localStorageMock.getItem.mockReturnValue(null); 21 | }); 22 | 23 | const renderSuccess = () => { 24 | return render( 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | it('should navigate to user data page when restart button is clicked', async () => { 32 | renderSuccess(); 33 | 34 | const restartButton = screen.getByTestId(testIds.restartButton); 35 | await userEvent.click(restartButton); 36 | 37 | await waitFor(() => { 38 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.USER_DATA); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'; 2 | 3 | import { PageWrapper } from '@/components/PageWrapper'; 4 | 5 | import { UserData } from '@/pages/UserData'; 6 | import { MoreInfo } from '@/pages/MoreInfo'; 7 | import { Confirmation } from '@/pages/Confirmation'; 8 | import { Success } from '@/pages/Success'; 9 | import { Error } from '@/pages/Error'; 10 | 11 | export const App = () => { 12 | const router = createBrowserRouter([ 13 | { 14 | path: '/', 15 | element: ( 16 | 17 | 18 | 19 | ), 20 | }, 21 | { 22 | path: '/more-info', 23 | element: ( 24 | 25 | 26 | 27 | ), 28 | }, 29 | { 30 | path: '/confirmation', 31 | element: ( 32 | 33 | 34 | 35 | ), 36 | }, 37 | { 38 | path: '/success', 39 | element: ( 40 | 41 | 42 | 43 | ), 44 | }, 45 | { 46 | path: '/error', 47 | element: ( 48 | 49 | 50 | 51 | ), 52 | }, 53 | ]); 54 | 55 | return ; 56 | }; 57 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import react from 'eslint-plugin-react'; 2 | import reactHooks from 'eslint-plugin-react-hooks'; 3 | import reactRefresh from 'eslint-plugin-react-refresh'; 4 | import js from '@eslint/js'; 5 | import globals from 'globals'; 6 | 7 | export default [ 8 | { 9 | ignores: ['dist', 'eslint.config.js'], 10 | }, 11 | { 12 | files: ['**/*.{js,jsx}'], 13 | languageOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | globals: { 17 | ...globals.browser, 18 | ...globals.es2020, 19 | }, 20 | parserOptions: { 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | }, 25 | }, 26 | plugins: { 27 | react, 28 | 'react-hooks': reactHooks, 29 | 'react-refresh': reactRefresh, 30 | }, 31 | settings: { 32 | react: { 33 | version: '18.2', 34 | }, 35 | }, 36 | rules: { 37 | ...js.configs.recommended.rules, 38 | ...react.configs.recommended.rules, 39 | ...react.configs['jsx-runtime'].rules, 40 | ...reactHooks.configs.recommended.rules, 41 | 'react/prop-types': 'off', 42 | 'react/no-unescaped-entities': 'off', 43 | 'no-console': 'warn', 44 | 'react/jsx-no-target-blank': 'off', 45 | 'react-refresh/only-export-components': [ 46 | 'warn', 47 | { allowConstantExport: true }, 48 | ], 49 | }, 50 | }, 51 | { 52 | files: ['**/*.test.jsx', '**/*.test.js', 'setup-tests.js'], 53 | languageOptions: { 54 | globals: { 55 | ...globals.jest, 56 | global: 'readonly', 57 | mockNavigate: 'readonly', 58 | localStorageMock: 'readonly', 59 | }, 60 | }, 61 | }, 62 | ]; -------------------------------------------------------------------------------- /server/index.test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from './index'; 3 | 4 | it("returns colors", async () => { 5 | const response = await request(app).get("/api/colors"); 6 | 7 | expect(response.headers["content-type"]).toEqual( 8 | expect.stringContaining("json") 9 | ); 10 | expect(response.statusCode).toBe(200); 11 | expect(response.body).toEqual(["black", "blue", "green", "red", "white"]); 12 | }); 13 | 14 | it("returns 404 when trying to GET the submit endpoint", async () => { 15 | const response = await request(app).get("/api/submit"); 16 | 17 | expect(response.statusCode).toBe(404); 18 | }); 19 | 20 | it("submits the user information", async () => { 21 | const response = await request(app).post("/api/submit").send({ 22 | name: "Foo", 23 | password: "foo", 24 | email: "foo@bar.ca", 25 | color: "blue", 26 | terms: true, 27 | }); 28 | 29 | expect(response.statusCode).toBe(200); 30 | }); 31 | 32 | it("returns 400 when submitting Error as the name", async () => { 33 | const response = await request(app).post("/api/submit").send({ 34 | name: "Error", 35 | password: "foo", 36 | email: "foo@bar.ca", 37 | color: "blue", 38 | terms: true, 39 | }); 40 | 41 | expect(response.statusCode).toBe(400); 42 | }); 43 | 44 | it("returns 400 when submitting with missing fields", async () => { 45 | const response = await request(app).post("/api/submit").send({ 46 | name: "Error", 47 | email: "foo@bar.ca", 48 | color: "blue", 49 | terms: true, 50 | }); 51 | 52 | expect(response.statusCode).toBe(400); 53 | }); 54 | 55 | it("returns 400 when submitting with an empty field", async () => { 56 | const response = await request(app).post("/api/submit").send({ 57 | name: "", 58 | email: "foo@bar.ca", 59 | color: "blue", 60 | terms: true, 61 | }); 62 | 63 | expect(response.statusCode).toBe(400); 64 | }); 65 | -------------------------------------------------------------------------------- /setup-tests.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@testing-library/react'; 2 | import '@testing-library/jest-dom'; 3 | 4 | import { TextEncoder, TextDecoder } from 'util'; 5 | 6 | jest.mock('antd', () => ({ 7 | ...jest.requireActual('antd'), 8 | notification: { 9 | error: jest.fn(), 10 | success: jest.fn(), 11 | warning: jest.fn(), 12 | info: jest.fn(), 13 | destroy: jest.fn(), 14 | }, 15 | })); 16 | 17 | const mockNavigate = jest.fn(); 18 | 19 | jest.mock('react-router-dom', () => ({ 20 | ...jest.requireActual('react-router-dom'), 21 | useNavigate: () => mockNavigate, 22 | })); 23 | 24 | jest.mock('@/apis/upgradeApi', () => ({ 25 | useGetColorsQuery: jest.fn(), 26 | useSubmitFormMutation: jest.fn(), 27 | })); 28 | 29 | global.mockNavigate = mockNavigate; 30 | 31 | const localStorageMock = { 32 | getItem: jest.fn(), 33 | setItem: jest.fn(), 34 | removeItem: jest.fn(), 35 | clear: jest.fn(), 36 | }; 37 | 38 | Object.defineProperty(window, 'localStorage', { 39 | value: localStorageMock, 40 | }); 41 | 42 | global.localStorageMock = localStorageMock; 43 | if (typeof global !== 'undefined') { 44 | global.TextEncoder = TextEncoder; 45 | global.TextDecoder = TextDecoder; 46 | } else if (typeof window !== 'undefined') { 47 | window.TextEncoder = TextEncoder; 48 | window.TextDecoder = TextDecoder; 49 | } 50 | 51 | if (typeof window !== 'undefined' && !window.matchMedia) { 52 | window.matchMedia = () => ({ 53 | matches: false, 54 | addListener: () => {}, 55 | removeListener: () => {}, 56 | addEventListener: () => {}, 57 | removeEventListener: () => {}, 58 | dispatchEvent: () => false, 59 | }); 60 | } 61 | 62 | if (typeof global !== 'undefined' && !global.fetch) { 63 | global.fetch = jest.fn(() => 64 | Promise.resolve({ 65 | ok: true, 66 | status: 200, 67 | json: () => Promise.resolve({}), 68 | }) 69 | ); 70 | } 71 | 72 | configure({ 73 | testIdAttribute: 'test-id', 74 | }); 75 | -------------------------------------------------------------------------------- /src/hooks/useFormStorage.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback } from 'react'; 2 | 3 | const STORAGE_KEY = 'signup_form_data'; 4 | 5 | export const useFormStorage = (form) => { 6 | useEffect(() => { 7 | const savedData = localStorage.getItem(STORAGE_KEY); 8 | 9 | if (savedData) { 10 | try { 11 | const parsedData = JSON.parse(savedData); 12 | form.setFieldsValue(parsedData); 13 | } catch (error) { 14 | // eslint-disable-next-line no-console 15 | console.error('Failed to parse saved form data:', error); 16 | localStorage.removeItem(STORAGE_KEY); 17 | } 18 | } 19 | }, [form]); 20 | 21 | const saveFormData = useCallback(() => { 22 | const currentFormData = form.getFieldsValue(); 23 | 24 | const existingData = localStorage.getItem(STORAGE_KEY); 25 | let existingFormData = {}; 26 | 27 | if (existingData) { 28 | try { 29 | existingFormData = JSON.parse(existingData); 30 | } catch (error) { 31 | // eslint-disable-next-line no-console 32 | console.error('Failed to parse existing form data:', error); 33 | existingFormData = {}; 34 | } 35 | } 36 | 37 | const mergedData = { ...existingFormData, ...currentFormData }; 38 | 39 | localStorage.setItem(STORAGE_KEY, JSON.stringify(mergedData)); 40 | }, [form]); 41 | 42 | const clearFormData = useCallback(() => { 43 | localStorage.removeItem(STORAGE_KEY); 44 | }, []); 45 | 46 | const getFormData = useCallback(() => { 47 | const savedData = localStorage.getItem(STORAGE_KEY); 48 | if (savedData) { 49 | try { 50 | return JSON.parse(savedData); 51 | } catch (error) { 52 | // eslint-disable-next-line no-console 53 | console.error('Failed to parse saved form data:', error); 54 | return {}; 55 | } 56 | } 57 | return {}; 58 | }, []); 59 | 60 | return { 61 | saveFormData, 62 | getFormData, 63 | clearFormData, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upgrade-challenge", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@ant-design/icons": "^6.0.0", 8 | "@reduxjs/toolkit": "^2.8.2", 9 | "antd": "^5.26.3", 10 | "express": "^4.18.2", 11 | "identity-obj-proxy": "^3.0.0", 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0", 14 | "react-redux": "^9.2.0", 15 | "react-router-dom": "^7.6.3", 16 | "styled-components": "^6.1.19" 17 | }, 18 | "devDependencies": { 19 | "@babel/preset-env": "^7.22.9", 20 | "@babel/preset-react": "^7.22.5", 21 | "@eslint/js": "^9.30.1", 22 | "@testing-library/dom": "^10.4.0", 23 | "@testing-library/jest-dom": "^6.6.3", 24 | "@testing-library/react": "^16.3.0", 25 | "@testing-library/user-event": "^14.6.1", 26 | "@vitejs/plugin-react": "^4.0.3", 27 | "eslint": "^9.30.1", 28 | "eslint-plugin-react": "^7.37.5", 29 | "eslint-plugin-react-hooks": "^5.2.0", 30 | "eslint-plugin-react-refresh": "^0.4.20", 31 | "globals": "^16.3.0", 32 | "jest": "^29.6.1", 33 | "jest-environment-jsdom": "^29.6.1", 34 | "jest-transform-stub": "^2.0.0", 35 | "npm-run-all": "^4.1.5", 36 | "supertest": "^6.3.3", 37 | "vite": "^4.4.4" 38 | }, 39 | "scripts": { 40 | "start:mocks": "node server/index.js", 41 | "start:dev": "vite", 42 | "start": "run-p start:*", 43 | "build": "vite build", 44 | "test": "jest", 45 | "test:watch": "jest --watch" 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "jest": { 60 | "testEnvironment": "jest-environment-jsdom", 61 | "setupFilesAfterEnv": [ 62 | "/setup-tests.js" 63 | ], 64 | "moduleNameMapper": { 65 | "^@/(.+)$": "/src/$1", 66 | "\\.(jpg|jpeg|png|gif|webp|svg)$": "/__mocks__/fileMock.js" 67 | }, 68 | "transformIgnorePatterns": [ 69 | "node_modules/(?!(react-router|react-router-dom)/)" 70 | ], 71 | "transform": { 72 | "^.+\\.[jt]sx?$": "babel-jest", 73 | "^.+\\.(jpg|jpeg|png|gif|webp|svg)$": "jest-transform-stub" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /INTRODUCTION.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Hi there! first and foremost, thank you so much for taking your time to review my challenge. I'm gonna try to briefly explain my thought process implementing the task and the tools I've used to. 4 | 5 | ## Initial setup and tooling 6 | 7 | My initial idea was to prepare the provided boilerplate with some foundation configs to be used during the actual implementation. In this first iteration, my goal was to prepare the project to support ESLint, prettier, an alias to the imports and a basic proxy to the mock api, so I could easily access it afterwards and avoid any CORS issues. 8 | 9 | Right after, I started setting up Redux and Redux Query, as they would be my main state management library. This part was really fast (as RTK is always a cake to setup) and I've quickly added the query to the colors endpoint and the mutation to submit the form. 10 | 11 | After that I've setup React Router and some initial structure of the files/pages I wanted to have. These changed a couple times during the implementation. 12 | 13 | ## Components 14 | 15 | I wanted to use styled-components as my main CSS library but I also didn't want to reinvent the wheel for the components. My idea was to use a UI library alongisde SC that could handle the core components (inputs, selects, buttons). I've opted to go with Ant Design, since it's a lightweight and simple to setup and use. I also used their form components in order to manage validations and input types. 16 | 17 | If I haven't chosen Antd, I'd probably use Formik and Yup, or even react-hook-form + Yup, but since Antd had a good form handling, I've chosen to use what's out of the box. 18 | 19 | ## Multi step form 20 | 21 | For the multi step form handling I wanted to make sure all the data was persistent between the routes and even after refreshes. In order to achieve this, I'm relying on a localStorage storing strategy, managed by a custom hook and integrated with the form. In order to keep the form as a single instance through the entire flow, I've opted to create it as a context. 22 | 23 | ## Unit tests 24 | 25 | Once I finished the actual implementation, I've setup React Testing Library and started to create the initial tests. I didn't want to create any type of "content"/"render" tests because there are no actual value on those IMO. The main goal of the tests is to ensure the form validations are in place and working as expected and the user interactions are correctly implemented. 26 | 27 | -------------------------------------------------------------------------------- /src/pages/Confirmation/Confirmation.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { Button, Typography, Descriptions, Spin, notification } from 'antd'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { useSignupFormContext } from '@/contexts'; 6 | import { SignupStep } from '@/constants'; 7 | import { useSubmitFormMutation } from '@/apis/upgradeApi'; 8 | import { 9 | StyledCard, 10 | CTAWrapper, 11 | } from '@/components/PageWrapper/PageWrapper.style'; 12 | import { testIds } from './Confirmation.model'; 13 | 14 | export const Confirmation = () => { 15 | const navigate = useNavigate(); 16 | 17 | const { formData, clearFormData } = useSignupFormContext(); 18 | 19 | const [submitFormMutation, { isLoading }] = useSubmitFormMutation(); 20 | 21 | const handleBack = useCallback(() => { 22 | navigate(SignupStep.MORE_INFO); 23 | }, [navigate]); 24 | 25 | const handleSubmit = useCallback(async () => { 26 | try { 27 | const submitData = { 28 | name: formData.firstName, 29 | email: formData.email, 30 | password: formData.password, 31 | color: formData.color, 32 | terms: formData.terms, 33 | }; 34 | 35 | await submitFormMutation(submitData).unwrap(); 36 | 37 | clearFormData(); 38 | navigate(SignupStep.SUCCESS); 39 | } catch (error) { 40 | notification.error({ 41 | key: 'error-confirmation', 42 | message: 'Your signup failed', 43 | description: error?.message, 44 | duration: 3, 45 | placement: 'top', 46 | }); 47 | navigate(SignupStep.ERROR); 48 | } 49 | }, [formData, submitFormMutation, clearFormData, navigate]); 50 | 51 | return ( 52 | <> 53 | Confirmation 54 | 55 | 56 | 57 | {formData.firstName} 58 | 59 | {formData.email} 60 | 61 | {'•'.repeat(8)} 62 | 63 | 64 | {formData.color} 65 | 66 | 67 | {formData.terms ? 'Yes' : 'No'} 68 | 69 | 70 | 71 | 72 | 83 | 95 | 96 | 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /src/pages/UserData/UserData.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { Input, Button, Typography, notification } from 'antd'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { useSignupFormContext } from '@/contexts'; 6 | import { SignupStep } from '@/constants'; 7 | import { 8 | StyledForm, 9 | StyledFormItem, 10 | StyledCard, 11 | } from '@/components/PageWrapper/PageWrapper.style'; 12 | 13 | import { testIds } from './UserData.model'; 14 | 15 | export const UserData = () => { 16 | const navigate = useNavigate(); 17 | 18 | const { form, handleFieldChange, validateAndProceed } = 19 | useSignupFormContext(); 20 | 21 | const handleNext = useCallback(async () => { 22 | const result = await validateAndProceed(['firstName', 'email', 'password']); 23 | 24 | if (result.success) { 25 | navigate(SignupStep.MORE_INFO); 26 | } else { 27 | notification.error({ 28 | key: 'error-user-data', 29 | message: 'Your signup failed', 30 | description: 31 | result.error?.message || 'Please fill in your data correctly.', 32 | duration: 3, 33 | placement: 'top', 34 | }); 35 | } 36 | }, [validateAndProceed, navigate]); 37 | 38 | return ( 39 | <> 40 | Sign Up 41 | 42 | 48 | 55 | 60 | 61 | 72 | 78 | 79 | 90 | 95 | 96 | 97 | 107 | 108 | 109 | 110 | 111 | ); 112 | }; 113 | -------------------------------------------------------------------------------- /src/assets/upgradeLogo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/useFormStorage.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react'; 2 | 3 | import { mockFormData } from '@/pages/UserData/UserData.mock.js'; 4 | 5 | import { useFormStorage } from './useFormStorage'; 6 | 7 | const createMockForm = () => ({ 8 | setFieldsValue: jest.fn(), 9 | getFieldsValue: jest.fn(), 10 | }); 11 | 12 | describe('useFormStorage', () => { 13 | let mockForm; 14 | let localStorageMock; 15 | 16 | beforeEach(() => { 17 | mockForm = createMockForm(); 18 | localStorageMock = global.localStorageMock; 19 | 20 | jest.clearAllMocks(); 21 | localStorageMock.getItem.mockClear(); 22 | localStorageMock.setItem.mockClear(); 23 | localStorageMock.removeItem.mockClear(); 24 | }); 25 | 26 | it('should load saved form data on mount', () => { 27 | const savedData = mockFormData; 28 | localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData)); 29 | 30 | renderHook(() => useFormStorage(mockForm)); 31 | 32 | expect(localStorageMock.getItem).toHaveBeenCalledWith('signup_form_data'); 33 | expect(mockForm.setFieldsValue).toHaveBeenCalledWith(savedData); 34 | }); 35 | 36 | it('should save current form data to localStorage', () => { 37 | const currentData = mockFormData; 38 | mockForm.getFieldsValue.mockReturnValue(currentData); 39 | 40 | const { result } = renderHook(() => useFormStorage(mockForm)); 41 | 42 | act(() => { 43 | result.current.saveFormData(); 44 | }); 45 | 46 | expect(mockForm.getFieldsValue).toHaveBeenCalled(); 47 | expect(localStorageMock.setItem).toHaveBeenCalledWith( 48 | 'signup_form_data', 49 | JSON.stringify(currentData) 50 | ); 51 | }); 52 | 53 | it('should merge with existing data when saving', () => { 54 | const existingData = { firstName: 'Test', age: 30 }; 55 | const currentData = { ...mockFormData, age: 31 }; 56 | 57 | localStorageMock.getItem.mockReturnValue(JSON.stringify(existingData)); 58 | mockForm.getFieldsValue.mockReturnValue(currentData); 59 | 60 | const { result } = renderHook(() => useFormStorage(mockForm)); 61 | 62 | act(() => { 63 | result.current.saveFormData(); 64 | }); 65 | 66 | expect(localStorageMock.setItem).toHaveBeenCalledWith( 67 | 'signup_form_data', 68 | expect.stringContaining(`"firstName":"${mockFormData.firstName}"`) 69 | ); 70 | expect(localStorageMock.setItem).toHaveBeenCalledWith( 71 | 'signup_form_data', 72 | expect.stringContaining(`"email":"${mockFormData.email}"`) 73 | ); 74 | expect(localStorageMock.setItem).toHaveBeenCalledWith( 75 | 'signup_form_data', 76 | expect.stringContaining('"age":31') 77 | ); 78 | }); 79 | 80 | it('should remove form data from localStorage', () => { 81 | const { result } = renderHook(() => useFormStorage(mockForm)); 82 | 83 | act(() => { 84 | result.current.clearFormData(); 85 | }); 86 | 87 | expect(localStorageMock.removeItem).toHaveBeenCalledWith( 88 | 'signup_form_data' 89 | ); 90 | }); 91 | 92 | it('should return parsed form data from localStorage', () => { 93 | const savedData = mockFormData; 94 | localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData)); 95 | 96 | const { result } = renderHook(() => useFormStorage(mockForm)); 97 | 98 | const retrievedData = result.current.getFormData(); 99 | 100 | expect(retrievedData).toEqual(savedData); 101 | expect(localStorageMock.getItem).toHaveBeenCalledWith('signup_form_data'); 102 | }); 103 | 104 | it('should return empty object when no data exists', () => { 105 | localStorageMock.getItem.mockReturnValue(null); 106 | 107 | const { result } = renderHook(() => useFormStorage(mockForm)); 108 | 109 | const retrievedData = result.current.getFormData(); 110 | 111 | expect(retrievedData).toEqual({}); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Upgrade Challenge 2 | 3 | ## Prerequisites 4 | 5 | - Latest [Node.js LTS](https://nodejs.org/en/download/) 6 | - [yarn](https://yarnpkg.com/en/) 7 | 8 | ## Goals 9 | 10 | The review team will be evaluating your solution based on: 11 | 12 | - Completion: The provided solution works as intended 13 | - Code organization: How well structured is the solution 14 | - JavaScript Knowledge: Good usage of language features in order to solve the proposed problem 15 | - UX/UI: Consistent usage of good user experience patterns and overall attention to detail 16 | 17 | _If you have questions regarding the challenge or feel the instructions are unclear, please reach out to your contact at Upgrade._ 18 | 19 | ## Implementing your solution 20 | 21 | This repository contains an empty project boilerplate created using [vite](https://vitejs.dev/). We ask that you please [fork](https://help.github.com/articles/fork-a-repo/) this repo and [clone](https://help.github.com/articles/cloning-a-repository/) this forked version to your local computer in order to start working on a solution. 22 | 23 | ### Quick start 24 | 25 | ```sh 26 | # with yarn 27 | yarn 28 | yarn start 29 | ``` 30 | 31 | This will install all required dependencies and start a development server. 32 | 33 | > For more information on how to manage the development server and tooling, please consult the [vite docs](https://vitejs.dev/guide). 34 | 35 | Once you're confident with your result, please submit your solution by contacting HR with a link to your working repository. Optionally, you may add a `INTRODUCTION.md` file to explain the different concepts explored within your implementation and why you decided to implement things the way they are, just keep in mind that the goal is to help guide the person that is going to be reviewing your code so try to make it clear and concise. 36 | 37 | ## Challenge 38 | 39 | The proposed scenario aims to replicate the regular tasks our front end team might have during the development phase of a project. The goal is to be short enough in order to not be too time consuming (we recommend the candidates to try and stay within an average of 3h-6h of work in their solution) while also allowing us to assess the candidate abilities in an environment that is closer to that of day to day development. 40 | 41 | **The challenge consists of building a multiple step form to collect basic user data.** Each step should have its own route. A confirmation page should be displayed as the last step with a submit button to post the form data. An error page should be displayed if the submission is unsuccessful. 42 | 43 | ### Routes 44 | 45 | Your app should respond to the following 5 routes: 46 | 47 | `/` (root) The initial step should have 3 fields: first name, email and password and a next button. 48 | 49 | `/more-info` The second step should have 2 fields: a favorite color select field and an agreement checkbox. A back button allows going back to the initial step and a next button to the confirmation screen. 50 | 51 | `/confirmation`: The third step is a read-only confirmation screen displaying the data collected in the 2 previous steps and a button to submit the form. A back button allows going back to to the previous step. 52 | 53 | `/success`: Final page to be shown if the form is successfully submitted. A restart button resets the data and returns to the initial step. 54 | 55 | `/error` An error page the user should be taken to if there are any server errors when submitting the form. 56 | 57 | ### Wireframes 58 | 59 | The following wireframe represents how each of these pages should look and the expected flow: 60 | 61 | ![wireframe](20180829-wireframe001.png) 62 | 63 | ### Specifications 64 | 65 | > **Note:** _There is no need to create any API endpoints_. The endpoints you need will be accessible on `http://localhost:3001` after you invoke `yarn start`. 66 | 67 | 1. The list of colors to be displayed in the `/more-info` page should be built from the response to a `GET` request to `http://localhost:3001/api/colors` 68 | 69 | 2. To submit the form data, use a `POST` request with an `application/json` content type to the `http://localhost:3001/api/submit` endpoint. The submitted data should look like: 70 | 71 | ```json 72 | { 73 | "name": "", 74 | "email": "", 75 | "password": "", 76 | "color": "", 77 | "terms": false 78 | } 79 | ``` 80 | 81 | 3. Show the success / error page according to the HTTP status returned from the submit request. 82 | 83 | 4. Add a loading indicator (spinner) to all API requests in order to provide UX feedback since the mocked server will have a delayed response 84 | -------------------------------------------------------------------------------- /src/pages/MoreInfo/MoreInfo.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useMemo } from 'react'; 2 | import { Button, Typography, Select, Checkbox, notification } from 'antd'; 3 | import { LoadingOutlined } from '@ant-design/icons'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | import { useSignupFormContext } from '@/contexts'; 7 | import { SignupStep } from '@/constants'; 8 | import { useGetColorsQuery } from '@/apis/upgradeApi'; 9 | import { 10 | StyledForm, 11 | StyledFormItem, 12 | StyledCard, 13 | CTAWrapper, 14 | } from '@/components/PageWrapper/PageWrapper.style'; 15 | 16 | import { testIds } from './MoreInfo.model'; 17 | 18 | export const MoreInfo = () => { 19 | const { form, handleFieldChange, validateAndProceed } = 20 | useSignupFormContext(); 21 | 22 | const navigate = useNavigate(); 23 | 24 | const { 25 | data: colors, 26 | isLoading: isLoadingColors, 27 | error: errorColors, 28 | refetch: refetchColors, 29 | } = useGetColorsQuery(); 30 | 31 | useEffect(() => { 32 | if (errorColors) { 33 | notification.error({ 34 | key: 'error-colors', 35 | message: 'Failed to load colors', 36 | description: ( 37 | refetchColors()} 39 | style={{ 40 | cursor: 'pointer', 41 | textDecoration: 'underline', 42 | color: '#4b9d2d', 43 | }} 44 | > 45 | Retry 46 | 47 | ), 48 | placement: 'top', 49 | duration: 0, 50 | closeIcon: false, 51 | }); 52 | } 53 | }, [errorColors, refetchColors]); 54 | 55 | useEffect(() => { 56 | if (isLoadingColors) { 57 | notification.info({ 58 | key: 'loading-colors', 59 | message: 'Loading colors', 60 | placement: 'top', 61 | icon: , 62 | duration: 0, 63 | closeIcon: false, 64 | }); 65 | } else { 66 | notification.destroy(); 67 | } 68 | }, [isLoadingColors]); 69 | 70 | const handleNext = useCallback(async () => { 71 | const result = await validateAndProceed(['color', 'terms']); 72 | 73 | if (result.success) { 74 | navigate(SignupStep.CONFIRMATION); 75 | } else { 76 | notification.error({ 77 | key: 'error-more-info', 78 | message: 'Your signup failed', 79 | description: 80 | result.error?.message || 81 | 'Please select your favorite color and accept the terms.', 82 | duration: 3, 83 | placement: 'top', 84 | }); 85 | } 86 | }, [validateAndProceed, navigate]); 87 | 88 | const handleBack = useCallback(() => { 89 | navigate(SignupStep.USER_DATA); 90 | }, [navigate]); 91 | 92 | const colorOptions = useMemo(() => { 93 | return colors?.map((color) => ( 94 | 95 | {color} 96 | 97 | )); 98 | }, [colors]); 99 | 100 | return ( 101 | <> 102 | Additional Info 103 | 104 | 109 | 115 | 124 | 125 | 131 | value 132 | ? Promise.resolve() 133 | : Promise.reject(new Error('You must accept the terms')), 134 | }, 135 | ]} 136 | > 137 | 138 | I agree to the terms and conditions 139 | 140 | 141 | 142 | 143 | 153 | 165 | 166 | 167 | 168 | 169 | 170 | ); 171 | }; 172 | -------------------------------------------------------------------------------- /src/pages/Confirmation/Confirmation.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { notification } from 'antd'; 5 | 6 | import { SignupStep } from '@/constants'; 7 | import { SignupFormProvider } from '@/providers/SignupFormProvider'; 8 | import { useSubmitFormMutation } from '@/apis/upgradeApi'; 9 | import { mockFormData } from '@/pages/UserData/UserData.mock'; 10 | 11 | import { Confirmation } from './Confirmation'; 12 | import { testIds } from './Confirmation.model'; 13 | 14 | const TestWrapper = ({ children }) => ( 15 | 16 | {children} 17 | 18 | ); 19 | 20 | describe('Confirmation page', () => { 21 | const mockSubmitFormMutation = jest.fn(); 22 | 23 | beforeEach(() => { 24 | jest.clearAllMocks(); 25 | notification.error.mockClear(); 26 | 27 | global.localStorageMock.getItem.mockImplementation((key) => { 28 | if (key === 'signup_form_data') { 29 | return JSON.stringify(mockFormData); 30 | } 31 | return null; 32 | }); 33 | 34 | useSubmitFormMutation.mockReturnValue([ 35 | mockSubmitFormMutation, 36 | { isLoading: false }, 37 | ]); 38 | }); 39 | 40 | const renderConfirmation = () => { 41 | return render( 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | describe('Loading state', () => { 49 | it('should disable buttons and show loading state when submitting', () => { 50 | useSubmitFormMutation.mockReturnValue([ 51 | mockSubmitFormMutation, 52 | { isLoading: true }, 53 | ]); 54 | 55 | renderConfirmation(); 56 | 57 | const backButton = screen.getByTestId(testIds.backButton); 58 | const submitButton = screen.getByTestId(testIds.submitButton); 59 | 60 | expect(backButton).toBeDisabled(); 61 | expect(submitButton).toBeDisabled(); 62 | expect(submitButton.querySelector('.ant-spin')).toBeInTheDocument(); 63 | }); 64 | 65 | it('should show spin component in submit button when loading', () => { 66 | useSubmitFormMutation.mockReturnValue([ 67 | mockSubmitFormMutation, 68 | { isLoading: true }, 69 | ]); 70 | 71 | renderConfirmation(); 72 | 73 | const submitButton = screen.getByTestId(testIds.submitButton); 74 | expect(submitButton.querySelector('.ant-spin')).toBeInTheDocument(); 75 | }); 76 | }); 77 | 78 | describe('Form submission', () => { 79 | it('should show error notification and navigate to error page when submission fails', async () => { 80 | const mockError = { message: 'Submission failed' }; 81 | mockSubmitFormMutation.mockReturnValue({ 82 | unwrap: jest.fn().mockRejectedValue(mockError), 83 | }); 84 | 85 | renderConfirmation(); 86 | 87 | const submitButton = screen.getByTestId(testIds.submitButton); 88 | await userEvent.click(submitButton); 89 | 90 | await waitFor(() => { 91 | expect(notification.error).toHaveBeenCalledWith({ 92 | key: 'error-confirmation', 93 | message: 'Your signup failed', 94 | description: 'Submission failed', 95 | duration: 3, 96 | placement: 'top', 97 | }); 98 | }); 99 | 100 | await waitFor(() => { 101 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.ERROR); 102 | }); 103 | }); 104 | 105 | it('should show error notification with generic message when error has no message', async () => { 106 | mockSubmitFormMutation.mockReturnValue({ 107 | unwrap: jest.fn().mockRejectedValue({}), 108 | }); 109 | 110 | renderConfirmation(); 111 | 112 | const submitButton = screen.getByTestId(testIds.submitButton); 113 | await userEvent.click(submitButton); 114 | 115 | await waitFor(() => { 116 | expect(notification.error).toHaveBeenCalledWith({ 117 | key: 'error-confirmation', 118 | message: 'Your signup failed', 119 | description: undefined, 120 | duration: 3, 121 | placement: 'top', 122 | }); 123 | }); 124 | 125 | await waitFor(() => { 126 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.ERROR); 127 | }); 128 | }); 129 | 130 | it('should submit form successfully and navigate to success page', async () => { 131 | mockSubmitFormMutation.mockReturnValue({ 132 | unwrap: jest.fn().mockResolvedValue({}), 133 | }); 134 | 135 | renderConfirmation(); 136 | 137 | const submitButton = screen.getByTestId(testIds.submitButton); 138 | await userEvent.click(submitButton); 139 | 140 | await waitFor(() => { 141 | expect(mockSubmitFormMutation).toHaveBeenCalledWith({ 142 | name: mockFormData.firstName, 143 | email: mockFormData.email, 144 | password: mockFormData.password, 145 | color: mockFormData.color, 146 | terms: mockFormData.terms, 147 | }); 148 | }); 149 | 150 | await waitFor(() => { 151 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.SUCCESS); 152 | }); 153 | }); 154 | 155 | it('should navigate back to more info when back button is clicked', async () => { 156 | renderConfirmation(); 157 | 158 | const backButton = screen.getByTestId(testIds.backButton); 159 | await userEvent.click(backButton); 160 | 161 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.MORE_INFO); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/pages/MoreInfo/MoreInfo.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor, fireEvent } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { notification } from 'antd'; 5 | 6 | import { SignupStep } from '@/constants'; 7 | import { SignupFormProvider } from '@/providers/SignupFormProvider'; 8 | import { useGetColorsQuery } from '@/apis/upgradeApi'; 9 | 10 | import { MoreInfo } from './MoreInfo'; 11 | import { testIds } from './MoreInfo.model'; 12 | import { mockColors } from './MoreInfo.mock'; 13 | 14 | const TestWrapper = ({ children }) => ( 15 | 16 | {children} 17 | 18 | ); 19 | 20 | describe('MoreInfo page', () => { 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | global.localStorageMock.getItem.mockReturnValue(null); 24 | notification.error.mockClear(); 25 | notification.info.mockClear(); 26 | }); 27 | 28 | const renderMoreInfo = () => { 29 | return render( 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | describe('Loading state', () => { 37 | it('should show loading state when fetching colors', () => { 38 | useGetColorsQuery.mockReturnValue({ 39 | data: undefined, 40 | isLoading: true, 41 | error: undefined, 42 | refetch: jest.fn(), 43 | }); 44 | 45 | renderMoreInfo(); 46 | 47 | expect(notification.info).toHaveBeenCalledWith({ 48 | key: 'loading-colors', 49 | message: 'Loading colors', 50 | placement: 'top', 51 | icon: expect.any(Object), 52 | duration: 0, 53 | closeIcon: false, 54 | }); 55 | }); 56 | 57 | it('should show error notification when colors fetch fails', () => { 58 | const mockRefetch = jest.fn(); 59 | useGetColorsQuery.mockReturnValue({ 60 | data: undefined, 61 | isLoading: false, 62 | error: { message: 'Failed to fetch colors' }, 63 | refetch: mockRefetch, 64 | }); 65 | 66 | renderMoreInfo(); 67 | 68 | expect(notification.error).toHaveBeenCalledWith({ 69 | key: 'error-colors', 70 | message: 'Failed to load colors', 71 | description: expect.any(Object), 72 | placement: 'top', 73 | duration: 0, 74 | closeIcon: false, 75 | }); 76 | }); 77 | 78 | it('should render color options when colors are loaded', () => { 79 | useGetColorsQuery.mockReturnValue({ 80 | data: mockColors, 81 | isLoading: false, 82 | error: undefined, 83 | refetch: jest.fn(), 84 | }); 85 | 86 | renderMoreInfo(); 87 | 88 | const colorSelect = screen.getByTestId(testIds.colorSelect); 89 | expect(colorSelect).toBeInTheDocument(); 90 | expect(colorSelect).not.toBeDisabled(); 91 | }); 92 | 93 | it('should disable form when loading colors', () => { 94 | useGetColorsQuery.mockReturnValue({ 95 | data: undefined, 96 | isLoading: true, 97 | error: undefined, 98 | refetch: jest.fn(), 99 | }); 100 | 101 | renderMoreInfo(); 102 | 103 | const colorSelect = screen.getByTestId(testIds.colorSelect); 104 | const nextButton = screen.getByTestId(testIds.nextButton); 105 | 106 | expect(colorSelect).toHaveClass('ant-select-disabled'); 107 | expect(nextButton).toBeDisabled(); 108 | }); 109 | 110 | it('should disable form when colors fetch fails', () => { 111 | useGetColorsQuery.mockReturnValue({ 112 | data: undefined, 113 | isLoading: false, 114 | error: { message: 'Failed to fetch colors' }, 115 | refetch: jest.fn(), 116 | }); 117 | 118 | renderMoreInfo(); 119 | 120 | const colorSelect = screen.getByTestId(testIds.colorSelect); 121 | const nextButton = screen.getByTestId(testIds.nextButton); 122 | 123 | expect(colorSelect).toHaveClass('ant-select-disabled'); 124 | expect(nextButton).toBeDisabled(); 125 | }); 126 | }); 127 | 128 | describe('Form validations', () => { 129 | beforeEach(() => { 130 | useGetColorsQuery.mockReturnValue({ 131 | data: mockColors, 132 | isLoading: false, 133 | error: undefined, 134 | refetch: jest.fn(), 135 | }); 136 | }); 137 | 138 | it('should not submit form when color is not selected', async () => { 139 | renderMoreInfo(); 140 | 141 | const termsCheckbox = screen.getByTestId(testIds.termsCheckbox); 142 | const nextButton = screen.getByTestId(testIds.nextButton); 143 | 144 | await userEvent.click(termsCheckbox); 145 | await userEvent.click(nextButton); 146 | 147 | await waitFor(() => { 148 | expect( 149 | screen.getByText('Please select your favorite color') 150 | ).toBeInTheDocument(); 151 | }); 152 | 153 | expect(global.mockNavigate).not.toHaveBeenCalled(); 154 | }); 155 | 156 | it('should not submit form when terms are not accepted', async () => { 157 | renderMoreInfo(); 158 | 159 | const nextButton = screen.getByTestId(testIds.nextButton); 160 | 161 | await userEvent.click(nextButton); 162 | 163 | await waitFor(() => { 164 | expect( 165 | screen.getByText('You must accept the terms') 166 | ).toBeInTheDocument(); 167 | }); 168 | 169 | expect(global.mockNavigate).not.toHaveBeenCalled(); 170 | }); 171 | 172 | it('should not submit form when both color and terms are missing', async () => { 173 | renderMoreInfo(); 174 | 175 | const nextButton = screen.getByTestId(testIds.nextButton); 176 | await userEvent.click(nextButton); 177 | 178 | await waitFor(() => { 179 | expect( 180 | screen.getByText('Please select your favorite color') 181 | ).toBeInTheDocument(); 182 | expect( 183 | screen.getByText('You must accept the terms') 184 | ).toBeInTheDocument(); 185 | }); 186 | 187 | expect(global.mockNavigate).not.toHaveBeenCalled(); 188 | }); 189 | 190 | it('should show error notification when validation fails', async () => { 191 | renderMoreInfo(); 192 | 193 | const nextButton = screen.getByTestId(testIds.nextButton); 194 | await userEvent.click(nextButton); 195 | 196 | await waitFor(() => { 197 | expect(notification.error).toHaveBeenCalledWith({ 198 | key: 'error-more-info', 199 | message: 'Your signup failed', 200 | description: 201 | 'Please select your favorite color and accept the terms.', 202 | duration: 3, 203 | placement: 'top', 204 | }); 205 | }); 206 | 207 | expect(global.mockNavigate).not.toHaveBeenCalled(); 208 | }); 209 | }); 210 | 211 | describe('Form submission', () => { 212 | beforeEach(() => { 213 | useGetColorsQuery.mockReturnValue({ 214 | data: mockColors, 215 | isLoading: false, 216 | error: undefined, 217 | refetch: jest.fn(), 218 | }); 219 | }); 220 | 221 | it('should navigate back to user data when back button is clicked', async () => { 222 | renderMoreInfo(); 223 | 224 | const backButton = screen.getByTestId(testIds.backButton); 225 | await userEvent.click(backButton); 226 | 227 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.USER_DATA); 228 | }); 229 | 230 | it('should submit form and navigate to confirmation when all fields are valid', async () => { 231 | renderMoreInfo(); 232 | 233 | const colorSelect = screen.getByTestId(testIds.colorSelect); 234 | const termsCheckbox = screen.getByTestId(testIds.termsCheckbox); 235 | 236 | await userEvent.click(termsCheckbox); 237 | 238 | const selector = colorSelect.querySelector('.ant-select-selector'); 239 | fireEvent.mouseDown(selector); 240 | 241 | await waitFor(() => { 242 | expect( 243 | document.querySelector('.ant-select-dropdown') 244 | ).toBeInTheDocument(); 245 | }); 246 | 247 | await userEvent.click(screen.getAllByTitle('red')[0]); 248 | 249 | const nextButton = screen.getByTestId(testIds.nextButton); 250 | await userEvent.click(nextButton); 251 | 252 | await waitFor(() => { 253 | expect(global.mockNavigate).toHaveBeenCalledWith( 254 | SignupStep.CONFIRMATION 255 | ); 256 | }); 257 | }); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /src/pages/UserData/UserData.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { notification } from 'antd'; 5 | 6 | import { SignupStep } from '@/constants'; 7 | import { SignupFormProvider } from '@/providers/SignupFormProvider'; 8 | 9 | import { UserData } from './UserData'; 10 | import { testIds } from './UserData.model'; 11 | import { mockFormData } from './UserData.mock'; 12 | 13 | const TestWrapper = ({ children }) => ( 14 | 15 | {children} 16 | 17 | ); 18 | 19 | describe('UserData page', () => { 20 | beforeEach(() => { 21 | jest.clearAllMocks(); 22 | global.localStorageMock.getItem.mockReturnValue(null); 23 | }); 24 | 25 | const renderUserData = () => { 26 | return render( 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | const fillValidForm = async () => { 34 | const firstNameInput = screen.getByTestId(testIds.firstNameInput); 35 | const emailInput = screen.getByTestId(testIds.emailInput); 36 | const passwordInput = screen.getByTestId(testIds.passwordInput); 37 | 38 | await userEvent.type(firstNameInput, mockFormData.firstName); 39 | await userEvent.type(emailInput, mockFormData.email); 40 | await userEvent.type(passwordInput, mockFormData.password); 41 | }; 42 | 43 | describe('Form validations', () => { 44 | it('should not submit form when firstName is empty', async () => { 45 | renderUserData(); 46 | 47 | const emailInput = screen.getByTestId(testIds.emailInput); 48 | const passwordInput = screen.getByTestId(testIds.passwordInput); 49 | const submitButton = screen.getByTestId(testIds.submitButton); 50 | 51 | await userEvent.type(emailInput, mockFormData.email); 52 | await userEvent.type(passwordInput, mockFormData.password); 53 | 54 | await userEvent.click(submitButton); 55 | 56 | await waitFor(() => { 57 | expect( 58 | screen.getByText('Please enter your first name') 59 | ).toBeInTheDocument(); 60 | }); 61 | 62 | expect(global.mockNavigate).not.toHaveBeenCalled(); 63 | expect(notification.error).toHaveBeenCalled(); 64 | }); 65 | 66 | it('should not submit form when email is invalid', async () => { 67 | renderUserData(); 68 | 69 | const firstNameInput = screen.getByTestId(testIds.firstNameInput); 70 | const emailInput = screen.getByTestId(testIds.emailInput); 71 | const passwordInput = screen.getByTestId(testIds.passwordInput); 72 | const submitButton = screen.getByTestId(testIds.submitButton); 73 | 74 | await userEvent.type(firstNameInput, mockFormData.firstName); 75 | await userEvent.type(emailInput, 'invalid-email'); 76 | await userEvent.type(passwordInput, mockFormData.password); 77 | 78 | await userEvent.click(submitButton); 79 | 80 | await waitFor(() => { 81 | expect( 82 | screen.getByText('Please enter a valid email') 83 | ).toBeInTheDocument(); 84 | }); 85 | 86 | expect(global.mockNavigate).not.toHaveBeenCalled(); 87 | expect(notification.error).toHaveBeenCalled(); 88 | }); 89 | 90 | it('should not submit form when password is too short', async () => { 91 | renderUserData(); 92 | 93 | const firstNameInput = screen.getByTestId(testIds.firstNameInput); 94 | const emailInput = screen.getByTestId(testIds.emailInput); 95 | const passwordInput = screen.getByTestId(testIds.passwordInput); 96 | const submitButton = screen.getByTestId(testIds.submitButton); 97 | 98 | await userEvent.type(firstNameInput, mockFormData.firstName); 99 | await userEvent.type(emailInput, mockFormData.email); 100 | await userEvent.type(passwordInput, '123'); 101 | 102 | await userEvent.click(submitButton); 103 | 104 | await waitFor(() => { 105 | expect( 106 | screen.getByText('Password must be at least 8 characters long') 107 | ).toBeInTheDocument(); 108 | }); 109 | 110 | expect(global.mockNavigate).not.toHaveBeenCalled(); 111 | expect(notification.error).toHaveBeenCalled(); 112 | }); 113 | 114 | it('should not submit form when email is empty', async () => { 115 | renderUserData(); 116 | 117 | const firstNameInput = screen.getByTestId(testIds.firstNameInput); 118 | const passwordInput = screen.getByTestId(testIds.passwordInput); 119 | const submitButton = screen.getByTestId(testIds.submitButton); 120 | 121 | await userEvent.type(firstNameInput, mockFormData.firstName); 122 | await userEvent.type(passwordInput, mockFormData.password); 123 | 124 | await userEvent.click(submitButton); 125 | 126 | await waitFor(() => { 127 | expect( 128 | screen.getByText('Please enter a valid email') 129 | ).toBeInTheDocument(); 130 | }); 131 | 132 | expect(global.mockNavigate).not.toHaveBeenCalled(); 133 | expect(notification.error).toHaveBeenCalled(); 134 | }); 135 | 136 | it('should not submit form when password is empty', async () => { 137 | renderUserData(); 138 | 139 | const firstNameInput = screen.getByTestId(testIds.firstNameInput); 140 | const emailInput = screen.getByTestId(testIds.emailInput); 141 | const submitButton = screen.getByTestId(testIds.submitButton); 142 | 143 | await userEvent.type(firstNameInput, mockFormData.firstName); 144 | await userEvent.type(emailInput, mockFormData.email); 145 | 146 | await userEvent.click(submitButton); 147 | 148 | await waitFor(() => { 149 | expect( 150 | screen.getByText('Password must be at least 8 characters long') 151 | ).toBeInTheDocument(); 152 | }); 153 | 154 | expect(global.mockNavigate).not.toHaveBeenCalled(); 155 | expect(notification.error).toHaveBeenCalled(); 156 | }); 157 | }); 158 | 159 | describe('Form submission', () => { 160 | it('should submit form and navigate to next step when all fields are valid', async () => { 161 | renderUserData(); 162 | 163 | await fillValidForm(); 164 | 165 | const submitButton = screen.getByTestId(testIds.submitButton); 166 | await userEvent.click(submitButton); 167 | 168 | await waitFor(() => { 169 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.MORE_INFO); 170 | }); 171 | }); 172 | 173 | it('should only allow submission when form is completely valid', async () => { 174 | renderUserData(); 175 | 176 | const firstNameInput = screen.getByTestId(testIds.firstNameInput); 177 | const emailInput = screen.getByTestId(testIds.emailInput); 178 | const passwordInput = screen.getByTestId(testIds.passwordInput); 179 | const submitButton = screen.getByTestId(testIds.submitButton); 180 | 181 | await userEvent.type(firstNameInput, mockFormData.firstName); 182 | await userEvent.click(submitButton); 183 | 184 | await waitFor(() => { 185 | expect( 186 | screen.getByText('Please enter a valid email') 187 | ).toBeInTheDocument(); 188 | expect( 189 | screen.getByText('Password must be at least 8 characters long') 190 | ).toBeInTheDocument(); 191 | }); 192 | 193 | await userEvent.type(emailInput, mockFormData.email); 194 | await userEvent.type(passwordInput, '123'); 195 | await userEvent.click(submitButton); 196 | 197 | await waitFor(() => { 198 | expect( 199 | screen.getByText('Password must be at least 8 characters long') 200 | ).toBeInTheDocument(); 201 | }); 202 | 203 | await userEvent.type(passwordInput, mockFormData.password); 204 | await userEvent.click(submitButton); 205 | 206 | await waitFor(() => { 207 | expect(global.mockNavigate).toHaveBeenCalledWith(SignupStep.MORE_INFO); 208 | }); 209 | }); 210 | 211 | it('should show error notification with generic message when validation fails without specific error', async () => { 212 | renderUserData(); 213 | 214 | const submitButton = screen.getByTestId(testIds.submitButton); 215 | await userEvent.click(submitButton); 216 | 217 | await waitFor(() => { 218 | expect( 219 | screen.getByText('Please enter your first name') 220 | ).toBeInTheDocument(); 221 | expect( 222 | screen.getByText('Please enter a valid email') 223 | ).toBeInTheDocument(); 224 | expect( 225 | screen.getByText('Password must be at least 8 characters long') 226 | ).toBeInTheDocument(); 227 | }); 228 | 229 | expect(notification.error).toHaveBeenCalled(); 230 | expect(global.mockNavigate).not.toHaveBeenCalled(); 231 | }); 232 | }); 233 | }); 234 | --------------------------------------------------------------------------------