main
ts 197 lines 8.51 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { createElement as h, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4 import { Dict, err2msg, Falsy, LIST, useStateMounted, wantArray, xlate,
5 HTTP_FORBIDDEN, HTTP_UNAUTHORIZED } from './misc'
6 import { IconBtn, spinner } from './mui'
7 import { Alert } from '@mui/material'
8 import _ from 'lodash'
9 import { state } from './state'
10 import { Refresh } from '@mui/icons-material'
11 import { produce, Draft } from 'immer'
12 import { ApiError, apiEvents, setDefaultApiCallOptions, useApi } from '@hfs/shared/api'
13 import { ApiHandler } from '../../src/apiMiddleware'
14 export * from '@hfs/shared/api'
15
16 setDefaultApiCallOptions({
17 async onResponse(res: Response, body: any) {
18 if (res.status === HTTP_UNAUTHORIZED) {
19 state.loginRequired = body?.possible !== false || HTTP_FORBIDDEN
20 throw new ApiError(res.status, body)
21 }
22 }
23 })
24
25 const ERRORS = { timeout: "Operation timeout" }
26 // expand useApi with things that cannot be shared with Frontend
27 export type ApiObject<T extends ApiHandler=any> = ReturnType<typeof useApiEx<T>>
28 export function useApiEx<T extends ApiHandler=any>(...args: Parameters<typeof useApi>) {
29 const res = useApi<T>(...args)
30 return {
31 ...res,
32 element: useMemo(() =>
33 !args[0] ? null
34 : res.error ? h(Alert, { severity: 'error' }, xlate(String(res.error), ERRORS),
35 h(IconBtn, { icon: Refresh, title: "Reload", onClick: res.reload, sx: { m:'-10px 0 -8px 16px' } }) )
36 : res.data === undefined ? spinner()
37 : null,
38 Object.values(res))
39 }
40 }
41
42 export function useApiList<T=any, S=T>(cmd:string|Falsy, params: Dict={}, { map, invert, pause, limit }: { limit?: number, pause?: boolean, invert?: boolean, map?: (rec: S) => unknown }={}) {
43 const [list, setList] = useStateMounted<T[]>([])
44 const [props, setProps] = useStateMounted<any>(undefined)
45 const [error, setError] = useStateMounted<any>(undefined)
46 const [connecting, setConnecting] = useStateMounted(true)
47 const [loading, setLoading] = useStateMounted(false)
48 const [initializing, setInitializing] = useStateMounted(true)
49 const [reloader, setReloader] = useState(0)
50 const idGenerator = useRef(0)
51 const [pausedList, setPausedList] = useState<typeof list | undefined>()
52 useEffect(() => setPausedList(pause ? list : undefined), [pause])
53 useEffect(() => {
54 if (!cmd) return
55 const bufferAdd: T[] = []
56 const apply = _.debounce(() => {
57 const chunk = bufferAdd.splice(0, Infinity)
58 if (!chunk.length) return
59 if (invert) chunk.reverse() // don't move this inside setList, as its callback can be called twice (and will, in dev)
60 setList(list => {
61 if (invert) {
62 const ret = [...chunk, ...list]
63 ret.splice(limit ?? Infinity, Infinity)
64 return ret
65 }
66 const ret = [...list, ...chunk]
67 ret.splice(0, ret.length - (limit ?? Infinity))
68 return ret
69 })
70 }, 1000, { maxWait: 1000 })
71 setError(undefined)
72 setLoading(true)
73 setConnecting(true)
74 setInitializing(true)
75 setList([])
76 const src = apiEvents(cmd, _.mapValues(params, x => x === false ? undefined : x), (type, data) => {
77 switch (type) {
78 case 'connected':
79 setConnecting(false)
80 return setTimeout(() => apply.flush()) // this trick we'll cause first entries to be rendered almost immediately, while the rest will be subject to normal debouncing
81 case 'error':
82 setError("Connection error")
83 setTimeout(reload, 1000)
84 return stop()
85 case 'closed':
86 return stop()
87 case 'msg':
88 if (src.readyState !== src.OPEN)
89 return stop()
90 const removeOnList: ReturnType<typeof _.matches>[] = []
91 const updateOnList: [object,object][] = []
92 wantArray(data).forEach(msg => {
93 if (!Array.isArray(msg))
94 return console.debug('illegal list packet', msg)
95 console.debug('LIST', ...msg)
96 const [op, par] = msg
97 if (op === LIST.ready) {
98 apply.flush()
99 setInitializing(false)
100 return
101 }
102 if (op === LIST.error) {
103 if (par === HTTP_UNAUTHORIZED)
104 state.loginRequired = msg[2]?.possible !== false || HTTP_FORBIDDEN
105 else
106 setError(_.isString(par) || _.isNumber(par) ? err2msg(par) : par)
107 return
108 }
109 if (op === LIST.props)
110 return setProps(par)
111 if (op === LIST.add) {
112 const mappedPar = map?.(par) ?? par
113 mappedPar.id ??= idGenerator.current = Math.max(idGenerator.current, Date.now()) + .001
114 bufferAdd.push(mappedPar)
115 apply()
116 return
117 }
118 if (op === LIST.remove) {
119 const match = _.matches(par)
120 if (_.isEmpty(_.remove(bufferAdd, match))) // first remove from the buffer
121 removeOnList.push(match)
122 return
123 }
124 if (op === LIST.update) {
125 const change = map?.(msg[2]) ?? msg[2]
126 const found = _.find(bufferAdd, par)
127 if (found)
128 return Object.assign(found, change)
129 updateOnList.push([par, change])
130 return
131 }
132 console.debug('unknown list api', op)
133 })
134 setList(list => {
135 let ret = list
136 let copy // optimization: remember if we already made a copy
137 if (removeOnList.length) {
138 copy = list.filter(rec => !removeOnList.some(match1 => match1(rec)))
139 if (copy.length < list.length) // avoid unnecessary render
140 ret = copy
141 }
142
143 if (updateOnList.length) {
144 for (const [search, change] of updateOnList) {
145 const foundAt = _.findIndex(ret, search)
146 if (foundAt < 0) continue
147 if (ret === list)
148 ret = copy ?? list.slice()
149 ret[foundAt] = { ...ret[foundAt], ...change }
150 }
151 }
152 return ret
153 })
154 }
155 })
156
157 return () => {
158 apply.cancel()
159 src.close()
160 }
161
162 function stop() {
163 setInitializing(false)
164 setLoading(false)
165 apply.flush()
166 }
167 }, [reloader, cmd, JSON.stringify(params)]) //eslint-disable-line
168 const updateList = useCallback((cb: (toModify: Draft<typeof list>) => void) => setList(list => produce(list, cb)),
169 [setList])
170 const updateEntry = useCallback((search: T, change: T) => {
171 updateList(list => {
172 const res = _.find(list, search as any)
173 if (res)
174 Object.assign(res, change)
175 })
176 }, [updateList])
177 return {
178 list: pausedList ?? list,
179 props,
180 loading,
181 error,
182 initializing,
183 connecting,
184 setList,
185 updateList,
186 updateEntry,
187 reload,
188 enabled: Boolean(cmd),
189 element: connecting || initializing || loading ? spinner()
190 : error ? h(Alert, { severity: 'error', sx: { flex: 1 } }, err2msg(error))
191 : null
192 }
193
194 function reload() {
195 setReloader(x => x + 1)
196 }
197 }