From 0e7a498c384c72c4866fcde888586bd0d618e813 Mon Sep 17 00:00:00 2001 From: SagiEv Date: Wed, 9 Sep 2026 19:27:39 +0300 Subject: [PATCH 1/9] added experience to profile page component --- backend/controllers/roles.controller.js | 20 +++ .../migrations/003_add_experience_tables.sql | 30 ++++ backend/repositories/profile.repository.js | 27 ++- backend/routes/roles.routes.js | 8 + backend/schema.sql | 18 ++ backend/server.js | 2 + backend/services/profile.service.js | 12 +- .../controllers/roles.controller.test.js | 50 ++++++ .../tests/services/profile.service.test.js | 54 +++++- frontend/src/components/ExperienceEditor.jsx | 158 ++++++++++++++++++ .../src/hooks/__tests__/useRolesBank.test.jsx | 53 ++++++ frontend/src/hooks/useRolesBank.js | 13 ++ frontend/src/pages/ProfilePage.jsx | 13 ++ .../src/pages/__tests__/ProfilePage.test.jsx | 90 ++++++++++ 14 files changed, 545 insertions(+), 3 deletions(-) create mode 100644 backend/controllers/roles.controller.js create mode 100644 backend/migrations/003_add_experience_tables.sql create mode 100644 backend/routes/roles.routes.js create mode 100644 backend/tests/controllers/roles.controller.test.js create mode 100644 frontend/src/components/ExperienceEditor.jsx create mode 100644 frontend/src/hooks/__tests__/useRolesBank.test.jsx create mode 100644 frontend/src/hooks/useRolesBank.js create mode 100644 frontend/src/pages/__tests__/ProfilePage.test.jsx diff --git a/backend/controllers/roles.controller.js b/backend/controllers/roles.controller.js new file mode 100644 index 0000000..cce4d19 --- /dev/null +++ b/backend/controllers/roles.controller.js @@ -0,0 +1,20 @@ +const supabase = require('../supabaseClient'); + +const getRolesBank = async (req, res) => { + try { + const { data, error } = await req.supabase + .from('roles_dictionary') + .select('*') + .order('name'); + + if (error) throw new Error(error.message); + + res.json(data); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}; + +module.exports = { + getRolesBank +}; diff --git a/backend/migrations/003_add_experience_tables.sql b/backend/migrations/003_add_experience_tables.sql new file mode 100644 index 0000000..47c2c56 --- /dev/null +++ b/backend/migrations/003_add_experience_tables.sql @@ -0,0 +1,30 @@ +-- Bank of roles and tags +CREATE TABLE IF NOT EXISTS roles_dictionary ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + category TEXT -- e.g., 'role', 'tag' +); + +-- Relational table for tracking user experience +CREATE TABLE IF NOT EXISTS user_experiences ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL, -- Link to user + role_id INTEGER REFERENCES roles_dictionary(id) ON DELETE CASCADE, + status TEXT CHECK (status IN ('current', 'previous')), + years INTEGER, -- Manual override (e.g., 0 for no experience, 1, 2...) + start_date DATE, -- Optional: used to dynamically calculate duration + end_date DATE -- Optional: marks the end of previous roles +); + +-- Seed initial roles +INSERT INTO roles_dictionary (name, category) VALUES + ('Software Engineer', 'role'), + ('Frontend Developer', 'role'), + ('Backend Developer', 'role'), + ('Fullstack Developer', 'role'), + ('DevOps Engineer', 'role'), + ('Data Scientist', 'role'), + ('Product Manager', 'role'), + ('UI', 'tag'), + ('UX', 'tag') +ON CONFLICT (name) DO NOTHING; diff --git a/backend/repositories/profile.repository.js b/backend/repositories/profile.repository.js index 988ab75..ead259e 100644 --- a/backend/repositories/profile.repository.js +++ b/backend/repositories/profile.repository.js @@ -21,8 +21,33 @@ const createProfile = async (userId, updateData, client) => { .single(); }; +const findUserExperiences = async (userId, client) => { + return await client + .from('user_experiences') + .select('*, roles_dictionary(*)') + .eq('user_id', userId); +}; + +const syncUserExperiences = async (userId, experiences, client) => { + // Simplified sync: delete all and re-insert + await client.from('user_experiences').delete().eq('user_id', userId); + if (experiences && experiences.length > 0) { + const rows = experiences.map(exp => ({ + user_id: userId, + role_id: exp.role_id, + status: exp.status, + years: exp.years || null, + start_date: exp.start_date || null, + end_date: exp.end_date || null + })); + await client.from('user_experiences').insert(rows); + } +}; + module.exports = { findFirstProfile, updateProfile, - createProfile + createProfile, + findUserExperiences, + syncUserExperiences }; \ No newline at end of file diff --git a/backend/routes/roles.routes.js b/backend/routes/roles.routes.js new file mode 100644 index 0000000..9de43fc --- /dev/null +++ b/backend/routes/roles.routes.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); +const rolesController = require('../controllers/roles.controller'); +const { authenticate } = require('../middleware/auth'); + +router.get('/', authenticate, rolesController.getRolesBank); + +module.exports = router; diff --git a/backend/schema.sql b/backend/schema.sql index 47e31bf..31ca342 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -162,3 +162,21 @@ CREATE TABLE IF NOT EXISTS rss_jobs ( seniority TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); + +-- Bank of roles and tags +CREATE TABLE IF NOT EXISTS roles_dictionary ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + category TEXT -- e.g., 'role', 'tag' +); + +-- Relational table for tracking user experience +CREATE TABLE IF NOT EXISTS user_experiences ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL, -- Link to user + role_id INTEGER REFERENCES roles_dictionary(id) ON DELETE CASCADE, + status TEXT CHECK (status IN ('current', 'previous')), + years INTEGER, -- Manual override (e.g., 0 for no experience, 1, 2...) + start_date DATE, -- Optional: used to dynamically calculate duration + end_date DATE -- Optional: marks the end of previous roles +); diff --git a/backend/server.js b/backend/server.js index 2751e2c..65ab5f4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -30,6 +30,7 @@ const { startMailPolling } = require('./cron/mail-poll-cron'); const notificationsRoutes = require('./routes/notifications.routes'); const rssRoutes = require('./routes/rss.routes'); const { startRssPolling } = require('./cron/rss-poll-cron'); +const rolesRoutes = require('./routes/roles.routes'); // --- Mount Routes --- app.use('/auth', userRoutes); @@ -49,6 +50,7 @@ app.use('/api/messages', messagesRoutes); app.use('/api/events', eventsRoutes); app.use('/api/notifications', notificationsRoutes); app.use('/api/rss-feeds', rssRoutes); +app.use('/api/roles-bank', rolesRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/backend/services/profile.service.js b/backend/services/profile.service.js index 9b9273d..9037c01 100644 --- a/backend/services/profile.service.js +++ b/backend/services/profile.service.js @@ -7,6 +7,11 @@ const getProfile = async (userId, supabaseClient) => { let profile = data || {}; + const { data: experiencesData, error: expError } = await profileRepository.findUserExperiences(userId, supabaseClient); + if (expError) throw new Error(expError.message); + + profile.experiences = experiencesData || []; + // Transformation logic if (profile.cv_data) { profile.cvData = profile.cv_data; @@ -20,7 +25,7 @@ const getProfile = async (userId, supabaseClient) => { }; const upsertProfile = async (userId, payload, supabaseClient) => { - const { id, ...updateData } = payload; + const { id, experiences, ...updateData } = payload; // Mapping frontend keys to database keys if ('cvData' in updateData) { @@ -37,6 +42,11 @@ const upsertProfile = async (userId, payload, supabaseClient) => { : await profileRepository.createProfile(userId, updateData, supabaseClient); if (error) throw new Error(error.message); + + if (experiences !== undefined) { + await profileRepository.syncUserExperiences(userId, experiences, supabaseClient); + } + return data; }; diff --git a/backend/tests/controllers/roles.controller.test.js b/backend/tests/controllers/roles.controller.test.js new file mode 100644 index 0000000..1446193 --- /dev/null +++ b/backend/tests/controllers/roles.controller.test.js @@ -0,0 +1,50 @@ +const rolesController = require('../../controllers/roles.controller'); + +describe('Roles Controller', () => { + let mockReq; + let mockRes; + + beforeEach(() => { + mockRes = { + json: jest.fn(), + status: jest.fn().mockReturnThis() + }; + }); + + it('should return roles bank successfully (Sunny Day)', async () => { + mockReq = { + supabase: { + from: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + order: jest.fn().mockResolvedValue({ + data: [{ id: 1, name: 'Software Engineer' }], + error: null + }) + }) + }) + } + }; + + await rolesController.getRolesBank(mockReq, mockRes); + expect(mockRes.json).toHaveBeenCalledWith([{ id: 1, name: 'Software Engineer' }]); + }); + + it('should handle db error gracefully (Rainy Day)', async () => { + mockReq = { + supabase: { + from: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + order: jest.fn().mockResolvedValue({ + data: null, + error: { message: 'Database error' } + }) + }) + }) + } + }; + + await rolesController.getRolesBank(mockReq, mockRes); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Database error' }); + }); +}); diff --git a/backend/tests/services/profile.service.test.js b/backend/tests/services/profile.service.test.js index 9c004ba..c51f3e7 100644 --- a/backend/tests/services/profile.service.test.js +++ b/backend/tests/services/profile.service.test.js @@ -14,6 +14,10 @@ describe('profile.service', () => { data: { id: 1, cv_data: { summary: 'My CV' }, website: null }, error: null, }); + profileRepository.findUserExperiences.mockResolvedValue({ + data: [], + error: null, + }); // Act const result = await profileService.getProfile('user-123'); @@ -29,6 +33,10 @@ describe('profile.service', () => { data: { id: 1, website: 'github.com/user' }, error: null, }); + profileRepository.findUserExperiences.mockResolvedValue({ + data: [], + error: null, + }); // Act const result = await profileService.getProfile('user-123'); @@ -43,12 +51,16 @@ describe('profile.service', () => { data: null, error: { code: 'PGRST116', message: 'Not found' }, }); + profileRepository.findUserExperiences.mockResolvedValue({ + data: [], + error: null, + }); // Act const result = await profileService.getProfile('user-123'); // Assert - expect(result).toEqual({}); + expect(result).toEqual({ experiences: [] }); }); it('should throw on non-PGRST116 error', async () => { @@ -61,6 +73,24 @@ describe('profile.service', () => { // Act & Assert await expect(profileService.getProfile('user-123')).rejects.toThrow('real error'); }); + + it('should fetch and attach user experiences', async () => { + // Arrange + profileRepository.findFirstProfile.mockResolvedValue({ + data: { id: 1 }, + error: null, + }); + profileRepository.findUserExperiences.mockResolvedValue({ + data: [{ id: 1, status: 'current', years: 2 }], + error: null, + }); + + // Act + const result = await profileService.getProfile('user-123'); + + // Assert + expect(result.experiences).toEqual([{ id: 1, status: 'current', years: 2 }]); + }); }); describe('upsertProfile', () => { @@ -69,6 +99,7 @@ describe('profile.service', () => { profileRepository.updateProfile.mockResolvedValue({ data: { id: 1 }, error: null, }); + profileRepository.syncUserExperiences.mockResolvedValue(); // Act await profileService.upsertProfile('user-123', { @@ -81,6 +112,27 @@ describe('profile.service', () => { expect.objectContaining({ cv_data: { summary: 'test' }, website: 'gh.com/me' }), undefined ); + expect(profileRepository.syncUserExperiences).not.toHaveBeenCalled(); + }); + + it('should call syncUserExperiences when experiences array is provided', async () => { + // Arrange + profileRepository.updateProfile.mockResolvedValue({ + data: { id: 1 }, error: null, + }); + profileRepository.syncUserExperiences.mockResolvedValue(); + + // Act + await profileService.upsertProfile('user-123', { + id: 1, experiences: [{ role_id: 1, status: 'current' }], + }); + + // Assert + expect(profileRepository.syncUserExperiences).toHaveBeenCalledWith( + 'user-123', + [{ role_id: 1, status: 'current' }], + undefined + ); }); it('should call createProfile when no id', async () => { diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx new file mode 100644 index 0000000..04e1b88 --- /dev/null +++ b/frontend/src/components/ExperienceEditor.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { useRolesBank } from '../hooks/useRolesBank'; + +const ExperienceEditor = ({ experiences = [], onChange, status }) => { + const { data: rolesBank = [], isLoading } = useRolesBank(); + const localExperiences = experiences.filter(exp => exp.status === status); + + const handleAdd = () => { + const newExp = { + role_id: rolesBank.length > 0 ? rolesBank[0].id : '', + status, + years: 0, + start_date: '', + end_date: '' + }; + const updated = [...experiences, newExp]; + onChange(updated); + }; + + const handleUpdate = (index, field, value) => { + const filtered = experiences.filter(exp => exp.status === status); + const itemToUpdate = filtered[index]; + const globalIndex = experiences.indexOf(itemToUpdate); + + if (globalIndex > -1) { + const updated = [...experiences]; + updated[globalIndex] = { ...updated[globalIndex], [field]: value }; + onChange(updated); + } + }; + + const handleRemove = (index) => { + const filtered = experiences.filter(exp => exp.status === status); + const itemToRemove = filtered[index]; + const globalIndex = experiences.indexOf(itemToRemove); + + if (globalIndex > -1) { + const updated = experiences.filter((_, i) => i !== globalIndex); + onChange(updated); + } + }; + + const calculateDuration = (start) => { + if (!start) return ''; + const startDate = new Date(start); + const now = new Date(); + if (startDate > now) return 'Start date in future'; + let months = (now.getFullYear() - startDate.getFullYear()) * 12; + months -= startDate.getMonth(); + months += now.getMonth(); + if (months <= 0) return 'Just started'; + const years = Math.floor(months / 12); + const remainingMonths = months % 12; + + const yearStr = years > 0 ? `${years} year${years > 1 ? 's' : ''}` : ''; + const monthStr = remainingMonths > 0 ? `${remainingMonths} month${remainingMonths > 1 ? 's' : ''}` : ''; + + if (yearStr && monthStr) return `${yearStr}, ${monthStr}`; + if (yearStr) return yearStr; + return monthStr; + }; + + if (isLoading) return
Loading roles...
; + + return ( +
+
+ {status === 'current' ? 'Current Experience' : 'Previous Experience'} + +
+ + {localExperiences.length === 0 && ( +
No {status} experience added.
+ )} + + {localExperiences.map((exp, index) => ( +
+
+
+
Role
+ +
+ +
+ +
+ {status === 'current' ? ( + <> +
+
Years of Experience (Manual)
+ handleUpdate(index, 'years', e.target.value === '' ? null : parseInt(e.target.value, 10))} + placeholder="e.g. 0" + /> +
+
+
Start Date (Optional)
+ handleUpdate(index, 'start_date', e.target.value || null)} + /> +
+ {exp.start_date && ( +
+ Dynamic duration: {calculateDuration(exp.start_date)} +
+ )} + + ) : ( + <> +
+
Start Date
+ handleUpdate(index, 'start_date', e.target.value || null)} + /> +
+
+
End Date
+ handleUpdate(index, 'end_date', e.target.value || null)} + /> +
+ {exp.start_date && exp.end_date && exp.end_date < exp.start_date && ( +
+ End date cannot be before start date. +
+ )} + + )} +
+
+ ))} +
+ ); +}; + +export default ExperienceEditor; diff --git a/frontend/src/hooks/__tests__/useRolesBank.test.jsx b/frontend/src/hooks/__tests__/useRolesBank.test.jsx new file mode 100644 index 0000000..2295e0b --- /dev/null +++ b/frontend/src/hooks/__tests__/useRolesBank.test.jsx @@ -0,0 +1,53 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useRolesBank } from '../useRolesBank'; +import apiClient from '../../services/apiClient'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { vi, describe, beforeEach, it, expect } from 'vitest'; + +vi.mock('../../services/apiClient', () => ({ + __esModule: true, + default: { + get: vi.fn() + }, + getAccessToken: vi.fn(() => 'mock-token') +})); + +describe('useRolesBank', () => { + let queryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + vi.clearAllMocks(); + }); + + const wrapper = ({ children }) => ( + + {children} + + ); + + it('fetches roles bank successfully (Sunny Day)', async () => { + const mockRoles = [{ id: 1, name: 'Software Engineer' }]; + apiClient.get.mockResolvedValueOnce({ data: mockRoles }); + + const { result } = renderHook(() => useRolesBank(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockRoles); + expect(apiClient.get).toHaveBeenCalledWith('/api/roles-bank'); + }); + + it('handles API error gracefully (Rainy Day)', async () => { + apiClient.get.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useRolesBank(), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/frontend/src/hooks/useRolesBank.js b/frontend/src/hooks/useRolesBank.js new file mode 100644 index 0000000..4ceb6e3 --- /dev/null +++ b/frontend/src/hooks/useRolesBank.js @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import apiClient, { getAccessToken } from '../services/apiClient'; + +export function useRolesBank() { + return useQuery({ + enabled: !!getAccessToken(), + queryKey: ['rolesBank'], + queryFn: async () => { + const response = await apiClient.get('/api/roles-bank'); + return response.data || []; + } + }); +} diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index fd08b0e..8cb8c3a 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -6,6 +6,7 @@ import ReactQuill from 'react-quill'; import 'react-quill/dist/quill.snow.css'; // import api from '../api'; import PageLoader from '../components/PageLoader'; +import ExperienceEditor from '../components/ExperienceEditor'; const EditableCVField = ({ title, value, onChange }) => { const confirm = useConfirm(); @@ -202,6 +203,18 @@ const ProfilePage = () => { + handleProfileChange('experiences', newExps)} + status="current" + /> + + handleProfileChange('experiences', newExps)} + status="previous" + /> + handleProfileChange('cvData.summary', val)} /> handleProfileChange('cvData.technicalSkills', val)} /> handleProfileChange('cvData.education', val)} /> diff --git a/frontend/src/pages/__tests__/ProfilePage.test.jsx b/frontend/src/pages/__tests__/ProfilePage.test.jsx new file mode 100644 index 0000000..a0fce90 --- /dev/null +++ b/frontend/src/pages/__tests__/ProfilePage.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ProfilePage from '../ProfilePage'; +import { useProfile } from '../../hooks/useProfile'; +import { useRolesBank } from '../../hooks/useRolesBank'; + +import { vi, describe, beforeEach, it, expect } from 'vitest'; + +// Mock the hooks +vi.mock('../../hooks/useProfile'); +vi.mock('../../hooks/useRolesBank'); +vi.mock('../../components/ToastProvider', () => ({ + useToast: () => ({ addToast: vi.fn() }) +})); +vi.mock('../../components/ConfirmProvider', () => ({ + useConfirm: () => vi.fn() +})); + +// Mock React Quill to avoid document undefined errors in tests +vi.mock('react-quill', () => { + const Component = () =>
; + return { default: Component }; +}); + +describe('ProfilePage', () => { + const mockHandleProfileChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + useRolesBank.mockReturnValue({ + data: [{ id: 1, name: 'Software Engineer' }, { id: 2, name: 'UI' }], + isLoading: false + }); + }); + + it('renders current experience and calculates months dynamically (Sunny Day)', () => { + useProfile.mockReturnValue({ + profile: { + experiences: [ + { status: 'current', role_id: 1, years: null, start_date: '2023-01-01' } + ] + }, + loading: false, + error: null, + handleProfileChange: mockHandleProfileChange + }); + + render(); + + expect(screen.getByText('Current Experience')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Software Engineer')).toBeInTheDocument(); + + // We can't strictly match dynamic text because "months/years" depends on current date, + // but we can check if "Dynamic duration:" is present. + expect(screen.getByText(/Dynamic duration:/)).toBeInTheDocument(); + }); + + it('allows adding 0 years of experience (Sunny Day Junior Case)', () => { + useProfile.mockReturnValue({ + profile: { + experiences: [ + { status: 'current', role_id: 2, years: 0, start_date: null } + ] + }, + loading: false, + error: null, + handleProfileChange: mockHandleProfileChange + }); + + render(); + const inputs = screen.getAllByPlaceholderText('e.g. 0'); + expect(inputs[0].value).toBe('0'); + }); + + it('displays warning if end date is before start date (Rainy Day)', () => { + useProfile.mockReturnValue({ + profile: { + experiences: [ + { status: 'previous', role_id: 1, start_date: '2023-01-01', end_date: '2022-01-01' } + ] + }, + loading: false, + error: null, + handleProfileChange: mockHandleProfileChange + }); + + render(); + expect(screen.getByText('End date cannot be before start date.')).toBeInTheDocument(); + }); +}); From 0948b38f34100a5912464b5ce9db880535f30063 Mon Sep 17 00:00:00 2001 From: SagiEv Date: Wed, 9 Sep 2026 22:29:39 +0300 Subject: [PATCH 2/9] Experience Editor & Validation Updates --- backend/services/profile.service.js | 3 + .../tests/services/profile.service.test.js | 18 +++++ frontend/src/components/ConfirmProvider.jsx | 72 +++++++++---------- frontend/src/components/ExperienceEditor.jsx | 59 +++++++++++++-- frontend/src/pages/ProfilePage.jsx | 22 +++--- frontend/tests/e2e/profile.spec.js | 26 +++++++ 6 files changed, 149 insertions(+), 51 deletions(-) create mode 100644 frontend/tests/e2e/profile.spec.js diff --git a/backend/services/profile.service.js b/backend/services/profile.service.js index 9037c01..514cfe4 100644 --- a/backend/services/profile.service.js +++ b/backend/services/profile.service.js @@ -44,6 +44,9 @@ const upsertProfile = async (userId, payload, supabaseClient) => { if (error) throw new Error(error.message); if (experiences !== undefined) { + if (experiences.filter(exp => exp.status === 'current').length > 1) { + throw new Error("Only one current experience is allowed."); + } await profileRepository.syncUserExperiences(userId, experiences, supabaseClient); } diff --git a/backend/tests/services/profile.service.test.js b/backend/tests/services/profile.service.test.js index c51f3e7..38adc7c 100644 --- a/backend/tests/services/profile.service.test.js +++ b/backend/tests/services/profile.service.test.js @@ -135,6 +135,24 @@ describe('profile.service', () => { ); }); + it('should throw error if multiple current experiences are provided', async () => { + // Arrange + profileRepository.updateProfile.mockResolvedValue({ + data: { id: 1 }, error: null, + }); + + // Act & Assert + await expect(profileService.upsertProfile('user-123', { + id: 1, + experiences: [ + { role_id: 1, status: 'current' }, + { role_id: 2, status: 'current' } + ] + })).rejects.toThrow('Only one current experience is allowed.'); + + expect(profileRepository.syncUserExperiences).not.toHaveBeenCalled(); + }); + it('should call createProfile when no id', async () => { // Arrange profileRepository.createProfile.mockResolvedValue({ diff --git a/frontend/src/components/ConfirmProvider.jsx b/frontend/src/components/ConfirmProvider.jsx index 52f7da8..8fcd7e7 100644 --- a/frontend/src/components/ConfirmProvider.jsx +++ b/frontend/src/components/ConfirmProvider.jsx @@ -11,25 +11,28 @@ export const ConfirmProvider = ({ children }) => { resolve: null, }); - const confirm = useCallback((message) => { + const confirm = useCallback((message, options = {}) => { return new Promise((resolve) => { setConfirmState({ isOpen: true, message, resolve, + options }); }); }, []); - const handleConfirm = useCallback(() => { - if (confirmState.resolve) confirmState.resolve(true); - setConfirmState({ isOpen: false, message: '', resolve: null }); + const handleAction = useCallback((value) => { + if (confirmState.resolve) confirmState.resolve(value); + setConfirmState({ isOpen: false, message: '', resolve: null, options: {} }); }, [confirmState]); - const handleCancel = useCallback(() => { - if (confirmState.resolve) confirmState.resolve(false); - setConfirmState({ isOpen: false, message: '', resolve: null }); - }, [confirmState]); + const defaultButtons = [ + { text: 'Cancel', value: false, style: 'secondary' }, + { text: 'Confirm', value: true, style: 'primary' } + ]; + + const buttons = confirmState.options?.buttons || defaultButtons; return ( @@ -69,34 +72,31 @@ export const ConfirmProvider = ({ children }) => { {confirmState.message}

- - + {buttons.map((btn, index) => ( + + ))}
diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx index 04e1b88..3a8f7be 100644 --- a/frontend/src/components/ExperienceEditor.jsx +++ b/frontend/src/components/ExperienceEditor.jsx @@ -1,11 +1,60 @@ import React from 'react'; import { useRolesBank } from '../hooks/useRolesBank'; +import { useToast } from './ToastProvider'; +import { useConfirm } from './ConfirmProvider'; const ExperienceEditor = ({ experiences = [], onChange, status }) => { const { data: rolesBank = [], isLoading } = useRolesBank(); + const { addToast } = useToast(); + const confirm = useConfirm(); const localExperiences = experiences.filter(exp => exp.status === status); - const handleAdd = () => { + const handleAdd = async () => { + if (localExperiences.length > 0) { + const lastExp = localExperiences[localExperiences.length - 1]; + const isFilled = lastExp.start_date || lastExp.end_date || (lastExp.years !== null && lastExp.years !== undefined && lastExp.years > 0); + if (!isFilled) { + addToast("Please fill out the existing experience before adding a new one.", "error"); + return; + } + } + + if (status === 'current' && localExperiences.length >= 1) { + const choice = await confirm( + "You already have a current role. What would you like to do?", + { + buttons: [ + { text: 'Cancel', value: 'cancel', style: 'secondary' }, + { text: 'Move to Previous', value: 'move', style: 'secondary' }, + { text: 'Overwrite', value: 'overwrite', style: 'primary' } + ] + } + ); + + if (choice === 'cancel' || !choice) return; + + let updated = [...experiences]; + if (choice === 'move') { + const currentExpIndex = updated.findIndex(e => e.status === 'current'); + if (currentExpIndex > -1) { + updated[currentExpIndex] = { ...updated[currentExpIndex], status: 'previous' }; + } + } else if (choice === 'overwrite') { + updated = updated.filter(e => e.status !== 'current'); + } + + const newExp = { + role_id: rolesBank.length > 0 ? rolesBank[0].id : '', + status: 'current', + years: 0, + start_date: '', + end_date: '' + }; + updated.push(newExp); + onChange(updated); + return; + } + const newExp = { role_id: rolesBank.length > 0 ? rolesBank[0].id : '', status, @@ -63,9 +112,11 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => { if (isLoading) return
Loading roles...
; return ( -
-
- {status === 'current' ? 'Current Experience' : 'Previous Experience'} +
+
+
+ {status === 'current' ? 'Current Experience' : 'Previous Experience'} +
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index 8cb8c3a..df3daef 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -201,19 +201,19 @@ const ProfilePage = () => {
Target Roles
handleProfileChange('roles', e.target.value)} />
-
- handleProfileChange('experiences', newExps)} - status="current" - /> + handleProfileChange('experiences', newExps)} + status="current" + /> - handleProfileChange('experiences', newExps)} - status="previous" - /> + handleProfileChange('experiences', newExps)} + status="previous" + /> +
handleProfileChange('cvData.summary', val)} /> handleProfileChange('cvData.technicalSkills', val)} /> diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js new file mode 100644 index 0000000..e4f2b63 --- /dev/null +++ b/frontend/tests/e2e/profile.spec.js @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Profile Page - Experience Editor', () => { + test.beforeEach(async ({ page }) => { + // Navigate to profile page assuming standard routing and auth is set up + await page.goto('/profile'); + // Wait for the page to load by waiting for the CV & Preferences card + await page.waitForSelector('text=CV & Preferences'); + }); + + test('should display toast when trying to add empty experience', async ({ page }) => { + // We try to add an experience. First find the "Current Experience" editor + const currentExpSection = page.locator('.experience-editor').filter({ hasText: 'Current Experience' }); + const addRoleBtn = currentExpSection.locator('button', { hasText: '+ Add Role' }); + + // Add one role (if list is empty, this adds an empty role) + await addRoleBtn.click(); + + // Attempt to add another role while the previous one is still empty + await addRoleBtn.click(); + + // Verify toast appears + const toast = page.locator('text=Please fill out the existing experience before adding a new one.'); + await expect(toast).toBeVisible(); + }); +}); From 0dfdb3e2508abe542057039f7d210b48f4d63533 Mon Sep 17 00:00:00 2001 From: SagiEv Date: Wed, 9 Sep 2026 22:42:22 +0300 Subject: [PATCH 3/9] fixed navigation to Profile page fail --- frontend/tests/e2e/profile.spec.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js index e4f2b63..dff7cc5 100644 --- a/frontend/tests/e2e/profile.spec.js +++ b/frontend/tests/e2e/profile.spec.js @@ -2,10 +2,14 @@ import { test, expect } from '@playwright/test'; test.describe('Profile Page - Experience Editor', () => { test.beforeEach(async ({ page }) => { - // Navigate to profile page assuming standard routing and auth is set up - await page.goto('/profile'); + // Navigate to root (Dashboard) which uses the globally authenticated state + await page.goto('/'); + + // Navigate to profile via UI + await page.getByText('Profile', { exact: true }).first().click(); + // Wait for the page to load by waiting for the CV & Preferences card - await page.waitForSelector('text=CV & Preferences'); + await expect(page.getByText('CV & Preferences')).toBeVisible({ timeout: 10000 }); }); test('should display toast when trying to add empty experience', async ({ page }) => { @@ -21,6 +25,6 @@ test.describe('Profile Page - Experience Editor', () => { // Verify toast appears const toast = page.locator('text=Please fill out the existing experience before adding a new one.'); - await expect(toast).toBeVisible(); + await expect(toast).toBeVisible({ timeout: 5000 }); }); }); From be121d89cc907b5d204b427563ec91489d8bc67b Mon Sep 17 00:00:00 2001 From: SagiEv Date: Wed, 9 Sep 2026 23:14:33 +0300 Subject: [PATCH 4/9] added cache for roles fetch --- frontend/src/hooks/useRolesBank.js | 6 +++++- frontend/tests/e2e/profile.spec.js | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useRolesBank.js b/frontend/src/hooks/useRolesBank.js index 4ceb6e3..017fa2e 100644 --- a/frontend/src/hooks/useRolesBank.js +++ b/frontend/src/hooks/useRolesBank.js @@ -8,6 +8,10 @@ export function useRolesBank() { queryFn: async () => { const response = await apiClient.get('/api/roles-bank'); return response.data || []; - } + }, + staleTime: Infinity, + gcTime: 1000 * 60 * 60 * 24, // 24 hours + refetchOnWindowFocus: false, + refetchOnMount: false, }); } diff --git a/frontend/tests/e2e/profile.spec.js b/frontend/tests/e2e/profile.spec.js index dff7cc5..bafb5af 100644 --- a/frontend/tests/e2e/profile.spec.js +++ b/frontend/tests/e2e/profile.spec.js @@ -5,6 +5,9 @@ test.describe('Profile Page - Experience Editor', () => { // Navigate to root (Dashboard) which uses the globally authenticated state await page.goto('/'); + // Ensure Dashboard is fully loaded before trying to click sidebar items + await expect(page.getByText('Dashboard', { exact: true }).first()).toBeVisible({ timeout: 10000 }); + // Navigate to profile via UI await page.getByText('Profile', { exact: true }).first().click(); From 79e8511fb1a3a9e1f3da6f8d09a76cd50b6cbb3b Mon Sep 17 00:00:00 2001 From: SagiEv Date: Wed, 9 Sep 2026 23:38:06 +0300 Subject: [PATCH 5/9] dropdown to see all prev experience --- frontend/src/components/ExperienceEditor.jsx | 45 +++++++++++++++++--- frontend/tests/e2e/profile.spec.js | 12 +++--- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ExperienceEditor.jsx b/frontend/src/components/ExperienceEditor.jsx index 3a8f7be..ceddb6c 100644 --- a/frontend/src/components/ExperienceEditor.jsx +++ b/frontend/src/components/ExperienceEditor.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useMemo } from 'react'; import { useRolesBank } from '../hooks/useRolesBank'; import { useToast } from './ToastProvider'; import { useConfirm } from './ConfirmProvider'; @@ -8,6 +8,22 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => { const { addToast } = useToast(); const confirm = useConfirm(); const localExperiences = experiences.filter(exp => exp.status === status); + const [showAll, setShowAll] = useState(false); + + // Calculate the most recent previous experience to display by default + const mostRecentIndex = useMemo(() => { + if (status !== 'previous' || localExperiences.length === 0) return -1; + let latestIdx = 0; + let latestDate = 0; + localExperiences.forEach((exp, i) => { + const dateVal = exp.end_date ? new Date(exp.end_date).getTime() : (exp.start_date ? new Date(exp.start_date).getTime() : 0); + if (dateVal > latestDate) { + latestDate = dateVal; + latestIdx = i; + } + }); + return latestIdx; + }, [localExperiences, status]); const handleAdd = async () => { if (localExperiences.length > 0) { @@ -124,9 +140,14 @@ const ExperienceEditor = ({ experiences = [], onChange, status }) => {
No {status} experience added.
)} - {localExperiences.map((exp, index) => ( -
-
+ {localExperiences.map((exp, index) => { + if (status === 'previous' && !showAll && localExperiences.length > 1 && index !== mostRecentIndex) { + return null; + } + + return ( +
+
Role