Added TypeScript for frontend

Added type definitions for components, functions, data
Added guards for network responses
fixes #8
This commit is contained in:
2023-07-12 18:59:17 +03:00
parent 8fc85e415f
commit a8b7cfbffa
52 changed files with 1616 additions and 1651 deletions

View File

@ -1,54 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { API_URL } from "../../config"
const useAddAnnouncement = () => {
const [status, setStatus] = useState("Опубликовать")
const timerIdRef = useRef()
const abortControllerRef = useRef()
const doAdd = async (formData) => {
if (status === "Загрузка") {
abortControllerRef.current?.abort()
setStatus("Отменено")
timerIdRef.current = setTimeout(() => setStatus("Опубликовать"), 3000)
return
}
setStatus("Загрузка")
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
const res = await fetch(API_URL + "/announcement", {
method: 'PUT',
body: formData,
signal: abortController.signal
})
const data = await res.json()
if (!data.Answer) {
throw new Error("Не удалось опубликовать объявление")
}
setStatus("Опубликовано")
} catch (err) {
setStatus(err.message ?? err)
timerIdRef.current = setTimeout(() => setStatus("Опубликовать"), 10000)
}
}
useEffect(() => {
const abortController = abortControllerRef.current
return () => {
clearTimeout(timerIdRef.current)
abortController?.abort()
}
})
return {doAdd, status}
}
export default useAddAnnouncement

View File

@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react"
import { API_URL } from "../../config"
import { isLiteralUnion } from "../../utils/types"
const addErrors = ["Не удалось опубликовать объявление", 'Неверный ответ от сервера', 'Неизвестная ошибка'] as const
type AddError = typeof addErrors[number]
const isAddError = (obj: unknown): obj is AddError => isLiteralUnion(obj, addErrors)
const buttonStates = ["Опубликовать", "Загрузка", "Опубликовано", "Отменено"] as const
type ButtonState = typeof buttonStates[number] | AddError
type AddResponse = {
Answer: boolean
}
const isAddResponse = (obj: unknown): obj is AddResponse =>
typeof obj === 'object' && obj !== null && typeof Reflect.get(obj, 'Answer') === 'boolean'
const useAddAnnouncement = () => {
const [status, setStatus] = useState<ButtonState>("Опубликовать")
const timerIdRef = useRef<number>()
const abortControllerRef = useRef<AbortController>()
const doAdd = async (formData: FormData) => {
if (status === "Загрузка") {
abortControllerRef.current?.abort()
setStatus("Отменено")
timerIdRef.current = setTimeout(() => setStatus("Опубликовать"), 3000)
return
}
setStatus("Загрузка")
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
const res = await fetch(API_URL + "/announcement", {
method: 'PUT',
body: formData,
signal: abortController.signal
})
const data: unknown = await res.json()
if (!isAddResponse(data)) throw new Error('Неверный ответ от сервера')
if (!data.Answer) {
throw new Error("Не удалось опубликовать объявление")
}
setStatus("Опубликовано")
} catch (err) {
setStatus(isAddError(err) ? err : "Неизвестная ошибка")
timerIdRef.current = setTimeout(() => setStatus("Опубликовать"), 10000)
}
}
useEffect(() => {
const abortController = abortControllerRef.current
return () => {
clearTimeout(timerIdRef.current)
abortController?.abort()
}
})
return { doAdd, status }
}
export default useAddAnnouncement

View File

@ -1,61 +0,0 @@
import { useState } from "react"
import { API_URL } from "../../config"
function useAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const doAuth = async (data, newAccount) => {
setLoading(true)
if (newAccount) {
try {
const res = await fetch(API_URL + "/signup", {
method: "POST",
body: data,
headers: {
'Content-Type': 'application/json'
}
})
const signupData = await res.json()
if (signupData.Success === false) {
throw new Error(signupData.Message)
}
} catch (err) {
setError(err.message)
setLoading(false)
return null
}
}
try {
const res = fetch(API_URL + '/auth/token' + new URLSearchParams({
username: data.email,
password: data.password
}), {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const loginData = await res.json()
const token = loginData.access_token
setError('')
setLoading(false)
return token
} catch (err) {
setError(err.message)
setLoading(false)
return null
}
}
return { doAuth, loading, error }
}
export default useAuth

View File

@ -0,0 +1,110 @@
import { useState } from "react"
import { API_URL } from "../../config"
import { isConst, isObject } from "../../utils/types"
interface AuthData {
email: string,
password: string,
}
// interface LoginData extends AuthData { }
// interface SignUpData extends AuthData {
// name: string,
// surname: string
// }
type SignUpResponse = {
Success: true
} | {
Success: false,
Message: string
}
const isSignUpResponse = (obj: unknown): obj is SignUpResponse => (
isObject(obj, {
"Success": isConst(true)
}) ||
isObject(obj, {
"Success": isConst(false),
"Message": "string"
})
)
interface LogInResponse {
access_token: string,
token_type: 'bearer'
}
const isLogInResponse = (obj: unknown): obj is LogInResponse => isObject(obj, {
"access_token": "string",
"token_type": isConst("bearer")
})
function useAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const doAuth = async (data: AuthData, newAccount: boolean) => {
setLoading(true)
if (newAccount) {
try {
const res = await fetch(API_URL + "/signup", {
method: "POST",
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
})
const signupData: unknown = await res.json()
if (!isSignUpResponse(signupData)) {
throw new Error("Malformed server response")
}
if (signupData.Success === false) {
throw new Error(signupData.Message)
}
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
try {
const res = await fetch(API_URL + '/auth/token' + new URLSearchParams({
username: data.email,
password: data.password
}).toString(), {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const logInData: unknown = await res.json()
if (!isLogInResponse(logInData)) {
throw new Error("Malformed server response")
}
const token = logInData.access_token
setError('')
setLoading(false)
return token
} catch (err) {
setError(err instanceof Error ? err.message : err as string)
setLoading(false)
return null
}
}
return { doAuth, loading, error }
}
export default useAuth

View File

@ -1,42 +0,0 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { getToken } from "../../utils/auth"
import { API_URL } from "../../config"
function useBook(id) {
const navigate = useNavigate()
const [status, setStatus] = useState('')
const handleBook = () => {
const token = getToken()
if (token) {
setStatus("Загрузка")
fetch(API_URL + '/book', {
method: 'POST',
body: JSON.stringify({
id: id
}),
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
}).then(res => res.json()).then(data => {
if (data.Success === true) {
setStatus('Забронировано')
} else {
setStatus("Ошибка бронирования")
}
})
} else {
return navigate("/login")
}
}
return { handleBook, status }
}
export default useBook

View File

@ -0,0 +1,69 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { getToken } from "../../utils/auth"
import { API_URL } from "../../config"
import { isObject } from "../../utils/types"
type BookResponse = {
Success: boolean
}
const isBookResponse = (obj: unknown): obj is BookResponse => isObject(obj, {
"Success": "boolean"
})
type BookStatus = "" | "Загрузка" | "Забронировано" | "Ошибка бронирования"
function useBook(id: number) {
const navigate = useNavigate()
const [status, setStatus] = useState<BookStatus>('')
const handleBook = async () => {
const token = getToken()
if (token) {
setStatus("Загрузка")
try {
const res = await fetch(API_URL + '/book', {
method: 'POST',
body: JSON.stringify({
id: id
}),
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
})
const data: unknown = await res.json()
if (!isBookResponse(data)) {
throw new Error("Malformed server response")
}
if (data.Success === true) {
setStatus('Забронировано')
} else {
throw new Error("Server refused to book")
}
}
catch (err) {
setStatus("Ошибка бронирования")
if (import.meta.env.DEV) {
console.log(err)
}
}
} else {
return navigate("/login")
}
}
return { handleBook, status }
}
export default useBook

View File

@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from "react"
import { isAborted } from '../../utils'
const useFetch = (url, params, initialData) => {
const useFetch = <T>(url: string, params: RequestInit | undefined, initialData: T, dataGuard: (obj: unknown) => obj is T) => {
const [data, setData] = useState(initialData)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const abortControllerRef = useRef(null)
const abortControllerRef = useRef<AbortController>()
useEffect(() => {
if (abortControllerRef.current) {
@ -33,11 +33,15 @@ const useFetch = (url, params, initialData) => {
return res.json()
})
.then(data => {
if (!dataGuard(data)) {
throw new Error("Неверный ответ от сервера")
}
setData(data)
setLoading(false)
})
.catch(err => {
if (!isAborted(err)) {
if (err instanceof Error && !isAborted(err)) {
setError("Ошибка сети")
}
@ -48,10 +52,13 @@ const useFetch = (url, params, initialData) => {
}
})
return () => abortControllerRef.current.abort()
}, [url, params])
return () => abortControllerRef.current?.abort()
}, [url, params, dataGuard])
return { data, loading, error, abort: abortControllerRef.current?.abort }
return {
data, loading, error,
abort: abortControllerRef.current?.abort.bind(abortControllerRef.current)
}
}
export default useFetch

View File

@ -1,26 +0,0 @@
import useFetch from './useFetch'
import { API_URL } from '../../config'
import { removeNull } from '../../utils'
const initialAnnouncements = { list_of_announcements: [], Success: true }
const useHomeAnnouncementList = (filters) => {
const { data, loading, error } = useFetch(
API_URL + '/announcements?' + new URLSearchParams(removeNull(filters)),
null,
initialAnnouncements
)
const annList = data.list_of_announcements
const res = annList.map(ann => ({
...ann,
lat: ann.latitude,
lng: ann.longtitude,
bestBy: ann.best_by
}))
return { data: error ? [] : res, loading, error }
}
export default useHomeAnnouncementList

View File

@ -0,0 +1,96 @@
import useFetch from './useFetch'
import { API_URL } from '../../config'
import { FiltersType, filterNames } from '../../utils/filters'
import { isArrayOf, isObject } from '../../utils/types'
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,12 +0,0 @@
import { API_URL } from "../../config"
import useFetch from "./useFetch"
const useTrashboxes = (position) => {
return useFetch(
API_URL + "/trashbox?" + new URLSearchParams({ lat: position.lat, lng: position.lng }),
undefined,
[]
)
}
export default useTrashboxes

View File

@ -0,0 +1,35 @@
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)
})
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)
)
}
export type { Trashbox }
export default useTrashboxes