Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/components/providers/AlertProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function AlertProvider({ children }: { children: React.ReactNode }) {
};
useEffect(() => {
if (!isOpen) setAlert(undefined);
}, [open]);
}, [isOpen]);

return (
<AlertContext.Provider
Expand Down
112 changes: 112 additions & 0 deletions src/components/settings/admin-users/AddAdminSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { useState } from 'react';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { AddAdminSheet } from '@/components/settings/admin-users/AddAdminSheet';
import { AlertProvider } from '@/components/providers/AlertProvider';

const refresh = vi.fn();
const toast = vi.fn();
const postNewAdminUser = vi.fn();

vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh }),
}));

vi.mock('@/lib/hooks/use-toast', () => ({
toast: (...args: unknown[]) => toast(...args),
}));

vi.mock('@/lib/api/settings/admins', () => ({
postNewAdminUser: (...args: unknown[]) => postNewAdminUser(...args),
}));

function OpenSheetHarness({
onOpenChange,
}: {
onOpenChange?: (open: boolean) => void;
}) {
const [open, setOpen] = useState(true);
return (
<AlertProvider>
<AddAdminSheet
open={open}
onOpenChange={next => {
setOpen(next);
onOpenChange?.(next);
}}
>
<button type="button">Create</button>
</AddAdminSheet>
</AlertProvider>
);
}

function renderSheet() {
return render(<OpenSheetHarness />);
}

describe('AddAdminSheet', () => {
beforeAll(() => {
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}
Element.prototype.scrollIntoView = () => {};
Element.prototype.hasPointerCapture = () => false;
Element.prototype.setPointerCapture = () => {};
Element.prototype.releasePointerCapture = () => {};
});

afterEach(() => {
cleanup();
refresh.mockReset();
toast.mockReset();
postNewAdminUser.mockReset();
});

it('keeps username and password inputs controlled with empty defaults', () => {
renderSheet();
expect(screen.getByPlaceholderText('Enter a username')).toHaveAttribute(
'value',
''
);
const [password, confirm] = screen.getAllByPlaceholderText('very secret');
expect(password).toHaveAttribute('value', '');
expect(confirm).toHaveAttribute('value', '');
});

it('closes after a successful create without tripping the unsaved-changes alert', async () => {
postNewAdminUser.mockResolvedValue(undefined);
const onOpenChange = vi.fn();

render(<OpenSheetHarness onOpenChange={onOpenChange} />);

fireEvent.change(screen.getByPlaceholderText('Enter a username'), {
target: { value: 'uiadmin1' },
});
const [password, confirm] = screen.getAllByPlaceholderText('very secret');
fireEvent.change(password, { target: { value: 'secretpass' } });
fireEvent.change(confirm, { target: { value: 'secretpass' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));

await waitFor(() => {
expect(postNewAdminUser).toHaveBeenCalledWith('uiadmin1', 'secretpass');
});
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
expect(
screen.queryByText(/unsaved changes will be lost/i)
).not.toBeInTheDocument();
expect(refresh).toHaveBeenCalled();
});
});
61 changes: 37 additions & 24 deletions src/components/settings/admin-users/AddAdminSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import {
Sheet,
SheetContent,
Expand Down Expand Up @@ -29,11 +31,17 @@ import { LucideX } from 'lucide-react';
import { ErrorPre } from '@/components/ui/error-pre';
import { useRouter } from 'next/navigation';

const emptyValues = {
username: '',
password: '',
confirmPassword: '',
};

const FormSchema = z
.object({
username: z.string(),
password: z.string(),
confirmPassword: z.string(),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
confirmPassword: z.string().min(1, 'Confirm your password'),
})
.refine(
schema => {
Expand Down Expand Up @@ -66,52 +74,57 @@ export const AddAdminSheet = ({
);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const setOpen = (next: boolean) => {
const setOpenState = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
controlledOnOpenChange?.(next);
};
const { addAlert } = useAlerts();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: rhfZodResolver(FormSchema),
defaultValues: emptyValues,
});
const router = useRouter();

const { formState, reset, control, handleSubmit } = form;
const { isDirty, isSubmitted } = formState;

useEffect(() => {
if (defaultOpen !== undefined && !isControlled)
setUncontrolledOpen(defaultOpen);
}, [defaultOpen, isControlled]);
const discardAndClose = () => {
reset(emptyValues);
setOpenState(false);
onClose?.();
};

useEffect(() => {
if (!open && formState.isSubmitted) {
onClose?.();
return reset();
const requestOpenChange = (next: boolean) => {
if (next) {
setOpenState(true);
return;
}
if (!open && formState.isDirty) {
if (isDirty && !isSubmitted) {
addAlert({
title: 'Add Admin',
description:
'Are you sure you want to close this sheet? Any unsaved changes will be lost.',
cancelText: 'Cancel',
actionText: 'Close',
onDecision: cancel => {
if (!cancel) {
onClose?.();
return reset();
}
setOpen(true);
if (!cancel) discardAndClose();
},
});
} else if (!open) {
onClose?.();
return;
}
}, [open, setOpen]);
discardAndClose();
};

useEffect(() => {
if (defaultOpen !== undefined && !isControlled)
setUncontrolledOpen(defaultOpen);
}, [defaultOpen, isControlled]);

function onSubmit(data: z.infer<typeof FormSchema>) {
postNewAdminUser(data.username, data.password)
.then(res => {
setOpen(false);
.then(() => {
reset(emptyValues);
setOpenState(false);
toast({
title: 'New Admin',
description: (
Expand Down Expand Up @@ -139,7 +152,7 @@ export const AddAdminSheet = ({
}

return (
<Sheet open={open} onOpenChange={setOpen}>
<Sheet open={open} onOpenChange={requestOpenChange}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent side="right">
<Form {...form}>
Expand Down
43 changes: 27 additions & 16 deletions src/lib/api/settings/admins/index.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,51 @@
'use server';
import { Admin } from '@/lib/models/User';
import { getApiClient } from '@/lib/api';
import { withAdminApiError } from '@/lib/logic/api-error';

export const getAdminById = async (id: string) => {
const res = await (await getApiClient()).get(`/admins/${id}`);
return res.data;
return withAdminApiError(async () => {
const res = await (await getApiClient()).get(`/admins/${id}`);
return res.data;
});
};
export const getAdmins = async (
skip: number,
limit: number
): Promise<{ admins: Admin[]; count: number }> => {
const res = await (
await getApiClient()
).get(`/admins`, {
params: {
skip,
limit,
},
return withAdminApiError(async () => {
const res = await (
await getApiClient()
).get(`/admins`, {
params: {
skip,
limit,
},
});
return res.data;
});
return res.data;
};
export const postNewAdminUser = async (username: string, password: string) => {
await (await getApiClient()).post(`/admins`, { username, password });
await withAdminApiError(async () => {
await (await getApiClient()).post(`/admins`, { username, password });
});
};

export const changeAdminsPasswordById = async (
adminId: string,
newPassword: string
) => {
await (
await getApiClient()
).put(`/admins/${adminId}/change-password`, {
newPassword,
await withAdminApiError(async () => {
await (
await getApiClient()
).put(`/admins/${adminId}/change-password`, {
newPassword,
});
});
};

export const deleteAdmin = async (id: string) => {
await (await getApiClient()).delete(`/admins/${id}`);
await withAdminApiError(async () => {
await (await getApiClient()).delete(`/admins/${id}`);
});
};
59 changes: 59 additions & 0 deletions src/lib/logic/api-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import {
formatAdminApiError,
isNextNavigationError,
withAdminApiError,
} from '@/lib/logic/api-error';

describe('formatAdminApiError', () => {
it('prefers the API response message from axios-like errors', () => {
expect(
formatAdminApiError({
message: 'Request failed with status code 500',
response: {
status: 500,
data: {
message:
'Admin validation failed: username: Path `username` is required.',
},
},
})
).toBe('Admin validation failed: username: Path `username` is required.');
});
});

describe('withAdminApiError', () => {
it('rethrows Next.js navigation errors unchanged', async () => {
const redirect = Object.assign(new Error('NEXT_REDIRECT'), {
digest: 'NEXT_REDIRECT;replace;/login;307;',
});
await expect(
withAdminApiError(async () => {
throw redirect;
})
).rejects.toBe(redirect);
expect(isNextNavigationError(redirect)).toBe(true);
});

it('converts axios-like failures into a plain Error so Server Actions can serialize them', async () => {
const axiosLike = {
message: 'Request failed with status code 500',
response: {
status: 500,
data: { message: 'Username already exists' },
},
};
await expect(
withAdminApiError(async () => {
throw axiosLike;
})
).rejects.toEqual(
expect.objectContaining({ message: 'Username already exists' })
);
await expect(
withAdminApiError(async () => {
throw axiosLike;
})
).rejects.toBeInstanceOf(Error);
});
});
9 changes: 9 additions & 0 deletions src/lib/logic/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export function formatAdminApiError(err: unknown): string {
return err instanceof Error ? err.message : 'Request failed';
}

export async function withAdminApiError<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (isNextNavigationError(err)) throw err;
throw new Error(formatAdminApiError(err));
}
}

export function formatCommunicationsApiError(err: unknown): string {
if (isAxiosLikeError(err)) {
if (err.response?.status === 404) {
Expand Down
Loading