diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index cfe2f65..8a94f64 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -4,6 +4,7 @@ import { Settings } from './Settings' import * as hooks from '@/hooks' import { useWorkspace } from '@/context' import * as api from '@/services/api' +import { ApiRequestError } from '@/services/errors' import { LanguageProvider } from '@/i18n' vi.mock('@/hooks') @@ -82,10 +83,29 @@ describe('Settings', () => { createdAt: '2026-01-01T00:00:00', } + const selfMember = { + uuid: 'pd-1', + workspaceId: 'ws-1', + name: 'Test Parent', + salaryType: 'monthly' as const, + employmentStatus: 'employed' as const, + createdAt: '2026-01-01T00:00:00', + } + + const partnerMember = { + uuid: 'pd-2', + workspaceId: 'ws-1', + name: 'Sam', + salaryType: 'monthly' as const, + employmentStatus: 'employed' as const, + createdAt: '2026-01-01T00:00:00', + } + let mockUpsertVacationDays: ReturnType let mockUpdateChildDetails: ReturnType let mockUpsertDayOverride: ReturnType let mockRemoveDayOverride: ReturnType + let mockRefreshMembers: ReturnType const mockCalendarWindow = { startYear: CURRENT_YEAR, @@ -101,8 +121,17 @@ describe('Settings', () => { mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined) mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined) mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined) + mockRefreshMembers = vi.fn().mockResolvedValue(undefined) vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any) + // Default: solo workspace (only the logged-in user) — the add-partner + // form should be visible. Tests that need a linked partner override this. + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ + data: [selfMember], + isLoading: false, + error: null, + refresh: mockRefreshMembers, + } as any) vi.mocked(hooks.useVacationDays).mockReturnValue({ data: mockVacationDays, isLoading: false, @@ -131,6 +160,8 @@ describe('Settings', () => { vi.mocked(api.updateWorkspace).mockResolvedValue({ ...mockWorkspaceContext.workspace } as any) vi.mocked(api.updateParentSettings).mockResolvedValue({} as any) vi.mocked(api.addPartner).mockResolvedValue({} as any) + vi.mocked(api.renameWorkspaceParent).mockResolvedValue({ ...partnerMember } as any) + vi.mocked(api.removeWorkspaceParent).mockResolvedValue(undefined) }) it('renders loading spinner while workspace loads', () => { @@ -274,6 +305,166 @@ describe('Settings', () => { fireEvent.click(screen.getByTestId('add-partner-button')) await waitFor(() => expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Partner Name')) + await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled()) + }) + + it('shows an add-partner error without a raw message_key when the name is rejected', async () => { + vi.mocked(api.addPartner).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.invalidName', + message: 'workspace.parents.invalidName', + details: { messageKey: 'workspace.parents.invalidName', data: {} }, + }), + ) + renderSettings() + fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'X' } }) + fireEvent.click(screen.getByTestId('add-partner-button')) + + const message = await screen.findByTestId('save-message') + expect(message).toHaveTextContent('Partnerns namn är ogiltigt.') + }) + + describe('workspace members', () => { + beforeEach(() => { + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ + data: [selfMember, partnerMember], + isLoading: false, + error: null, + refresh: mockRefreshMembers, + } as any) + }) + + it('shows the linked partner and hides the add-partner form', () => { + renderSettings() + expect(screen.getByTestId('member-name-pd-2')).toHaveTextContent('Sam') + expect(screen.queryByTestId('add-partner-button')).not.toBeInTheDocument() + }) + + it('shows a "you" badge on the logged-in user\'s own row', () => { + renderSettings() + expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('Test Parent') + expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('(Du)') + }) + + it('does not offer a remove button on the logged-in user\'s own row', () => { + renderSettings() + expect(screen.queryByTestId('remove-button-pd-1')).not.toBeInTheDocument() + }) + + it('renames a member and refreshes the member list', async () => { + renderSettings() + fireEvent.click(screen.getByTestId('rename-button-pd-2')) + fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Samuel' } }) + fireEvent.click(screen.getByTestId('rename-save-pd-2')) + + await waitFor(() => + expect(api.renameWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2', 'Samuel'), + ) + await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled()) + }) + + it('allows renaming the logged-in user\'s own profile too', () => { + renderSettings() + fireEvent.click(screen.getByTestId('rename-button-pd-1')) + expect(screen.getByTestId('rename-input-pd-1')).toBeInTheDocument() + }) + + it('cancels a rename without calling the API', () => { + renderSettings() + fireEvent.click(screen.getByTestId('rename-button-pd-2')) + fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Something else' } }) + fireEvent.click(screen.getByTestId('rename-cancel-pd-2')) + + expect(screen.queryByTestId('rename-input-pd-2')).not.toBeInTheDocument() + expect(api.renameWorkspaceParent).not.toHaveBeenCalled() + }) + + it('requires an in-app confirmation before removing a member', async () => { + renderSettings() + fireEvent.click(screen.getByTestId('remove-button-pd-2')) + + expect(api.removeWorkspaceParent).not.toHaveBeenCalled() + expect(screen.getByTestId('remove-confirm-pd-2')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) + await waitFor(() => + expect(api.removeWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2'), + ) + }) + + it('cancelling the remove confirmation does not call the API', () => { + renderSettings() + fireEvent.click(screen.getByTestId('remove-button-pd-2')) + fireEvent.click(screen.getByTestId('remove-cancel-pd-2')) + + expect(screen.queryByTestId('remove-confirm-pd-2')).not.toBeInTheDocument() + expect(api.removeWorkspaceParent).not.toHaveBeenCalled() + }) + + it('refreshes the member list after a successful removal', async () => { + renderSettings() + fireEvent.click(screen.getByTestId('remove-button-pd-2')) + fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) + + await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled()) + }) + + it('shows the has-login refusal without a raw message_key', async () => { + vi.mocked(api.removeWorkspaceParent).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.remove.hasLogin', + message: 'workspace.parents.remove.hasLogin', + details: { messageKey: 'workspace.parents.remove.hasLogin', data: {} }, + }), + ) + renderSettings() + fireEvent.click(screen.getByTestId('remove-button-pd-2')) + fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) + + const message = await screen.findByTestId('member-action-error-pd-2') + expect(message).toHaveTextContent('den här personen har ett eget konto och egen inloggning') + expect(message).not.toHaveTextContent('workspace.parents.remove.hasLogin') + }) + + it('names each blocker on a hasDependentData refusal, not a raw message_key', async () => { + vi.mocked(api.removeWorkspaceParent).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.remove.hasDependentData', + message: 'workspace.parents.remove.hasDependentData', + details: { + messageKey: 'workspace.parents.remove.hasDependentData', + data: { blockers: ['leave_wishes', 'planned_leave'] }, + }, + }), + ) + renderSettings() + fireEvent.click(screen.getByTestId('remove-button-pd-2')) + fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) + + const message = await screen.findByTestId('member-action-error-pd-2') + expect(message).toHaveTextContent('önskemål om ledighet') + expect(message).toHaveTextContent('planerade lediga dagar') + expect(message).not.toHaveTextContent('hasDependentData') + }) + + it('shows the remove-self refusal without a raw message_key', async () => { + // Defence in depth: the UI never renders a remove button for the + // caller's own row, but the handler still routes a self-removal + // refusal from the backend through the same safe error mapping. + vi.mocked(api.removeWorkspaceParent).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.remove.self', + message: 'workspace.parents.remove.self', + details: { messageKey: 'workspace.parents.remove.self', data: {} }, + }), + ) + renderSettings() + fireEvent.click(screen.getByTestId('remove-button-pd-2')) + fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) + + const message = await screen.findByTestId('member-action-error-pd-2') + expect(message).not.toHaveTextContent('workspace.parents.remove.self') + }) }) it('toggles week numbers', () => { diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 8b0c164..ed4137e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,8 +1,16 @@ import { ReactNode, useEffect, useMemo, useState } from 'react' import { useWorkspace } from '@/context' -import { useVacationDays, useShowWeekNumbers, useChildSettings, useDayOverrides, useCalendarDays } from '@/hooks' +import { + useVacationDays, + useShowWeekNumbers, + useChildSettings, + useDayOverrides, + useCalendarDays, + useWorkspaceMembers, +} from '@/hooks' import * as api from '@/services/api' -import { useTranslation, type Locale } from '@/i18n' +import { isApiError } from '@/services/errors' +import { useTranslation, type Locale, type TranslationKey } from '@/i18n' import { Layout } from '@/components/layout/Layout' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' @@ -26,6 +34,56 @@ function isEmploymentStatus(value: string): value is EmploymentStatus { return value === 'employed' || value === 'jobseeking' || value === 'none' } +// Backend `message_key`s for the parent add/rename/remove endpoints (see +// workspace_service.py) mapped to real translation keys — same idiom as +// `PlanResults.tsx`'s `WARNING_BODY_KEYS` etc. A key not present here is +// intentionally never shown raw to the user (§ "Render the refusal cases +// properly") — callers fall back to a generic message instead. +const PARTNER_ERROR_KEYS: Record = { + 'workspace.notFound': 'settings.partnerActionErrorNotFound', + 'workspace.parents.notFound': 'settings.partnerActionErrorNotFound', + 'workspace.parents.invalidName': 'settings.partnerNameInvalid', + 'workspace.parents.remove.self': 'settings.removePartnerErrorSelf', + 'workspace.parents.remove.hasLogin': 'settings.removePartnerErrorHasLogin', +} + +// `hasDependentData`'s `data.blockers` is a subset of these four kinds — +// each gets its own i18n label so the refusal message can name what's +// actually blocking removal instead of a generic "can't remove". +const BLOCKER_KEYS: Record = { + leave_wishes: 'settings.removeBlockerLeaveWishes', + planned_leave: 'settings.removeBlockerPlannedLeave', + parental_leave: 'settings.removeBlockerParentalLeave', + vacation_days: 'settings.removeBlockerVacationDays', +} + +function isBlockerList(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === 'string') +} + +/** Turns a caught error from an add/rename/remove-partner call into a safe, + * translated message. Never surfaces a raw `message_key` — an unrecognised + * structured error falls back to `fallback`, same as a non-API error would. */ +function describePartnerError(err: unknown, t: (key: TranslationKey, params?: Record) => string, fallback: string): string { + if (isApiError(err)) { + const details = err.error.details as { messageKey?: string; data?: Record } | undefined + const messageKey = details?.messageKey + if (messageKey === 'workspace.parents.remove.hasDependentData') { + const blockers = isBlockerList(details?.data?.blockers) ? details.data.blockers : [] + const blockerLabels = blockers + .map((b) => (BLOCKER_KEYS[b] ? t(BLOCKER_KEYS[b]) : b)) + .join(', ') + return t('settings.removePartnerErrorHasDependentData', { blockers: blockerLabels }) + } + if (messageKey && PARTNER_ERROR_KEYS[messageKey]) { + return t(PARTNER_ERROR_KEYS[messageKey]) + } + // A structured error we don't recognise — never show the raw key. + if (messageKey) return fallback + } + return err instanceof Error ? err.message : fallback +} + /** Current year through (at minimum) the year the child turns 4. */ function computeVacationYearRange(birthDate: string | undefined, currentYear: number): number[] { let endYear = currentYear @@ -47,6 +105,11 @@ export function Settings(): ReactNode { const { t, explicitLocale, setLocale } = useTranslation() const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace() const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '') + const { + data: members, + isLoading: membersLoading, + refresh: refreshMembers, + } = useWorkspaceMembers(workspace?.id ?? null) const { data: childDetails, isLoading: childDetailsLoading, @@ -74,6 +137,10 @@ export function Settings(): ReactNode { const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [isSaving, setIsSaving] = useState(false) const [newPartnerName, setNewPartnerName] = useState('') + const [renamingUuid, setRenamingUuid] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [removeConfirmUuid, setRemoveConfirmUuid] = useState(null) + const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null) const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading @@ -94,6 +161,7 @@ export function Settings(): ReactNode { }, [workspace?.planningGoal, workspace?.planningMode]) const selectedChild = children.find((c) => c.id === selectedChildId) + const hasPartner = members.some((m) => m.uuid !== personalDataUuid) const currentYear = new Date().getFullYear() const vacationYears = useMemo( @@ -237,11 +305,70 @@ export function Settings(): ReactNode { setIsSaving(true) setSaveMessage(null) await api.addPartner(workspace.id, newPartnerName) - await refreshContext() + await refreshMembers() setNewPartnerName('') showMessage('success', t('settings.addPartnerSuccess')) } catch (err) { - showMessage('error', err instanceof Error ? err.message : t('settings.addPartnerError')) + showMessage('error', describePartnerError(err, t, t('settings.addPartnerError'))) + } finally { + setIsSaving(false) + } + } + + const handleStartRename = (member: { uuid: string; name: string }) => { + setMemberActionError(null) + setRemoveConfirmUuid(null) + setRenamingUuid(member.uuid) + setRenameValue(member.name) + } + + const handleCancelRename = () => { + setRenamingUuid(null) + setRenameValue('') + } + + const handleSaveRename = async (uuid: string) => { + if (!workspace?.id || !renameValue.trim()) { + setMemberActionError({ uuid, text: t('settings.partnerNameRequired') }) + return + } + try { + setIsSaving(true) + setMemberActionError(null) + await api.renameWorkspaceParent(workspace.id, uuid, renameValue) + await refreshMembers() + if (uuid === personalDataUuid) { + await refreshContext() + } + setRenamingUuid(null) + setRenameValue('') + } catch (err) { + setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.renamePartnerError')) }) + } finally { + setIsSaving(false) + } + } + + const handleRequestRemove = (uuid: string) => { + setMemberActionError(null) + setRenamingUuid(null) + setRemoveConfirmUuid(uuid) + } + + const handleCancelRemove = () => { + setRemoveConfirmUuid(null) + } + + const handleConfirmRemove = async (uuid: string) => { + if (!workspace?.id) return + try { + setIsSaving(true) + setMemberActionError(null) + await api.removeWorkspaceParent(workspace.id, uuid) + setRemoveConfirmUuid(null) + await refreshMembers() + } catch (err) { + setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.removePartnerError')) }) } finally { setIsSaving(false) } @@ -395,25 +522,133 @@ export function Settings(): ReactNode {

{t('settings.partnerSectionTitle')}

-

{t('settings.partnerIntro')}

-
- setNewPartnerName(e.target.value)} - disabled={isSaving} - className="settings-page__partner-input" - /> - -
+

{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}

+ + {membersLoading ? ( +

{t('settings.membersLoading')}

+ ) : ( +
    + {members.map((member) => { + const isSelf = member.uuid === personalDataUuid + const isRenaming = renamingUuid === member.uuid + const isConfirmingRemove = removeConfirmUuid === member.uuid + return ( +
  • + {isRenaming ? ( + <> + setRenameValue(e.target.value)} + disabled={isSaving} + className="settings-page__partner-input" + data-testid={`rename-input-${member.uuid}`} + /> + + + + ) : ( + <> + + {member.name} + {isSelf && ( + {t('settings.youBadge')} + )} + + + {!isSelf && + (isConfirmingRemove ? ( + + {t('settings.removeConfirmPrompt', { name: member.name })} + + + + ) : ( + + ))} + + )} + {memberActionError?.uuid === member.uuid && ( +

    + {memberActionError.text} +

    + )} +
  • + ) + })} +
+ )} + + {!hasPartner && ( +
+ setNewPartnerName(e.target.value)} + disabled={isSaving} + className="settings-page__partner-input" + /> + +
+ )}
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 2ce4ed7..22e6b4b 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -829,6 +829,68 @@ describe('endpoint functions (representative coverage)', () => { expect(result.targetWeeks).toBe(8) }) + it('updateLeaveWish sends every provided field, snake_cased', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: 'lw1', + workspace_id: 'ws1', + child_id: 'c1', + name: 'Höst', + window_start_month: 9, + window_start_day: 1, + window_end_month: 10, + window_end_day: 1, + target_weeks: 4, + recurrence: 'once', + first_year: 2027, + last_year: 2027, + priority: 2, + parent_mode: 'specific', + parent_personal_data_uuid: 'pd1', + goal_override: 'max_time', + window_mode: 'fixed', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }), + }) + + await api.updateLeaveWish('lw1', { + childId: 'c1', + name: 'Höst', + windowStartMonth: 9, + windowStartDay: 1, + windowEndMonth: 10, + windowEndDay: 1, + targetWeeks: 4, + recurrence: 'once', + firstYear: 2027, + lastYear: 2027, + priority: 2, + parentMode: 'specific', + parentPersonalDataUuid: 'pd1', + goalOverride: 'max_time', + }) + + expect(lastRequestBody()).toEqual({ + child_id: 'c1', + name: 'Höst', + window_start_month: 9, + window_start_day: 1, + window_end_month: 10, + window_end_day: 1, + target_weeks: 4, + recurrence: 'once', + first_year: 2027, + last_year: 2027, + priority: 2, + parent_mode: 'specific', + parent_personal_data_uuid: 'pd1', + goal_override: 'max_time', + }) + }) + // PLANNING_ENGINE_SPEC.md §10.1/§10.5 — window_mode round-trips through the // API client like every other leave-wish field: camelCase on the wire out, // snake_case in the request body, and back to camelCase on the response. @@ -1052,6 +1114,49 @@ describe('endpoint functions (representative coverage)', () => { expect(result.age4Projections[0]!.fourthBirthday).toBe('2030-01-01') }) + it('proposePlan defaults a missing cost breakdown field to 0', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + days: [], + outcomes: [ + { + wish_id: 'lw1', + wish_name: 'Sommar', + year: 2026, + start_date: '2026-06-15', + end_date: '2026-07-26', + weeks_wished: 6, + weeks_funded: 6, + shortfall_days: 0, + strategy: 'parent_swap', + message_key: 'planOutcomes.windowExplanation', + data: {}, + // The backend can omit zero-valued keys entirely — the mapper + // must default each one rather than surfacing `undefined`. + cost_breakdown: {}, + remedies: [], + }, + ], + warnings: [], + pool_balances: [], + vacation_balances: [], + surplus_advice: [], + age4_projections: [], + }), + }) + + const result = await api.proposePlan('ws1') + + expect(result.outcomes[0]!.costBreakdown).toEqual({ + free: 0, + vacation: 0, + sjukpenning: 0, + lagsta: 0, + }) + }) + it('applyPlan maps rejections to messageKey/data (§3.5)', async () => { fetchMock.mockResolvedValue({ ok: true, @@ -1107,6 +1212,27 @@ describe('endpoint functions (representative coverage)', () => { expect(result[0]!.salaryType).toBe('monthly') }) + it('listWorkspaceMembers defaults a null created_at to an empty string', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => [ + { + uuid: 'pd1', + workspace_id: 'ws1', + name: 'Alex', + salary_type: 'monthly', + employment_status: 'employed', + created_at: null, + }, + ], + }) + + const result = await api.listWorkspaceMembers('ws1') + + expect(result[0]!.createdAt).toBe('') + }) + it('getTips fetches tips for a workspace', async () => { const tips = [{ id: 't1', severity: 'info', messageKey: 'k', data: {} }] fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => tips }) @@ -1282,3 +1408,164 @@ describe('endpoint functions (representative coverage)', () => { }) }) }) + +describe('apiFetch — structured {message_key, data} errors', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + globalThis.fetch = fetchMock as any + }) + + afterEach(() => { + vi.clearAllMocks() + api.setAccessToken(null) + }) + + it('captures message_key/data in error.details instead of leaking the raw key into message', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ + detail: { message_key: 'workspace.parents.remove.hasDependentData', data: { blockers: ['leave_wishes'] } }, + }), + }) + + try { + await api.getTips('ws1') + expect.unreachable('expected getTips to throw') + } catch (err) { + expect(err).toBeInstanceOf(ApiRequestError) + const apiErr = err as ApiRequestError + expect(apiErr.status).toBe(422) + expect(apiErr.error.details).toEqual({ + messageKey: 'workspace.parents.remove.hasDependentData', + data: { blockers: ['leave_wishes'] }, + }) + } + }) + + it('defaults data to an empty object when the structured error omits it', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ detail: { message_key: 'workspace.notFound' } }), + }) + + await expect(api.getTips('ws1')).rejects.toMatchObject({ + status: 404, + error: { details: { messageKey: 'workspace.notFound', data: {} } }, + }) + }) + + it('still handles a plain string `detail` the old way (not misread as structured)', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ detail: 'Invalid payload' }), + }) + + await expect(api.getTips('ws1')).rejects.toMatchObject({ + status: 400, + error: { code: '400', message: 'Invalid payload' }, + }) + }) +}) + +describe('workspace parent management (rename/remove)', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + globalThis.fetch = fetchMock as any + }) + + afterEach(() => { + vi.clearAllMocks() + api.setAccessToken(null) + }) + + function lastRequestBody(): Record { + const calls = fetchMock.mock.calls as any[][] + const options = calls[calls.length - 1]![1] as RequestInit + return JSON.parse(options.body as string) as Record + } + + function lastRequest(): { url: string; method?: string | undefined } { + const calls = fetchMock.mock.calls as any[][] + const [url, options] = calls[calls.length - 1]! + return { url, method: (options as RequestInit | undefined)?.method } + } + + it('renameWorkspaceParent PATCHes the new display name and maps the response to camelCase', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + uuid: 'pd2', + workspace_id: 'ws1', + name: 'Samuel', + salary_type: 'monthly', + employment_status: 'employed', + created_at: '2026-01-01', + }), + }) + + const result = await api.renameWorkspaceParent('ws1', 'pd2', 'Samuel') + + expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'PATCH' }) + expect(lastRequestBody()).toEqual({ display_name: 'Samuel' }) + expect(result.name).toBe('Samuel') + expect(result.uuid).toBe('pd2') + }) + + it('renameWorkspaceParent surfaces an invalidName refusal as a structured error', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ detail: { message_key: 'workspace.parents.invalidName', data: {} } }), + }) + + await expect(api.renameWorkspaceParent('ws1', 'pd2', '')).rejects.toMatchObject({ + status: 422, + error: { details: { messageKey: 'workspace.parents.invalidName' } }, + }) + }) + + it('removeWorkspaceParent DELETEs and resolves to undefined on 204', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) }) + + const result = await api.removeWorkspaceParent('ws1', 'pd2') + + expect(lastRequest()).toEqual({ url: '/api/workspace/ws1/parents/pd2', method: 'DELETE' }) + expect(result).toBeUndefined() + }) + + it('removeWorkspaceParent surfaces a hasDependentData refusal with its blockers', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ + detail: { + message_key: 'workspace.parents.remove.hasDependentData', + data: { blockers: ['leave_wishes', 'vacation_days'] }, + }, + }), + }) + + await expect(api.removeWorkspaceParent('ws1', 'pd2')).rejects.toMatchObject({ + status: 422, + error: { + details: { + messageKey: 'workspace.parents.remove.hasDependentData', + data: { blockers: ['leave_wishes', 'vacation_days'] }, + }, + }, + }) + }) +}) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6ed916d..56eeae9 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -509,16 +509,35 @@ async function apiFetch( } try { const body = (await response.json()) as { - detail?: string | Array<{ msg?: string }> + detail?: string | Array<{ msg?: string }> | { message_key: string; data?: Record } code?: string message?: string } - const detail = Array.isArray(body.detail) - ? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ') - : body.detail - error = { - code: body.code ?? String(response.status), - message: detail ?? body.message ?? response.statusText, + if ( + body.detail && + typeof body.detail === 'object' && + !Array.isArray(body.detail) && + 'message_key' in body.detail + ) { + // Structured `{message_key, data}` error (see workspace.py's + // exception mapping) — kept out of `message` so callers never + // accidentally render the raw key; `details.messageKey`/`data` is + // the contract callers (e.g. Settings.tsx's `describePartnerError`) + // read instead. + const structured = body.detail + error = { + code: structured.message_key, + message: structured.message_key, + details: { messageKey: structured.message_key, data: structured.data ?? {} }, + } + } else { + const detail = Array.isArray(body.detail) + ? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ') + : body.detail + error = { + code: body.code ?? String(response.status), + message: detail ?? body.message ?? response.statusText, + } } } catch { // response body wasn't JSON — use status text @@ -873,6 +892,32 @@ export async function listWorkspaceMembers(workspaceId: string): Promise { + const raw = (await apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, { + method: 'PATCH', + body: JSON.stringify({ display_name: displayName }), + })) as RawPersonalData + return toPersonalData(raw) +} + +/** Soft-deletes a workspace member. Refused (422) for the caller's own + * profile, a member with their own login, or one with dependent data — + * see `describePartnerError` in `Settings.tsx` for how those surface. */ +export async function removeWorkspaceParent( + workspaceId: string, + personalDataUuid: string, +): Promise { + return apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, { + method: 'DELETE', + }) +} + // --------------------------------------------------------------------------- // Tips // --------------------------------------------------------------------------- diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index a4f69ec..fa4b2b6 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -341,6 +341,48 @@ button.ghost:hover:not(:disabled) { border-radius: var(--radius); } +.settings-page__member-list { + list-style: none; + margin: 0 0 var(--sp-4) 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.settings-page__member-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) 0; +} + +.settings-page__you-badge { + color: var(--c-text-muted); + font-size: var(--fs-sm); +} + +.settings-page__remove-confirm { + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--c-danger); + font-size: var(--fs-sm); +} + +.settings-page__button--secondary { + background-color: transparent; + color: var(--c-text); + border: 1px solid var(--c-border); +} + +.settings-page__button--danger { + background-color: var(--c-danger-weak); + color: var(--c-danger); + border: 1px solid var(--c-danger); +} + /* Vacation Days Table (multi-year) */ .vacation-days-table { width: 100%;