diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0ff2e..5c684cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,9 @@ jobs: - name: Verify Phase 0-to-Phase 1A migration compatibility run: ./supabase/scripts/migration-compatibility-test.sh + - name: Verify profile-core-to-experience migration compatibility + run: ./supabase/scripts/work-experience-migration-compatibility-test.sh + - name: Reset database from empty for test suite run: supabase db reset diff --git a/README.md b/README.md index 1119734..fbe5ff2 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ hackathons, scholarships, and other resume-building opportunities; compares them detailed saved profile; explains where the student is competitive and where they are not; and tracks the full lifecycle from "found it" to "applied" to "interviewed." -Phase 0 is implemented, and the first Phase 1A profile-core/education slice adds routed manual -profile and primary-education workflows: a React/TypeScript/Vite frontend, a local Supabase stack -(Postgres/Auth/PostgREST), the initial `profiles`/`education_entries` schema with Row Level -Security, and CI. See [Local development setup](#local-development-setup) below to run it. +Phase 0 is implemented. Phase 1A currently provides routed manual profile, primary-education, +and experience/research workflows: a React/TypeScript/Vite frontend, a local Supabase stack +(Postgres/Auth/PostgREST), `profiles`, `education_entries`, and `work_experience` protected by +Row Level Security, and CI. Projects, links, skills, preferences, and resume features remain +deferred. See [Local development setup](#local-development-setup) below to run it. This repository is public for portfolio, education, and review purposes — see [License status](#license-status) and [Contributing](#contributing) below before assuming more diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 61d0dee..f7487f5 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -34,6 +34,11 @@ vi.mock('./lib/educationRepository', () => ({ list: () => Promise.resolve({ data: [], error: null }), }, })) +vi.mock('./lib/experienceRepository', () => ({ + experienceRepository: { + list: () => Promise.resolve({ data: [], error: null }), + }, +})) vi.mock('./lib/profileReviewRepository', () => ({ profileReviewRepository: { state: () => Promise.resolve({ data: [], error: null }), diff --git a/app/src/App.tsx b/app/src/App.tsx index 6603508..f203aca 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,8 @@ import { ProfileLayout } from './pages/ProfileLayout' import { BasicProfilePage } from './pages/BasicProfilePage' import { EducationEditorPage } from './pages/EducationEditorPage' import { EducationListPage } from './pages/EducationListPage' +import { ExperienceEditorPage } from './pages/ExperienceEditorPage' +import { ExperienceListPage } from './pages/ExperienceListPage' import { ProfilePage } from './pages/ProfilePage' import { SignInPage } from './pages/SignInPage' @@ -97,6 +99,12 @@ const router = createBrowserRouter([ path: 'education/:educationId/edit', element: , }, + { path: 'experience', element: }, + { path: 'experience/new', element: }, + { + path: 'experience/:experienceId/edit', + element: , + }, ], }, ], diff --git a/app/src/AppRouter.test.tsx b/app/src/AppRouter.test.tsx index 9c422ac..71f3447 100644 --- a/app/src/AppRouter.test.tsx +++ b/app/src/AppRouter.test.tsx @@ -4,14 +4,14 @@ import { RouterProvider } from 'react-router/dom' import { describe, expect, it } from 'vitest' describe('router foundation', () => { - it('uses an in-memory router to honor a bookmarkable route', async () => { + it('uses an in-memory router to honor a bookmarkable nested route', async () => { const memoryRouter = createMemoryRouter( - [{ path: '/profile/education', element:

Education

}], - { initialEntries: ['/profile/education'] }, + [{ path: '/profile/experience/new', element:

Add experience

}], + { initialEntries: ['/profile/experience/new'] }, ) render() expect( - await screen.findByRole('heading', { name: 'Education' }), + await screen.findByRole('heading', { name: 'Add experience' }), ).toBeInTheDocument() }) }) diff --git a/app/src/index.css b/app/src/index.css index 8fd759b..848a0d3 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -56,7 +56,9 @@ label { font-size: 0.9rem; } -input { +input, +select, +textarea { font: inherit; padding: 0.5rem 0.6rem; border: 1px solid var(--border); @@ -65,6 +67,18 @@ input { color: var(--text); } +textarea { + min-height: 7rem; + resize: vertical; +} + +nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0 0 1.5rem; +} + button { font: inherit; padding: 0.5rem 0.9rem; diff --git a/app/src/lib/experienceRepository.ts b/app/src/lib/experienceRepository.ts new file mode 100644 index 0000000..8f0e56e --- /dev/null +++ b/app/src/lib/experienceRepository.ts @@ -0,0 +1,57 @@ +import { supabase } from './supabaseClient' +import type { ExperienceKind, WorkExperience } from './profileTypes' + +export type ExperienceInput = { + experience_kind: ExperienceKind + organization: string + role: string + location: string | null + start_year: number + start_month: number | null + end_year: number | null + end_month: number | null + is_current: boolean + description: string | null +} + +const columns = + 'id, user_id, experience_kind, organization, role, location, start_year, start_month, end_year, end_month, is_current, description' + +export const experienceRepository = { + async list(userId: string) { + return supabase + .from('work_experience') + .select(columns) + .eq('user_id', userId) + .order('is_current', { ascending: false }) + .order('start_year', { ascending: false }) + .order('start_month', { ascending: false }) + .order('created_at', { ascending: false }) + .returns() + }, + async get(id: string) { + return supabase + .from('work_experience') + .select(columns) + .eq('id', id) + .maybeSingle() + }, + async create(userId: string, input: ExperienceInput) { + return supabase + .from('work_experience') + .insert({ user_id: userId, ...input }) + .select(columns) + .maybeSingle() + }, + async update(id: string, input: ExperienceInput) { + return supabase + .from('work_experience') + .update(input) + .eq('id', id) + .select(columns) + .maybeSingle() + }, + async remove(id: string) { + return supabase.from('work_experience').delete().eq('id', id).select('id') + }, +} diff --git a/app/src/lib/profileCompleteness.test.ts b/app/src/lib/profileCompleteness.test.ts index fa16342..9782d90 100644 --- a/app/src/lib/profileCompleteness.test.ts +++ b/app/src/lib/profileCompleteness.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { completenessVersion, - evaluateEducationSlice, + evaluateExperienceSlice, } from './profileCompleteness' -describe('education completeness slice', () => { +describe('experience completeness slice', () => { it('is versioned and exposes named missing actions without a percentage', () => { - const checks = evaluateEducationSlice( + const checks = evaluateExperienceSlice( [], [ { @@ -20,8 +20,9 @@ describe('education completeness slice', () => { reviewed_content_revision: null, }, ], + [], ) - expect(completenessVersion).toBe('profile-completeness/v2-slice-education') + expect(completenessVersion).toBe('profile-completeness/v2-slice-experience') expect( checks.find((check) => check.id === 'primary_education'), ).toMatchObject({ @@ -35,12 +36,12 @@ describe('education completeness slice', () => { it('never treats missing, null, or stale review state as current', () => { expect( - evaluateEducationSlice([], []).find( + evaluateExperienceSlice([], [], []).find( (check) => check.id === 'basic_profile_review', )?.outcome, ).toBe('unconfirmed') expect( - evaluateEducationSlice( + evaluateExperienceSlice( [], [ { @@ -49,10 +50,11 @@ describe('education completeness slice', () => { reviewed_content_revision: null, }, ], + [], ).find((check) => check.id === 'basic_profile_review')?.outcome, ).toBe('unconfirmed') expect( - evaluateEducationSlice( + evaluateExperienceSlice( [], [ { @@ -61,7 +63,29 @@ describe('education completeness slice', () => { reviewed_content_revision: 1, }, ], + [], ).find((check) => check.id === 'basic_profile_review')?.outcome, ).toBe('unconfirmed') }) + + it('reports an empty reviewed experience section as present and deferred work as neutral', () => { + const checks = evaluateExperienceSlice( + [], + [ + { + section_key: 'experience', + content_revision: 0, + reviewed_content_revision: 0, + }, + ], + [], + ) + expect( + checks.find((check) => check.id === 'experience_review'), + ).toMatchObject({ availability: 'implemented', outcome: 'present' }) + expect(checks.find((check) => check.id === 'projects')).toMatchObject({ + availability: 'not_implemented', + outcome: null, + }) + }) }) diff --git a/app/src/lib/profileCompleteness.ts b/app/src/lib/profileCompleteness.ts index da6895e..8535664 100644 --- a/app/src/lib/profileCompleteness.ts +++ b/app/src/lib/profileCompleteness.ts @@ -1,7 +1,11 @@ -import type { EducationEntry, SectionState } from './profileTypes' +import type { + EducationEntry, + SectionState, + WorkExperience, +} from './profileTypes' export const completenessVersion = - 'profile-completeness/v2-slice-education' as const + 'profile-completeness/v2-slice-experience' as const export type CompletenessOutcome = 'present' | 'missing' | 'unconfirmed' export type CompletenessCheckId = | 'basic_profile_review' @@ -9,12 +13,20 @@ export type CompletenessCheckId = | 'degree_year' | 'graduation_timing' | 'education_review' + | 'experience_review' + | 'projects' + | 'skills' + | 'languages' + | 'preferences' + | 'eligibility' + | 'targets' export interface CompletenessCheck { id: CompletenessCheckId - outcome: CompletenessOutcome + availability: 'implemented' | 'not_implemented' + outcome: CompletenessOutcome | null action: string - href: string + href: string | null } function reviewed(state: SectionState[], section: SectionState['section_key']) { @@ -26,32 +38,41 @@ function reviewed(state: SectionState[], section: SectionState['section_key']) { ) } -export function evaluateEducationSlice( +export function evaluateExperienceSlice( entries: EducationEntry[], state: SectionState[], + experience: WorkExperience[], ): CompletenessCheck[] { + // Projects are deferred, so experience rows do not yet drive the final + // experience-or-project evidence-strength check. Keep the evaluator input + // explicit for the next slice rather than duplicating overview logic. + void experience const primary = entries.find((entry) => entry.is_primary) return [ { id: 'basic_profile_review', + availability: 'implemented', outcome: reviewed(state, 'basic_profile') ? 'present' : 'unconfirmed', action: 'Review basic profile', href: '/profile/basic', }, { id: 'primary_education', + availability: 'implemented', outcome: primary ? 'present' : 'missing', action: 'Add or select primary education', href: '/profile/education', }, { id: 'degree_year', + availability: 'implemented', outcome: primary?.degree_year ? 'present' : 'missing', action: 'Add degree year', href: '/profile/education', }, { id: 'graduation_timing', + availability: 'implemented', outcome: primary?.expected_graduation_month && primary?.expected_graduation_year ? 'present' @@ -61,9 +82,31 @@ export function evaluateEducationSlice( }, { id: 'education_review', + availability: 'implemented', outcome: reviewed(state, 'education') ? 'present' : 'unconfirmed', action: 'Review education', href: '/profile/education', }, + { + id: 'experience_review', + availability: 'implemented', + outcome: reviewed(state, 'experience') ? 'present' : 'unconfirmed', + action: 'Review experience', + href: '/profile/experience', + }, + ...[ + ['projects', 'Projects are not yet available.'], + ['skills', 'Skills are not yet available.'], + ['languages', 'Languages are not yet available.'], + ['preferences', 'Preferences are not yet available.'], + ['eligibility', 'Work eligibility is not yet available.'], + ['targets', 'Targets are not yet available.'], + ].map(([id, action]) => ({ + id: id as CompletenessCheckId, + availability: 'not_implemented' as const, + outcome: null, + action, + href: null, + })), ] } diff --git a/app/src/lib/profileReviewRepository.ts b/app/src/lib/profileReviewRepository.ts index a0a0cc4..01d21ea 100644 --- a/app/src/lib/profileReviewRepository.ts +++ b/app/src/lib/profileReviewRepository.ts @@ -1,9 +1,9 @@ import { supabase } from './supabaseClient' -import type { SectionState } from './profileTypes' +import type { SectionKey, SectionState } from './profileTypes' export function reviewStatus( state: SectionState[] | null, - section: SectionState['section_key'], + section: SectionKey, ): 'current' | 'stale' | 'not_reviewed' { const item = state?.find((entry) => entry.section_key === section) if (!item || item.reviewed_content_revision === null) return 'not_reviewed' @@ -28,7 +28,7 @@ export const profileReviewRepository = { if (reviews.error) return { data: null, error: reviews.error } return { data: (revisions.data ?? []).map((revision) => ({ - section_key: revision.section_key as SectionState['section_key'], + section_key: revision.section_key as SectionKey, content_revision: revision.content_revision, reviewed_content_revision: reviews.data?.find( @@ -38,7 +38,7 @@ export const profileReviewRepository = { error: null, } }, - async review(sectionKey: SectionState['section_key']) { + async review(sectionKey: SectionKey) { return supabase.rpc('review_profile_section', { requested_section_key: sectionKey, }) diff --git a/app/src/lib/profileTypes.ts b/app/src/lib/profileTypes.ts index eeaa4ca..37c5245 100644 --- a/app/src/lib/profileTypes.ts +++ b/app/src/lib/profileTypes.ts @@ -1,6 +1,16 @@ export type EducationStatus = 'current' | 'completed' | 'paused' | 'withdrawn' | 'unknown' +export type ExperienceKind = + | 'employment' + | 'internship' + | 'research' + | 'volunteering' + | 'student_leadership' + | 'other' + +export type SectionKey = 'basic_profile' | 'education' | 'experience' + export interface ProfileRow { user_id: string preferred_name: string | null @@ -23,8 +33,23 @@ export interface EducationEntry { end_date: string | null } +export interface WorkExperience { + id: string + user_id: string + experience_kind: ExperienceKind + organization: string + role: string + location: string | null + start_year: number + start_month: number | null + end_year: number | null + end_month: number | null + is_current: boolean + description: string | null +} + export interface SectionState { - section_key: 'basic_profile' | 'education' + section_key: SectionKey content_revision: number reviewed_content_revision: number | null } diff --git a/app/src/lib/profileValidation.test.ts b/app/src/lib/profileValidation.test.ts new file mode 100644 index 0000000..5b62bb6 --- /dev/null +++ b/app/src/lib/profileValidation.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { normalizeExperience, validateExperience } from './profileValidation' + +const valid = { + experience_kind: 'internship', + organization: 'Example Organization', + role: 'Engineering Intern', + location: '', + start_year: '2025', + start_month: '', + end_year: '', + end_month: '', + is_current: 'false', + description: '', +} + +describe('experience validation', () => { + it('normalizes optional plain-text fields without inventing month precision', () => { + expect( + normalizeExperience({ + ...valid, + organization: ' Example Organization ', + role: ' Engineering Intern ', + }), + ).toMatchObject({ + organization: 'Example Organization', + role: 'Engineering Intern', + start_month: null, + end_month: null, + }) + }) + + it('rejects invalid kinds, text, years, and months', () => { + expect( + validateExperience({ + ...valid, + experience_kind: 'unknown', + organization: ' ', + role: ' ', + start_year: '1800', + start_month: '13', + end_year: '2200', + }), + ).toMatchObject({ + experience_kind: expect.any(String), + organization: expect.any(String), + role: expect.any(String), + start_year: expect.any(String), + start_month: expect.any(String), + end_year: expect.any(String), + }) + }) + + it('rejects impossible current and ordered end periods', () => { + expect( + validateExperience({ + ...valid, + start_month: '7', + end_year: '2025', + end_month: '6', + }).end_period, + ).toMatch(/End month cannot precede/i) + expect( + validateExperience({ + ...valid, + is_current: 'true', + end_year: '2026', + }).end_period, + ).toMatch(/Current experience cannot have an end period/i) + }) + + it('accepts honest same-year partial months and an unknown non-current end', () => { + expect( + validateExperience({ + ...valid, + end_year: '2025', + end_month: '1', + }), + ).toEqual({}) + }) +}) diff --git a/app/src/lib/profileValidation.ts b/app/src/lib/profileValidation.ts index d6b26f9..5c77f00 100644 --- a/app/src/lib/profileValidation.ts +++ b/app/src/lib/profileValidation.ts @@ -1,4 +1,5 @@ import type { EducationInput } from './educationRepository' +import type { ExperienceInput } from './experienceRepository' export type FieldErrors = Record const trimmed = (value: string) => value.trim() @@ -99,3 +100,95 @@ export function validateEducation(values: Record): FieldErrors { errors.graduation = 'Expected graduation cannot precede the start month.' return errors } + +const experienceKinds: ExperienceInput['experience_kind'][] = [ + 'employment', + 'internship', + 'research', + 'volunteering', + 'student_leadership', + 'other', +] + +export function normalizeExperience( + values: Record, +): ExperienceInput { + const number = (value: string) => (value === '' ? null : Number(value)) + return { + experience_kind: + values.experience_kind as ExperienceInput['experience_kind'], + organization: trimmed(values.organization), + role: trimmed(values.role), + location: optional(values.location), + start_year: Number(values.start_year), + start_month: number(values.start_month), + end_year: number(values.end_year), + end_month: number(values.end_month), + is_current: values.is_current === 'true', + description: optional(values.description), + } +} + +export function validateExperience( + values: Record, +): FieldErrors { + const errors: FieldErrors = {} + if ( + !experienceKinds.includes( + values.experience_kind as ExperienceInput['experience_kind'], + ) + ) + errors.experience_kind = 'Choose an experience kind.' + if (!trimmed(values.organization)) + errors.organization = 'Organization is required.' + if (trimmed(values.organization).length > 200) + errors.organization = 'Organization must be 200 characters or fewer.' + if (!trimmed(values.role)) errors.role = 'Role is required.' + if (trimmed(values.role).length > 160) + errors.role = 'Role must be 160 characters or fewer.' + if (trimmed(values.location).length > 160) + errors.location = 'Location must be 160 characters or fewer.' + if (trimmed(values.description).length > 2000) + errors.description = 'Description must be 2,000 characters or fewer.' + + const startYear = Number(values.start_year) + if ( + !values.start_year || + !Number.isInteger(startYear) || + startYear < 1900 || + startYear > 2100 + ) + errors.start_year = 'Start year must be between 1900 and 2100.' + + const monthIsValid = (value: string) => + Number.isInteger(Number(value)) && Number(value) >= 1 && Number(value) <= 12 + if (values.start_month && !monthIsValid(values.start_month)) + errors.start_month = 'Start month must be between 1 and 12.' + if (values.end_month && !monthIsValid(values.end_month)) + errors.end_month = 'End month must be between 1 and 12.' + + const endYear = values.end_year ? Number(values.end_year) : null + if ( + endYear !== null && + (!Number.isInteger(endYear) || endYear < 1900 || endYear > 2100) + ) + errors.end_year = 'End year must be between 1900 and 2100.' + if (values.end_month && !values.end_year) + errors.end_month = 'Enter an end year before adding an end month.' + + if (values.is_current === 'true' && (values.end_year || values.end_month)) + errors.end_period = 'Current experience cannot have an end period.' + if (endYear !== null && Number.isInteger(startYear)) { + if (endYear < startYear) + errors.end_period = 'End year cannot precede start year.' + if ( + endYear === startYear && + values.start_month && + values.end_month && + Number(values.end_month) < Number(values.start_month) + ) + errors.end_period = + 'End month cannot precede start month in the same year.' + } + return errors +} diff --git a/app/src/pages/ExperienceEditorPage.test.tsx b/app/src/pages/ExperienceEditorPage.test.tsx new file mode 100644 index 0000000..fd1613c --- /dev/null +++ b/app/src/pages/ExperienceEditorPage.test.tsx @@ -0,0 +1,178 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ExperienceEditorPage } from './ExperienceEditorPage' + +const get = vi.fn() +const update = vi.fn() +const create = vi.fn() + +vi.mock('../contexts/AuthContext', () => ({ + useAuth: () => ({ session: { user: { id: 'user-1' } } }), +})) +vi.mock('../lib/experienceRepository', () => ({ + experienceRepository: { + get: (...args: unknown[]) => get(...args), + update: (...args: unknown[]) => update(...args), + create: (...args: unknown[]) => create(...args), + }, +})) + +const entry = { + id: 'experience-1', + user_id: 'user-1', + experience_kind: 'research' as const, + organization: 'Example Lab', + role: 'Research Assistant', + location: null, + start_year: 2025, + start_month: null, + end_year: null, + end_month: null, + is_current: true, + description: 'Synthetic description', +} + +function renderEdit() { + return render( + + + } + /> + Experience list

} /> +
+
, + ) +} + +function renderCreate() { + return render( + + + } + /> + Experience list

} /> +
+
, + ) +} + +afterEach(() => vi.resetAllMocks()) + +describe('ExperienceEditorPage', () => { + it('shows loading rather than a submit-capable form while editing loads', () => { + get.mockReturnValue(new Promise(() => {})) + renderEdit() + expect(screen.getByText('Loading…')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /save experience/i }), + ).not.toBeInTheDocument() + }) + + it('renders a bookmarkable create form with accessible labels', () => { + renderCreate() + expect( + screen.getByRole('heading', { name: 'Add experience' }), + ).toBeInTheDocument() + expect(screen.getByLabelText('Organization')).toBeInTheDocument() + expect(screen.getByLabelText('Start month (optional)')).toBeInTheDocument() + }) + + it('populates an existing entry and keeps its current state', async () => { + get.mockResolvedValue({ data: entry, error: null }) + renderEdit() + expect(await screen.findByDisplayValue('Example Lab')).toBeInTheDocument() + expect(screen.getByLabelText(/This experience is current/)).toBeChecked() + expect(screen.getByLabelText('End year (optional)')).toBeDisabled() + }) + + it('does not render a submit-capable form for a missing entry', async () => { + get.mockResolvedValue({ data: null, error: null }) + renderEdit() + expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /save experience/i }), + ).not.toBeInTheDocument() + }) + + it('offers retry after a safe load failure', async () => { + get.mockResolvedValue({ data: null, error: { code: '', message: '' } }) + renderEdit() + expect(await screen.findByRole('alert')).toHaveTextContent(/try again/i) + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + it('shows an accessible validation summary for required and period fields', async () => { + const user = userEvent.setup() + renderCreate() + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect((await screen.findAllByRole('alert'))[0]).toHaveTextContent( + /Organization is required/i, + ) + expect(screen.getByLabelText('Organization')).toHaveAttribute( + 'aria-describedby', + 'organization-error', + ) + expect(screen.getByRole('link', { name: /start year/i })).toHaveAttribute( + 'href', + '#start_year', + ) + }) + + it('clears and disables end fields when current is selected', async () => { + const user = userEvent.setup() + renderCreate() + await user.type(screen.getByLabelText('End year (optional)'), '2026') + await user.type(screen.getByLabelText('End month (optional)'), '2') + await user.click(screen.getByLabelText(/This experience is current/)) + expect(screen.getByLabelText('End year (optional)')).toHaveValue(null) + expect(screen.getByLabelText('End year (optional)')).toBeDisabled() + expect(screen.getByLabelText('End month (optional)')).toBeDisabled() + }) + + it('keeps values after a recoverable save failure', async () => { + get.mockResolvedValue({ data: entry, error: null }) + update.mockResolvedValue({ data: null, error: { code: '', message: '' } }) + const user = userEvent.setup() + renderEdit() + const organization = await screen.findByLabelText('Organization') + await user.clear(organization) + await user.type(organization, 'Changed Lab') + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect(await screen.findByRole('alert')).toHaveTextContent(/try again/i) + expect(screen.getByDisplayValue('Changed Lab')).toBeInTheDocument() + }) + + it('treats a zero-row update as a missing entry', async () => { + get.mockResolvedValue({ data: entry, error: null }) + update.mockResolvedValue({ data: null, error: null }) + const user = userEvent.setup() + renderEdit() + await screen.findByDisplayValue('Example Lab') + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument() + }) + + it('creates an entry using normalized form values', async () => { + create.mockResolvedValue({ data: entry, error: null }) + const user = userEvent.setup() + renderCreate() + await user.type(screen.getByLabelText('Organization'), ' Example Lab ') + await user.type(screen.getByLabelText('Role'), ' Research Assistant ') + await user.type(screen.getByLabelText('Start year'), '2025') + await user.click(screen.getByRole('button', { name: 'Save experience' })) + expect(create).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + organization: 'Example Lab', + role: 'Research Assistant', + }), + ) + expect(await screen.findByText('Experience list')).toBeInTheDocument() + }) +}) diff --git a/app/src/pages/ExperienceEditorPage.tsx b/app/src/pages/ExperienceEditorPage.tsx new file mode 100644 index 0000000..5a9fd12 --- /dev/null +++ b/app/src/pages/ExperienceEditorPage.tsx @@ -0,0 +1,280 @@ +import { Link, useNavigate, useParams } from 'react-router' +import { useCallback, useEffect, useState, type FormEvent } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { experienceRepository } from '../lib/experienceRepository' +import { + normalizeExperience, + validateExperience, +} from '../lib/profileValidation' +import { errorMessage, safeError } from '../lib/profileTypes' + +const empty = { + experience_kind: 'employment', + organization: '', + role: '', + location: '', + start_year: '', + start_month: '', + end_year: '', + end_month: '', + is_current: 'false', + description: '', +} + +const kinds = [ + ['employment', 'Employment'], + ['internship', 'Internship'], + ['research', 'Research'], + ['volunteering', 'Volunteering'], + ['student_leadership', 'Student organization or leadership'], + ['other', 'Other'], +] + +export function ExperienceEditorPage() { + const { experienceId } = useParams() + const navigate = useNavigate() + const { session } = useAuth() + const [values, setValues] = useState>(empty) + const [errors, setErrors] = useState>({}) + const [message, setMessage] = useState(null) + const [saving, setSaving] = useState(false) + const [loadState, setLoadState] = useState< + 'loading' | 'loaded' | 'missing' | 'error' + >(experienceId ? 'loading' : 'loaded') + + const load = useCallback(async () => { + if (!experienceId) return + const result = await experienceRepository.get(experienceId) + if (result.error) { + const kind = safeError(result.error) + setLoadState(kind === 'missing' ? 'missing' : 'error') + setMessage( + kind === 'missing' + ? 'This experience entry no longer exists.' + : errorMessage(kind), + ) + return + } + if (!result.data) { + setLoadState('missing') + setMessage('This experience entry no longer exists.') + return + } + const entry = result.data + setValues({ + experience_kind: entry.experience_kind, + organization: entry.organization, + role: entry.role, + location: entry.location ?? '', + start_year: entry.start_year.toString(), + start_month: entry.start_month?.toString() ?? '', + end_year: entry.end_year?.toString() ?? '', + end_month: entry.end_month?.toString() ?? '', + is_current: entry.is_current ? 'true' : 'false', + description: entry.description ?? '', + }) + setLoadState('loaded') + }, [experienceId]) + + useEffect(() => { + void load() + }, [load]) + + function field(name: string, value: string) { + setValues((current) => ({ ...current, [name]: value })) + } + + function setCurrent(isCurrent: boolean) { + setValues((current) => ({ + ...current, + is_current: isCurrent ? 'true' : 'false', + end_year: isCurrent ? '' : current.end_year, + end_month: isCurrent ? '' : current.end_month, + })) + } + + async function save(event: FormEvent) { + event.preventDefault() + const next = validateExperience(values) + setErrors(next) + if (Object.keys(next).length || loadState !== 'loaded' || saving) return + setSaving(true) + const input = normalizeExperience(values) + const result = experienceId + ? await experienceRepository.update(experienceId, input) + : await experienceRepository.create(session!.user.id, input) + if (result.error) { + setMessage(errorMessage(safeError(result.error))) + setSaving(false) + return + } + if (!result.data) { + setMessage( + 'This experience entry no longer exists. Return to experience.', + ) + setLoadState('missing') + setSaving(false) + return + } + navigate('/profile/experience', { replace: true }) + } + + const input = ( + name: string, + label: string, + type = 'text', + disabled = false, + ) => ( +
+ + field(name, event.target.value)} + aria-invalid={Boolean(errors[name])} + aria-describedby={errors[name] ? `${name}-error` : undefined} + /> + {errors[name] && ( + + )} +
+ ) + + if (loadState === 'loading') + return ( +
+

Edit experience

+

Loading…

+
+ ) + if (loadState === 'missing' || loadState === 'error') + return ( +
+

Edit experience

+

{message}

+

+ Return to experience +

+ {loadState === 'error' && ( + + )} +
+ ) + + const current = values.is_current === 'true' + const summaryFields = Object.keys(errors).map((name) => + name === 'end_period' ? 'experience-period' : name, + ) + return ( +
+

{experienceId ? 'Edit experience' : 'Add experience'}

+
+ {summaryFields.length > 0 && ( +
+

Please correct the fields below.

+ +
+ )} +
+ + + {errors.experience_kind && ( + + )} +
+ {input('organization', 'Organization')} + {input('role', 'Role')} + {input('location', 'Location')} +
+ Experience period + {input('start_year', 'Start year', 'number')} + {input('start_month', 'Start month (optional)', 'number')} +
+ +
+ {input('end_year', 'End year (optional)', 'number', current)} +
+ + field('end_month', event.target.value)} + aria-invalid={Boolean(errors.end_month)} + aria-describedby={ + errors.end_month ? 'end_month-error' : undefined + } + /> + {errors.end_month && ( + + )} +
+ {errors.end_period &&

{errors.end_period}

} +
+
+ +