cleanup api
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-07-03 20:48:29 -04:00
parent 9158648f3f
commit 09daf6d511
30 changed files with 444 additions and 1597 deletions
+5 -5
View File
@@ -27,7 +27,7 @@ Also follow the repository root guide at `../AGENTS.md`.
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. React Query `isLoading` is initial-load-only (false on background refetches), so a full-page early-return on `isLoading` is fine for pages with no persistent filter form.
- For mutations, use `useMutationWithToast` (declarative `.mutate(vars, options)` for fire-and-forget) or its imperative sibling `useToastMutation` (awaited, returns a success `boolean`) instead of hand-rolling `mutateAsync``getResponseError` → toast blocks.
- For mutations, use `useMutationWithToast` (declarative `.mutate(vars, options)` for fire-and-forget) or its imperative sibling `useToastMutation` (awaited, returns a success `boolean`) instead of hand-rolling `mutateAsync`toast/catch blocks.
- Use `SegmentedControl` for active/inactive toggle groups (view mode, period, reader theme/font) rather than re-implementing `option.map` + ternary class toggling; pass per-call `activeClassName`/`inactiveClassName`.
- For a persistent/progress toast that resolves in place (long-running actions), create it with `showInfo(msg, 0)` and finish with `updateToast(id, { message, type, duration })`.
- Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first.
@@ -43,10 +43,10 @@ Also follow the repository root guide at `../AGENTS.md`.
### Important behavior
- The generated client returns `{ data, status, headers }` for both success and error responses.
- Do not assume non-2xx responses throw.
- Trust the generated discriminated-union response types and narrow on `status`; avoid hand-rolled re-validation of fields the schema already guarantees.
- Centralize mutation/reqest error handling via `useMutationWithToast` (toast options) or `getResponseError` (`utils/errors`) for imperative flows — don't re-implement inline `status` checks or `'message' in response.data` extraction.
- The generated client returns the documented **success body directly** and throws `ApiError` (`src/utils/apiFetch.ts`) for non-2xx responses.
- Use React Query's native error flow (`isError`, `error`, `onError`) instead of status narrowing.
- Use `getErrorMessage` (`src/utils/errors.ts`) to display caught errors; `ApiError.message` already contains the server-provided error message when present.
- Centralize mutation error/success feedback via `useMutationWithToast` or `useToastMutation` rather than inline toast/catch duplication.
## 4) Auth / Query State
+8 -2
View File
@@ -8,10 +8,16 @@ export default defineConfig({
target: 'src/generated',
schemas: 'src/generated/model',
client: 'react-query',
httpClient: 'fetch',
mock: false,
override: {
useQuery: true,
mutations: true,
fetch: {
includeHttpResponseReturnType: false,
},
mutator: {
path: './src/utils/apiFetch.ts',
name: 'apiFetch',
},
},
},
input: {
+4 -16
View File
@@ -12,9 +12,7 @@ import {
type AuthState,
getUnauthenticatedAuthState,
resolveAuthStateFromMe,
authUserFromMutation,
} from './authHelpers';
import { getResponseError } from '../utils/errors';
interface AuthContextType extends AuthState {
login: (_username: string, _password: string) => Promise<void>;
@@ -50,19 +48,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = useCallback(
async (username: string, password: string) => {
try {
const response = await loginMutation.mutateAsync({
const user = await loginMutation.mutateAsync({
data: {
username,
password,
},
});
const user = authUserFromMutation(response);
if (!user) {
throw new Error(getResponseError(response) ?? 'Login failed');
}
queryClient.setQueryData(getGetMeQueryKey(), response);
queryClient.setQueryData(getGetMeQueryKey(), user);
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/');
} catch (error) {
@@ -76,19 +69,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const register = useCallback(
async (username: string, password: string) => {
try {
const response = await registerMutation.mutateAsync({
const user = await registerMutation.mutateAsync({
data: {
username,
password,
},
});
const user = authUserFromMutation(response);
if (!user) {
throw new Error(getResponseError(response) ?? 'Registration failed');
}
queryClient.setQueryData(getGetMeQueryKey(), response);
queryClient.setQueryData(getGetMeQueryKey(), user);
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/');
} catch (error) {
+2 -48
View File
@@ -3,7 +3,6 @@ import {
getCheckingAuthState,
getUnauthenticatedAuthState,
resolveAuthStateFromMe,
authUserFromMutation,
type AuthState,
} from './authHelpers';
@@ -31,11 +30,7 @@ describe('authHelpers', () => {
it('resolves auth state from a successful /auth/me response', () => {
expect(
resolveAuthStateFromMe({
meData: {
status: 200,
data: { username: 'evan', is_admin: false },
headers: new Headers(),
},
meData: { username: 'evan', is_admin: false },
meError: undefined,
meLoading: false,
previousState,
@@ -47,20 +42,7 @@ describe('authHelpers', () => {
});
});
it('resolves auth state to unauthenticated on 401 or query error', () => {
expect(
resolveAuthStateFromMe({
meData: {
status: 401,
data: { code: 401, message: 'unauthorized' },
headers: new Headers(),
},
meError: undefined,
meLoading: false,
previousState,
})
).toEqual(getUnauthenticatedAuthState());
it('resolves auth state to unauthenticated when the me query errors (e.g. 401)', () => {
expect(
resolveAuthStateFromMe({
meData: undefined,
@@ -108,32 +90,4 @@ describe('authHelpers', () => {
isCheckingAuth: false,
});
});
it('extracts the user from successful login and register responses', () => {
expect(
authUserFromMutation({
status: 200,
data: { username: 'evan', is_admin: false },
headers: new Headers(),
})
).toEqual({ username: 'evan', is_admin: false });
expect(
authUserFromMutation({
status: 201,
data: { username: 'evan', is_admin: true },
headers: new Headers(),
})
).toEqual({ username: 'evan', is_admin: true });
});
it('returns null for unsuccessful auth mutation responses', () => {
expect(
authUserFromMutation({
status: 401,
data: { code: 401, message: 'unauthorized' },
headers: new Headers(),
})
).toBeNull();
});
});
+4 -9
View File
@@ -1,4 +1,3 @@
import type { getMeResponse, loginResponse, registerResponse } from '../generated/anthoLumeAPIV1';
import type { LoginResponse } from '../generated/model';
export type AuthUser = LoginResponse;
@@ -34,7 +33,7 @@ export function getAuthenticatedAuthState(user: AuthUser): AuthState {
}
export function resolveAuthStateFromMe(params: {
meData?: getMeResponse;
meData?: LoginResponse;
meError?: unknown;
meLoading: boolean;
previousState: AuthState;
@@ -45,11 +44,11 @@ export function resolveAuthStateFromMe(params: {
return getCheckingAuthState(previousState);
}
if (meData?.status === 200) {
return getAuthenticatedAuthState(meData.data);
if (meData) {
return getAuthenticatedAuthState(meData);
}
if (meError || meData?.status === 401) {
if (meError) {
return getUnauthenticatedAuthState();
}
@@ -58,7 +57,3 @@ export function resolveAuthStateFromMe(params: {
isCheckingAuth: false,
};
}
export function authUserFromMutation(response: loginResponse | registerResponse): AuthUser | null {
return response.status === 200 || response.status === 201 ? response.data : null;
}
+1 -2
View File
@@ -4,7 +4,6 @@ import { SettingsIcon, GitIcon } from '../icons';
import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { navItems, adminNavItems } from './navigation';
import { dataForStatus } from '../utils/apiResponses';
import { cn } from '../utils/cn';
function hasPrefix(path: string, prefix: string): boolean {
@@ -47,7 +46,7 @@ export default function HamburgerMenu() {
staleTime: Infinity,
},
});
const version = dataForStatus(infoData, 200)?.version ?? 'v1.0.0';
const version = infoData?.version ?? 'v1.0.0';
const closeMenu = () => setIsOpen(false);
return (
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -3,7 +3,6 @@ import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
import { dataForStatus } from '../utils/apiResponses';
export interface UseAuthFormResult {
username: string;
@@ -29,7 +28,7 @@ export function useAuthForm(mode: 'login' | 'register'): UseAuthFormResult {
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
query: { staleTime: Infinity },
});
const registrationEnabled = dataForStatus(infoData, 200)?.registration_enabled ?? false;
const registrationEnabled = infoData?.registration_enabled ?? false;
const submit = useCallback(
async (e: SyntheticEvent<HTMLFormElement>) => {
+3 -11
View File
@@ -5,7 +5,7 @@ import type { UpdateProgressRequest } from '../generated/model/updateProgressReq
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage, getResponseError } from '../utils/errors';
import { getErrorMessage } from '../utils/errors';
interface UseEpubReaderOptions {
documentId: string;
@@ -96,11 +96,7 @@ export function useEpubReader({
// Swallow Save Failures - Transient progress-save errors must not take down the reader
// (they previously routed to onError, which hides the whole book behind an error overlay).
try {
const response = await updateProgress(payload);
const message = getResponseError(response);
if (message) {
showError(`Failed to save progress: ${message}`);
}
await updateProgress(payload);
} catch (err) {
showError(`Failed to save progress: ${getErrorMessage(err)}`);
}
@@ -108,11 +104,7 @@ export function useEpubReader({
const saveActivity = async (payload: CreateActivityRequest) => {
try {
const response = await createActivity(payload);
const message = getResponseError(response);
if (message) {
showError(`Failed to save activity: ${message}`);
}
await createActivity(payload);
} catch (err) {
showError(`Failed to save activity: ${getErrorMessage(err)}`);
}
+10 -19
View File
@@ -1,5 +1,5 @@
import { useToasts } from '../components/ToastContext';
import { getErrorMessage, getResponseError, type ApiResponseLike } from '../utils/errors';
import { getErrorMessage } from '../utils/errors';
interface ToastMutationOptions {
success: string;
@@ -9,19 +9,15 @@ interface ToastMutationOptions {
/**
* Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call,
* centralizing the shared "toast success / toast error / treat non-2xx as failure" pattern.
* centralizing the shared "toast success / toast error" pattern. The generated client throws on
* non-2xx, so success and failure map cleanly onto React Query's onSuccess/onError.
*/
export function useMutationWithToast() {
const { showInfo, showError } = useToasts();
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
return {
onSuccess: (response: ApiResponseLike) => {
const message = getResponseError(response);
if (message) {
showError(`${error}: ${message}`);
return;
}
onSuccess: () => {
onSuccess?.();
showInfo(success);
},
@@ -33,29 +29,24 @@ export function useMutationWithToast() {
interface RunToastMutationOptions<T> {
error: string;
success?: string;
onSuccess?: (response: T) => void;
onSuccess?: (result: T) => void;
}
/**
* Imperative sibling of `useMutationWithToast` for flows that must `await` a result (e.g. keep an
* editor open on failure). Runs the action, treats non-2xx as failure, toasts accordingly, and
* resolves to `true` only on success.
* editor open on failure). Runs the action, toasts on success/failure, and resolves to `true` only
* when the action succeeds.
*/
export function useToastMutation() {
const { showInfo, showError } = useToasts();
return async function runWithToast<T extends ApiResponseLike>(
return async function runWithToast<T>(
action: () => Promise<T>,
{ error, success, onSuccess }: RunToastMutationOptions<T>
): Promise<boolean> {
try {
const response = await action();
const message = getResponseError(response);
if (message) {
showError(`${error}: ${message}`);
return false;
}
onSuccess?.(response);
const result = await action();
onSuccess?.(result);
if (success) {
showInfo(success);
}
+9 -1
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from './components/ToastContext';
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
import App from './App';
import { ApiError } from './utils/apiFetch';
import './index.css';
initializeThemeMode();
@@ -13,7 +14,14 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
// 4xx responses are deterministic (e.g. /auth/me 401 when logged out); only retry transient
// network/5xx failures.
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) {
return false;
}
return failureCount < 1;
},
},
mutations: {
retry: 0,
+1 -2
View File
@@ -6,7 +6,6 @@ import { Table, type Column } from '../components/Table';
import { documentColumn } from '../components/documentColumn';
import { usePaginatedList } from '../hooks/usePaginatedList';
import { formatDuration, formatDateTime } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
const ACTIVITY_PAGE_SIZE = 25;
@@ -22,7 +21,7 @@ export default function ActivityPage() {
page,
limit,
});
const response = dataForStatus(data, 200);
const response = data;
const activities = response?.activities ?? [];
const columns: Column<Activity>[] = [
+1 -2
View File
@@ -6,7 +6,6 @@ import type { DirectoryItem } from '../generated/model';
import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminImportPage() {
const [currentPath, setCurrentPath] = useState<string>('');
@@ -21,7 +20,7 @@ export default function AdminImportPage() {
const postImport = usePostImport();
const directoryResponse = dataForStatus(directoryData, 200);
const directoryResponse = directoryData;
const directories = directoryResponse?.items ?? [];
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
@@ -2,11 +2,10 @@ import { useGetImportResults } from '../generated/anthoLumeAPIV1';
import { Table, type Column } from '../components';
import type { ImportResult } from '../generated/model';
import { Link } from 'react-router-dom';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminImportResultsPage() {
const { data: resultsData, isLoading } = useGetImportResults();
const results = dataForStatus(resultsData, 200)?.results ?? [];
const results = resultsData?.results ?? [];
const columns: Column<ImportResult>[] = [
{
+1 -2
View File
@@ -3,14 +3,13 @@ import { useGetLogs } from '../generated/anthoLumeAPIV1';
import { Button, LoadingState, TextInput, IconInput } from '../components';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon } from '../icons';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminLogsPage() {
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300);
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
const logs = dataForStatus(logsData, 200)?.logs ?? [];
const logs = logsData?.logs ?? [];
const handleFilterSubmit = (e: SyntheticEvent) => {
e.preventDefault();
+2 -12
View File
@@ -3,7 +3,7 @@ import { usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { getErrorMessage, getResponseError } from '../utils/errors';
import { getErrorMessage } from '../utils/errors';
import { streamResponseToFile, backupFilename } from '../utils/download';
interface BackupTypes {
@@ -65,23 +65,13 @@ export default function AdminPage() {
const toastId = showInfo('Restore started', 0);
try {
const response = await postAdminAction.mutateAsync({
await postAdminAction.mutateAsync({
data: {
action: 'RESTORE',
restore_file: restoreFile,
},
});
const message = getResponseError(response);
if (message) {
updateToast(toastId, {
type: 'error',
message: `Restore failed: ${message}`,
duration: 5000,
});
return;
}
updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 });
} catch (error) {
updateToast(toastId, {
+1 -2
View File
@@ -8,7 +8,6 @@ import { AddIcon, DeleteIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { useToasts } from '../components/ToastContext';
import { formatDate } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
interface AddUserFormProps {
onCreate: (_username: string, _password: string, _isAdmin: boolean) => void;
@@ -117,7 +116,7 @@ export default function AdminUsersPage() {
const [showAddForm, setShowAddForm] = useState(false);
const [resetUserId, setResetUserId] = useState<string | null>(null);
const users = dataForStatus(usersData, 200)?.users ?? [];
const users = usersData?.users ?? [];
const handleCreateUser = (username: string, password: string, isAdmin: boolean) => {
if (!username || !password) {
+1 -2
View File
@@ -8,7 +8,6 @@ import {
} from '../generated/anthoLumeAPIV1';
import type { EditDocumentBody } from '../generated/model';
import { formatDuration } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
import { useToastMutation } from '../hooks/useMutationWithToast';
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
@@ -122,7 +121,7 @@ export default function DocumentPage() {
return <LoadingState />;
}
const doc = dataForStatus(docData, 200)?.document;
const doc = docData?.document;
if (!doc) {
return <div className="text-content-muted">Document not found</div>;
+1 -2
View File
@@ -11,7 +11,6 @@ import { cn } from '../utils/cn';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { usePaginatedList } from '../hooks/usePaginatedList';
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
import { dataForStatus } from '../utils/apiResponses';
const DOCUMENTS_PAGE_SIZE = 9;
@@ -117,7 +116,7 @@ export default function DocumentsPage() {
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument();
const documentsResponse = dataForStatus(data, 200);
const documentsResponse = data;
const docs = documentsResponse?.documents;
const previousPage = documentsResponse?.previous_page;
const nextPage = documentsResponse?.next_page;
+1 -2
View File
@@ -5,7 +5,6 @@ import { useGetHome } from '../generated/anthoLumeAPIV1';
import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
import { formatNumber, formatDuration } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
interface InfoCardProps {
title: string;
@@ -156,7 +155,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome();
const homeResponse = dataForStatus(homeData, 200);
const homeResponse = homeData;
const dbInfo = homeResponse?.database_info;
const streaks = homeResponse?.streaks?.streaks;
const graphData = homeResponse?.graph_data?.graph_data;
-9
View File
@@ -56,12 +56,9 @@ describe('LoginPage', () => {
});
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: false,
},
},
} as ReturnType<typeof useGetInfo>);
});
@@ -154,12 +151,9 @@ describe('LoginPage', () => {
it('shows the registration link only when registration is enabled', () => {
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: true,
},
},
} as ReturnType<typeof useGetInfo>);
const { rerender } = render(
@@ -171,12 +165,9 @@ describe('LoginPage', () => {
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: false,
},
},
} as ReturnType<typeof useGetInfo>);
rerender(
+1 -2
View File
@@ -5,7 +5,6 @@ import { Table, type Column } from '../components/Table';
import { documentColumn } from '../components/documentColumn';
import { usePaginatedList } from '../hooks/usePaginatedList';
import { formatDate } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
const PROGRESS_PAGE_SIZE = 15;
@@ -13,7 +12,7 @@ export default function ProgressPage() {
const { page, setPage } = usePaginatedList();
const limit = PROGRESS_PAGE_SIZE;
const { data, isLoading } = useGetProgressList({ page, limit });
const response = dataForStatus(data, 200);
const response = data;
const progress = response?.progress ?? [];
const columns: Column<Progress>[] = [
+2 -3
View File
@@ -13,7 +13,6 @@ import {
useLocalSetting,
} from '../utils/localSettings';
import { useEpubReader } from '../hooks/useEpubReader';
import { dataForStatus } from '../utils/apiResponses';
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm';
const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
@@ -40,8 +39,8 @@ export default function ReaderPage() {
enabled: Boolean(id),
},
});
const doc = dataForStatus(documentResponse, 200)?.document;
const progress = dataForStatus(progressResponse, 200)?.progress;
const doc = documentResponse?.document;
const progress = progressResponse?.progress;
const deviceId = defaultDeviceId;
const deviceName = defaultDeviceName;
-9
View File
@@ -56,12 +56,9 @@ describe('RegisterPage', () => {
});
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: true,
},
},
isLoading: false,
} as ReturnType<typeof useGetInfo>);
});
@@ -155,12 +152,9 @@ describe('RegisterPage', () => {
it('redirects to login when registration is disabled', async () => {
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: false,
},
},
isLoading: false,
} as ReturnType<typeof useGetInfo>);
@@ -177,12 +171,9 @@ describe('RegisterPage', () => {
it('disables the form when registration is disabled', () => {
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: false,
},
},
isLoading: false,
} as ReturnType<typeof useGetInfo>);
-6
View File
@@ -14,12 +14,9 @@ describe('SearchPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseGetSearch.mockReturnValue({
data: {
status: 200,
data: {
results: [],
},
},
isLoading: false,
} as ReturnType<typeof useGetSearch>);
});
@@ -66,8 +63,6 @@ describe('SearchPage', () => {
it('renders search results from the generated hook response', () => {
mockedUseGetSearch.mockReturnValue({
data: {
status: 200,
data: {
results: [
{
@@ -80,7 +75,6 @@ describe('SearchPage', () => {
},
],
},
},
isLoading: false,
} as ReturnType<typeof useGetSearch>);
+1 -2
View File
@@ -6,7 +6,6 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components'
import { inputClassName } from '../components/TextInput';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon, BookIcon } from '../icons';
import { dataForStatus } from '../utils/apiResponses';
const searchColumns: Column<SearchItem>[] = [
{
@@ -87,7 +86,7 @@ export default function SearchPage() {
},
}
);
const results = dataForStatus(data, 200)?.results ?? [];
const results = data?.results ?? [];
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
+1 -2
View File
@@ -9,7 +9,6 @@ import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWith
import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings';
import { formatDateTime } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
const deviceColumns: Column<Device>[] = [
{
@@ -192,7 +191,7 @@ function DevicesSection({ devices }: { devices: Device[] }) {
export default function SettingsPage() {
const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings();
const settingsData = dataForStatus(data, 200);
const settingsData = data;
const { showError } = useToasts();
const toastMutationOptions = useMutationWithToast();
const runWithToast = useToastMutation();
+55
View File
@@ -0,0 +1,55 @@
import type { ErrorResponse } from '../generated/model';
// Thrown by the generated API client for any non-2xx response. `body` is the parsed error payload
// (the API's ErrorResponse for documented failures); `message` is the server-provided message when
// present, so `error.message` is always display-ready.
export class ApiError<TBody = ErrorResponse> extends Error {
readonly status: number;
readonly body: TBody;
constructor(status: number, body: TBody, message: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
const EMPTY_BODY_STATUSES = new Set([204, 205, 304]);
function messageFromBody(body: unknown, fallback: string): string {
if (body && typeof body === 'object' && 'message' in body) {
const { message } = body as { message?: unknown };
if (typeof message === 'string' && message.trim() !== '') {
return message;
}
}
return fallback;
}
async function parseBody(response: Response): Promise<unknown> {
if (EMPTY_BODY_STATUSES.has(response.status)) {
return undefined;
}
const raw = await response.text();
if (!raw) {
return undefined;
}
const contentType = response.headers.get('content-type') ?? '';
return contentType.includes('application/json') ? JSON.parse(raw) : raw;
}
// Orval mutator - The generated client calls this for every request. Returns the parsed success
// body directly and throws ApiError on non-2xx, so React Query surfaces failures via isError/onError.
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
const body = await parseBody(response);
if (!response.ok) {
throw new ApiError(response.status, body, messageFromBody(body, response.statusText || 'Request failed'));
}
return body as T;
}
-13
View File
@@ -1,13 +0,0 @@
type ApiResponse = {
status: number;
data: unknown;
};
export function dataForStatus<TResponse extends ApiResponse, TStatus extends TResponse['status']>(
response: TResponse | undefined,
status: TStatus
): Extract<TResponse, { status: TStatus }>['data'] | undefined {
return response?.status === status
? (response.data as Extract<TResponse, { status: TStatus }>['data'])
: undefined;
}
+3 -17
View File
@@ -1,3 +1,6 @@
// Extracts a display message from a caught error. The generated client throws ApiError (an Error
// subclass whose `message` is the server-provided message), so this covers both API and unexpected
// failures in a single catch path.
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
if (error instanceof Error && error.message) {
return error.message;
@@ -12,20 +15,3 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
return fallback;
}
export interface ApiResponseLike {
status: number;
data: unknown;
}
/**
* Non-2xx Check - The generated client resolves non-2xx instead of throwing; this returns the
* extracted error message for failure responses, or null for success (2xx) so callers can branch.
*/
export function getResponseError(response: ApiResponseLike): string | null {
if (response.status >= 200 && response.status < 300) {
return null;
}
return getErrorMessage(response.data, 'Request failed');
}