Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions frontend/src/api/apiErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
extractApiErrorOr,
getApiStatus,
getErrorParams,
getFieldError,
hasErrorCode,
} from './apiErrors';
import { Code } from './validationCodes';
Expand Down Expand Up @@ -152,3 +153,61 @@ describe('getErrorParams', () => {
expect(getErrorParams(err, Code.Event.WouldRemoveNonMembers)).toBeNull();
});
});

describe('getFieldError', () => {
it('returns the message for the entry scoped to the field', () => {
const err = axiosError(400, {
detail: [{ code: Code.Survey.SlugAlreadyExists, field: 'slug' }],
});
expect(getFieldError(err, 'slug')).toBe('a survey with that slug already exists');
});

it('picks the entry matching the requested field out of several', () => {
const err = axiosError(400, {
detail: [
{ code: Code.Survey.SlugAlreadyExists, field: 'slug' },
{ code: Code.Event.NotFound, field: 'linked_event_id' },
],
});
expect(getFieldError(err, 'linked_event_id')).toBe('event not found');
});

it('returns null when no entry carries that field', () => {
const err = axiosError(400, {
detail: [{ code: Code.Survey.SlugAlreadyExists, field: 'slug' }],
});
expect(getFieldError(err, 'title')).toBeNull();
});

it('returns null for legacy string-detail responses', () => {
expect(getFieldError(axiosError(400, { detail: 'free text' }), 'slug')).toBeNull();
});

it('returns null for axios errors with no response (e.g. network)', () => {
const networkErr = Object.assign(new Error('network'), { isAxiosError: true });
expect(getFieldError(networkErr, 'slug')).toBeNull();
});

it('returns null for non-axios errors', () => {
expect(getFieldError(new Error('boom'), 'slug')).toBeNull();
expect(getFieldError('string', 'slug')).toBeNull();
expect(getFieldError(undefined, 'slug')).toBeNull();
});

it('falls back to a safe message for an unknown code on the field', () => {
const err = axiosError(400, { detail: [{ code: 'survey.brand_new', field: 'slug' }] });
expect(getFieldError(err, 'slug')).toMatch(/double-check/i);
});

it('skips malformed detail entries that carry no code', () => {
const err = axiosError(400, {
detail: [
{ field: 'slug' },
null,
'oops',
{ code: Code.Survey.SlugAlreadyExists, field: 'slug' },
],
});
expect(getFieldError(err, 'slug')).toBe('a survey with that slug already exists');
});
});
20 changes: 19 additions & 1 deletion frontend/src/api/apiErrors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isAxiosError } from 'axios';

import { type FieldError, messagesFromFieldErrors } from './validationCodes';
import { type FieldError, messageForCode, messagesFromFieldErrors } from './validationCodes';

/**
* Extract a user-facing message from any API error.
Expand Down Expand Up @@ -35,6 +35,24 @@ export function extractApiErrorOr(err: unknown, fallback: string): string {
return extractApiError(err) ?? fallback;
}

/**
* Lets forms attach a backend error to the offending input instead of the
* generic alert. Null when the error carries nothing for that field.
*/
export function getFieldError(err: unknown, field: string): string | null {
if (!isAxiosError(err)) return null;
const data = err.response?.data as Record<string, unknown> | undefined;
if (!data || !Array.isArray(data.detail)) return null;
const match = data.detail.find(
(e): e is FieldError =>
typeof e === 'object' &&
e !== null &&
typeof (e as FieldError).code === 'string' &&
(e as FieldError).field === field,
);
return match ? messageForCode(match) : null;
}

/**
* HTTP status from any API error, or null if the error isn't an axios error
* with a response. Use this instead of importing `isAxiosError` directly so
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/surveyAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface WireSurveyFull {
visibility: string;
is_active: boolean;
one_response_per_user?: boolean;
linked_event_id?: string | null;
questions?: {
id: string;
label: string;
Expand Down Expand Up @@ -87,6 +88,7 @@ function mapSurveyFull(w: WireSurveyFull): PublicSurvey {
visibility: w.visibility,
isActive: w.is_active,
oneResponsePerUser: w.one_response_per_user ?? false,
linkedEventId: w.linked_event_id ?? null,
questions: (w.questions ?? [])
.map((q) => ({
id: q.id,
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/surveys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Survey {
visibility: string;
isActive: boolean;
oneResponsePerUser: boolean;
linkedEventId: string | null;
questions: SurveyQuestion[];
myResponseId: string | null;
myAnswers: Record<string, { label: string; answer: string | Record<string, string> }> | null;
Expand Down Expand Up @@ -52,6 +53,7 @@ interface WireSurvey {
visibility: string;
is_active: boolean;
one_response_per_user?: boolean;
linked_event_id?: string | null;
questions?: WireQuestion[];
my_response_id?: string | null;
my_answers?: Record<string, { label: string; answer: string | Record<string, string> }> | null;
Expand Down Expand Up @@ -80,6 +82,7 @@ function mapSurvey(w: WireSurvey): Survey {
visibility: w.visibility,
isActive: w.is_active,
oneResponsePerUser: w.one_response_per_user ?? false,
linkedEventId: w.linked_event_id ?? null,
questions: (w.questions ?? [])
.map((q) => ({
id: q.id,
Expand Down
155 changes: 138 additions & 17 deletions frontend/src/screens/admin/SurveyAdminListScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AxiosError, type AxiosResponse } from 'axios';
import type * as RouterDom from 'react-router-dom';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { SurveySummary } from '@/api/surveyAdmin';

import SurveyAdminListScreen from './SurveyAdminListScreen';

const toastSuccess = vi.fn();
vi.mock('sonner', () => ({
toast: {
success: (m: string) => {
toastSuccess(m);
},
error: vi.fn(),
},
}));
const createMutateAsync = vi.fn();
const navigate = vi.fn();

const surveys: SurveySummary[] = [
{
Expand All @@ -39,22 +36,67 @@ const surveys: SurveySummary[] = [
},
];

vi.mock('sonner', () => ({
toast: {
success: (m: string) => {
toastSuccess(m);
},
error: vi.fn(),
},
}));

vi.mock('@/api/surveyAdmin', () => ({
useAdminSurveys: () => ({ data: surveys, isPending: false, isError: false }),
useCreateSurvey: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteSurvey: () => ({ mutate: vi.fn() }),
useCreateSurvey: () => ({ mutateAsync: createMutateAsync, isPending: false }),
useDeleteSurvey: () => ({ mutate: vi.fn(), isPending: false }),
}));

vi.mock('@/api/events', () => ({
useEvents: () => ({
data: [{ id: 'evt-1', title: 'Potluck', startDatetime: new Date('2026-10-01T18:00:00Z') }],
}),
}));

vi.mock('react-router-dom', async (importActual) => {
const actual = await importActual<typeof RouterDom>();
return { ...actual, useNavigate: () => navigate };
});

function fieldError(code: string, field: string) {
return new AxiosError('Request failed', 'ERR', undefined, undefined, {
status: 400,
data: { detail: [{ code, field }] },
} as AxiosResponse);
}

function renderScreen() {
render(
<MemoryRouter>
<SurveyAdminListScreen />
</MemoryRouter>,
);
}

async function openCreateDialog() {
await userEvent.click(screen.getByRole('button', { name: 'new survey' }));
}

async function fillCreateForm() {
await userEvent.type(screen.getByLabelText('title'), 'retreat a');
await userEvent.type(screen.getByLabelText('slug'), 'retreat-a');
await userEvent.selectOptions(screen.getByLabelText('linked event'), 'evt-1');
}

describe('SurveyAdminListScreen', () => {
beforeEach(() => {
toastSuccess.mockReset();
});

it('copies the participant link for the clicked row', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });

render(
<MemoryRouter>
<SurveyAdminListScreen />
</MemoryRouter>,
);
renderScreen();

expect(screen.getAllByRole('button', { name: 'copy link' })).toHaveLength(2);

Expand All @@ -68,3 +110,82 @@ describe('SurveyAdminListScreen', () => {
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/surveys/summer-picnic`);
});
});

describe('SurveyAdminListScreen create dialog', () => {
beforeEach(() => {
createMutateAsync.mockReset();
createMutateAsync.mockResolvedValue({ id: 's-new' });
navigate.mockReset();
});

it('resets the form after a successful create', async () => {
renderScreen();
await openCreateDialog();
await fillCreateForm();
await userEvent.click(screen.getByRole('button', { name: 'create' }));

await waitFor(() => {
expect(createMutateAsync).toHaveBeenCalled();
});

await openCreateDialog();
expect(screen.getByLabelText('title')).toHaveValue('');
expect(screen.getByLabelText('slug')).toHaveValue('');
expect(screen.getByLabelText('linked event')).toHaveValue('');
});

it('resets the form after cancelling', async () => {
renderScreen();
await openCreateDialog();
await fillCreateForm();
await userEvent.click(screen.getByRole('button', { name: 'cancel' }));

await openCreateDialog();
expect(screen.getByLabelText('title')).toHaveValue('');
expect(screen.getByLabelText('slug')).toHaveValue('');
expect(screen.getByLabelText('linked event')).toHaveValue('');
});

it('shows a slug collision inline on the slug field and clears it on change', async () => {
createMutateAsync.mockRejectedValue(fieldError('survey.slug_already_exists', 'slug'));
renderScreen();
await openCreateDialog();
await fillCreateForm();
await userEvent.click(screen.getByRole('button', { name: 'create' }));

expect(await screen.findByText('a survey with that slug already exists')).toBeInTheDocument();
const slug = screen.getByLabelText('slug');
expect(slug).toHaveAttribute('aria-invalid', 'true');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();

await userEvent.type(slug, '-2');
expect(slug).not.toHaveAttribute('aria-invalid');
expect(screen.queryByText('a survey with that slug already exists')).not.toBeInTheDocument();
});

it('shows a linked event error on the dropdown', async () => {
createMutateAsync.mockRejectedValue(fieldError('event.not_found', 'linked_event_id'));
renderScreen();
await openCreateDialog();
await fillCreateForm();
await userEvent.click(screen.getByRole('button', { name: 'create' }));

expect(await screen.findByText('event not found')).toBeInTheDocument();
expect(screen.getByLabelText('linked event')).toHaveAttribute('aria-invalid', 'true');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('still shows a generic banner for a failure with no field error', async () => {
createMutateAsync.mockRejectedValue(new Error('network down'));
renderScreen();
await openCreateDialog();
await fillCreateForm();
await userEvent.click(screen.getByRole('button', { name: 'create' }));

expect(await screen.findByRole('alert')).toHaveTextContent(
"couldn't complete that action — try again",
);
expect(screen.getByLabelText('slug')).not.toHaveAttribute('aria-invalid');
});
});
Loading