Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d885922ada |
+1
-4
@@ -16,10 +16,7 @@
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": ["./tsconfig.json", "./job-application-demo/tsconfig.json"],
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "prettier"],
|
||||
"rules": {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
name: Status Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
extension-unit-tests:
|
||||
name: Extension Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.18.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run extension unit tests
|
||||
run: pnpm test -- --run --reporter=verbose
|
||||
|
||||
demo-unit-tests:
|
||||
name: Demo App Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.18.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run demo unit tests
|
||||
run: cd job-application-demo && pnpm test -- --run --reporter=verbose
|
||||
|
||||
demo-e2e-tests:
|
||||
name: Demo App E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.18.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: cd job-application-demo && npx playwright install --with-deps chromium firefox
|
||||
|
||||
- name: Run E2E tests
|
||||
run: cd job-application-demo && pnpm test:e2e
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: job-application-demo/playwright-report/
|
||||
retention-days: 30
|
||||
@@ -9,4 +9,3 @@ coverage
|
||||
*.swo
|
||||
*~
|
||||
web-ext-artifacts
|
||||
.claude/
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Ross
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -82,30 +82,8 @@ 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**:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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
|
||||
@@ -1,251 +0,0 @@
|
||||
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();
|
||||
});
|
||||
|
||||
test('should show validation errors for incomplete required fields', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Fill some fields but leave others empty to trigger custom 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();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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": "playwright test --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"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,199 +0,0 @@
|
||||
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 fields/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(() => {
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveTextContent(/please add at least one skill/i);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
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} noValidate>
|
||||
<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;
|
||||
@@ -1,115 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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++']);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
* {
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
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,
|
||||
},
|
||||
});
|
||||
+14
-26
@@ -9,10 +9,10 @@
|
||||
"copy:icons": "mkdir -p dist/icons && cp src/icons/*.svg dist/icons/",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"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}\""
|
||||
"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}\""
|
||||
},
|
||||
"keywords": [
|
||||
"firefox",
|
||||
@@ -21,34 +21,22 @@
|
||||
"autofill"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.18.1",
|
||||
"devDependencies": {
|
||||
"@types/firefox-webext-browser": "^143.0.0",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"@types/firefox-webext-browser": "^120.0.4",
|
||||
"@types/node": "^20.14.9",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.16.0",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jsdom": "^27.0.1",
|
||||
"prettier": "^3.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^7.1.10",
|
||||
"vite": "^5.3.2",
|
||||
"vite-plugin-web-extension": "^4.1.6",
|
||||
"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"
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+793
-1910
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
packages:
|
||||
- 'job-application-demo'
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -0,0 +1,44 @@
|
||||
John Smith
|
||||
john.smith@email.com
|
||||
(555) 123-4567
|
||||
San Francisco, CA 94102
|
||||
|
||||
PROFESSIONAL SUMMARY
|
||||
Experienced software engineer with 5+ years of expertise in full-stack web development.
|
||||
Proficient in modern JavaScript frameworks and cloud technologies.
|
||||
|
||||
WORK EXPERIENCE
|
||||
|
||||
Senior Software Engineer
|
||||
Tech Solutions Inc. | San Francisco, CA
|
||||
January 2020 - Present
|
||||
- Lead development of customer-facing web applications
|
||||
- Mentor junior developers and conduct code reviews
|
||||
- Improved application performance by 40%
|
||||
|
||||
Software Developer
|
||||
Digital Innovations | San Jose, CA
|
||||
June 2018 - December 2019
|
||||
- Built RESTful APIs using Node.js and Express
|
||||
- Implemented responsive UI components with React
|
||||
- Collaborated with cross-functional teams
|
||||
|
||||
EDUCATION
|
||||
|
||||
Bachelor of Science in Computer Science
|
||||
Stanford University | Stanford, CA
|
||||
Graduated: May 2018
|
||||
GPA: 3.8/4.0
|
||||
|
||||
SKILLS
|
||||
JavaScript, TypeScript, React, Node.js, Python, Java, SQL, MongoDB, PostgreSQL,
|
||||
AWS, Docker, Kubernetes, Git, CI/CD, Agile, REST APIs, GraphQL
|
||||
|
||||
CERTIFICATIONS
|
||||
- AWS Certified Solutions Architect
|
||||
- Google Cloud Professional Developer
|
||||
|
||||
PROJECTS
|
||||
- Open source contributor to popular JavaScript libraries
|
||||
- Personal portfolio website: https://johnsmith.dev
|
||||
- LinkedIn: https://linkedin.com/in/johnsmith
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,52 +0,0 @@
|
||||
John Smith
|
||||
john.smith@email.com
|
||||
+1-(555)-123-4567
|
||||
123 Market Street
|
||||
San Francisco, CA 94102
|
||||
United States
|
||||
LinkedIn: https://linkedin.com/in/johnsmith
|
||||
GitHub: https://github.com/johnsmith
|
||||
Website: https://johnsmith.dev
|
||||
|
||||
PROFESSIONAL SUMMARY
|
||||
Experienced software engineer with 5+ years of expertise in full-stack web development.
|
||||
Proficient in modern JavaScript frameworks and cloud technologies. Passionate about building
|
||||
scalable applications and mentoring junior developers.
|
||||
|
||||
WORK EXPERIENCE
|
||||
|
||||
Senior Software Engineer
|
||||
Tech Solutions Inc. | San Francisco, CA
|
||||
January 2020 - Present
|
||||
Lead development of customer-facing web applications using React and Node.js. Mentor junior
|
||||
developers and conduct code reviews. Improved application performance by 40% through optimization
|
||||
and refactoring. Implemented CI/CD pipelines and cloud infrastructure on AWS.
|
||||
|
||||
Software Developer
|
||||
Digital Innovations | San Jose, CA
|
||||
June 2018 - December 2019
|
||||
Built RESTful APIs using Node.js and Express. Implemented responsive UI components with React.
|
||||
Collaborated with cross-functional teams to deliver high-quality software solutions. Participated
|
||||
in agile development processes and sprint planning.
|
||||
|
||||
EDUCATION
|
||||
|
||||
Bachelor of Science in Computer Science
|
||||
Stanford University
|
||||
Field of Study: Computer Science
|
||||
Graduated: May 2018
|
||||
GPA: 3.8
|
||||
|
||||
CERTIFICATIONS
|
||||
|
||||
AWS Certified Solutions Architect
|
||||
Acquired: March 2021
|
||||
Area of Expertise: Cloud Architecture and Infrastructure
|
||||
|
||||
Google Cloud Professional Developer
|
||||
Acquired: September 2020
|
||||
Area of Expertise: Cloud Development and Deployment
|
||||
|
||||
SKILLS
|
||||
JavaScript, TypeScript, React, Node.js, Python, Java, SQL, MongoDB, PostgreSQL,
|
||||
AWS, Docker, Kubernetes, Git, CI/CD, Agile, REST APIs, GraphQL
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,24 +0,0 @@
|
||||
John Smith
|
||||
john.smith@email.com
|
||||
+1-(555)-123-4567
|
||||
San Francisco, CA
|
||||
|
||||
WORK EXPERIENCE
|
||||
|
||||
Senior Software Engineer
|
||||
Tech Solutions Inc.
|
||||
January 2020 - Present
|
||||
Lead development of customer-facing web applications using React and Node.js. Mentor junior
|
||||
developers and conduct code reviews. Improved application performance by 40% through optimization
|
||||
and refactoring. Implemented CI/CD pipelines and cloud infrastructure on AWS.
|
||||
|
||||
Software Developer
|
||||
Digital Innovations
|
||||
June 2018 - December 2019
|
||||
Built RESTful APIs using Node.js and Express. Implemented responsive UI components with React.
|
||||
Collaborated with cross-functional teams to deliver high-quality software solutions. Participated
|
||||
in agile development processes and sprint planning.
|
||||
|
||||
SKILLS
|
||||
JavaScript, TypeScript, React, Node.js, Python, Java, SQL, MongoDB, PostgreSQL,
|
||||
AWS, Docker, Kubernetes, Git, CI/CD, Agile, REST APIs, GraphQL
|
||||
+52
-2
@@ -4,9 +4,59 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>JobSpeed</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="popup.tsx" type="module"></script>
|
||||
<div class="popup-container">
|
||||
<header>
|
||||
<h1>JobSpeed</h1>
|
||||
<p class="subtitle">Auto-fill job applications</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="resume-section">
|
||||
<h2>Resume</h2>
|
||||
<div id="resume-status" class="status-container">
|
||||
<p class="status-text">No resume uploaded</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-container">
|
||||
<input
|
||||
type="file"
|
||||
id="resume-file-input"
|
||||
accept=".pdf,.txt,.doc,.docx"
|
||||
style="display: none"
|
||||
/>
|
||||
<button id="upload-button" class="primary-button">
|
||||
Upload Resume
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="resume-preview" class="resume-preview hidden">
|
||||
<h3>Resume Data</h3>
|
||||
<div id="resume-data"></div>
|
||||
<button id="clear-resume-button" class="secondary-button">
|
||||
Clear Resume
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="actions-section">
|
||||
<h2>Actions</h2>
|
||||
<button id="scan-page-button" class="primary-button">
|
||||
Scan Current Page
|
||||
</button>
|
||||
<p class="help-text">
|
||||
Click to detect form fields on the current page and preview
|
||||
auto-fill suggestions
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p class="version">v1.0.0</p>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="popup.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
import type { ResumeData, MessageResponse } from './types';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const uploadButton = document.getElementById(
|
||||
'upload-button'
|
||||
) as HTMLButtonElement;
|
||||
const resumeFileInput = document.getElementById(
|
||||
'resume-file-input'
|
||||
) as HTMLInputElement;
|
||||
const resumeStatus = document.getElementById('resume-status');
|
||||
const resumePreview = document.getElementById('resume-preview');
|
||||
const resumeDataDiv = document.getElementById('resume-data');
|
||||
const clearResumeButton = document.getElementById(
|
||||
'clear-resume-button'
|
||||
) as HTMLButtonElement;
|
||||
const scanPageButton = document.getElementById(
|
||||
'scan-page-button'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
// Load and display existing resume data
|
||||
void loadResumeStatus();
|
||||
|
||||
uploadButton.addEventListener('click', () => {
|
||||
resumeFileInput.click();
|
||||
});
|
||||
|
||||
resumeFileInput.addEventListener('change', (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const resumeData = parseResume(text);
|
||||
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'SAVE_RESUME_DATA',
|
||||
data: resumeData,
|
||||
});
|
||||
|
||||
await loadResumeStatus();
|
||||
alert('Resume uploaded successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error uploading resume:', error);
|
||||
alert('Error uploading resume. Please try again.');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
clearResumeButton.addEventListener('click', () => {
|
||||
if (!confirm('Are you sure you want to clear your resume data?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'SAVE_RESUME_DATA',
|
||||
data: null,
|
||||
});
|
||||
|
||||
await loadResumeStatus();
|
||||
alert('Resume cleared successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error clearing resume:', error);
|
||||
alert('Error clearing resume. Please try again.');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
scanPageButton.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const tabs = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
const activeTab = tabs[0];
|
||||
|
||||
if (!activeTab?.id) {
|
||||
alert('No active tab found');
|
||||
return;
|
||||
}
|
||||
|
||||
await browser.tabs.sendMessage(activeTab.id, {
|
||||
type: 'SCAN_PAGE',
|
||||
});
|
||||
|
||||
window.close();
|
||||
} catch (error) {
|
||||
console.error('Error scanning page:', error);
|
||||
alert('Error scanning page. Please try again.');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
async function loadResumeStatus() {
|
||||
try {
|
||||
const response = (await browser.runtime.sendMessage({
|
||||
type: 'GET_RESUME_DATA',
|
||||
})) as MessageResponse;
|
||||
|
||||
if (response.success && response.data) {
|
||||
const resumeData = response.data as ResumeData;
|
||||
displayResumeData(resumeData);
|
||||
resumeStatus?.classList.add('has-resume');
|
||||
if (resumeStatus) {
|
||||
const statusText = resumeStatus.querySelector('.status-text');
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Resume uploaded ✓';
|
||||
}
|
||||
}
|
||||
resumePreview?.classList.remove('hidden');
|
||||
if (scanPageButton) {
|
||||
scanPageButton.disabled = false;
|
||||
}
|
||||
} else {
|
||||
resumeStatus?.classList.remove('has-resume');
|
||||
if (resumeStatus) {
|
||||
const statusText = resumeStatus.querySelector('.status-text');
|
||||
if (statusText) {
|
||||
statusText.textContent = 'No resume uploaded';
|
||||
}
|
||||
}
|
||||
resumePreview?.classList.add('hidden');
|
||||
if (scanPageButton) {
|
||||
scanPageButton.disabled = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading resume status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function displayResumeData(data: ResumeData) {
|
||||
if (!resumeDataDiv) return;
|
||||
|
||||
const html = `
|
||||
<p><strong>Name:</strong> ${data.personalInfo.fullName}</p>
|
||||
<p><strong>Email:</strong> ${data.personalInfo.email}</p>
|
||||
<p><strong>Phone:</strong> ${data.personalInfo.phone}</p>
|
||||
<p><strong>Location:</strong> ${data.personalInfo.city}, ${data.personalInfo.state}</p>
|
||||
<p><strong>Skills:</strong> ${data.skills.slice(0, 5).join(', ')}${data.skills.length > 5 ? '...' : ''}</p>
|
||||
`;
|
||||
|
||||
resumeDataDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function parseResume(text: string): ResumeData {
|
||||
// Basic resume parser - extracts email, phone, and text content
|
||||
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/g;
|
||||
const phoneRegex =
|
||||
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
|
||||
|
||||
const email = text.match(emailRegex)?.[0] || '';
|
||||
const phone = text.match(phoneRegex)?.[0] || '';
|
||||
|
||||
// Extract name (assume first line is name)
|
||||
const lines = text.split('\n').filter((line) => line.trim());
|
||||
const fullName = lines[0] || '';
|
||||
|
||||
// Basic skill extraction (look for common keywords)
|
||||
const skillKeywords = [
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Python',
|
||||
'Java',
|
||||
'React',
|
||||
'Node.js',
|
||||
'SQL',
|
||||
'HTML',
|
||||
'CSS',
|
||||
'Git',
|
||||
'AWS',
|
||||
'Azure',
|
||||
'Docker',
|
||||
'Kubernetes',
|
||||
];
|
||||
const skills = skillKeywords.filter((skill) =>
|
||||
text.toLowerCase().includes(skill.toLowerCase())
|
||||
);
|
||||
|
||||
return {
|
||||
personalInfo: {
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
},
|
||||
workExperience: [],
|
||||
education: [],
|
||||
skills,
|
||||
summary: text.substring(0, 500),
|
||||
};
|
||||
}
|
||||
});
|
||||
-353
@@ -1,353 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
CssBaseline,
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Alert,
|
||||
Chip,
|
||||
Stack,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Upload as UploadIcon,
|
||||
Delete as DeleteIcon,
|
||||
Search as SearchIcon,
|
||||
Description as DescriptionIcon,
|
||||
} from '@mui/icons-material';
|
||||
import type { ResumeData } from './types';
|
||||
|
||||
// Create a custom theme for the extension
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#667eea',
|
||||
},
|
||||
secondary: {
|
||||
main: '#764ba2',
|
||||
},
|
||||
background: {
|
||||
default: '#f5f5f5',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif',
|
||||
},
|
||||
});
|
||||
|
||||
const PopupApp: React.FC = () => {
|
||||
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadResumeStatus();
|
||||
}, []);
|
||||
|
||||
const loadResumeStatus = async () => {
|
||||
try {
|
||||
const response = (await browser.runtime.sendMessage({
|
||||
type: 'GET_RESUME_DATA',
|
||||
})) as { success: boolean; data?: ResumeData | null };
|
||||
|
||||
if (response.success && response.data) {
|
||||
setResumeData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading resume:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadResume = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf,.txt,.doc,.docx';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedData = parseResume(text);
|
||||
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'SAVE_RESUME_DATA',
|
||||
data: parsedData,
|
||||
});
|
||||
|
||||
await loadResumeStatus();
|
||||
alert('Resume uploaded successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error uploading resume:', error);
|
||||
alert('Error uploading resume. Please try again.');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleClearResume = async () => {
|
||||
if (!confirm('Are you sure you want to clear your resume data?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'SAVE_RESUME_DATA',
|
||||
data: null,
|
||||
});
|
||||
|
||||
await loadResumeStatus();
|
||||
alert('Resume cleared successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error clearing resume:', error);
|
||||
alert('Error clearing resume. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanPage = async () => {
|
||||
try {
|
||||
const tabs = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
const activeTab = tabs[0];
|
||||
|
||||
if (!activeTab?.id) {
|
||||
alert('No active tab found');
|
||||
return;
|
||||
}
|
||||
|
||||
await browser.tabs.sendMessage(activeTab.id, {
|
||||
type: 'SCAN_PAGE',
|
||||
});
|
||||
|
||||
window.close();
|
||||
} catch (error) {
|
||||
console.error('Error scanning page:', error);
|
||||
alert('Error scanning page. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const parseResume = (text: string): ResumeData => {
|
||||
const lines = text.split('\n').filter((line) => line.trim());
|
||||
|
||||
return {
|
||||
personalInfo: {
|
||||
fullName:
|
||||
lines
|
||||
.find((line) => line.match(/^[A-Z][a-z]+ [A-Z][a-z]+/))
|
||||
?.trim() || '',
|
||||
email:
|
||||
lines
|
||||
.find((line) =>
|
||||
line.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/)
|
||||
)
|
||||
?.trim() || '',
|
||||
phone:
|
||||
lines
|
||||
.find((line) =>
|
||||
line.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)
|
||||
)
|
||||
?.trim() || '',
|
||||
address:
|
||||
lines
|
||||
.find((line) => line.match(/\d+\s+\w+\s+(St|Ave|Rd|Blvd|Ln|Dr)/i))
|
||||
?.trim() || '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
},
|
||||
workExperience: [],
|
||||
education: [],
|
||||
skills: [],
|
||||
summary: '',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ width: 400, bgcolor: 'background.default' }}>
|
||||
{/* Header */}
|
||||
<AppBar
|
||||
position="static"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<Box sx={{ flexGrow: 1, textAlign: 'center' }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 600 }}>
|
||||
JobSpeed
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||
Auto-fill job applications
|
||||
</Typography>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
{/* Resume Section */}
|
||||
<Paper elevation={0} sx={{ p: 2, mb: 3, bgcolor: 'white' }}>
|
||||
<Stack spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Resume
|
||||
</Typography>
|
||||
{resumeData && (
|
||||
<Chip
|
||||
icon={<DescriptionIcon />}
|
||||
label="Uploaded"
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!resumeData ? (
|
||||
<Alert severity="info" sx={{ mb: 1 }}>
|
||||
No resume uploaded
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="success" sx={{ mb: 1 }}>
|
||||
Resume data loaded
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<UploadIcon />}
|
||||
onClick={handleUploadResume}
|
||||
fullWidth
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
textTransform: 'none',
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
Upload Resume
|
||||
</Button>
|
||||
|
||||
{resumeData && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ bgcolor: '#f8f9fa', p: 2, borderRadius: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ mb: 1, fontWeight: 600 }}
|
||||
>
|
||||
Resume Data
|
||||
</Typography>
|
||||
<Stack
|
||||
spacing={0.5}
|
||||
sx={{ fontSize: '0.875rem', color: '#666' }}
|
||||
>
|
||||
{resumeData.personalInfo.fullName && (
|
||||
<Typography variant="body2">
|
||||
<strong>Name:</strong>{' '}
|
||||
{resumeData.personalInfo.fullName}
|
||||
</Typography>
|
||||
)}
|
||||
{resumeData.personalInfo.email && (
|
||||
<Typography variant="body2">
|
||||
<strong>Email:</strong>{' '}
|
||||
{resumeData.personalInfo.email}
|
||||
</Typography>
|
||||
)}
|
||||
{resumeData.personalInfo.phone && (
|
||||
<Typography variant="body2">
|
||||
<strong>Phone:</strong>{' '}
|
||||
{resumeData.personalInfo.phone}
|
||||
</Typography>
|
||||
)}
|
||||
{resumeData.personalInfo.address && (
|
||||
<Typography variant="body2">
|
||||
<strong>Address:</strong>{' '}
|
||||
{resumeData.personalInfo.address}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => void handleClearResume()}
|
||||
fullWidth
|
||||
color="error"
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Clear Resume
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Actions Section */}
|
||||
<Paper elevation={0} sx={{ p: 2, bgcolor: 'white' }}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Actions
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SearchIcon />}
|
||||
onClick={() => void handleScanPage()}
|
||||
fullWidth
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
textTransform: 'none',
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
Scan Current Page
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Click to detect form fields on the current page and preview
|
||||
auto-fill suggestions
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
textAlign: 'center',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: '#f8f9fa',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
v1.0.0
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Mount the React app
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<PopupApp />);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export class FieldMatcher {
|
||||
// Personal Info mappings - check specific names first before generic "name"
|
||||
if (this.matchesAny(fieldText, ['first name', 'firstname', 'given name'])) {
|
||||
const firstName =
|
||||
this.resumeData.personalInfo.fullName.split(' ')[0] ?? '';
|
||||
this.resumeData.personalInfo.fullName.split(' ')[0] || '';
|
||||
return this.createMapping(field, 'personalInfo.firstName', firstName);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export class FieldMatcher {
|
||||
])
|
||||
) {
|
||||
const parts = this.resumeData.personalInfo.fullName.split(' ');
|
||||
const lastName = parts[parts.length - 1] ?? '';
|
||||
const lastName = parts[parts.length - 1] || '';
|
||||
return this.createMapping(field, 'personalInfo.lastName', lastName);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'personalInfo.address',
|
||||
this.resumeData.personalInfo.address ?? ''
|
||||
this.resumeData.personalInfo.address
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'personalInfo.zipCode',
|
||||
this.resumeData.personalInfo.zipCode ?? ""
|
||||
this.resumeData.personalInfo.zipCode
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'personalInfo.country',
|
||||
this.resumeData.personalInfo.country ?? ''
|
||||
this.resumeData.personalInfo.country
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,15 +121,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'personalInfo.linkedIn',
|
||||
this.resumeData.personalInfo.linkedIn ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
if (this.matchesAny(fieldText, ['github', 'git hub', 'github url'])) {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'personalInfo.github',
|
||||
this.resumeData.personalInfo.github ?? ''
|
||||
this.resumeData.personalInfo.linkedIn || ''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +129,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'personalInfo.website',
|
||||
this.resumeData.personalInfo.website ?? ''
|
||||
this.resumeData.personalInfo.website || ''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +146,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'workExperience.company',
|
||||
latestJob?.company ?? ''
|
||||
latestJob?.company || ''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,7 +163,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'workExperience.position',
|
||||
latestJob?.position ?? ''
|
||||
latestJob?.position || ''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,20 +176,20 @@ export class FieldMatcher {
|
||||
'institution',
|
||||
])
|
||||
) {
|
||||
const latestEd = this.resumeData.education?.[0];
|
||||
const latestEd = this.resumeData.education[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'education.institution',
|
||||
latestEd?.institution ?? ''
|
||||
latestEd?.institution || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (this.matchesAny(fieldText, ['degree', 'qualification'])) {
|
||||
const latestEd = this.resumeData.education?.[0];
|
||||
const latestEd = this.resumeData.education[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'education.degree',
|
||||
latestEd?.degree ?? ''
|
||||
latestEd?.degree || ''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,66 +201,17 @@ export class FieldMatcher {
|
||||
'specialization',
|
||||
])
|
||||
) {
|
||||
const latestEd = this.resumeData.education?.[0];
|
||||
const latestEd = this.resumeData.education[0];
|
||||
return this.createMapping(
|
||||
field,
|
||||
'education.field',
|
||||
latestEd?.field ?? ''
|
||||
latestEd?.field || ''
|
||||
);
|
||||
}
|
||||
|
||||
if (this.matchesAny(fieldText, ['gpa'])) {
|
||||
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 ?? ''
|
||||
);
|
||||
const latestEd = this.resumeData.education[0];
|
||||
return this.createMapping(field, 'education.gpa', latestEd?.gpa || '');
|
||||
}
|
||||
|
||||
// Skills
|
||||
@@ -294,7 +237,7 @@ export class FieldMatcher {
|
||||
return this.createMapping(
|
||||
field,
|
||||
'summary',
|
||||
this.resumeData.summary ?? ''
|
||||
this.resumeData.summary || ''
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
-11
@@ -3,13 +3,12 @@ export interface ResumeData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address?: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode?: string;
|
||||
country?: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
linkedIn?: string;
|
||||
github?: string;
|
||||
website?: string;
|
||||
};
|
||||
workExperience: Array<{
|
||||
@@ -20,18 +19,13 @@ 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
-2
@@ -5,7 +5,6 @@
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
@@ -31,5 +30,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "job-application-demo"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import webExtension from 'vite-plugin-web-extension';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -9,7 +8,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export default defineConfig({
|
||||
root: 'src',
|
||||
plugins: [
|
||||
react(),
|
||||
webExtension({
|
||||
manifest: 'manifest.json',
|
||||
watchFilePaths: ['**/*'],
|
||||
|
||||
@@ -8,11 +8,6 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/job-application-demo/**',
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user