Show and manage the linked partner in Settings
Settings never called GET /workspace/{id}/members -- confirmed by network
capture, not inference. There was no member list, no loading state and no
swallowed error; the code was simply absent. A workspace with a partner
therefore looked identical to a solo one, and nothing stopped a second
partner being added.
Settings now lists both members, marks the logged-in user, and offers
rename on either row and remove on the partner. The add-form appears only
when no partner exists. Removal asks for confirmation inline rather than
via window.confirm, which this codebase does not use anywhere and which
blocks the browser tooling used to verify it.
Own-name rename is offered too: the backend documents this route as the
only place a name can be corrected, so hiding it would leave that
capability dead.
Also fixes apiFetch's error parsing, which handled only string and array
detail bodies. A structured {message_key, data} object fell through and
was assigned to error.message, rendering as [object Object] or leaking
the raw key -- latent until this session's backend work started
returning exactly that shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -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<typeof vi.fn>
|
||||
|
||||
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<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn()
|
||||
globalThis.fetch = fetchMock as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.setAccessToken(null)
|
||||
})
|
||||
|
||||
function lastRequestBody(): Record<string, unknown> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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'] },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -509,16 +509,35 @@ async function apiFetch<T>(
|
||||
}
|
||||
try {
|
||||
const body = (await response.json()) as {
|
||||
detail?: string | Array<{ msg?: string }>
|
||||
detail?: string | Array<{ msg?: string }> | { message_key: string; data?: Record<string, unknown> }
|
||||
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<Persona
|
||||
return raw.map(toPersonalData)
|
||||
}
|
||||
|
||||
/** Renames a workspace member (partner or the logged-in user themselves —
|
||||
* the backend deliberately allows self-rename through this route). */
|
||||
export async function renameWorkspaceParent(
|
||||
workspaceId: string,
|
||||
personalDataUuid: string,
|
||||
displayName: string,
|
||||
): Promise<PersonalData> {
|
||||
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<void> {
|
||||
return apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user