Introduce result type and generic safe fetch functions. Do proper error handling/display.
This commit is contained in:
parent
9138a187a9
commit
65fd45fc16
1 changed files with 245 additions and 79 deletions
314
src/App.tsx
314
src/App.tsx
|
|
@ -1,5 +1,5 @@
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { useReducer, useRef, useEffect, createContext, useContext } from "react"
|
import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react"
|
||||||
import {
|
import {
|
||||||
CssBaseline,
|
CssBaseline,
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -12,12 +12,51 @@ import {
|
||||||
|
|
||||||
// TODO: Improve error-handling. Introduce some proper server response.
|
// TODO: Improve error-handling. Introduce some proper server response.
|
||||||
|
|
||||||
|
type Result<A, E> = { tag: "ok"; value: A } | { tag: "error"; error: E }
|
||||||
|
|
||||||
|
const Result = {
|
||||||
|
ok<A>(value: A): Result<A, never> {
|
||||||
|
return { tag: "ok", value }
|
||||||
|
},
|
||||||
|
err<E>(error: E): Result<never, E> {
|
||||||
|
return { tag: "error", error }
|
||||||
|
},
|
||||||
|
map<A, B, E>(result: Result<A, E>, f: (x: A) => B): Result<B, E> {
|
||||||
|
switch (result.tag) {
|
||||||
|
case "ok":
|
||||||
|
return { tag: "ok", value: f(result.value) }
|
||||||
|
case "error":
|
||||||
|
return { tag: "error", error: result.error }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestError =
|
||||||
|
| { tag: "networkError"; error: unknown }
|
||||||
|
| { tag: "httpError"; status: number; statusText: string }
|
||||||
|
| { tag: "jsonParseError"; error: unknown }
|
||||||
|
| { tag: "invalidResponse"; error: z.ZodError }
|
||||||
|
|
||||||
|
const RequestError = {
|
||||||
|
toString(error: RequestError): string {
|
||||||
|
switch (error.tag) {
|
||||||
|
case "networkError":
|
||||||
|
return "Network error. Check your connection and try again."
|
||||||
|
case "httpError":
|
||||||
|
return `Server returned ${String(error.status)} ${error.statusText}.`
|
||||||
|
case "jsonParseError":
|
||||||
|
return "The server returned an invalid response."
|
||||||
|
case "invalidResponse":
|
||||||
|
return "The image data had an unexpected format."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// === Image ===
|
// === Image ===
|
||||||
type ImageId = string
|
type ImageId = string
|
||||||
type ImageRef = string
|
type ImageRef = string
|
||||||
|
|
||||||
// === Dimension ===
|
// === Dimension ===
|
||||||
|
|
||||||
type Dimension = {
|
type Dimension = {
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
|
@ -67,6 +106,69 @@ const Page = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Generic safe fetch functions ===
|
||||||
|
async function fetchJsonSafe(url: string): Promise<Result<unknown, RequestError>> {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(url)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.err({ tag: "networkError", error })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return Result.err({
|
||||||
|
tag: "httpError",
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const json: unknown = await response.json()
|
||||||
|
return Result.ok(json)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.err({ tag: "jsonParseError", error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assumes `f` is some sort of a zod parsing function that may throw `z.ZodError`
|
||||||
|
async function fetchJsonSafeWith<A>(
|
||||||
|
url: string,
|
||||||
|
f: (json: unknown) => A,
|
||||||
|
): Promise<Result<A, RequestError>> {
|
||||||
|
const result = await fetchJsonSafe(url)
|
||||||
|
switch (result.tag) {
|
||||||
|
case "error":
|
||||||
|
return Result.err(result.error)
|
||||||
|
case "ok":
|
||||||
|
// TODO
|
||||||
|
try {
|
||||||
|
return Result.ok(f(result.value))
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return Result.err({ tag: "invalidResponse", error })
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Generic Remote Data ===
|
||||||
|
type Remote<A, E> = { tag: "loading" } | Result<A, E>
|
||||||
|
|
||||||
|
const Remote = {
|
||||||
|
loading(): Remote<never, never> {
|
||||||
|
return { tag: "loading" }
|
||||||
|
},
|
||||||
|
error<E>(error: E): Remote<never, E> {
|
||||||
|
return { tag: "error", error }
|
||||||
|
},
|
||||||
|
loaded<A>(value: A): Remote<A, never> {
|
||||||
|
return { tag: "ok", value }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// === api ===
|
// === api ===
|
||||||
const picsumApiImageSchema = z.object({
|
const picsumApiImageSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
@ -78,23 +180,19 @@ const picsumApiImageSchema = z.object({
|
||||||
})
|
})
|
||||||
type PicsumApiImage = z.infer<typeof picsumApiImageSchema>
|
type PicsumApiImage = z.infer<typeof picsumApiImageSchema>
|
||||||
|
|
||||||
async function getPicsumImages({ page, limit }: Page): Promise<PicsumApiImage[]> {
|
async function getPicsumImages({
|
||||||
const response = await fetch(
|
page,
|
||||||
|
limit,
|
||||||
|
}: Page): Promise<Result<PicsumApiImage[], RequestError>> {
|
||||||
|
return fetchJsonSafeWith(
|
||||||
`https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`,
|
`https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`,
|
||||||
|
(json) => z.array(picsumApiImageSchema).parse(json),
|
||||||
)
|
)
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch images: ${String(response.status)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const json: unknown = await response.json()
|
|
||||||
const data = z.array(picsumApiImageSchema).parse(json)
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getImageIds(page: Page): Promise<ImageId[]> {
|
async function getImageIds(page: Page): Promise<Result<ImageId[], RequestError>> {
|
||||||
const data = await getPicsumImages(page)
|
const result = await getPicsumImages(page)
|
||||||
return data.map(({ id }) => id)
|
return Result.map(result, (data) => data.map(({ id }) => id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use this for `<img src=... />`
|
// Use this for `<img src=... />`
|
||||||
|
|
@ -102,29 +200,15 @@ function getImageSource(id: ImageId, dimension: Dimension): ImageRef {
|
||||||
return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}`
|
return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Generic Remote Data ===
|
|
||||||
type Remote<A, E> = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A }
|
|
||||||
|
|
||||||
const Remote = {
|
|
||||||
loading(): Remote<never, never> {
|
|
||||||
return { tag: "loading" }
|
|
||||||
},
|
|
||||||
error<E>(error: E): Remote<never, E> {
|
|
||||||
return { tag: "error", error }
|
|
||||||
},
|
|
||||||
loaded<A>(value: A): Remote<A, never> {
|
|
||||||
return { tag: "loaded", value }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// === App ===
|
// === App ===
|
||||||
// TODO: introduce proper error type
|
// TODO: introduce proper error type
|
||||||
type AppError = string
|
type AppError = RequestError
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
imageIds: Remote<ImageId[], AppError>
|
imageIds: Remote<ImageId[], AppError>
|
||||||
page: Page
|
page: Page
|
||||||
selectedImage: ImageId | undefined
|
selectedImage: ImageId | undefined
|
||||||
|
refreshSignal: boolean // This is here only because of the refresh button. It is an artificial way to trigger a reload without the current page changing. The value is meaningless. It just has to change.
|
||||||
}
|
}
|
||||||
|
|
||||||
const State = {
|
const State = {
|
||||||
|
|
@ -133,12 +217,14 @@ const State = {
|
||||||
imageIds: Remote.loading(),
|
imageIds: Remote.loading(),
|
||||||
page: Page.init(),
|
page: Page.init(),
|
||||||
selectedImage: undefined,
|
selectedImage: undefined,
|
||||||
|
refreshSignal: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type Msg =
|
type Msg =
|
||||||
| { tag: "imagesReceived"; imageIds: ImageId[]; forPage: Page }
|
| { tag: "imagesReceived"; result: Result<ImageId[], AppError>; forPage: Page }
|
||||||
|
| { tag: "retryButtonClicked" }
|
||||||
| { tag: "previousButtonClicked" }
|
| { tag: "previousButtonClicked" }
|
||||||
| { tag: "nextButtonClicked" }
|
| { tag: "nextButtonClicked" }
|
||||||
| { tag: "imageClicked"; imageId: ImageId }
|
| { tag: "imageClicked"; imageId: ImageId }
|
||||||
|
|
@ -151,36 +237,42 @@ function useApp(): [State, Dispatch] {
|
||||||
// Could also cache `Promise<ImageId[]>` in case two requests are made really fast one after another (not really the case in this app so whatever)
|
// Could also cache `Promise<ImageId[]>` in case two requests are made really fast one after another (not really the case in this app so whatever)
|
||||||
const cacheRef = useRef<Map<PageKey, ImageId[]>>(new Map())
|
const cacheRef = useRef<Map<PageKey, ImageId[]>>(new Map())
|
||||||
|
|
||||||
async function getImageIdsCached(page: Page): Promise<ImageId[]> {
|
async function getImageIdsCached(page: Page): Promise<Result<ImageId[], AppError>> {
|
||||||
const pageKey = Page.key(page)
|
const pageKey = Page.key(page)
|
||||||
const maybeImages = cacheRef.current.get(pageKey)
|
const maybeImages = cacheRef.current.get(pageKey)
|
||||||
if (maybeImages === undefined) {
|
if (maybeImages === undefined) {
|
||||||
console.log("CACHE-MISS")
|
// console.log("CACHE-MISS")
|
||||||
const images = await getImageIds(page)
|
const result = await getImageIds(page)
|
||||||
cacheRef.current.set(pageKey, images)
|
if (result.tag === "ok") {
|
||||||
return images
|
cacheRef.current.set(pageKey, result.value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
} else {
|
} else {
|
||||||
console.log("CACHE-HIT")
|
// console.log("CACHE-HIT")
|
||||||
return maybeImages
|
return Result.ok(maybeImages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === initialization & reloading ===
|
// === initialization & reloading ===
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: error-handling
|
// TODO: error-handling
|
||||||
getImageIdsCached(state.page).then((imageIds) => {
|
void getImageIdsCached(state.page).then((result) => {
|
||||||
dispatch({ tag: "imagesReceived", forPage: state.page, imageIds })
|
dispatch({ tag: "imagesReceived", forPage: state.page, result })
|
||||||
})
|
})
|
||||||
}, [state.page]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm
|
}, [state.page, state.refreshSignal]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm
|
||||||
|
|
||||||
function update(state: State, msg: Msg): State {
|
function update(state: State, msg: Msg): State {
|
||||||
switch (msg.tag) {
|
switch (msg.tag) {
|
||||||
case "imagesReceived":
|
case "imagesReceived":
|
||||||
if (Page.eq(state.page, msg.forPage)) {
|
if (Page.eq(state.page, msg.forPage)) {
|
||||||
return { ...state, imageIds: Remote.loaded(msg.imageIds) }
|
return { ...state, imageIds: msg.result }
|
||||||
} else {
|
} else {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
case "retryButtonClicked":
|
||||||
|
console.log("TODO")
|
||||||
|
// TODO: This actually needs to trigger the reload somehow /facepalm. But we can refresh only on page change.
|
||||||
|
return { ...state, imageIds: Remote.loading(), refreshSignal: !state.refreshSignal }
|
||||||
case "previousButtonClicked": {
|
case "previousButtonClicked": {
|
||||||
const newPage = Page.previous(state.page)
|
const newPage = Page.previous(state.page)
|
||||||
if (Page.eq(newPage, state.page)) {
|
if (Page.eq(newPage, state.page)) {
|
||||||
|
|
@ -193,11 +285,9 @@ function useApp(): [State, Dispatch] {
|
||||||
return { ...state, page: Page.next(state.page), imageIds: Remote.loading() }
|
return { ...state, page: Page.next(state.page), imageIds: Remote.loading() }
|
||||||
}
|
}
|
||||||
case "imageClicked": {
|
case "imageClicked": {
|
||||||
console.log(msg.imageId)
|
|
||||||
return { ...state, selectedImage: msg.imageId }
|
return { ...state, selectedImage: msg.imageId }
|
||||||
}
|
}
|
||||||
case "modalCloseButtonClicked": {
|
case "modalCloseButtonClicked": {
|
||||||
console.log("modal closed")
|
|
||||||
return { ...state, selectedImage: undefined }
|
return { ...state, selectedImage: undefined }
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
@ -223,16 +313,51 @@ function useDispatch() {
|
||||||
|
|
||||||
// === Views ===
|
// === Views ===
|
||||||
function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) {
|
function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) {
|
||||||
|
const dispatch = useDispatch()
|
||||||
switch (images.tag) {
|
switch (images.tag) {
|
||||||
case "loading":
|
case "loading":
|
||||||
return <ImagesSkeleton />
|
return <ImagesSkeleton />
|
||||||
case "error":
|
case "error":
|
||||||
return <div>Error: TODO</div>
|
return (
|
||||||
case "loaded":
|
<Box sx={{ position: "relative" }}>
|
||||||
|
<ImagesSkeleton visible={false} />
|
||||||
|
<Box sx={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
|
||||||
|
<ErrorView
|
||||||
|
error={images.error}
|
||||||
|
onRetry={() => {
|
||||||
|
dispatch({ tag: "retryButtonClicked" })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
case "ok":
|
||||||
return <Images images={images.value} />
|
return <Images images={images.value} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Could not load images</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "oklch(50% 0.02 var(--base-hue))" }}>
|
||||||
|
{RequestError.toString(error)}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function imageGridStyle(dimension: Dimension) {
|
function imageGridStyle(dimension: Dimension) {
|
||||||
return {
|
return {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
|
@ -246,18 +371,29 @@ function imageGridStyle(dimension: Dimension) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2x5 on mobile, 5x2 on desktop (assuming 10 images per page)
|
// 2x5 on mobile, 5x2 on desktop (assuming 10 images per page)
|
||||||
function ImagesSkeleton() {
|
function ImagesSkeleton({ visible = true }: { visible?: boolean }) {
|
||||||
const images = Array.from({ length: 10 })
|
const images = Array.from({ length: 10 })
|
||||||
return (
|
return (
|
||||||
<Box sx={imageGridStyle(Dimension.medium)}>
|
<Box sx={imageGridStyle(Dimension.medium)}>
|
||||||
{images.map((_, index) => (
|
{images.map((_, index) =>
|
||||||
|
visible ? (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={index}
|
key={index}
|
||||||
|
animation="pulse"
|
||||||
variant="rectangular"
|
variant="rectangular"
|
||||||
width={Dimension.medium.width}
|
width={Dimension.medium.width}
|
||||||
height={Dimension.medium.height}
|
height={Dimension.medium.height}
|
||||||
></Skeleton>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
sx={{
|
||||||
|
width: Dimension.medium.width,
|
||||||
|
height: Dimension.medium.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -267,26 +403,68 @@ function Images({ images }: { images: ImageId[] }) {
|
||||||
return (
|
return (
|
||||||
<Box sx={imageGridStyle(Dimension.medium)}>
|
<Box sx={imageGridStyle(Dimension.medium)}>
|
||||||
{images.map((imageId) => (
|
{images.map((imageId) => (
|
||||||
<Box
|
<Image
|
||||||
component="img"
|
imageId={imageId}
|
||||||
src={getImageSource(imageId, Dimension.medium)}
|
dimension={Dimension.medium}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch({ tag: "imageClicked", imageId })
|
dispatch({ tag: "imageClicked", imageId })
|
||||||
}}
|
}}
|
||||||
sx={{
|
|
||||||
width: Dimension.medium.width,
|
|
||||||
height: Dimension.medium.height,
|
|
||||||
objectFit: "cover",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
key={imageId}
|
key={imageId}
|
||||||
alt=""
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Image({
|
||||||
|
imageId,
|
||||||
|
dimension,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
imageId: ImageId
|
||||||
|
dimension: Dimension
|
||||||
|
onClick?: () => void
|
||||||
|
}) {
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: dimension.width,
|
||||||
|
height: dimension.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!loaded && (
|
||||||
|
<Skeleton
|
||||||
|
animation="pulse"
|
||||||
|
variant="rectangular"
|
||||||
|
width={dimension.width}
|
||||||
|
height={dimension.height}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={getImageSource(imageId, dimension)}
|
||||||
|
onLoad={() => {
|
||||||
|
setLoaded(true)
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: dimension.width,
|
||||||
|
height: dimension.height,
|
||||||
|
objectFit: "cover",
|
||||||
|
cursor: onClick === undefined ? "default" : "pointer",
|
||||||
|
opacity: loaded ? 1 : 0,
|
||||||
|
}}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
|
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
return (
|
return (
|
||||||
|
|
@ -298,19 +476,7 @@ function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
|
||||||
maxWidth={false}
|
maxWidth={false}
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ p: 0, overflow: "hidden" }}>
|
<DialogContent sx={{ p: 0, overflow: "hidden" }}>
|
||||||
{selectedImage !== undefined && (
|
{selectedImage !== undefined && <Image imageId={selectedImage} dimension={Dimension.big} />}
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src={getImageSource(selectedImage, Dimension.big)}
|
|
||||||
sx={{
|
|
||||||
display: "block",
|
|
||||||
maxWidth: "90vw",
|
|
||||||
maxHeight: "80vh",
|
|
||||||
objectFit: "contain",
|
|
||||||
}}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue