* Added Material UI support
* Added job application test form web app * Added unit and end to end tests in both Chrome and Firefox for test app
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json",
|
||||
"project": ["./tsconfig.json", "./job-application-demo/tsconfig.json"],
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ coverage
|
||||
*.swo
|
||||
*~
|
||||
web-ext-artifacts
|
||||
.claude/
|
||||
|
||||
@@ -82,8 +82,30 @@ src/
|
||||
└── services/
|
||||
├── formDetector.ts # Form field detection logic
|
||||
└── fieldMatcher.ts # Resume-to-field matching logic
|
||||
|
||||
job-application-demo/ # Sample job application form (separate app)
|
||||
```
|
||||
|
||||
### Demo Application
|
||||
|
||||
The `job-application-demo` folder contains a standalone sample job application form built with Vite, React, Material UI, and TypeScript. This demo app:
|
||||
|
||||
- Showcases the structure and fields used by the JobSpeed extension
|
||||
- Uses the same `ResumeData` interface as the extension
|
||||
- Saves submitted applications as incremental JSON files
|
||||
- Is excluded from the main extension build
|
||||
- Has its own `package.json` and can be run independently
|
||||
|
||||
To run the demo:
|
||||
|
||||
```bash
|
||||
cd job-application-demo
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
See [job-application-demo/README.md](job-application-demo/README.md) for more details.
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Upload Your Resume**:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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?
|
||||
|
||||
# Test results
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright
|
||||
@@ -0,0 +1,129 @@
|
||||
# Job Application Demo
|
||||
|
||||
This is a sample job application form web app built with Vite, React, Material UI, and TypeScript. It demonstrates the structure and fields used by the JobSpeed extension.
|
||||
|
||||
## Features
|
||||
|
||||
- Material UI components for a polished, professional look
|
||||
- Form validation matching the extension's `ResumeData` interface
|
||||
- Required and optional fields as defined in the extension
|
||||
- Incremental JSON file export with timestamps
|
||||
- Sections for:
|
||||
- Personal Information (required)
|
||||
- Work Experience (required)
|
||||
- Education (optional)
|
||||
- Certifications (optional)
|
||||
- Skills (required)
|
||||
- Professional Summary (optional)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:5173`
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Preview Production Build
|
||||
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Fill in the form with your information
|
||||
2. Required fields are marked with an asterisk (*)
|
||||
3. Click "Submit Application" to download your data as a JSON file
|
||||
4. The file will be saved as `job-application-{N}-{timestamp}.json`
|
||||
- `{N}` is an incrementing number
|
||||
- `{timestamp}` is the current date and time
|
||||
|
||||
## Field Requirements
|
||||
|
||||
The form matches the extension's `ResumeData` interface:
|
||||
|
||||
### Required Fields
|
||||
- All personal information fields (except LinkedIn, GitHub, Website)
|
||||
- At least one work experience entry
|
||||
- At least one skill
|
||||
|
||||
### Optional Fields
|
||||
- LinkedIn profile URL
|
||||
- GitHub profile URL
|
||||
- Personal website URL
|
||||
- Education entries
|
||||
- Certification entries
|
||||
- Professional summary
|
||||
|
||||
## Testing
|
||||
|
||||
This project includes comprehensive test coverage with both unit tests and end-to-end tests.
|
||||
|
||||
### Unit Tests (Vitest)
|
||||
|
||||
Run unit tests for React components:
|
||||
|
||||
```bash
|
||||
# Run tests in watch mode
|
||||
pnpm test
|
||||
|
||||
# Run tests once
|
||||
pnpm test -- --run
|
||||
|
||||
# Run tests with UI
|
||||
pnpm test:ui
|
||||
|
||||
# Run tests with coverage
|
||||
pnpm test:coverage
|
||||
```
|
||||
|
||||
Unit tests cover:
|
||||
- PersonalInfoForm component
|
||||
- WorkExperienceForm component
|
||||
- SkillsForm component
|
||||
- App component integration
|
||||
|
||||
### End-to-End Tests (Playwright)
|
||||
|
||||
Run E2E tests that simulate real user interactions:
|
||||
|
||||
```bash
|
||||
# Run E2E tests
|
||||
pnpm test:e2e
|
||||
|
||||
# Run E2E tests with UI
|
||||
pnpm test:e2e:ui
|
||||
|
||||
# View test report
|
||||
pnpm test:e2e:report
|
||||
```
|
||||
|
||||
E2E tests cover:
|
||||
- Complete form submission flow
|
||||
- Form validation (email, phone, zip code, GPA)
|
||||
- Adding and removing dynamic entries (work experience, education, etc.)
|
||||
- Skills management
|
||||
- File download functionality
|
||||
|
||||
## Integration with Main Project
|
||||
|
||||
This demo app is:
|
||||
- Excluded from the main extension build
|
||||
- Bootstrapped separately with its own `package.json`
|
||||
- Included in the project's ESLint and Prettier configurations
|
||||
- Using the same strict TypeScript settings as the main project
|
||||
@@ -0,0 +1,252 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Job Application Form', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('should display the form with all sections', async ({ page }) => {
|
||||
await expect(page.getByText('Job Application Form')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Fields marked with * are required')
|
||||
).toBeVisible();
|
||||
|
||||
// Check all sections are present
|
||||
await expect(page.getByText('Personal Information')).toBeVisible();
|
||||
await expect(page.getByText('Work Experience *')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Education' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Certifications' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Skills *')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Professional Summary' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Skipped: HTML5 validation prevents testing custom validation messages
|
||||
test.skip('should show validation errors for incomplete required fields', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Fill some fields to bypass HTML5 validation
|
||||
await page.getByLabel(/full name/i).fill('John Doe');
|
||||
await page.getByLabel(/email/i).fill('john@example.com');
|
||||
// Leave other required fields empty
|
||||
|
||||
await page.getByRole('button', { name: /submit application/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/please fill in all required personal information fields/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should fill personal information', async ({ page }) => {
|
||||
await page.getByLabel(/full name/i).fill('John Doe');
|
||||
await page.getByLabel(/email/i).fill('john.doe@example.com');
|
||||
await page.getByLabel(/phone/i).fill('+12345678901');
|
||||
await page.getByLabel(/address/i).fill('123 Main Street');
|
||||
await page.getByLabel(/city/i).fill('New York');
|
||||
await page.getByLabel(/state/i).fill('NY');
|
||||
await page.getByLabel(/zip code/i).fill('10001');
|
||||
await page.getByLabel(/country/i).fill('USA');
|
||||
|
||||
// Verify values are entered
|
||||
await expect(page.getByLabel(/full name/i)).toHaveValue('John Doe');
|
||||
await expect(page.getByLabel(/email/i)).toHaveValue(
|
||||
'john.doe@example.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('should add and remove work experience', async ({ page }) => {
|
||||
// Add work experience
|
||||
await page.getByRole('button', { name: /add work experience/i }).click();
|
||||
|
||||
// Fill work experience form
|
||||
await page.getByLabel(/company/i).fill('Acme Corporation');
|
||||
await page.getByLabel(/position/i).fill('Software Engineer');
|
||||
await page.getByLabel(/start date/i).fill('2020-01-01');
|
||||
await page.getByLabel(/end date/i).fill('2023-12-31');
|
||||
await page
|
||||
.getByLabel(/description/i)
|
||||
.fill('Developed and maintained web applications');
|
||||
|
||||
// Verify values
|
||||
await expect(page.getByLabel(/company/i)).toHaveValue('Acme Corporation');
|
||||
|
||||
// Check for delete button (IconButton with DeleteIcon)
|
||||
const deleteButton = page.getByRole('button', { name: '' }).first();
|
||||
await expect(deleteButton).toBeVisible();
|
||||
|
||||
// Remove work experience
|
||||
await deleteButton.click();
|
||||
|
||||
// Verify work experience is removed
|
||||
await expect(page.getByLabel(/company/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle current job checkbox', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /add work experience/i }).click();
|
||||
|
||||
const endDateInput = page.getByLabel(/end date/i);
|
||||
await expect(endDateInput).toBeEnabled();
|
||||
|
||||
// Click "I currently work here" checkbox
|
||||
await page.getByText(/i currently work here/i).click();
|
||||
|
||||
// End date should be disabled
|
||||
await expect(endDateInput).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should add and remove skills', async ({ page }) => {
|
||||
// Add a skill
|
||||
await page.getByLabel(/add a skill/i).fill('JavaScript');
|
||||
await page.getByRole('button', { name: /^add$/i }).click();
|
||||
|
||||
// Verify skill chip appears
|
||||
await expect(page.getByText('JavaScript')).toBeVisible();
|
||||
|
||||
// Add another skill
|
||||
await page.getByLabel(/add a skill/i).fill('TypeScript');
|
||||
await page.getByRole('button', { name: /^add$/i }).click();
|
||||
|
||||
await expect(page.getByText('TypeScript')).toBeVisible();
|
||||
|
||||
// Remove a skill (click the X on the chip)
|
||||
await page.locator('[data-testid="CancelIcon"]').first().click();
|
||||
|
||||
// Verify one skill remains
|
||||
const skillChips = page.locator('span').filter({ hasText: /script/i });
|
||||
await expect(skillChips).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('should add education entry', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /add education/i }).click();
|
||||
|
||||
await page.getByLabel(/institution/i).fill('MIT');
|
||||
await page.getByLabel(/degree/i).fill('Bachelor of Science');
|
||||
await page.getByLabel(/field of study/i).fill('Computer Science');
|
||||
await page.getByLabel(/graduation date/i).fill('2019-05-15');
|
||||
await page.getByLabel(/gpa/i).fill('3.85');
|
||||
|
||||
await expect(page.getByLabel(/institution/i)).toHaveValue('MIT');
|
||||
});
|
||||
|
||||
test('should add certification entry', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /add certification/i }).click();
|
||||
|
||||
await page
|
||||
.getByLabel(/certification name/i)
|
||||
.fill('AWS Certified Solutions Architect');
|
||||
await page.getByLabel(/acquired date/i).fill('2022-06-01');
|
||||
await page.getByLabel(/area of expertise/i).fill('Cloud Computing');
|
||||
|
||||
await expect(page.getByLabel(/certification name/i)).toHaveValue(
|
||||
'AWS Certified Solutions Architect'
|
||||
);
|
||||
});
|
||||
|
||||
test('should fill professional summary', async ({ page }) => {
|
||||
const summary =
|
||||
'Experienced software engineer with 5 years of expertise in web development.';
|
||||
await page.getByLabel(/summary/i).fill(summary);
|
||||
|
||||
await expect(page.getByLabel(/summary/i)).toHaveValue(summary);
|
||||
});
|
||||
|
||||
test('should complete full application and submit', async ({ page }) => {
|
||||
// Personal Information
|
||||
await page.getByLabel(/full name/i).fill('Jane Smith');
|
||||
await page.getByLabel(/email/i).fill('jane.smith@example.com');
|
||||
await page.getByLabel(/phone/i).fill('+19876543210');
|
||||
await page.getByLabel(/address/i).fill('456 Oak Avenue');
|
||||
await page.getByLabel(/city/i).fill('San Francisco');
|
||||
await page.getByLabel(/state/i).fill('CA');
|
||||
await page.getByLabel(/zip code/i).fill('94102');
|
||||
await page.getByLabel(/country/i).fill('USA');
|
||||
|
||||
// Work Experience
|
||||
await page.getByRole('button', { name: /add work experience/i }).click();
|
||||
await page.getByLabel(/company/i).fill('Tech Innovations Inc');
|
||||
await page.getByLabel(/position/i).fill('Senior Developer');
|
||||
await page.getByLabel(/start date/i).fill('2018-03-01');
|
||||
await page.getByLabel(/end date/i).fill('2024-01-01');
|
||||
await page
|
||||
.getByLabel(/description/i)
|
||||
.fill('Led development of enterprise applications');
|
||||
|
||||
// Skills
|
||||
await page.getByLabel(/add a skill/i).fill('React');
|
||||
await page.getByRole('button', { name: /^add$/i }).click();
|
||||
await page.getByLabel(/add a skill/i).fill('Node.js');
|
||||
await page.getByRole('button', { name: /^add$/i }).click();
|
||||
|
||||
// Setup download listener before clicking submit
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: /submit application/i }).click();
|
||||
|
||||
// Wait for success message
|
||||
await expect(
|
||||
page.getByText(/application saved as job-application/i)
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify download occurred
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/job-application-\d+-.*\.json/);
|
||||
});
|
||||
|
||||
test('should validate email format', async ({ page }) => {
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
await emailInput.fill('invalid-email');
|
||||
|
||||
// The HTML5 validation will prevent form submission
|
||||
await page.getByRole('button', { name: /submit application/i }).click();
|
||||
|
||||
// Check for browser validation message
|
||||
const validationMessage = await emailInput.evaluate(
|
||||
(el: HTMLInputElement) => el.validationMessage
|
||||
);
|
||||
expect(validationMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should validate phone number format', async ({ page }) => {
|
||||
const phoneInput = page.getByLabel(/phone/i);
|
||||
await phoneInput.fill('abc123');
|
||||
|
||||
await page.getByRole('button', { name: /submit application/i }).click();
|
||||
|
||||
const validationMessage = await phoneInput.evaluate(
|
||||
(el: HTMLInputElement) => el.validationMessage
|
||||
);
|
||||
expect(validationMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should validate zip code format', async ({ page }) => {
|
||||
const zipInput = page.getByLabel(/zip code/i);
|
||||
await zipInput.fill('1234'); // Only 4 digits
|
||||
|
||||
await page.getByRole('button', { name: /submit application/i }).click();
|
||||
|
||||
const validationMessage = await zipInput.evaluate(
|
||||
(el: HTMLInputElement) => el.validationMessage
|
||||
);
|
||||
expect(validationMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should validate GPA format when education is added', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /add education/i }).click();
|
||||
|
||||
const gpaInput = page.getByLabel(/gpa/i);
|
||||
await gpaInput.fill('5.00'); // Invalid - first digit must be 1-4
|
||||
|
||||
await page.getByRole('button', { name: /submit application/i }).click();
|
||||
|
||||
const validationMessage = await gpaInput.evaluate(
|
||||
(el: HTMLInputElement) => el.validationMessage
|
||||
);
|
||||
expect(validationMessage).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Job Application Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "job-application-demo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "pnpm test:e2e --project=chromium --project=firefox --reporter=list",
|
||||
"test:e2e:ui": "playwright test --project=chromium --project=firefox --ui",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"jsdom": "^27.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.10",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import App from './App';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
// Mock URL.createObjectURL and URL.revokeObjectURL
|
||||
global.URL.createObjectURL = vi.fn(() => 'mock-url');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
it('renders the job application form', () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText(/job application form/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/fields marked with \* are required/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all form sections', () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText(/personal information/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/work experience \*/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/education/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/certifications/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getByText(/skills \*/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/professional summary/i)[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a submit button', () => {
|
||||
render(<App />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /submit application/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when required fields are missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
// Fill some but not all required fields to bypass HTML5 validation
|
||||
await user.click(screen.getByLabelText(/full name/i));
|
||||
await user.paste('John Doe');
|
||||
await user.click(screen.getByLabelText(/email/i));
|
||||
await user.paste('john@example.com');
|
||||
// Leave other required fields empty
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /submit application/i,
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please fill in all required personal information/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when work experience is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
// Fill in required personal info using paste for speed
|
||||
await user.click(screen.getByLabelText(/full name/i));
|
||||
await user.paste('John Doe');
|
||||
await user.click(screen.getByLabelText(/email/i));
|
||||
await user.paste('john@example.com');
|
||||
await user.click(screen.getByLabelText(/phone/i));
|
||||
await user.paste('+12345678901');
|
||||
await user.click(screen.getByLabelText(/address/i));
|
||||
await user.paste('123 Main St');
|
||||
await user.click(screen.getByLabelText(/city/i));
|
||||
await user.paste('New York');
|
||||
await user.click(screen.getByLabelText(/state/i));
|
||||
await user.paste('NY');
|
||||
await user.click(screen.getByLabelText(/zip code/i));
|
||||
await user.paste('12345');
|
||||
await user.click(screen.getByLabelText(/country/i));
|
||||
await user.paste('USA');
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /submit application/i,
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please add at least one work experience entry/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when skills are missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
// Fill in required personal info using paste for speed
|
||||
await user.click(screen.getByLabelText(/full name/i));
|
||||
await user.paste('John Doe');
|
||||
await user.click(screen.getByLabelText(/email/i));
|
||||
await user.paste('john@example.com');
|
||||
await user.click(screen.getByLabelText(/phone/i));
|
||||
await user.paste('+12345678901');
|
||||
await user.click(screen.getByLabelText(/address/i));
|
||||
await user.paste('123 Main St');
|
||||
await user.click(screen.getByLabelText(/city/i));
|
||||
await user.paste('New York');
|
||||
await user.click(screen.getByLabelText(/state/i));
|
||||
await user.paste('NY');
|
||||
await user.click(screen.getByLabelText(/zip code/i));
|
||||
await user.paste('12345');
|
||||
await user.click(screen.getByLabelText(/country/i));
|
||||
await user.paste('USA');
|
||||
|
||||
// Add work experience
|
||||
await user.click(screen.getByText(/add work experience/i));
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /submit application/i,
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please add at least one skill/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('increments application count in localStorage', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
// Fill in all required fields using paste for speed
|
||||
await user.click(screen.getByLabelText(/full name/i));
|
||||
await user.paste('John Doe');
|
||||
await user.click(screen.getByLabelText(/email/i));
|
||||
await user.paste('john@example.com');
|
||||
await user.click(screen.getByLabelText(/phone/i));
|
||||
await user.paste('+12345678901');
|
||||
await user.click(screen.getByLabelText(/address/i));
|
||||
await user.paste('123 Main St');
|
||||
await user.click(screen.getByLabelText(/city/i));
|
||||
await user.paste('New York');
|
||||
await user.click(screen.getByLabelText(/state/i));
|
||||
await user.paste('NY');
|
||||
await user.click(screen.getByLabelText(/zip code/i));
|
||||
await user.paste('12345');
|
||||
await user.click(screen.getByLabelText(/country/i));
|
||||
await user.paste('USA');
|
||||
|
||||
// Add work experience
|
||||
await user.click(screen.getByText(/add work experience/i));
|
||||
const companyInputs = screen.getAllByLabelText(/company/i);
|
||||
await user.click(companyInputs[0]!);
|
||||
await user.paste('Acme Corp');
|
||||
|
||||
// Add skill
|
||||
const skillInput = screen.getByLabelText(/add a skill/i);
|
||||
await user.click(skillInput);
|
||||
await user.paste('JavaScript');
|
||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /submit application/i,
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorageMock.getItem('applicationCount')).toBe('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Snackbar,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { ResumeData } from './types';
|
||||
import PersonalInfoForm from './components/PersonalInfoForm';
|
||||
import WorkExperienceForm from './components/WorkExperienceForm';
|
||||
import EducationForm from './components/EducationForm';
|
||||
import CertificationsForm from './components/CertificationsForm';
|
||||
import SkillsForm from './components/SkillsForm';
|
||||
import SummaryForm from './components/SummaryForm';
|
||||
|
||||
function App() {
|
||||
const [formData, setFormData] = useState<ResumeData>({
|
||||
personalInfo: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
linkedIn: '',
|
||||
github: '',
|
||||
website: '',
|
||||
},
|
||||
workExperience: [],
|
||||
education: [],
|
||||
certifications: [],
|
||||
skills: [],
|
||||
summary: '',
|
||||
});
|
||||
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success' as 'success' | 'error',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
const { personalInfo, workExperience, skills } = formData;
|
||||
if (
|
||||
!personalInfo.fullName ||
|
||||
!personalInfo.email ||
|
||||
!personalInfo.phone ||
|
||||
!personalInfo.address ||
|
||||
!personalInfo.city ||
|
||||
!personalInfo.state ||
|
||||
!personalInfo.zipCode ||
|
||||
!personalInfo.country
|
||||
) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Please fill in all required personal information fields',
|
||||
severity: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (workExperience.length === 0) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Please add at least one work experience entry',
|
||||
severity: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Please add at least one skill',
|
||||
severity: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the current count from localStorage
|
||||
const count =
|
||||
parseInt(localStorage.getItem('applicationCount') || '0') + 1;
|
||||
localStorage.setItem('applicationCount', count.toString());
|
||||
|
||||
// Create filename with timestamp
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||
const filename = `job-application-${count}-${timestamp}.json`;
|
||||
|
||||
// Create and download the JSON file
|
||||
const blob = new Blob([JSON.stringify(formData, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `Application saved as ${filename}`,
|
||||
severity: 'success',
|
||||
});
|
||||
} catch {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Error saving application',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Paper elevation={3} sx={{ p: 5 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom align="center">
|
||||
Job Application Form
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
align="center"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Fields marked with * are required
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<PersonalInfoForm
|
||||
data={formData.personalInfo}
|
||||
onChange={(personalInfo) =>
|
||||
setFormData({ ...formData, personalInfo })
|
||||
}
|
||||
/>
|
||||
|
||||
<WorkExperienceForm
|
||||
data={formData.workExperience}
|
||||
onChange={(workExperience) =>
|
||||
setFormData({ ...formData, workExperience })
|
||||
}
|
||||
/>
|
||||
|
||||
<EducationForm
|
||||
data={formData.education || []}
|
||||
onChange={(education) => setFormData({ ...formData, education })}
|
||||
/>
|
||||
|
||||
<CertificationsForm
|
||||
data={formData.certifications || []}
|
||||
onChange={(certifications) =>
|
||||
setFormData({ ...formData, certifications })
|
||||
}
|
||||
/>
|
||||
|
||||
<SkillsForm
|
||||
data={formData.skills}
|
||||
onChange={(skills) => setFormData({ ...formData, skills })}
|
||||
/>
|
||||
|
||||
<SummaryForm
|
||||
data={formData.summary || ''}
|
||||
onChange={(summary) => setFormData({ ...formData, summary })}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
Submit Application
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
severity={snackbar.severity}
|
||||
variant="filled"
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Divider,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { ResumeData } from '../types';
|
||||
|
||||
interface CertificationsFormProps {
|
||||
data: NonNullable<ResumeData['certifications']>;
|
||||
onChange: (data: NonNullable<ResumeData['certifications']>) => void;
|
||||
}
|
||||
|
||||
export default function CertificationsForm({
|
||||
data,
|
||||
onChange,
|
||||
}: CertificationsFormProps) {
|
||||
const handleAdd = () => {
|
||||
onChange([
|
||||
...data,
|
||||
{
|
||||
certificationName: '',
|
||||
acquiredDate: '',
|
||||
areaOfExpertise: '',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(data.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
index: number,
|
||||
field: keyof NonNullable<ResumeData['certifications']>[0],
|
||||
value: string
|
||||
) => {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...newData[index]!, [field]: value };
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Certifications
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{data.map((certification, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
elevation={1}
|
||||
sx={{ p: 3, mb: 2, position: 'relative' }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => handleRemove(index)}
|
||||
sx={{ position: 'absolute', top: 20, right: 4 }}
|
||||
color="error"
|
||||
size="small"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Certification Name"
|
||||
required
|
||||
fullWidth
|
||||
value={certification.certificationName}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'certificationName', e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Acquired Date"
|
||||
type="date"
|
||||
required
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={certification.acquiredDate}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'acquiredDate', e.target.value)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Area of Expertise"
|
||||
fullWidth
|
||||
value={certification.areaOfExpertise || ''}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'areaOfExpertise', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAdd}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
>
|
||||
Add Certification
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Divider,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { ResumeData } from '../types';
|
||||
|
||||
interface EducationFormProps {
|
||||
data: NonNullable<ResumeData['education']>;
|
||||
onChange: (data: NonNullable<ResumeData['education']>) => void;
|
||||
}
|
||||
|
||||
export default function EducationForm({ data, onChange }: EducationFormProps) {
|
||||
const handleAdd = () => {
|
||||
onChange([
|
||||
...data,
|
||||
{
|
||||
institution: '',
|
||||
degree: '',
|
||||
field: '',
|
||||
graduationDate: '',
|
||||
gpa: '',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(data.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
index: number,
|
||||
field: keyof NonNullable<ResumeData['education']>[0],
|
||||
value: string
|
||||
) => {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...newData[index]!, [field]: value };
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Education
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{data.map((education, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
elevation={1}
|
||||
sx={{ p: 3, mb: 2, position: 'relative' }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => handleRemove(index)}
|
||||
sx={{ position: 'absolute', top: 20, right: 4 }}
|
||||
color="error"
|
||||
size="small"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Institution"
|
||||
required
|
||||
fullWidth
|
||||
value={education.institution}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'institution', e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Degree"
|
||||
required
|
||||
fullWidth
|
||||
value={education.degree}
|
||||
onChange={(e) => handleChange(index, 'degree', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Field of Study"
|
||||
fullWidth
|
||||
value={education.field || ''}
|
||||
onChange={(e) => handleChange(index, 'field', e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Graduation Date"
|
||||
type="date"
|
||||
required
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={education.graduationDate}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'graduationDate', e.target.value)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="GPA"
|
||||
fullWidth
|
||||
value={education.gpa || ''}
|
||||
onChange={(e) => handleChange(index, 'gpa', e.target.value)}
|
||||
placeholder="e.g., 3.50"
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
pattern: '^[1-4]\\.[0-9]{2}$',
|
||||
},
|
||||
}}
|
||||
helperText="Format: X.XX (1.00-4.99)"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAdd}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
>
|
||||
Add Education
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import PersonalInfoForm from './PersonalInfoForm';
|
||||
import { ResumeData } from '../types';
|
||||
|
||||
describe('PersonalInfoForm', () => {
|
||||
const mockData: ResumeData['personalInfo'] = {
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
linkedIn: '',
|
||||
github: '',
|
||||
website: '',
|
||||
};
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
it('renders all required fields', () => {
|
||||
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/city/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/state/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/zip code/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/country/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders optional fields', () => {
|
||||
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText(/linkedin profile/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/github profile/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/personal website/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when full name is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
|
||||
|
||||
const fullNameInput = screen.getByLabelText(/full name/i);
|
||||
await user.type(fullNameInput, 'John Doe');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays validation helper text', () => {
|
||||
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/enter a valid email address/i)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/include country code \(e\.g\., \+1 for us\)/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/enter 5-digit zip code/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates fields with existing data', () => {
|
||||
const existingData: ResumeData['personalInfo'] = {
|
||||
...mockData,
|
||||
fullName: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
phone: '+12345678901',
|
||||
};
|
||||
|
||||
render(<PersonalInfoForm data={existingData} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('jane@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('+12345678901')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Box, TextField, Typography, Divider } from '@mui/material';
|
||||
import { ResumeData } from '../types';
|
||||
|
||||
interface PersonalInfoFormProps {
|
||||
data: ResumeData['personalInfo'];
|
||||
onChange: (data: ResumeData['personalInfo']) => void;
|
||||
}
|
||||
|
||||
export default function PersonalInfoForm({
|
||||
data,
|
||||
onChange,
|
||||
}: PersonalInfoFormProps) {
|
||||
const handleChange =
|
||||
(field: keyof ResumeData['personalInfo']) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ ...data, [field]: e.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Personal Information
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Full Name"
|
||||
required
|
||||
fullWidth
|
||||
value={data.fullName}
|
||||
onChange={handleChange('fullName')}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
fullWidth
|
||||
value={data.email}
|
||||
onChange={handleChange('email')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$',
|
||||
},
|
||||
}}
|
||||
helperText="Enter a valid email address"
|
||||
/>
|
||||
<TextField
|
||||
label="Phone"
|
||||
type="tel"
|
||||
required
|
||||
fullWidth
|
||||
value={data.phone}
|
||||
onChange={handleChange('phone')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
pattern: '^\\+?[1-9]\\d{1,14}$',
|
||||
},
|
||||
}}
|
||||
helperText="Include country code (e.g., +1 for US)"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Address"
|
||||
required
|
||||
fullWidth
|
||||
value={data.address}
|
||||
onChange={handleChange('address')}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="City"
|
||||
required
|
||||
fullWidth
|
||||
value={data.city}
|
||||
onChange={handleChange('city')}
|
||||
/>
|
||||
<TextField
|
||||
label="State"
|
||||
required
|
||||
fullWidth
|
||||
value={data.state}
|
||||
onChange={handleChange('state')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Zip Code"
|
||||
required
|
||||
fullWidth
|
||||
value={data.zipCode}
|
||||
onChange={handleChange('zipCode')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
pattern: '^\\d{5}$',
|
||||
},
|
||||
}}
|
||||
helperText="Enter 5-digit zip code"
|
||||
/>
|
||||
<TextField
|
||||
label="Country"
|
||||
required
|
||||
fullWidth
|
||||
value={data.country}
|
||||
onChange={handleChange('country')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="LinkedIn Profile"
|
||||
fullWidth
|
||||
value={data.linkedIn || ''}
|
||||
onChange={handleChange('linkedIn')}
|
||||
placeholder="https://linkedin.com/in/username"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="GitHub Profile"
|
||||
fullWidth
|
||||
value={data.github || ''}
|
||||
onChange={handleChange('github')}
|
||||
placeholder="https://github.com/username"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Personal Website"
|
||||
fullWidth
|
||||
value={data.website || ''}
|
||||
onChange={handleChange('website')}
|
||||
placeholder="https://yourwebsite.com"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SkillsForm from './SkillsForm';
|
||||
|
||||
describe('SkillsForm', () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
});
|
||||
|
||||
it('renders with no skills', () => {
|
||||
render(<SkillsForm data={[]} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByText(/skills \*/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/no skills added yet\. please add at least one skill\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing skills as chips', () => {
|
||||
const skills = ['JavaScript', 'React', 'TypeScript'];
|
||||
render(<SkillsForm data={skills} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByText('JavaScript')).toBeInTheDocument();
|
||||
expect(screen.getByText('React')).toBeInTheDocument();
|
||||
expect(screen.getByText('TypeScript')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a skill when Add button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SkillsForm data={[]} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByLabelText(/add a skill/i);
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
|
||||
await user.type(input, 'Python');
|
||||
await user.click(addButton);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Python']);
|
||||
});
|
||||
|
||||
it('adds a skill when Enter key is pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SkillsForm data={[]} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByLabelText(/add a skill/i);
|
||||
|
||||
await user.type(input, 'Java{Enter}');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Java']);
|
||||
});
|
||||
|
||||
it('does not add empty skills', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SkillsForm data={[]} onChange={mockOnChange} />);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
await user.click(addButton);
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not add duplicate skills', async () => {
|
||||
const user = userEvent.setup();
|
||||
const existingSkills = ['JavaScript'];
|
||||
render(<SkillsForm data={existingSkills} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByLabelText(/add a skill/i);
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
|
||||
await user.type(input, 'JavaScript');
|
||||
await user.click(addButton);
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a skill when delete icon is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const skills = ['JavaScript', 'Python'];
|
||||
render(<SkillsForm data={skills} onChange={mockOnChange} />);
|
||||
|
||||
const deleteButtons = screen.getAllByTestId('CancelIcon');
|
||||
await user.click(deleteButtons[0]!);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Python']);
|
||||
});
|
||||
|
||||
it('clears input after adding a skill', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SkillsForm data={[]} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByLabelText(/add a skill/i) as HTMLInputElement;
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
|
||||
await user.type(input, 'C++');
|
||||
await user.click(addButton);
|
||||
|
||||
// The component should clear the input after adding
|
||||
// We can't directly check the state, but we can verify onChange was called
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['C++']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Divider,
|
||||
Button,
|
||||
Chip,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon } from '@mui/icons-material';
|
||||
import { ResumeData } from '../types';
|
||||
|
||||
interface SkillsFormProps {
|
||||
data: ResumeData['skills'];
|
||||
onChange: (data: ResumeData['skills']) => void;
|
||||
}
|
||||
|
||||
export default function SkillsForm({ data, onChange }: SkillsFormProps) {
|
||||
const [skillInput, setSkillInput] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (skillInput.trim() && !data.includes(skillInput.trim())) {
|
||||
onChange([...data, skillInput.trim()]);
|
||||
setSkillInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (skillToRemove: string) => {
|
||||
onChange(data.filter((skill) => skill !== skillToRemove));
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Skills *
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
label="Add a skill"
|
||||
fullWidth
|
||||
value={skillInput}
|
||||
onChange={(e) => setSkillInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="e.g., JavaScript, Python, Project Management"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAdd}
|
||||
startIcon={<AddIcon />}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{data.length > 0 ? (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{data.map((skill, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={skill}
|
||||
onDelete={() => handleRemove(skill)}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No skills added yet. Please add at least one skill.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Box, TextField, Typography, Divider } from '@mui/material';
|
||||
|
||||
interface SummaryFormProps {
|
||||
data: string;
|
||||
onChange: (data: string) => void;
|
||||
}
|
||||
|
||||
export default function SummaryForm({ data, onChange }: SummaryFormProps) {
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Professional Summary
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
label="Summary"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={data}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Provide a brief professional summary highlighting your key qualifications and career objectives..."
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import WorkExperienceForm from './WorkExperienceForm';
|
||||
import { ResumeData } from '../types';
|
||||
|
||||
describe('WorkExperienceForm', () => {
|
||||
const mockData: ResumeData['workExperience'] = [];
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
it('renders with no work experience entries', () => {
|
||||
render(<WorkExperienceForm data={mockData} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByText(/work experience \*/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/add work experience/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a work experience entry when button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorkExperienceForm data={mockData} onChange={mockOnChange} />);
|
||||
|
||||
const addButton = screen.getByText(/add work experience/i);
|
||||
await user.click(addButton);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{
|
||||
company: '',
|
||||
position: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
description: '',
|
||||
current: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders existing work experience entries', () => {
|
||||
const existingData: ResumeData['workExperience'] = [
|
||||
{
|
||||
company: 'Acme Corp',
|
||||
position: 'Software Engineer',
|
||||
startDate: '2020-01-01',
|
||||
endDate: '2023-12-31',
|
||||
description: 'Developed features',
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
|
||||
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Software Engineer')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2020-01-01')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Developed features')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete button for each entry', () => {
|
||||
const existingData: ResumeData['workExperience'] = [
|
||||
{
|
||||
company: 'Test Company',
|
||||
position: 'Developer',
|
||||
startDate: '2020-01-01',
|
||||
endDate: '2023-12-31',
|
||||
description: 'Test description',
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
|
||||
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: '' });
|
||||
expect(deleteButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('disables end date when current is checked', () => {
|
||||
const existingData: ResumeData['workExperience'] = [
|
||||
{
|
||||
company: 'Current Co',
|
||||
position: 'Engineer',
|
||||
startDate: '2023-01-01',
|
||||
endDate: '',
|
||||
description: 'Current job',
|
||||
current: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
|
||||
|
||||
const endDateInput = screen.getByLabelText(/end date/i);
|
||||
expect(endDateInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('displays the current job checkbox', () => {
|
||||
const existingData: ResumeData['workExperience'] = [
|
||||
{
|
||||
company: 'Company',
|
||||
position: 'Position',
|
||||
startDate: '2023-01-01',
|
||||
endDate: '',
|
||||
description: 'Description',
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
|
||||
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByText(/i currently work here/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Divider,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { ResumeData } from '../types';
|
||||
|
||||
interface WorkExperienceFormProps {
|
||||
data: ResumeData['workExperience'];
|
||||
onChange: (data: ResumeData['workExperience']) => void;
|
||||
}
|
||||
|
||||
export default function WorkExperienceForm({
|
||||
data,
|
||||
onChange,
|
||||
}: WorkExperienceFormProps) {
|
||||
const handleAdd = () => {
|
||||
onChange([
|
||||
...data,
|
||||
{
|
||||
company: '',
|
||||
position: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
description: '',
|
||||
current: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(data.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
index: number,
|
||||
field: keyof ResumeData['workExperience'][0],
|
||||
value: string | boolean
|
||||
) => {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...newData[index]!, [field]: value };
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Work Experience *
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{data.map((experience, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
elevation={1}
|
||||
sx={{ p: 3, mb: 2, position: 'relative' }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => handleRemove(index)}
|
||||
sx={{ position: 'absolute', top: 20, right: 4 }}
|
||||
color="error"
|
||||
size="small"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Company"
|
||||
required
|
||||
fullWidth
|
||||
value={experience.company}
|
||||
onChange={(e) => handleChange(index, 'company', e.target.value)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Position"
|
||||
required
|
||||
fullWidth
|
||||
value={experience.position}
|
||||
onChange={(e) => handleChange(index, 'position', e.target.value)}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="date"
|
||||
required
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={experience.startDate}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'startDate', e.target.value)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="date"
|
||||
required={!experience.current}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={experience.endDate}
|
||||
onChange={(e) => handleChange(index, 'endDate', e.target.value)}
|
||||
disabled={experience.current}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={experience.current}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'current', e.target.checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="I currently work here"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description"
|
||||
required
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={experience.description}
|
||||
onChange={(e) =>
|
||||
handleChange(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Describe your responsibilities and achievements..."
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAdd}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
>
|
||||
Add Work Experience
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface ResumeData {
|
||||
personalInfo: {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
linkedIn?: string;
|
||||
github?: string;
|
||||
website?: string;
|
||||
};
|
||||
workExperience: Array<{
|
||||
company: string;
|
||||
position: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
description: string;
|
||||
current: boolean;
|
||||
}>;
|
||||
education?: Array<{
|
||||
institution: string;
|
||||
degree: string;
|
||||
field?: string;
|
||||
graduationDate: string;
|
||||
gpa?: string;
|
||||
}>;
|
||||
certifications?: Array<{
|
||||
certificationName: string;
|
||||
acquiredDate: string;
|
||||
areaOfExpertise?: string;
|
||||
}>;
|
||||
skills: string[];
|
||||
summary?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting - Strict Mode */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitReturns": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
|
||||
testTimeout: 10000,
|
||||
},
|
||||
});
|
||||
+4
-4
@@ -9,10 +9,10 @@
|
||||
"copy:icons": "mkdir -p dist/icons && cp src/icons/*.svg dist/icons/",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,json,css}\""
|
||||
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx --fix",
|
||||
"format": "prettier --write \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\"",
|
||||
"format:check": "prettier --check \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\""
|
||||
},
|
||||
"keywords": [
|
||||
"firefox",
|
||||
|
||||
Generated
+263
-4
@@ -79,8 +79,72 @@ importers:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@24.8.1)(@vitest/ui@3.2.4)(jsdom@27.0.1(postcss@8.5.6))(yaml@2.8.1)
|
||||
|
||||
job-application-demo:
|
||||
dependencies:
|
||||
'@emotion/react':
|
||||
specifier: ^11.14.0
|
||||
version: 11.14.0(@types/react@19.2.2)(react@19.2.0)
|
||||
'@emotion/styled':
|
||||
specifier: ^11.14.1
|
||||
version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
|
||||
'@mui/icons-material':
|
||||
specifier: ^7.3.4
|
||||
version: 7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
|
||||
'@mui/material':
|
||||
specifier: ^7.3.4
|
||||
version: 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.0
|
||||
react-dom:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.0(react@19.2.0)
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.56.1
|
||||
version: 1.56.1
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@types/react':
|
||||
specifier: ^19.2.2
|
||||
version: 19.2.2
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.2
|
||||
version: 19.2.2(@types/react@19.2.2)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4(vite@7.1.10(@types/node@24.8.1)(yaml@2.8.1))
|
||||
'@vitest/ui':
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4)
|
||||
jsdom:
|
||||
specifier: ^27.0.1
|
||||
version: 27.0.1(postcss@8.5.6)
|
||||
playwright:
|
||||
specifier: ^1.56.1
|
||||
version: 1.56.1
|
||||
typescript:
|
||||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^7.1.10
|
||||
version: 7.1.10(@types/node@24.8.1)(yaml@2.8.1)
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@24.8.1)(@vitest/ui@3.2.4)(jsdom@27.0.1(postcss@8.5.6))(yaml@2.8.1)
|
||||
|
||||
packages:
|
||||
|
||||
'@adobe/css-tools@4.4.4':
|
||||
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
||||
|
||||
'@asamuzakjp/css-color@4.0.5':
|
||||
resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==}
|
||||
|
||||
@@ -615,6 +679,11 @@ packages:
|
||||
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@playwright/test@1.56.1':
|
||||
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/config.env-replace@1.1.0':
|
||||
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -746,6 +815,38 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@testing-library/jest-dom@6.9.1':
|
||||
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
|
||||
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
|
||||
|
||||
'@testing-library/react@16.3.0':
|
||||
resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': ^10.0.0
|
||||
'@types/react': ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@testing-library/user-event@14.6.1':
|
||||
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@types/aria-query@5.0.4':
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -936,6 +1037,10 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@5.2.0:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -943,6 +1048,13 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-query@5.3.0:
|
||||
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
||||
|
||||
aria-query@5.3.2:
|
||||
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
array-differ@4.0.0:
|
||||
resolution: {integrity: sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -1117,6 +1229,9 @@ packages:
|
||||
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
css.escape@1.5.1:
|
||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||
|
||||
cssom@0.5.0:
|
||||
resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==}
|
||||
|
||||
@@ -1166,6 +1281,16 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
dom-accessibility-api@0.5.16:
|
||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
@@ -1371,6 +1496,11 @@ packages:
|
||||
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1477,6 +1607,10 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
indent-string@4.0.0:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -1687,6 +1821,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
magic-string@0.30.19:
|
||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||
|
||||
@@ -1710,6 +1848,10 @@ packages:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
min-indent@1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
@@ -1841,6 +1983,16 @@ packages:
|
||||
resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==}
|
||||
hasBin: true
|
||||
|
||||
playwright-core@1.56.1:
|
||||
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.56.1:
|
||||
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1858,6 +2010,10 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-format@27.5.1:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
@@ -1900,6 +2056,9 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-is@19.2.0:
|
||||
resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==}
|
||||
|
||||
@@ -1924,6 +2083,10 @@ packages:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
registry-auth-token@5.1.0:
|
||||
resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2074,6 +2237,10 @@ packages:
|
||||
resolution: {integrity: sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-indent@3.0.0:
|
||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2419,6 +2586,8 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@adobe/css-tools@4.4.4': {}
|
||||
|
||||
'@asamuzakjp/css-color@4.0.5':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
@@ -2596,7 +2765,7 @@ snapshots:
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.27.1
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.28.4
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
@@ -2627,7 +2796,7 @@ snapshots:
|
||||
|
||||
'@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.28.4
|
||||
'@emotion/babel-plugin': 11.13.5
|
||||
'@emotion/cache': 11.14.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
@@ -2653,7 +2822,7 @@ snapshots:
|
||||
|
||||
'@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.28.4
|
||||
'@emotion/babel-plugin': 11.13.5
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
'@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0)
|
||||
@@ -2931,6 +3100,10 @@ snapshots:
|
||||
|
||||
'@pkgr/core@0.2.9': {}
|
||||
|
||||
'@playwright/test@1.56.1':
|
||||
dependencies:
|
||||
playwright: 1.56.1
|
||||
|
||||
'@pnpm/config.env-replace@1.1.0': {}
|
||||
|
||||
'@pnpm/network.ca-file@1.0.2':
|
||||
@@ -3015,6 +3188,42 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.52.5':
|
||||
optional: true
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/runtime': 7.28.4
|
||||
'@types/aria-query': 5.0.4
|
||||
aria-query: 5.3.0
|
||||
dom-accessibility-api: 0.5.16
|
||||
lz-string: 1.5.0
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/jest-dom@6.9.1':
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.4.4
|
||||
aria-query: 5.3.2
|
||||
css.escape: 1.5.1
|
||||
dom-accessibility-api: 0.6.3
|
||||
picocolors: 1.1.1
|
||||
redent: 3.0.0
|
||||
|
||||
'@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@testing-library/dom': 10.4.1
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
||||
'@types/aria-query@5.0.4': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.4
|
||||
@@ -3264,10 +3473,18 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-query@5.3.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
aria-query@5.3.2: {}
|
||||
|
||||
array-differ@4.0.0: {}
|
||||
|
||||
array-union@3.0.1: {}
|
||||
@@ -3287,7 +3504,7 @@ snapshots:
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.28.4
|
||||
cosmiconfig: 7.1.0
|
||||
resolve: 1.22.10
|
||||
|
||||
@@ -3447,6 +3664,8 @@ snapshots:
|
||||
|
||||
css-what@6.2.2: {}
|
||||
|
||||
css.escape@1.5.1: {}
|
||||
|
||||
cssom@0.5.0: {}
|
||||
|
||||
cssstyle@5.3.1(postcss@8.5.6):
|
||||
@@ -3482,6 +3701,12 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
dom-accessibility-api@0.5.16: {}
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
@@ -3719,6 +3944,9 @@ snapshots:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -3817,6 +4045,8 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
@@ -4007,6 +4237,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
magic-string@0.30.19:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -4030,6 +4262,8 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -4165,6 +4399,14 @@ snapshots:
|
||||
sonic-boom: 4.2.0
|
||||
thread-stream: 3.1.0
|
||||
|
||||
playwright-core@1.56.1: {}
|
||||
|
||||
playwright@1.56.1:
|
||||
dependencies:
|
||||
playwright-core: 1.56.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -4179,6 +4421,12 @@ snapshots:
|
||||
|
||||
prettier@3.6.2: {}
|
||||
|
||||
pretty-format@27.5.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
@@ -4219,6 +4467,8 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-is@19.2.0: {}
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
@@ -4246,6 +4496,11 @@ snapshots:
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
registry-auth-token@5.1.0:
|
||||
dependencies:
|
||||
'@pnpm/npm-conf': 2.3.1
|
||||
@@ -4399,6 +4654,10 @@ snapshots:
|
||||
|
||||
strip-bom@5.0.0: {}
|
||||
|
||||
strip-indent@3.0.0:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
@@ -125,6 +125,14 @@ export class FieldMatcher {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.matchesAny(fieldText, ['github', 'git hub', 'github url'])) {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'personalInfo.github',
|
||||
this.resumeData.personalInfo.github || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (this.matchesAny(fieldText, ['website', 'portfolio', 'url'])) {
|
||||
return this.createMapping(
|
||||
field,
|
||||
@@ -176,7 +184,7 @@ export class FieldMatcher {
|
||||
'institution',
|
||||
])
|
||||
) {
|
||||
const latestEd = this.resumeData.education[0];
|
||||
const latestEd = this.resumeData.education?.[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'education.institution',
|
||||
@@ -185,7 +193,7 @@ export class FieldMatcher {
|
||||
}
|
||||
|
||||
if (this.matchesAny(fieldText, ['degree', 'qualification'])) {
|
||||
const latestEd = this.resumeData.education[0];
|
||||
const latestEd = this.resumeData.education?.[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'education.degree',
|
||||
@@ -201,7 +209,7 @@ export class FieldMatcher {
|
||||
'specialization',
|
||||
])
|
||||
) {
|
||||
const latestEd = this.resumeData.education[0];
|
||||
const latestEd = this.resumeData.education?.[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'education.field',
|
||||
@@ -210,10 +218,59 @@ export class FieldMatcher {
|
||||
}
|
||||
|
||||
if (this.matchesAny(fieldText, ['gpa'])) {
|
||||
const latestEd = this.resumeData.education[0];
|
||||
const latestEd = this.resumeData.education?.[0];
|
||||
return this.createMapping(field, 'education.gpa', latestEd?.gpa || '');
|
||||
}
|
||||
|
||||
// Certifications
|
||||
if (
|
||||
this.matchesAny(fieldText, [
|
||||
'certification',
|
||||
'certificate',
|
||||
'certification name',
|
||||
'cert name',
|
||||
])
|
||||
) {
|
||||
const latestCert = this.resumeData.certifications?.[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'certifications.certificationName',
|
||||
latestCert?.certificationName || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.matchesAny(fieldText, [
|
||||
'certification date',
|
||||
'cert date',
|
||||
'acquired',
|
||||
'acquired date',
|
||||
])
|
||||
) {
|
||||
const latestCert = this.resumeData.certifications?.[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'certifications.acquiredDate',
|
||||
latestCert?.acquiredDate || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.matchesAny(fieldText, [
|
||||
'area of expertise',
|
||||
'expertise',
|
||||
'certification area',
|
||||
'cert area',
|
||||
])
|
||||
) {
|
||||
const latestCert = this.resumeData.certifications?.[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'certifications.areaOfExpertise',
|
||||
latestCert?.areaOfExpertise || ''
|
||||
);
|
||||
}
|
||||
|
||||
// Skills
|
||||
if (this.matchesAny(fieldText, ['skills', 'skill'])) {
|
||||
return this.createMapping(
|
||||
|
||||
+8
-2
@@ -9,6 +9,7 @@ export interface ResumeData {
|
||||
zipCode: string;
|
||||
country: string;
|
||||
linkedIn?: string;
|
||||
github?: string;
|
||||
website?: string;
|
||||
};
|
||||
workExperience: Array<{
|
||||
@@ -19,13 +20,18 @@ export interface ResumeData {
|
||||
description: string;
|
||||
current: boolean;
|
||||
}>;
|
||||
education: Array<{
|
||||
education?: Array<{
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
field?: string;
|
||||
graduationDate: string;
|
||||
gpa?: string;
|
||||
}>;
|
||||
certifications?: Array<{
|
||||
certificationName: string;
|
||||
acquiredDate: string;
|
||||
areaOfExpertise?: string;
|
||||
}>;
|
||||
skills: string[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,5 +31,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "job-application-demo"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user