FEAT-1: add typed gerbils/lookups API + useApi/useMutation hooks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,5 +62,7 @@ export const api = {
|
|||||||
export const resources = {
|
export const resources = {
|
||||||
gerbils: '/gerbils',
|
gerbils: '/gerbils',
|
||||||
litters: '/litters',
|
litters: '/litters',
|
||||||
breeders: '/breeders',
|
contacts: '/contacts',
|
||||||
|
enclosures: '/enclosures',
|
||||||
|
colorVarieties: '/color-varieties',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
24
gerbil-manager-web/src/api/gerbils.ts
Normal file
24
gerbil-manager-web/src/api/gerbils.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** Typed API functions for the Tiere (Rennmäuse) resource. */
|
||||||
|
import { api, resources } from './client'
|
||||||
|
import { toQueryString, type GridifyQuery } from './gridify'
|
||||||
|
import type { CreateGerbil, Gerbil, Paged, UpdateGerbil } from './types'
|
||||||
|
|
||||||
|
export function listGerbils(query: GridifyQuery): Promise<Paged<Gerbil>> {
|
||||||
|
return api.get<Paged<Gerbil>>(`${resources.gerbils}${toQueryString(query)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGerbil(id: string): Promise<Gerbil> {
|
||||||
|
return api.get<Gerbil>(`${resources.gerbils}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGerbil(body: CreateGerbil): Promise<Gerbil> {
|
||||||
|
return api.post<Gerbil>(resources.gerbils, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGerbil(id: string, body: UpdateGerbil): Promise<Gerbil> {
|
||||||
|
return api.put<Gerbil>(`${resources.gerbils}/${id}`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGerbil(id: string): Promise<void> {
|
||||||
|
return api.delete(`${resources.gerbils}/${id}`)
|
||||||
|
}
|
||||||
34
gerbil-manager-web/src/api/lookups.ts
Normal file
34
gerbil-manager-web/src/api/lookups.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Lookup resources used to populate form dropdowns (Farbschlag, Becken,
|
||||||
|
* Kontakte, Würfe). These endpoints are Gridify-paged like the rest; for
|
||||||
|
* dropdowns we request a large page and read `.items`.
|
||||||
|
*/
|
||||||
|
import { api, resources } from './client'
|
||||||
|
import { toQueryString } from './gridify'
|
||||||
|
import type { ColorVariety, Contact, Enclosure, Litter, Paged } from './types'
|
||||||
|
|
||||||
|
const ALL = toQueryString({ page: 1, pageSize: 1000, orderBy: 'name' })
|
||||||
|
|
||||||
|
export async function listColorVarieties(): Promise<ColorVariety[]> {
|
||||||
|
const paged = await api.get<Paged<ColorVariety>>(
|
||||||
|
`${resources.colorVarieties}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'sortOrder' })}`,
|
||||||
|
)
|
||||||
|
return paged.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEnclosures(): Promise<Enclosure[]> {
|
||||||
|
const paged = await api.get<Paged<Enclosure>>(`${resources.enclosures}${ALL}`)
|
||||||
|
return paged.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listContacts(): Promise<Contact[]> {
|
||||||
|
const paged = await api.get<Paged<Contact>>(`${resources.contacts}${ALL}`)
|
||||||
|
return paged.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLitters(): Promise<Litter[]> {
|
||||||
|
const paged = await api.get<Paged<Litter>>(
|
||||||
|
`${resources.litters}${toQueryString({ page: 1, pageSize: 1000, orderBy: 'date desc' })}`,
|
||||||
|
)
|
||||||
|
return paged.items
|
||||||
|
}
|
||||||
98
gerbil-manager-web/src/hooks/useApi.ts
Normal file
98
gerbil-manager-web/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Minimal async-data hooks (no react-query dependency).
|
||||||
|
*
|
||||||
|
* `useApi` runs an async loader and tracks loading/error/data, re-running when
|
||||||
|
* `deps` change. `reload()` re-fetches on demand (e.g. after a mutation).
|
||||||
|
*
|
||||||
|
* Design notes (to satisfy the strict react-hooks rules):
|
||||||
|
* - `loading` is DERIVED (resolved-key !== current-request-key), never set
|
||||||
|
* synchronously inside the effect.
|
||||||
|
* - the loader is read through a ref so the effect can key purely on the
|
||||||
|
* request signature without re-running on every render.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
|
export interface AsyncState<T> {
|
||||||
|
data: T | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) return err.message
|
||||||
|
if (err instanceof Error && err.message) return err.message
|
||||||
|
return de.api.errors.unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Resolved<T> {
|
||||||
|
key: string | null
|
||||||
|
data: T | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi<T>(loader: () => Promise<T>, deps: unknown[]): AsyncState<T> {
|
||||||
|
const loaderRef = useRef(loader)
|
||||||
|
// Keep the loader ref current (declared before the request effect so it is
|
||||||
|
// synced first within a commit). Writing refs in render is disallowed.
|
||||||
|
useEffect(() => {
|
||||||
|
loaderRef.current = loader
|
||||||
|
})
|
||||||
|
|
||||||
|
const [nonce, setNonce] = useState(0)
|
||||||
|
const requestKey = `${nonce}:${JSON.stringify(deps)}`
|
||||||
|
|
||||||
|
const [resolved, setResolved] = useState<Resolved<T>>({ key: null, data: null, error: null })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
loaderRef
|
||||||
|
.current()
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setResolved({ key: requestKey, data: result, error: null })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setResolved({ key: requestKey, data: null, error: messageFor(err) })
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [requestKey])
|
||||||
|
|
||||||
|
const reload = useCallback(() => setNonce((n) => n + 1), [])
|
||||||
|
const loading = resolved.key !== requestKey
|
||||||
|
|
||||||
|
return { data: resolved.data, loading, error: loading ? null : resolved.error, reload }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a mutation (create/update/delete) with pending + error state. */
|
||||||
|
export function useMutation<TArgs extends unknown[], TResult>(
|
||||||
|
mutator: (...args: TArgs) => Promise<TResult>,
|
||||||
|
): {
|
||||||
|
run: (...args: TArgs) => Promise<TResult>
|
||||||
|
pending: boolean
|
||||||
|
error: string | null
|
||||||
|
} {
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (...args: TArgs) => {
|
||||||
|
setPending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
return await mutator(...args)
|
||||||
|
} catch (err) {
|
||||||
|
setError(messageFor(err))
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutator],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { run, pending, error }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user