diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new.zip b/07_js-debug/projects/debugging-multi-step-form-template-new.zip new file mode 100644 index 0000000..672f3e7 Binary files /dev/null and b/07_js-debug/projects/debugging-multi-step-form-template-new.zip differ diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/.gitignore b/07_js-debug/projects/debugging-multi-step-form-template-new/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/.oxlintrc.json b/07_js-debug/projects/debugging-multi-step-form-template-new/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/README.md b/07_js-debug/projects/debugging-multi-step-form-template-new/README.md new file mode 100644 index 0000000..18ecd3b --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/README.md @@ -0,0 +1,19 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. + +Note: This will impact Vite dev & build performances. +You can also try [the experimental native React Compiler support in plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md#rust-react-compiler) by using `compiler: true` in the plugin options instead of using the Babel plugin. + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project. diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/index.html b/07_js-debug/projects/debugging-multi-step-form-template-new/index.html new file mode 100644 index 0000000..cb4ec49 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/index.html @@ -0,0 +1,13 @@ + + + + + + + debugging-multi-step-form-template-new + + +
+ + + diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/package.json b/07_js-debug/projects/debugging-multi-step-form-template-new/package.json new file mode 100644 index 0000000..47a08a7 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/package.json @@ -0,0 +1,37 @@ +{ + "name": "debugging-multi-step-form-template-new", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui" + }, + "dependencies": { + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@babel/core": "^8.1.0", + "@rolldown/plugin-babel": "^0.3.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/vite": "^4.3.4", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.2.1", + "@vitest/ui": "^5.1.0", + "babel-plugin-react-compiler": "^1.0.0", + "jsdom": "^31.0.0", + "oxlint": "^1.91.0", + "tailwindcss": "^4.3.4", + "vite": "^8.3.6", + "vitest": "^5.1.0" + } +} diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/public/favicon.svg b/07_js-debug/projects/debugging-multi-step-form-template-new/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/public/icons.svg b/07_js-debug/projects/debugging-multi-step-form-template-new/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/App.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/App.jsx new file mode 100644 index 0000000..af3ed2e --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/App.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import MultiStepForm from './MultiStepForm'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/MultiStepForm.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/MultiStepForm.jsx new file mode 100644 index 0000000..e56c9fc --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/MultiStepForm.jsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; + +import ProgressIndicator from './components/ProgressIndicator'; +import NavigationButtons from './components/NavigationButtons'; +import AccountInfo from './steps/AccountInfo'; +import ActivityInfo from './steps/ActivityInfo'; +import GoalSetting from './steps/GoalSetting'; + +import { validate } from './utils/validation'; + +function MultiStepForm() { + const [stepIndex, setStepIndex] = useState(1); + const [errors, setErrors] = useState({}); + const [formData, setFormData] = useState({ + fullName: '', + email: '', + password: '', + activityLevel: 'never', + energyLevel: 'low', + goals: [], + timeCommitment: '15', + }); + + const formSteps = [ + { id: 1, name: 'Account Info', component: AccountInfo }, + { id: 2, name: 'Activity Info', component: ActivityInfo }, + { id: 3, name: 'Goal Setting', component: GoalSetting }, + ]; + + // ===== Event Handlers ===== + const handleChange = (e) => { + const { name, value } = e.target; + setFormData({ ...formData, [name]: value }); + }; + + const handleCheckboxChange = (field, id, isChecked) => { + setFormData((prev) => ({ + ...prev, + [field]: isChecked + ? [...prev[field], id] + : prev[field].filter((item) => item !== id), + })); + }; + + // ===== Form Navigation ===== + const handleNext = () => { + if (handleValidation()) { + setStepIndex(stepIndex + 1); + } + }; + + const handlePrev = () => { + setErrors({}); + setStepIndex(stepIndex - 1); + }; + + // ===== Form Validation & Submission ===== + const handleValidation = () => { + const validationErrors = validate(formData, stepIndex); + setErrors(validationErrors); + return Object.keys(validationErrors).length === 0; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + + if (handleValidation()) { + alert('Form submitted successfully!'); + console.log('Submitted Data:', formData); + } + }; + + return ( +
+
+
+
+

+ Let's get you started! +

+ + + +
+ {formSteps.map((s) => { + const StepComponent = s.component; + return ( +
+ {stepIndex === s.id && ( + + )} +
+ ); + })} + + {/* Navigation Buttons */} + + +
+
+
+
+ ); +} + +export default MultiStepForm; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormCheckboxes.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormCheckboxes.jsx new file mode 100644 index 0000000..8df52e2 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormCheckboxes.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +const FormCheckboxes = ({ legend, required, options, onChange, error }) => { + return ( +
+ + +
+ {legend} +
+ {options.map((option) => ( +
+
+ onChange(option.id, e.target.checked)} + checked={option.checked} + /> +
+
+ +
+
+ ))} +
+
+ +

{error}

+
+ ); +}; + +export default FormCheckboxes; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormInput.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormInput.jsx new file mode 100644 index 0000000..0b6827a --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormInput.jsx @@ -0,0 +1,47 @@ +import React from 'react'; + +const FormInput = ({ + htmlFor, + label, + id, + name, + value, + onChange, + error, + placeholder, + required = false, + type = 'text', +}) => { + return ( +
+ + +
+ +
+ +

{error}

+
+ ); +}; + +export default FormInput; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormSelect.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormSelect.jsx new file mode 100644 index 0000000..185a5c2 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/FormSelect.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +const FormSelect = ({ + id, + name, + options, + defaultValue, + onChange, + label, + required = false, + className = '', + labelClassName = '', + error, +}) => { + return ( +
+ {label && ( + + )} + + +

{error}

+
+ ); +}; + +export default FormSelect; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/NavigationButtons.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/NavigationButtons.jsx new file mode 100644 index 0000000..37b90fc --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/NavigationButtons.jsx @@ -0,0 +1,43 @@ +import React from 'react'; + +function NavigationButtons({ + currentStep, + totalSteps, + handlePrev, + handleNext, + handleSubmit, +}) { + return ( +
+ {currentStep > 1 && ( + + )} + {currentStep < totalSteps && ( + + )} + {currentStep === totalSteps && ( + + )} +
+ ); +} + +export default NavigationButtons; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/ProgressIndicator.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/ProgressIndicator.jsx new file mode 100644 index 0000000..8359817 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/components/ProgressIndicator.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +function ProgressIndicator({ steps, currentStep }) { + return ( +
+ {steps.map((step, index) => ( +
+
+
= step.id + ? 'bg-teal-600 text-white' + : 'bg-gray-300 text-gray-700' + }`} + aria-current={currentStep === step.id ? 'step' : undefined} + > + {step.id} +
+ {index !== steps.length - 1 && ( +
step.id ? 'bg-teal-600' : 'bg-gray-300' + }`} + >
+ )} +
+
+ ))} +
+ ); +} + +export default ProgressIndicator; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/index.css b/07_js-debug/projects/debugging-multi-step-form-template-new/src/index.css new file mode 100644 index 0000000..bb6c52e --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/index.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/forms"; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/main.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/AccountInfo.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/AccountInfo.jsx new file mode 100644 index 0000000..5abdb13 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/AccountInfo.jsx @@ -0,0 +1,60 @@ +import FormInput from '../components/FormInput'; + +function AccountInfo({ formData, handleChange, errors }) { + return ( +
+

+ Welcome to BrightPath Wellness! +

+

+ Let’s create your account and begin your wellness journey. +

+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} + +export default AccountInfo; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/ActivityInfo.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/ActivityInfo.jsx new file mode 100644 index 0000000..92c2736 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/ActivityInfo.jsx @@ -0,0 +1,45 @@ +import FormSelect from '../components/FormSelect'; + +function ActivityInfo({ handleChange, errors }) { + return ( +
+

Activity & Energy

+

+ Tell us a little about your activity level and energy. +

+ +
+ +
+ +
+ +
+
+ ); +} + +export default ActivityInfo; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/GoalSetting.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/GoalSetting.jsx new file mode 100644 index 0000000..10889cf --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/steps/GoalSetting.jsx @@ -0,0 +1,65 @@ +import FormSelect from '../components/FormSelect'; +import FormCheckboxes from '../components/FormCheckboxes'; + +function GoalSetting({ formData, handleChange, handleCheckboxChange, errors }) { + return ( +
+

Activity & Energy

+

+ Tell us a little about your activity level and energy. +

+ +
+ handleCheckboxChange('goals', id)} + required={true} + error={errors.goals} + /> +
+ +
+ +
+
+ ); +} + +export default GoalSetting; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/src/utils/validation.js b/07_js-debug/projects/debugging-multi-step-form-template-new/src/utils/validation.js new file mode 100644 index 0000000..99c87f7 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/src/utils/validation.js @@ -0,0 +1,96 @@ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const PASSWORD_MIN_LENGTH = 6; + +// Helper Functions +export const isEmpty = (value) => { + return !value || !value.trim(); +}; + +export const isValidEmail = (email) => { + return EMAIL_REGEX.test(email); +}; + +export const hasMinLength = (value, min) => { + return value.length >= min; +}; + +export const hasTwoWords = (fullName) => { + return fullName.trim().split(' ').length >= 2; +}; + +// Main Validation Function +export const validate = (formData, currentStep) => { + const errors = {}; + + const { + fullName, + email, + password, + activityLevel, + energyLevel, + goals, + timeCommitment, + } = formData; + + switch (currentStep) { + case 1: + // Full Name Validations + if (isEmpty(fullName)) { + errors.fullName = 'Full Name is required'; + } else if (!hasTwoWords(fullName)) { + errors.fullName = 'Full Name must contain at least two words'; + } + + // Email Validations + if (isEmpty(email)) { + errors.email = 'Email is required'; + } else if (!isValidEmail(email)) { + errors.email = 'Email address is invalid'; + } + + // Password Validations + if (isEmpty(password)) { + errors.password = 'Password is required'; + } else { + if (!hasMinLength(password, PASSWORD_MIN_LENGTH)) { + errors.password = `Password must be at least ${PASSWORD_MIN_LENGTH} characters`; + } + } + break; + + case 2: + // Activity Level Validation + if (isEmpty(activityLevel)) { + errors.activityLevel = 'Activity Level is required'; + } + + // Energy Level Validation + if (isEmpty(energyLevel)) { + errors.energyLevel = 'Energy Level is required'; + } + break; + + case 3: + // Goals Validation + if (!Array.isArray(goals) || goals.length === 0) { + errors.goals = 'At least one goal is required'; + } else { + goals.forEach((goal, index) => { + if (isEmpty(goal)) { + errors[`goals.${index}`] = 'Goal cannot be empty'; + } + }); + } + + // Time Commitment Validation + if (isEmpty(timeCommitment)) { + errors.timeCommitment = 'Time Commitment is required'; + } + break; + + default: + break; + } + + return errors; +}; diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step1.test.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step1.test.jsx new file mode 100644 index 0000000..0aed95e --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step1.test.jsx @@ -0,0 +1,125 @@ +import { it, expect, describe, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; + +import AccountInfo from '../../src/steps/AccountInfo'; +import { validate } from '../../src/utils/validation'; + +describe('AccountInfo Component', () => { + afterEach(cleanup); + + const step = 1; + + it('renders all form fields', () => { + render( + {}} + errors={{}} + /> + ); + + expect(screen.getByLabelText(/Full Name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Password/i)).toBeInTheDocument(); + }); + + it('displays validation errors for invalid data', () => { + const formData = { fullName: '', email: '', password: '' }; + const errors = validate(formData, step); + + render( + {}} + errors={errors} + /> + ); + + expect(screen.getByText(/Full Name is required/i)).toBeInTheDocument(); + expect(screen.getByText(/Email is required/i)).toBeInTheDocument(); + expect(screen.getByText(/Password is required/i)).toBeInTheDocument(); + }); + + it('displays no validation errors for valid data', () => { + const formData = { + fullName: 'John Doe', + email: 'john.doe@example.com', + password: 'securepassword', + }; + const errors = validate(formData, step); + + render( + {}} + errors={errors} + /> + ); + + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/invalid/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/must be at least 6 characters/i) + ).not.toBeInTheDocument(); + }); + + it('calls handleChange and updates error messages dynamically', async () => { + let formData = { fullName: '', email: '', password: '' }; + let errors = validate(formData, step); + + const handleChangeTest = (field, value) => { + formData = { ...formData, [field]: value }; + errors = validate(formData, step); + }; + + const { rerender } = render( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + const fullNameInput = screen.getByLabelText(/Full Name/i); + await userEvent.type(fullNameInput, 'John'); + + rerender( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + expect( + screen.getByText(/Full Name must contain at least two words/i) + ).toBeInTheDocument(); + }); + + it('retains user input in form fields', () => { + const formData = { + fullName: 'Jane Doe', + email: 'jane.doe@example.com', + password: 'securepassword', + }; + const errors = validate(formData, step); + + render( + {}} + errors={errors} + /> + ); + + expect(screen.getByLabelText(/Full Name/i)).toHaveValue('Jane Doe'); + expect(screen.getByLabelText(/Email/i)).toHaveValue('jane.doe@example.com'); + expect(screen.getByLabelText(/Password/i)).toHaveValue('securepassword'); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step2.test.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step2.test.jsx new file mode 100644 index 0000000..7505148 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step2.test.jsx @@ -0,0 +1,114 @@ +import { it, expect, describe, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; + +import ActivityInfo from '../../src/steps/ActivityInfo'; +import { validate } from '../../src/utils/validation'; + +describe('ActivityInfo Component', () => { + afterEach(cleanup); + + const step = 2; + + it('renders all form fields', () => { + render( {}} errors={{}} />); + + expect( + screen.getByLabelText(/How often do you exercise\?/i) + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + /How would you describe your average energy level\?/i + ) + ).toBeInTheDocument(); + }); + + it('displays validation errors for invalid data', () => { + const formData = { + fullName: 'John Doe', + email: 'john@example.com', + password: 'password123', + activityLevel: '', + energyLevel: '', + goals: [], + timeCommitment: '15', + }; + const errors = validate(formData, step); + + render( {}} errors={errors} />); + + expect(screen.getByText(/Activity Level is required/i)).toBeInTheDocument(); + expect(screen.getByText(/Energy Level is required/i)).toBeInTheDocument(); + }); + + it('displays no validation errors for valid data', () => { + const formData = { + fullName: 'John Doe', + email: 'john@example.com', + password: 'password123', + activityLevel: 'moderate', + energyLevel: 'high', + goals: [], + timeCommitment: '15', + }; + const errors = validate(formData, step); + + render( {}} errors={errors} />); + + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument(); + }); + + it('calls handleChange and updates error messages dynamically', async () => { + let formData = { + fullName: 'John Doe', + email: 'john@example.com', + password: 'password123', + activityLevel: '', + energyLevel: '', + goals: [], + timeCommitment: '15', + }; + let errors = validate(formData, step); + + const handleChangeTest = (field, value) => { + formData = { ...formData, [field]: value }; + errors = validate(formData, step); + }; + + const { rerender } = render( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + const activitySelect = screen.getByLabelText( + /How often do you exercise\?/i + ); + const energySelect = screen.getByLabelText( + /How would you describe your average energy level\?/i + ); + + await userEvent.selectOptions(activitySelect, '3+ times a week'); + await userEvent.selectOptions(energySelect, 'medium'); + + rerender( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + expect( + screen.queryByText(/Activity Level is required/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/Energy Level is required/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step3.test.jsx b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step3.test.jsx new file mode 100644 index 0000000..57fb4ff --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/step3.test.jsx @@ -0,0 +1,173 @@ +import { it, expect, describe, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; + +import GoalSetting from '../../src/steps/GoalSetting'; +import { validate } from '../../src/utils/validation'; + +describe('GoalSetting Component', () => { + afterEach(cleanup); + + const step = 3; + + it('renders all form fields', () => { + render( + {}} + handleCheckboxChange={() => {}} + errors={{}} + /> + ); + + // Use getByRole to target the fieldset for "What are your goals?" + expect( + screen.getByRole('group', { name: /What are your goals\?/i }) + ).toBeInTheDocument(); + + expect( + screen.getByLabelText(/How much time can you commit each day\?/i) + ).toBeInTheDocument(); + }); + + it('displays validation errors for empty fields', () => { + const formData = { + goals: [], + timeCommitment: '', + }; + const errors = validate(formData, step); + + render( + {}} + handleCheckboxChange={() => {}} + errors={errors} + /> + ); + + expect( + screen.getByText(/At least one goal is required/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/Time Commitment is required/i) + ).toBeInTheDocument(); + }); + + it('displays no validation errors for valid data', () => { + const formData = { + goals: ['weightLoss', 'muscleGain'], + timeCommitment: '30', + }; + const errors = validate(formData, step); + + render( + {}} + handleCheckboxChange={() => {}} + errors={errors} + /> + ); + + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument(); + }); + + it('handles checkbox interactions and updates goals dynamically', async () => { + let formData = { goals: [], timeCommitment: '' }; + let errors = validate(formData, step); + + const handleCheckboxChangeTest = (field, id, isChecked) => { + const updatedGoals = isChecked + ? [...formData[field], id] + : formData[field].filter((goal) => goal !== id); + + formData = { ...formData, [field]: updatedGoals }; + errors = validate(formData, step); + }; + + const { rerender } = render( + {}} + handleCheckboxChange={handleCheckboxChangeTest} + errors={errors} + /> + ); + + const weightLossCheckbox = screen.getByLabelText(/Weight Loss/i); + const muscleGainCheckbox = screen.getByLabelText(/Muscle Gain/i); + + await userEvent.click(weightLossCheckbox); + rerender( + {}} + handleCheckboxChange={handleCheckboxChangeTest} + errors={errors} + /> + ); + + expect(weightLossCheckbox).toBeChecked(); + expect( + screen.queryByText(/At least one goal is required/i) + ).not.toBeInTheDocument(); + + await userEvent.click(muscleGainCheckbox); + rerender( + {}} + handleCheckboxChange={handleCheckboxChangeTest} + errors={errors} + /> + ); + + expect(muscleGainCheckbox).toBeChecked(); + expect(formData.goals).toContain('weightLoss'); + expect(formData.goals).toContain('muscleGain'); + }); + + it('handles select interactions and updates time commitment dynamically', async () => { + let formData = { goals: ['weightLoss'], timeCommitment: '' }; + let errors = validate(formData, step); + + const handleChangeTest = (field, value) => { + formData = { ...formData, [field]: value }; + errors = validate(formData, step); + }; + + const { rerender } = render( + + handleChangeTest(target.name, target.value) + } + handleCheckboxChange={() => {}} + errors={errors} + /> + ); + + const timeCommitmentSelect = screen.getByLabelText( + /How much time can you commit each day\?/i + ); + await userEvent.selectOptions(timeCommitmentSelect, '30'); + + rerender( + + handleChangeTest(target.name, target.value) + } + handleCheckboxChange={() => {}} + errors={errors} + /> + ); + + expect(timeCommitmentSelect).toHaveValue('30'); + expect( + screen.queryByText(/Time Commitment is required/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/utility.test.js b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/utility.test.js new file mode 100644 index 0000000..6c3b9cd --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/tests/validation/utility.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { + isEmpty, + isValidEmail, + hasMinLength, + hasTwoWords, +} from '../../src/utils/validation'; + +describe('Utility Functions', () => { + describe('isEmpty', () => { + it('should return true for empty strings', () => { + expect(isEmpty('')).toBe(true); + expect(isEmpty(' ')).toBe(true); + }); + + it('should return false for non-empty strings', () => { + expect(isEmpty('John')).toBe(false); + expect(isEmpty(' John Doe ')).toBe(false); + }); + }); + + describe('isValidEmail', () => { + it('should validate correct email formats', () => { + expect(isValidEmail('john@example.com')).toBe(true); + expect(isValidEmail('jane.doe@sub.domain.co')).toBe(true); + }); + + it('should invalidate incorrect email formats', () => { + expect(isValidEmail('johnexample.com')).toBe(false); + expect(isValidEmail('jane@.com')).toBe(false); + expect(isValidEmail('jane@com')).toBe(false); + }); + }); + + describe('hasMinLength', () => { + it('should return true if value meets minimum length', () => { + expect(hasMinLength('password', 6)).toBe(true); + expect(hasMinLength('123456', 6)).toBe(true); + }); + + it('should return false if value does not meet minimum length', () => { + expect(hasMinLength('pass', 6)).toBe(false); + expect(hasMinLength('123', 6)).toBe(false); + }); + }); + + describe('hasTwoWords', () => { + it('should return true if fullName has at least two words', () => { + expect(hasTwoWords('John Doe')).toBe(true); + expect(hasTwoWords('Jane Mary Doe')).toBe(true); + }); + + it('should return false if fullName has less than two words', () => { + expect(hasTwoWords('John')).toBe(false); + expect(hasTwoWords(' Jane ')).toBe(false); + }); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/vite.config.js b/07_js-debug/projects/debugging-multi-step-form-template-new/vite.config.js new file mode 100644 index 0000000..5b7c4e9 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/vite.config.js @@ -0,0 +1,13 @@ +import react, { reactCompilerPreset } from '@vitejs/plugin-react' +import babel from '@rolldown/plugin-babel' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react(), + babel({ presets: [reactCompilerPreset()] }), + tailwindcss(), + ], +}) diff --git a/07_js-debug/projects/debugging-multi-step-form-template-new/vitest.config.js b/07_js-debug/projects/debugging-multi-step-form-template-new/vitest.config.js new file mode 100644 index 0000000..5853726 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template-new/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, + plugins: [react()], +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template.zip b/07_js-debug/projects/debugging-multi-step-form-template.zip new file mode 100644 index 0000000..c8e601a Binary files /dev/null and b/07_js-debug/projects/debugging-multi-step-form-template.zip differ diff --git a/07_js-debug/projects/debugging-multi-step-form-template/.gitignore b/07_js-debug/projects/debugging-multi-step-form-template/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/07_js-debug/projects/debugging-multi-step-form-template/README.md b/07_js-debug/projects/debugging-multi-step-form-template/README.md new file mode 100644 index 0000000..2861a84 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/README.md @@ -0,0 +1,132 @@ +# Assignment: Completing the Multi-Step Wizard Form + +In this assignment, you will complete a multi-step wizard form by adding two final steps, Step 4 (Personal Preferences) and Step 5 (Ready to Start), bringing the total number of steps to five. Your tasks include: + +- Fixing any failing components so that all existing tests pass. +- Adding two new steps with validation logic. +- Writing tests to ensure the new steps are functional and robust. + +Below are detailed requirements for the two new steps and their associated tests. + +## Adding Step 4 and Step 5 to the Multi-Step Wizard Form + +This section outlines the requirements for implementing Step 4: Personal Preferences and Step 5: Ready to Start. Your implementation should maintain consistency with the existing form design and functionality while ensuring robust validation and comprehensive test coverage. + +### Step 4: Personal Preferences + +This step gathers information about the user’s preferred activities and dietary preferences. + +#### Inputs + +- **Checkboxes**: Allow users to select one or more activities. Options include: + - Yoga + - Cardio + - Strength Training + - Meditation +- **Dropdown (Select)**: Allow users to specify their dietary preferences. Options include: + - None + - Vegetarian + - Vegan + - Gluten-Free + +Display the following text in this step: “Let us know your preferences to make the experience personal.” + +#### What to Test + +1. Rendering of Form Fields: + +- **Activity Checkboxes**: + - Verify that checkboxes for activities are rendered and grouped under the label “What type of activities do you prefer?” + - Ensure that all activity options (Yoga, Cardio, Strength Training, Meditation) are present and selectable. +- **Dietary Preferences Dropdown**: + - Confirm that the dropdown for dietary preferences is rendered with the label “Do you have any dietary preferences?” + - Ensure that all dietary options (None, Vegetarian, Vegan, Gluten-Free) are available for selection. + +2. Validation of User Input: + +- **Activity Preferences Validation**: + - Attempt to proceed without selecting any activities and verify that an error message appears stating “At least one activity preference is required.” + - Select one or more activities and ensure that the error message disappears upon valid input. +- **Dietary Preferences Validation**: + - Attempt to proceed without selecting a dietary preference and verify that an error message appears stating “Dietary preference is required.” + - Select a dietary preference and ensure that the error message disappears upon valid input. + +3. Dynamic Updates to Form Data: + +- **Activity Checkboxes Interaction**: + - Select and deselect activity checkboxes and confirm that the activityPreferences array in the form data updates accordingly. +- **Dietary Preferences Dropdown Interaction**: + - Change the selection in the dietary preferences dropdown and verify that the diet field in the form data updates to reflect the current selection. + +#### Test Summary for Step 4: Personal Preferences + +- **Test Case 1**: Verifies that all form fields (activity checkboxes and dietary preferences dropdown) are rendered correctly with the appropriate labels and options. +- **Test Case 2**: Ensures validation errors appear when no activities or dietary preferences are selected and disappear when valid inputs are provided. +- **Test Case 3**: Confirms that interacting with the form elements (selecting activities and dietary preferences) dynamically updates the form data. + +### Step 5: Ready to Start + +This step collects motivational information, enables users to join optional challenges, and allows them to choose their preferred reminder method. + +#### Inputs + +- **Short Text Input**: A field for users to describe what motivates them the most. Placeholder: “What motivates you the most?” +- **Checkboxes**: Options to join one or more optional challenges, including: + - Fitness Challenge + - Healthy Eating Challenge + - Mindfulness Challenge +- **Dropdown (Select)**: A field to specify how users want to receive reminders. Options include: + - Email + - Push Notifications + - None + +Display the following text in this step: “We are ready to guide you! Set your preferences and begin your journey.” + +#### What to Test + +1. Rendering of Form Fields: + +- **Motivational Input Field**: + - Verify that the motivational input field is rendered with the label “What motivates you the most?” and the placeholder text is correctly displayed. +- **Optional Challenges Checkboxes**: + - Confirm that checkboxes for optional challenges are rendered and grouped under the label “Would you like to join any optional challenges?” + - Ensure that all challenge options (Fitness Challenge, Healthy Eating Challenge, Mindfulness Challenge) are present and selectable. +- **Reminders Dropdown**: + - Check that the dropdown for reminder preferences is rendered with the label “Preferred way to receive reminders?” + - Ensure that all reminder options (Email, Push Notifications, None) are available for selection. + +2. Validation and Dynamic Updates of User Input: + +- **Form Validation**: + - Enter valid inputs for all fields: + - Type a non-empty string into the motivation field. + - Optionally select one or more challenges (since they are optional, the form should accept both selecting and not selecting any challenges). + - Select a valid option from the reminders dropdown. + - Ensure that the form passes validation without any errors when all inputs are valid. +- **Dynamic Updates to Form Data**: + - Confirm that typing into the motivation field updates the motivation field in the form data. + - Verify that selecting or deselecting challenges updates the optionalChallenges array in the form data. + - Ensure that changing the selection in the reminders dropdown updates the reminders field in the form data. + +#### Test Summary for Step 5: Ready to Start + +- **Test Case 1**: Verifies that all form fields (motivation input, optional challenges checkboxes, and reminders dropdown) are rendered correctly with the appropriate labels and options. +- **Test Case 2**: Ensures the form passes validation with valid inputs and confirms that user interactions (typing in the motivation field, selecting/deselecting challenges, choosing a reminder option) dynamically update the form data accurately. + +## License and terms of use + +© Web Professional Institute Inc. All rights reserved. + +This material is provided solely for students enrolled in courses offered by Web Professional Institute Inc. By accessing or using this code, you acknowledge that it is strictly for educational use within the context of Web Professional Institute Inc. programs. + +**Usage Restrictions:** + +- Redistribution, sharing, or copying of this material outside the course environment is strictly prohibited. +- The content is designed to support your learning objectives and is not authorized for commercial projects, public repositories, or applications beyond the course scope. +- Unauthorized commercial use or open-source distribution is strictly prohibited and may result in expulsion from the program and legal action. + +**Agreement & Rights:** + +As a student, you have agreed to these terms as part of your enrollment agreement, which includes additional details on permitted uses, restrictions, and policies. The author and Web Professional Institute Inc. retain all rights to this material, including the code base and instructional content. + +For any questions regarding these terms, please contact Web Professional Institute Inc. for clarification. diff --git a/07_js-debug/projects/debugging-multi-step-form-template/eslint.config.js b/07_js-debug/projects/debugging-multi-step-form-template/eslint.config.js new file mode 100644 index 0000000..238d2e4 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import globals from 'globals' +import react from 'eslint-plugin-react' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/07_js-debug/projects/debugging-multi-step-form-template/index.html b/07_js-debug/projects/debugging-multi-step-form-template/index.html new file mode 100644 index 0000000..0c589ec --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + + +
+ + + diff --git a/07_js-debug/projects/debugging-multi-step-form-template/package.json b/07_js-debug/projects/debugging-multi-step-form-template/package.json new file mode 100644 index 0000000..5bd42b3 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/package.json @@ -0,0 +1,42 @@ +{ + "name": "debugging-assignment-wizard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@tailwindcss/forms": "^0.5.9", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "@vitest/ui": "^2.1.5", + "autoprefixer": "^10.4.20", + "eslint": "^9.13.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "i": "^0.3.7", + "jsdom": "^25.0.1", + "npm": "^10.9.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.14", + "vite": "^5.4.10", + "vitest": "^2.1.5" + } +} diff --git a/07_js-debug/projects/debugging-multi-step-form-template/postcss.config.js b/07_js-debug/projects/debugging-multi-step-form-template/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/07_js-debug/projects/debugging-multi-step-form-template/public/vite.svg b/07_js-debug/projects/debugging-multi-step-form-template/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/App.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/App.jsx new file mode 100644 index 0000000..af3ed2e --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/App.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import MultiStepForm from './MultiStepForm'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/MultiStepForm.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/MultiStepForm.jsx new file mode 100644 index 0000000..e56c9fc --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/MultiStepForm.jsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; + +import ProgressIndicator from './components/ProgressIndicator'; +import NavigationButtons from './components/NavigationButtons'; +import AccountInfo from './steps/AccountInfo'; +import ActivityInfo from './steps/ActivityInfo'; +import GoalSetting from './steps/GoalSetting'; + +import { validate } from './utils/validation'; + +function MultiStepForm() { + const [stepIndex, setStepIndex] = useState(1); + const [errors, setErrors] = useState({}); + const [formData, setFormData] = useState({ + fullName: '', + email: '', + password: '', + activityLevel: 'never', + energyLevel: 'low', + goals: [], + timeCommitment: '15', + }); + + const formSteps = [ + { id: 1, name: 'Account Info', component: AccountInfo }, + { id: 2, name: 'Activity Info', component: ActivityInfo }, + { id: 3, name: 'Goal Setting', component: GoalSetting }, + ]; + + // ===== Event Handlers ===== + const handleChange = (e) => { + const { name, value } = e.target; + setFormData({ ...formData, [name]: value }); + }; + + const handleCheckboxChange = (field, id, isChecked) => { + setFormData((prev) => ({ + ...prev, + [field]: isChecked + ? [...prev[field], id] + : prev[field].filter((item) => item !== id), + })); + }; + + // ===== Form Navigation ===== + const handleNext = () => { + if (handleValidation()) { + setStepIndex(stepIndex + 1); + } + }; + + const handlePrev = () => { + setErrors({}); + setStepIndex(stepIndex - 1); + }; + + // ===== Form Validation & Submission ===== + const handleValidation = () => { + const validationErrors = validate(formData, stepIndex); + setErrors(validationErrors); + return Object.keys(validationErrors).length === 0; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + + if (handleValidation()) { + alert('Form submitted successfully!'); + console.log('Submitted Data:', formData); + } + }; + + return ( +
+
+
+
+

+ Let's get you started! +

+ + + +
+ {formSteps.map((s) => { + const StepComponent = s.component; + return ( +
+ {stepIndex === s.id && ( + + )} +
+ ); + })} + + {/* Navigation Buttons */} + + +
+
+
+
+ ); +} + +export default MultiStepForm; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormCheckboxes.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormCheckboxes.jsx new file mode 100644 index 0000000..8df52e2 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormCheckboxes.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +const FormCheckboxes = ({ legend, required, options, onChange, error }) => { + return ( +
+ + +
+ {legend} +
+ {options.map((option) => ( +
+
+ onChange(option.id, e.target.checked)} + checked={option.checked} + /> +
+
+ +
+
+ ))} +
+
+ +

{error}

+
+ ); +}; + +export default FormCheckboxes; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormInput.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormInput.jsx new file mode 100644 index 0000000..0b6827a --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormInput.jsx @@ -0,0 +1,47 @@ +import React from 'react'; + +const FormInput = ({ + htmlFor, + label, + id, + name, + value, + onChange, + error, + placeholder, + required = false, + type = 'text', +}) => { + return ( +
+ + +
+ +
+ +

{error}

+
+ ); +}; + +export default FormInput; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormSelect.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormSelect.jsx new file mode 100644 index 0000000..185a5c2 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/components/FormSelect.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +const FormSelect = ({ + id, + name, + options, + defaultValue, + onChange, + label, + required = false, + className = '', + labelClassName = '', + error, +}) => { + return ( +
+ {label && ( + + )} + + +

{error}

+
+ ); +}; + +export default FormSelect; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/components/NavigationButtons.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/components/NavigationButtons.jsx new file mode 100644 index 0000000..37b90fc --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/components/NavigationButtons.jsx @@ -0,0 +1,43 @@ +import React from 'react'; + +function NavigationButtons({ + currentStep, + totalSteps, + handlePrev, + handleNext, + handleSubmit, +}) { + return ( +
+ {currentStep > 1 && ( + + )} + {currentStep < totalSteps && ( + + )} + {currentStep === totalSteps && ( + + )} +
+ ); +} + +export default NavigationButtons; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/components/ProgressIndicator.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/components/ProgressIndicator.jsx new file mode 100644 index 0000000..8359817 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/components/ProgressIndicator.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +function ProgressIndicator({ steps, currentStep }) { + return ( +
+ {steps.map((step, index) => ( +
+
+
= step.id + ? 'bg-teal-600 text-white' + : 'bg-gray-300 text-gray-700' + }`} + aria-current={currentStep === step.id ? 'step' : undefined} + > + {step.id} +
+ {index !== steps.length - 1 && ( +
step.id ? 'bg-teal-600' : 'bg-gray-300' + }`} + >
+ )} +
+
+ ))} +
+ ); +} + +export default ProgressIndicator; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/index.css b/07_js-debug/projects/debugging-multi-step-form-template/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/main.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/steps/AccountInfo.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/steps/AccountInfo.jsx new file mode 100644 index 0000000..5abdb13 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/steps/AccountInfo.jsx @@ -0,0 +1,60 @@ +import FormInput from '../components/FormInput'; + +function AccountInfo({ formData, handleChange, errors }) { + return ( +
+

+ Welcome to BrightPath Wellness! +

+

+ Let’s create your account and begin your wellness journey. +

+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} + +export default AccountInfo; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/steps/ActivityInfo.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/steps/ActivityInfo.jsx new file mode 100644 index 0000000..92c2736 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/steps/ActivityInfo.jsx @@ -0,0 +1,45 @@ +import FormSelect from '../components/FormSelect'; + +function ActivityInfo({ handleChange, errors }) { + return ( +
+

Activity & Energy

+

+ Tell us a little about your activity level and energy. +

+ +
+ +
+ +
+ +
+
+ ); +} + +export default ActivityInfo; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/steps/GoalSetting.jsx b/07_js-debug/projects/debugging-multi-step-form-template/src/steps/GoalSetting.jsx new file mode 100644 index 0000000..0e1b1ef --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/steps/GoalSetting.jsx @@ -0,0 +1,64 @@ +import FormSelect from '../components/FormSelect'; +import FormCheckboxes from '../components/FormCheckboxes'; + +function GoalSetting({ formData, handleChange, handleCheckboxChange, errors }) { + return ( +
+

Activity & Energy

+

Tell us a little about your activity level and energy.

+ +
+ handleCheckboxChange('goals', id)} + required={true} + error={errors.goals} + /> +
+ +
+ +
+
+ ); +} + +export default GoalSetting; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/src/utils/validation.js b/07_js-debug/projects/debugging-multi-step-form-template/src/utils/validation.js new file mode 100644 index 0000000..99c87f7 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/src/utils/validation.js @@ -0,0 +1,96 @@ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const PASSWORD_MIN_LENGTH = 6; + +// Helper Functions +export const isEmpty = (value) => { + return !value || !value.trim(); +}; + +export const isValidEmail = (email) => { + return EMAIL_REGEX.test(email); +}; + +export const hasMinLength = (value, min) => { + return value.length >= min; +}; + +export const hasTwoWords = (fullName) => { + return fullName.trim().split(' ').length >= 2; +}; + +// Main Validation Function +export const validate = (formData, currentStep) => { + const errors = {}; + + const { + fullName, + email, + password, + activityLevel, + energyLevel, + goals, + timeCommitment, + } = formData; + + switch (currentStep) { + case 1: + // Full Name Validations + if (isEmpty(fullName)) { + errors.fullName = 'Full Name is required'; + } else if (!hasTwoWords(fullName)) { + errors.fullName = 'Full Name must contain at least two words'; + } + + // Email Validations + if (isEmpty(email)) { + errors.email = 'Email is required'; + } else if (!isValidEmail(email)) { + errors.email = 'Email address is invalid'; + } + + // Password Validations + if (isEmpty(password)) { + errors.password = 'Password is required'; + } else { + if (!hasMinLength(password, PASSWORD_MIN_LENGTH)) { + errors.password = `Password must be at least ${PASSWORD_MIN_LENGTH} characters`; + } + } + break; + + case 2: + // Activity Level Validation + if (isEmpty(activityLevel)) { + errors.activityLevel = 'Activity Level is required'; + } + + // Energy Level Validation + if (isEmpty(energyLevel)) { + errors.energyLevel = 'Energy Level is required'; + } + break; + + case 3: + // Goals Validation + if (!Array.isArray(goals) || goals.length === 0) { + errors.goals = 'At least one goal is required'; + } else { + goals.forEach((goal, index) => { + if (isEmpty(goal)) { + errors[`goals.${index}`] = 'Goal cannot be empty'; + } + }); + } + + // Time Commitment Validation + if (isEmpty(timeCommitment)) { + errors.timeCommitment = 'Time Commitment is required'; + } + break; + + default: + break; + } + + return errors; +}; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/tailwind.config.js b/07_js-debug/projects/debugging-multi-step-form-template/tailwind.config.js new file mode 100644 index 0000000..2be9ef7 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [require('@tailwindcss/forms')], +}; diff --git a/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step1.test.jsx b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step1.test.jsx new file mode 100644 index 0000000..0aed95e --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step1.test.jsx @@ -0,0 +1,125 @@ +import { it, expect, describe, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; + +import AccountInfo from '../../src/steps/AccountInfo'; +import { validate } from '../../src/utils/validation'; + +describe('AccountInfo Component', () => { + afterEach(cleanup); + + const step = 1; + + it('renders all form fields', () => { + render( + {}} + errors={{}} + /> + ); + + expect(screen.getByLabelText(/Full Name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Password/i)).toBeInTheDocument(); + }); + + it('displays validation errors for invalid data', () => { + const formData = { fullName: '', email: '', password: '' }; + const errors = validate(formData, step); + + render( + {}} + errors={errors} + /> + ); + + expect(screen.getByText(/Full Name is required/i)).toBeInTheDocument(); + expect(screen.getByText(/Email is required/i)).toBeInTheDocument(); + expect(screen.getByText(/Password is required/i)).toBeInTheDocument(); + }); + + it('displays no validation errors for valid data', () => { + const formData = { + fullName: 'John Doe', + email: 'john.doe@example.com', + password: 'securepassword', + }; + const errors = validate(formData, step); + + render( + {}} + errors={errors} + /> + ); + + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/invalid/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/must be at least 6 characters/i) + ).not.toBeInTheDocument(); + }); + + it('calls handleChange and updates error messages dynamically', async () => { + let formData = { fullName: '', email: '', password: '' }; + let errors = validate(formData, step); + + const handleChangeTest = (field, value) => { + formData = { ...formData, [field]: value }; + errors = validate(formData, step); + }; + + const { rerender } = render( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + const fullNameInput = screen.getByLabelText(/Full Name/i); + await userEvent.type(fullNameInput, 'John'); + + rerender( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + expect( + screen.getByText(/Full Name must contain at least two words/i) + ).toBeInTheDocument(); + }); + + it('retains user input in form fields', () => { + const formData = { + fullName: 'Jane Doe', + email: 'jane.doe@example.com', + password: 'securepassword', + }; + const errors = validate(formData, step); + + render( + {}} + errors={errors} + /> + ); + + expect(screen.getByLabelText(/Full Name/i)).toHaveValue('Jane Doe'); + expect(screen.getByLabelText(/Email/i)).toHaveValue('jane.doe@example.com'); + expect(screen.getByLabelText(/Password/i)).toHaveValue('securepassword'); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step2.test.jsx b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step2.test.jsx new file mode 100644 index 0000000..7505148 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step2.test.jsx @@ -0,0 +1,114 @@ +import { it, expect, describe, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; + +import ActivityInfo from '../../src/steps/ActivityInfo'; +import { validate } from '../../src/utils/validation'; + +describe('ActivityInfo Component', () => { + afterEach(cleanup); + + const step = 2; + + it('renders all form fields', () => { + render( {}} errors={{}} />); + + expect( + screen.getByLabelText(/How often do you exercise\?/i) + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + /How would you describe your average energy level\?/i + ) + ).toBeInTheDocument(); + }); + + it('displays validation errors for invalid data', () => { + const formData = { + fullName: 'John Doe', + email: 'john@example.com', + password: 'password123', + activityLevel: '', + energyLevel: '', + goals: [], + timeCommitment: '15', + }; + const errors = validate(formData, step); + + render( {}} errors={errors} />); + + expect(screen.getByText(/Activity Level is required/i)).toBeInTheDocument(); + expect(screen.getByText(/Energy Level is required/i)).toBeInTheDocument(); + }); + + it('displays no validation errors for valid data', () => { + const formData = { + fullName: 'John Doe', + email: 'john@example.com', + password: 'password123', + activityLevel: 'moderate', + energyLevel: 'high', + goals: [], + timeCommitment: '15', + }; + const errors = validate(formData, step); + + render( {}} errors={errors} />); + + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument(); + }); + + it('calls handleChange and updates error messages dynamically', async () => { + let formData = { + fullName: 'John Doe', + email: 'john@example.com', + password: 'password123', + activityLevel: '', + energyLevel: '', + goals: [], + timeCommitment: '15', + }; + let errors = validate(formData, step); + + const handleChangeTest = (field, value) => { + formData = { ...formData, [field]: value }; + errors = validate(formData, step); + }; + + const { rerender } = render( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + const activitySelect = screen.getByLabelText( + /How often do you exercise\?/i + ); + const energySelect = screen.getByLabelText( + /How would you describe your average energy level\?/i + ); + + await userEvent.selectOptions(activitySelect, '3+ times a week'); + await userEvent.selectOptions(energySelect, 'medium'); + + rerender( + + handleChangeTest(target.name, target.value) + } + errors={errors} + /> + ); + + expect( + screen.queryByText(/Activity Level is required/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/Energy Level is required/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step3.test.jsx b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step3.test.jsx new file mode 100644 index 0000000..9f18107 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/step3.test.jsx @@ -0,0 +1,139 @@ +import { it, expect, describe, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; + +import GoalSetting from '../../src/steps/GoalSetting'; +import { validate } from '../../src/utils/validation'; + +describe('GoalSetting Component', () => { + afterEach(cleanup); + + const step = 3; + + it('renders all form fields', () => { + render( + {}} + handleCheckboxChange={() => {}} + errors={{}} + />, + ); + + // Use getByRole to target the fieldset for "What are your goals?" + expect(screen.getByRole('group', { name: /What are your goals\?/i })).toBeInTheDocument(); + + expect(screen.getByLabelText(/How much time can you commit each day\?/i)).toBeInTheDocument(); + }); + + it('displays validation errors for empty fields', () => { + const formData = { + goals: [], + timeCommitment: '', + }; + const errors = validate(formData, step); + + render( {}} handleCheckboxChange={() => {}} errors={errors} />); + + expect(screen.getByText(/At least one goal is required/i)).toBeInTheDocument(); + expect(screen.getByText(/Time Commitment is required/i)).toBeInTheDocument(); + }); + + it('displays no validation errors for valid data', () => { + const formData = { + goals: ['weightLoss', 'muscleGain'], + timeCommitment: '30', + }; + const errors = validate(formData, step); + + render( {}} handleCheckboxChange={() => {}} errors={errors} />); + + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument(); + }); + + it('handles checkbox interactions and updates goals dynamically', async () => { + let formData = { goals: [], timeCommitment: '' }; + let errors = validate(formData, step); + + const handleCheckboxChangeTest = (field, id, isChecked) => { + const updatedGoals = isChecked ? [...formData[field], id] : formData[field].filter((goal) => goal !== id); + + formData = { ...formData, [field]: updatedGoals }; + errors = validate(formData, step); + }; + + const { rerender } = render( + {}} + handleCheckboxChange={handleCheckboxChangeTest} + errors={errors} + />, + ); + + const weightLossCheckbox = screen.getByLabelText(/Weight Loss/i); + const muscleGainCheckbox = screen.getByLabelText(/Muscle Gain/i); + + await userEvent.click(weightLossCheckbox); + rerender( + {}} + handleCheckboxChange={handleCheckboxChangeTest} + errors={errors} + />, + ); + + expect(weightLossCheckbox).toBeChecked(); + expect(screen.queryByText(/At least one goal is required/i)).not.toBeInTheDocument(); + + await userEvent.click(muscleGainCheckbox); + rerender( + {}} + handleCheckboxChange={handleCheckboxChangeTest} + errors={errors} + />, + ); + + expect(muscleGainCheckbox).toBeChecked(); + expect(formData.goals).toContain('weightLoss'); + expect(formData.goals).toContain('muscleGain'); + }); + + it('handles select interactions and updates time commitment dynamically', async () => { + let formData = { goals: ['weightLoss'], timeCommitment: '' }; + let errors = validate(formData, step); + + const handleChangeTest = (field, value) => { + formData = { ...formData, [field]: value }; + errors = validate(formData, step); + }; + + const { rerender } = render( + handleChangeTest(target.name, target.value)} + handleCheckboxChange={() => {}} + errors={errors} + />, + ); + + const timeCommitmentSelect = screen.getByLabelText(/How much time can you commit each day\?/i); + await userEvent.selectOptions(timeCommitmentSelect, '30'); + + rerender( + handleChangeTest(target.name, target.value)} + handleCheckboxChange={() => {}} + errors={errors} + />, + ); + + expect(timeCommitmentSelect).toHaveValue('30'); + expect(screen.queryByText(/Time Commitment is required/i)).not.toBeInTheDocument(); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/utility.test.js b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/utility.test.js new file mode 100644 index 0000000..6c3b9cd --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/tests/validation/utility.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { + isEmpty, + isValidEmail, + hasMinLength, + hasTwoWords, +} from '../../src/utils/validation'; + +describe('Utility Functions', () => { + describe('isEmpty', () => { + it('should return true for empty strings', () => { + expect(isEmpty('')).toBe(true); + expect(isEmpty(' ')).toBe(true); + }); + + it('should return false for non-empty strings', () => { + expect(isEmpty('John')).toBe(false); + expect(isEmpty(' John Doe ')).toBe(false); + }); + }); + + describe('isValidEmail', () => { + it('should validate correct email formats', () => { + expect(isValidEmail('john@example.com')).toBe(true); + expect(isValidEmail('jane.doe@sub.domain.co')).toBe(true); + }); + + it('should invalidate incorrect email formats', () => { + expect(isValidEmail('johnexample.com')).toBe(false); + expect(isValidEmail('jane@.com')).toBe(false); + expect(isValidEmail('jane@com')).toBe(false); + }); + }); + + describe('hasMinLength', () => { + it('should return true if value meets minimum length', () => { + expect(hasMinLength('password', 6)).toBe(true); + expect(hasMinLength('123456', 6)).toBe(true); + }); + + it('should return false if value does not meet minimum length', () => { + expect(hasMinLength('pass', 6)).toBe(false); + expect(hasMinLength('123', 6)).toBe(false); + }); + }); + + describe('hasTwoWords', () => { + it('should return true if fullName has at least two words', () => { + expect(hasTwoWords('John Doe')).toBe(true); + expect(hasTwoWords('Jane Mary Doe')).toBe(true); + }); + + it('should return false if fullName has less than two words', () => { + expect(hasTwoWords('John')).toBe(false); + expect(hasTwoWords(' Jane ')).toBe(false); + }); + }); +}); diff --git a/07_js-debug/projects/debugging-multi-step-form-template/vite.config.js b/07_js-debug/projects/debugging-multi-step-form-template/vite.config.js new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/07_js-debug/projects/debugging-multi-step-form-template/vitest.config.js b/07_js-debug/projects/debugging-multi-step-form-template/vitest.config.js new file mode 100644 index 0000000..5853726 --- /dev/null +++ b/07_js-debug/projects/debugging-multi-step-form-template/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, + plugins: [react()], +});