Improved useFetch

Related to #19
This commit is contained in:
Dmitriy Shishkov 2023-07-14 20:32:52 +03:00
parent 7ef4194bbd
commit 48a48f9364
Signed by: dm1sh
GPG Key ID: 027994B0AA357688
15 changed files with 272 additions and 148 deletions

View File

View File

@ -0,0 +1,59 @@
import { isObject } from '../../utils/types'
import { Category, isCategory } from '../../assets/category'
type AnnouncementResponse = {
id: number,
user_id: number,
name: string,
category: Category,
best_by: number,
address: string,
longtitude: number,
latitude: number,
description: string,
src: string | null,
metro: string,
trashId: number | null,
booked_by: number
}
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => isObject(obj, {
'id': 'number',
'user_id': 'number',
'name': 'string',
'category': isCategory,
'best_by': 'number',
'address': 'string',
'longtitude': 'number',
'latitude': 'number',
'description': 'string',
'src': 'string?',
'metro': 'string',
'trashId': 'number?',
'booked_by': 'number'
})
type Announcement = {
id: number,
userId: number,
name: string,
category: Category,
bestBy: number,
address: string,
lng: number,
lat: number,
description: string | null,
src: string | null,
metro: string,
trashId: number | null,
bookedBy: number
}
export type {
Announcement,
AnnouncementResponse,
}
export {
isAnnouncementResponse,
}

View File

@ -0,0 +1,24 @@
import { API_URL } from '../../config'
import { FiltersType, URLEncodeFilters } from '../../utils/filters'
import { Announcement } from '../announcement/types'
import { AnnouncementsResponse } from './types'
const initialAnnouncements: Announcement[] = []
const composeAnnouncementsURL = (filters: FiltersType) =>
API_URL + '/announcements?' + new URLSearchParams(URLEncodeFilters(filters)).toString()
const processAnnouncements = (data: AnnouncementsResponse): Announcement[] => {
const annList = data.list_of_announcements
return annList.map(ann => ({
...ann,
lat: ann.latitude,
lng: ann.longtitude,
bestBy: ann.best_by,
bookedBy: ann.booked_by,
userId: ann.user_id
}))
}
export { initialAnnouncements, composeAnnouncementsURL, processAnnouncements }

View File

@ -0,0 +1,20 @@
import { isArrayOf, isObject } from '../../utils/types'
import { AnnouncementResponse, isAnnouncementResponse } from '../announcement/types'
type AnnouncementsResponse = {
list_of_announcements: AnnouncementResponse[],
Success: boolean
}
const isAnnouncementsResponse = (obj: unknown): obj is AnnouncementsResponse => isObject(obj, {
'list_of_announcements': obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse),
'Success': 'boolean'
})
export type {
AnnouncementsResponse,
}
export {
isAnnouncementsResponse,
}

View File

@ -0,0 +1,11 @@
import { LatLng } from 'leaflet'
import { API_URL } from '../../config'
const composeTrashboxURL = (position: LatLng) =>
API_URL + '/trashbox?' + new URLSearchParams({
lat: position.lat.toString(),
lng: position.lng.toString()
}).toString()
export { composeTrashboxURL }

View File

@ -0,0 +1,22 @@
import { isArrayOf, isObject, isString } from '../../utils/types'
type Trashbox = {
Lat: number,
Lng: number,
Address: string,
Categories: string[]
}
const isTrashbox = (obj: unknown): obj is Trashbox => isObject(obj, {
'Lat': 'number',
'Lng': 'number',
'Address': 'string',
'Categories': obj => isArrayOf<string>(obj, isString)
})
type TrashboxResponse = Trashbox[]
const isTrashboxResponse = (obj: unknown): obj is Trashbox[] => isArrayOf(obj, isTrashbox)
export type { Trashbox, TrashboxResponse }
export { isTrashbox, isTrashboxResponse }

View File

@ -4,7 +4,7 @@ import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
import LineDot from './LineDot'
import { categoryNames } from '../assets/category'
import { useBook } from '../hooks/api'
import { Announcement } from '../hooks/api/useHomeAnnouncementList'
import { Announcement } from '../hooks/api/useAnnouncements'
import { iconItem } from '../utils/markerIcons'
type AnnouncementDetailsProps = {

View File

@ -1,4 +1,4 @@
export { default as useHomeAnnouncementList } from './useHomeAnnouncementList'
export { default as useAnnouncements } from './useAnnouncements'
export { default as useBook } from './useBook'
export { default as useAuth } from './useAuth'
export { default as useTrashboxes } from './useTrashboxes'

View File

@ -0,0 +1,18 @@
import useFetch from './useFetch'
import { FiltersType } from '../../utils/filters'
import { composeAnnouncementsURL, initialAnnouncements, processAnnouncements } from '../../api/announcements'
import { isAnnouncementsResponse } from '../../api/announcements/types'
const useAnnouncements = (filters: FiltersType) => {
return useFetch(
composeAnnouncementsURL(filters),
'GET',
false,
processAnnouncements,
isAnnouncementsResponse,
initialAnnouncements
)
}
export default useAnnouncements

View File

@ -1,11 +1,53 @@
import { useEffect, useRef, useState } from 'react'
import { handleHTTPErrors, isAborted } from '../../utils'
import { getToken } from '../../utils/auth'
import { useNavigate } from 'react-router-dom'
const useFetch = <T>(url: string, params: RequestInit | undefined, initialData: T, dataGuard: (obj: unknown) => obj is T) => {
type UseFetchShared = {
loading: boolean,
abort?: () => void,
}
type UseFetchSucced<T> = {
error: null,
data: T,
} & UseFetchShared
type UseFetchErrored = {
error: string,
data: undefined
} & UseFetchShared
const gotError = <T>(res: UseFetchErrored | UseFetchSucced<T>): res is UseFetchErrored => {
return typeof res.error === 'string'
}
type UseFetchReturn<T> = ({
error: null,
data: T
} | {
error: string,
data: undefined
}) & {
loading: boolean,
abort?: (() => void)
}
const useFetch = <R, T>(
url: string,
method: RequestInit['method'],
needAuth: boolean,
processData: (data: R) => T,
guardResponse: (data: unknown) => data is R,
initialData?: T,
params?: RequestInit
): UseFetchReturn<T> => {
const [data, setData] = useState(initialData)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const abortControllerRef = useRef<AbortController>()
@ -17,39 +59,65 @@ const useFetch = <T>(url: string, params: RequestInit | undefined, initialData:
const abortController = new AbortController()
abortControllerRef.current = abortController
fetch(url, { ...params, signal: abortControllerRef.current.signal })
const headers = new Headers({
...params?.headers
})
if (needAuth) {
const token = getToken()
if (token === null) {
return navigate('/login')
}
headers.append('Auth', `Bearer ${token}`)
}
fetch(url, {
method,
...params,
headers,
signal: abortControllerRef.current.signal,
})
.then(res => {
handleHTTPErrors(res)
return res.json()
})
.then(data => {
if (!dataGuard(data)) {
throw new Error('Неверный ответ от сервера')
if (!guardResponse(data)) {
throw new Error('Malformed server response')
}
setData(data)
setData(processData(data))
setLoading(false)
})
.catch(err => {
if (err instanceof Error && !isAborted(err)) {
setError('Ошибка сети')
if (import.meta.env.DEV) {
console.log(url, params, err)
}
}
setLoading(false)
if (import.meta.env.DEV) {
console.log(url, params, err)
}
})
return () => abortControllerRef.current?.abort()
}, [url, params, dataGuard])
}, [url, method, needAuth, params, guardResponse, processData, navigate])
return {
data, loading, error,
...(
error === null ? ({
data: data!, error: null
}) : ({ data: undefined, error })
),
loading,
abort: abortControllerRef.current?.abort.bind(abortControllerRef.current)
}
}
export default useFetch
export { gotError }

View File

@ -1,96 +0,0 @@
import useFetch from './useFetch'
import { FiltersType, filterNames } from '../../utils/filters'
import { isArrayOf, isObject } from '../../utils/types'
import { API_URL } from '../../config'
import { Category, isCategory } from '../../assets/category'
const initialAnnouncements = { list_of_announcements: [], Success: true }
type AnnouncementsListResponse = {
list_of_announcements: AnnouncementResponse[],
Success: boolean
}
const isAnnouncementsListResponse = (obj: unknown): obj is AnnouncementsListResponse => isObject(obj, {
'list_of_announcements': obj => isArrayOf<AnnouncementResponse>(obj, isAnnouncementResponse),
'Success': 'boolean'
})
type AnnouncementResponse = {
id: number,
user_id: number,
name: string,
category: Category,
best_by: number,
address: string,
longtitude: number,
latitude: number,
description: string,
src: string | null,
metro: string,
trashId: number | null,
booked_by: number
}
const isAnnouncementResponse = (obj: unknown): obj is AnnouncementResponse => isObject(obj, {
'id': 'number',
'user_id': 'number',
'name': 'string',
'category': isCategory,
'best_by': 'number',
'address': 'string',
'longtitude': 'number',
'latitude': 'number',
'description': 'string',
'src': 'string?',
'metro': 'string',
'trashId': 'number?',
'booked_by': 'number'
})
type Announcement = {
id: number,
userId: number,
name: string,
category: Category,
bestBy: number,
address: string,
lng: number,
lat: number,
description: string | null,
src: string | null,
metro: string,
trashId: number | null,
bookedBy: number
}
const composeFilters = (filters: FiltersType) => Object.fromEntries(
filterNames.map(
fName => [fName, filters[fName]?.toString()]
).filter((p): p is [string, string] => typeof p[1] !== 'undefined')
)
const useHomeAnnouncementList = (filters: FiltersType) => {
const { data, loading, error } = useFetch(
API_URL + '/announcements?' + new URLSearchParams(composeFilters(filters)).toString(),
undefined,
initialAnnouncements,
isAnnouncementsListResponse
)
const annList = data.list_of_announcements
const res: Announcement[] = annList.map(ann => ({
...ann,
lat: ann.latitude,
lng: ann.longtitude,
bestBy: ann.best_by,
bookedBy: ann.booked_by,
userId: ann.user_id
}))
return { data: error ? [] : res, loading, error }
}
export type { Announcement, AnnouncementsListResponse }
export default useHomeAnnouncementList

View File

@ -1,35 +1,18 @@
import { LatLng } from 'leaflet'
import { API_URL } from '../../config'
import { isArrayOf, isObject } from '../../utils/types'
import useFetch from './useFetch'
import { isString } from '../../utils/types'
type Trashbox = {
Lat: number,
Lng: number,
Address: string,
Categories: string[]
}
const isTrashbox = (obj: unknown): obj is Trashbox => isObject(obj, {
'Lat': 'number',
'Lng': 'number',
'Address': 'string',
'Categories': obj => isArrayOf<string>(obj, isString)
})
import { composeTrashboxURL } from '../../api/trashbox'
import { isTrashboxResponse } from '../../api/trashbox/types'
const useTrashboxes = (position: LatLng) => {
return useFetch(
API_URL + '/trashbox?' + new URLSearchParams({
lat: position.lat.toString(),
lng: position.lng.toString()
}).toString(),
undefined,
[],
(obj): obj is Trashbox[] => isArrayOf(obj, isTrashbox)
composeTrashboxURL(position),
'GET',
true,
(data) => data,
isTrashboxResponse,
[]
)
}
export type { Trashbox }
export default useTrashboxes

View File

@ -9,6 +9,7 @@ import { isObject } from '../utils/types'
import { handleHTTPErrors } from '../utils'
import { categories, categoryNames } from '../assets/category'
import { stations, lines, lineNames } from '../assets/metro'
import { gotError } from '../hooks/api/useFetch'
function AddPage() {
const [addressPosition, setAddressPosition] = useState(latLng(59.972, 30.3227))
@ -165,7 +166,7 @@ function AddPage() {
<p>Загрузка...</p>
</div>
) : (
trashboxes.error ? (
gotError(trashboxes) ? (
<p
style={{ height: 400 }}
className='text-danger'
@ -191,7 +192,7 @@ function AddPage() {
)
}
</div>
{selectedTrashbox.index > -1 ? (
{!gotError(trashboxes) && selectedTrashbox.index > -1 ? (
<p>Выбран пункт сбора мусора на {
trashboxes.data[selectedTrashbox.index].Address
} с категорией {selectedTrashbox.category}</p>

View File

@ -4,12 +4,14 @@ import { Story } from 'react-insta-stories/dist/interfaces'
import { BottomNavBar, AnnouncementDetails, Filters } from '../components'
import { useStoryDimensions } from '../hooks'
import { useHomeAnnouncementList } from '../hooks/api'
import { useAnnouncements } from '../hooks/api'
import { defaultFilters } from '../utils/filters'
import { Announcement } from '../hooks/api/useHomeAnnouncementList'
import puffSpinner from '../assets/puff.svg'
import { Announcement } from '../api/announcement/types'
import { categoryGraphics } from '../assets/category'
import puffSpinner from '../assets/puff.svg'
import { gotError } from '../hooks/api/useFetch'
function generateStories(announcements: Announcement[]): Story[] {
return announcements.map(announcement => {
return ({
@ -21,15 +23,15 @@ function generateStories(announcements: Announcement[]): Story[] {
})
}
function fallbackGenerateStories(announcementsFetch: ReturnType<typeof useHomeAnnouncementList>) {
const stories = generateStories(announcementsFetch.data)
function fallbackGenerateStories(announcementsFetch: ReturnType<typeof useAnnouncements>) {
if (announcementsFetch.loading)
return fallbackStory()
if (announcementsFetch.error)
if (gotError(announcementsFetch))
return fallbackStory(announcementsFetch.error, true)
const stories = generateStories(announcementsFetch.data)
if (stories.length === 0)
return fallbackStory('Здесь пока пусто')
@ -49,19 +51,25 @@ const fallbackStory = (text = '', isError = false): Story[] => [{
},
}]
const storiesContainerCSS = {
display: 'flex',
justifyContent: 'center',
backgroundColor: 'rgb(17, 17, 17)'
}
function HomePage() {
const { height, width } = useStoryDimensions(16 / 10)
const [filterShown, setFilterShown] = useState(false)
const [filter, setFilter] = useState(defaultFilters)
const announcementsFetch = useHomeAnnouncementList(filter)
const announcementsFetch = useAnnouncements(filter)
const stories = fallbackGenerateStories(announcementsFetch)
return (<>
<Filters filter={filter} setFilter={setFilter} filterShown={filterShown} setFilterShown={setFilterShown} />
<div style={{ display: 'flex', justifyContent: 'center', backgroundColor: 'rgb(17, 17, 17)' }}>
<div style={storiesContainerCSS}>
<Stories
stories={stories}
defaultInterval={11000}

View File

@ -1,4 +1,4 @@
import { Announcement } from '../hooks/api/useHomeAnnouncementList'
import { Announcement } from '../api/announcement/types'
const filterNames = ['userId', 'category', 'metro', 'bookedBy'] as const
type FilterNames = typeof filterNames[number]
@ -7,5 +7,11 @@ type FiltersType = Partial<Pick<Announcement, FilterNames>>
const defaultFilters: FiltersType = { userId: undefined, category: undefined, metro: undefined, bookedBy: undefined }
const URLEncodeFilters = (filters: FiltersType) => Object.fromEntries(
filterNames.map(
fName => [fName, filters[fName]?.toString()]
).filter((p): p is [string, string] => typeof p[1] !== 'undefined')
)
export type { FilterNames, FiltersType }
export { defaultFilters, filterNames }
export { defaultFilters, filterNames, URLEncodeFilters }