248 lines
6.0 KiB
TypeScript
248 lines
6.0 KiB
TypeScript
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
|
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
|
|
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
|
import { pageCountFor } from '@/lib/pagination'
|
|
import { sameRouteQuery } from '@/lib/pageQuery'
|
|
|
|
const SEARCH_DEBOUNCE_MS = 120
|
|
|
|
export type UrlMode = 'push' | 'replace'
|
|
|
|
export interface PagedQuery {
|
|
q: string
|
|
page: number
|
|
size: number
|
|
}
|
|
|
|
export type PagedNotice<Q> = { kind: 'range' } | { kind: 'error'; query: Q; mode: UrlMode }
|
|
|
|
export type PagedOutcome =
|
|
| {
|
|
kind: 'loaded'
|
|
total: number
|
|
count: number
|
|
page?: number
|
|
redirected?: boolean
|
|
apply?: () => void
|
|
}
|
|
| { kind: 'failed'; notice: boolean; settled?: boolean; resetDesired?: boolean }
|
|
| { kind: 'stale' }
|
|
|
|
interface PagedContext<Q> {
|
|
loaded: boolean
|
|
shown: Q
|
|
}
|
|
|
|
interface PagedQueryOptions<Q extends PagedQuery> {
|
|
path: string
|
|
initial: Q
|
|
request: (query: Q, context: PagedContext<Q>) => Promise<PagedOutcome>
|
|
parseRoute: (query: LocationQuery, ownReplace: boolean) => Q
|
|
toRoute: (query: Q, current: LocationQuery) => Record<string, string>
|
|
same: (left: Q, right: Q) => boolean
|
|
sameRoute?: (current: LocationQuery, target: Record<string, string>) => boolean
|
|
enabled?: () => boolean
|
|
isLoading?: () => boolean
|
|
pendingPageFor?: (query: Q, loaded: boolean) => number | null
|
|
initialLoading?: boolean
|
|
onRouteQuery?: (query: Q) => void
|
|
onLoaded?: () => void
|
|
}
|
|
|
|
export function usePagedQuery<Q extends PagedQuery>(options: PagedQueryOptions<Q>) {
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const skeleton = useDelayedSkeleton()
|
|
const sameRoute = options.sameRoute ?? sameRouteQuery
|
|
|
|
const total = ref(0)
|
|
const shown = ref(options.initial) as Ref<Q>
|
|
const desired = ref(options.initial) as Ref<Q>
|
|
const hasLoaded = ref(false)
|
|
const loadedOnce = ref(false)
|
|
const loading = ref(options.initialLoading ?? false)
|
|
const pendingPage = ref<number | null>(null)
|
|
const notice = ref<PagedNotice<Q> | null>(null)
|
|
let token = 0
|
|
let syncedTarget: Record<string, string> | null = null
|
|
|
|
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
|
|
const skeletonActive = computed(() => loading.value && skeleton.visible.value)
|
|
|
|
async function syncUrl(query: Q, mode: UrlMode) {
|
|
if (route.path !== options.path) {
|
|
return
|
|
}
|
|
const target = options.toRoute(query, route.query)
|
|
if (sameRoute(route.query, target)) {
|
|
return
|
|
}
|
|
syncedTarget = target
|
|
await router[mode]({ query: target })
|
|
}
|
|
|
|
async function load(query: Q, mode: UrlMode, successNotice: PagedNotice<Q> | null = null) {
|
|
const current = ++token
|
|
desired.value = query
|
|
loading.value = true
|
|
pendingPage.value = options.pendingPageFor
|
|
? options.pendingPageFor(query, loadedOnce.value)
|
|
: loadedOnce.value
|
|
? query.page
|
|
: null
|
|
skeleton.start()
|
|
const outcome = await options.request(query, {
|
|
loaded: loadedOnce.value,
|
|
shown: shown.value,
|
|
})
|
|
if (current !== token || outcome.kind === 'stale') {
|
|
return
|
|
}
|
|
if (outcome.kind === 'failed') {
|
|
finish()
|
|
if (outcome.settled) {
|
|
hasLoaded.value = true
|
|
}
|
|
if (outcome.resetDesired) {
|
|
desired.value = shown.value
|
|
}
|
|
if (outcome.notice) {
|
|
notice.value = { kind: 'error', query, mode }
|
|
}
|
|
return
|
|
}
|
|
const lastPage = pageCountFor(outcome.total, query.size)
|
|
if (
|
|
successNotice === null &&
|
|
!outcome.redirected &&
|
|
outcome.count === 0 &&
|
|
outcome.total > 0 &&
|
|
query.page > lastPage
|
|
) {
|
|
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
|
|
return
|
|
}
|
|
const effective = {
|
|
...query,
|
|
page: outcome.page ?? (outcome.total === 0 ? 1 : query.page),
|
|
}
|
|
outcome.apply?.()
|
|
total.value = outcome.total
|
|
shown.value = effective
|
|
desired.value = effective
|
|
hasLoaded.value = true
|
|
loadedOnce.value = true
|
|
notice.value = successNotice ?? (outcome.redirected ? { kind: 'range' } : null)
|
|
finish()
|
|
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
|
|
options.onLoaded?.()
|
|
}
|
|
|
|
function finish() {
|
|
loading.value = false
|
|
pendingPage.value = null
|
|
skeleton.stop()
|
|
}
|
|
|
|
function changePage(page: number) {
|
|
return load({ ...desired.value, page }, 'push')
|
|
}
|
|
|
|
function changeSize(size: number) {
|
|
return load({ ...desired.value, size, page: 1 }, 'push')
|
|
}
|
|
|
|
function retryNotice() {
|
|
const current = notice.value as PagedNotice<Q> | null
|
|
if (current?.kind === 'error') {
|
|
return load(current.query, current.mode)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => route.query,
|
|
() => {
|
|
if (route.path !== options.path || options.enabled?.() === false) {
|
|
return
|
|
}
|
|
const ownReplace = syncedTarget !== null && sameRoute(route.query, syncedTarget)
|
|
syncedTarget = null
|
|
const next = options.parseRoute(route.query, ownReplace)
|
|
if (hasLoaded.value && options.same(next, shown.value)) {
|
|
syncUrl(shown.value, 'replace')
|
|
return
|
|
}
|
|
const busy = options.isLoading ? options.isLoading() : loading.value
|
|
if (options.same(next, desired.value) && busy) {
|
|
return
|
|
}
|
|
options.onRouteQuery?.(next)
|
|
load(next, 'replace')
|
|
},
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
token++
|
|
})
|
|
|
|
return {
|
|
total,
|
|
shown,
|
|
desired,
|
|
hasLoaded,
|
|
loadedOnce,
|
|
loading,
|
|
pendingPage,
|
|
notice,
|
|
pageCount,
|
|
showSkeleton: skeleton.visible,
|
|
skeletonActive,
|
|
load,
|
|
syncUrl,
|
|
changePage,
|
|
changeSize,
|
|
retryNotice,
|
|
}
|
|
}
|
|
|
|
export function useSearchInput(
|
|
initial: string,
|
|
current: () => string,
|
|
commit: (query: string) => unknown,
|
|
) {
|
|
const searchInput = ref(initial)
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function cancel() {
|
|
if (timer !== null) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
}
|
|
|
|
function changeSearch(value: string) {
|
|
const q = value.trim()
|
|
if (q !== current()) {
|
|
commit(q)
|
|
}
|
|
}
|
|
|
|
function clearSearch() {
|
|
cancel()
|
|
searchInput.value = ''
|
|
changeSearch('')
|
|
}
|
|
|
|
watch(searchInput, (value) => {
|
|
cancel()
|
|
timer = setTimeout(() => {
|
|
timer = null
|
|
changeSearch(value)
|
|
}, SEARCH_DEBOUNCE_MS)
|
|
})
|
|
|
|
onBeforeUnmount(cancel)
|
|
|
|
return { searchInput, changeSearch, clearSearch }
|
|
}
|