Skip to content

Commit

Permalink
Add support for unknown-length pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
ChiriVulpes committed Dec 27, 2024
1 parent ce01cf9 commit 54df633
Show file tree
Hide file tree
Showing 4 changed files with 91 additions and 21 deletions.
36 changes: 30 additions & 6 deletions src/endpoint/Endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ interface Endpoint<ROUTE extends keyof Paths, QUERY extends EndpointQuery<ROUTE>
removeHeader (header: string): this
noResponse (): this
query: QUERY
prep: (...parameters: Parameters<QUERY>) => PreparedEndpointQuery<ROUTE, QUERY>
prep: (...parameters: Parameters<QUERY>) => ConfigurablePreparedEndpointQuery<ROUTE, QUERY>
}

interface ConfigurablePreparedEndpointQuery<ROUTE extends keyof Paths, QUERY extends EndpointQuery<ROUTE>> extends PreparedEndpointQuery<ROUTE, QUERY> {
getPageSize (): number | undefined
setPageSize (size: number): this
}

interface PreparedEndpointQuery<ROUTE extends keyof Paths, QUERY extends EndpointQuery<ROUTE>> extends Omit<Endpoint<ROUTE, QUERY>, "query"> {
Expand All @@ -70,7 +75,8 @@ interface PreparedEndpointQuery<ROUTE extends keyof Paths, QUERY extends Endpoin
export type PreparedQueryOf<ENDPOINT extends Endpoint<any, any>> = ENDPOINT extends Endpoint<infer ROUTE, infer QUERY> ? PreparedEndpointQuery<ROUTE, QUERY> : never

function Endpoint<ROUTE extends keyof Paths> (route: ROUTE, method: Paths[ROUTE]["method"], headers?: Record<string, string>) {
const endpoint: Endpoint<ROUTE> = {
let pageSize: number | undefined
const endpoint: ConfigurablePreparedEndpointQuery<ROUTE, any> = {
header (header, value) {
headers ??= {}
headers[header] = value
Expand All @@ -84,30 +90,48 @@ function Endpoint<ROUTE extends keyof Paths> (route: ROUTE, method: Paths[ROUTE]
delete headers?.[header]
return endpoint
},
getPageSize: () => pageSize,
setPageSize: (size: number) => {
pageSize = size
return endpoint
},
noResponse: () => endpoint.removeHeader("Accept"),
query: query as Endpoint<ROUTE>["query"],
prep: (...parameters) => {
return Object.assign(Endpoint(route, method, headers), {
const endpoint = Endpoint(route, method, headers) as any as ConfigurablePreparedEndpointQuery<ROUTE, any>
return Object.assign(endpoint, {
query: (...p2: any[]) => {
const newParameters: any[] = []
const length = Math.max(parameters.length, p2.length)
for (let i = 0; i < length; i++)
newParameters.push(Objects.merge(parameters[i], p2[i]))

const ownPageSize = pageSize
pageSize = endpoint.getPageSize()

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return query(...newParameters)
const result = query(...newParameters)

pageSize = ownPageSize
return result
},
}) as any
},
}

return endpoint
return endpoint as any as Endpoint<ROUTE>

async function query (data?: EndpointQueryData) {
const body = !data?.body ? undefined : JSON.stringify(data.body)
const url = route.slice(1)
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
.replaceAll(/\{([^}]+)\}/g, (match, paramName) => data?.params?.[paramName])
const qs = data?.query ? "?" + new URLSearchParams(data.query as Record<string, string>).toString() : ""

const params = new URLSearchParams(data?.query as Record<string, string>)
if (pageSize)
params.set("page_size", `${pageSize}`)

const qs = params.size ? "?" + params.toString() : ""

let error: ErrorResponse<any> | undefined
const response = await fetch(`${Env.API_ORIGIN}${url}${qs}`, {
Expand Down
57 changes: 44 additions & 13 deletions src/ui/component/core/Paginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Component from "ui/Component"
import Block from "ui/component/core/Block"
import Button from "ui/component/core/Button"
import Slot from "ui/component/core/Slot"
import Async from "utility/Async"
import State from "utility/State"

type PaginatedEndpointRouteFiltered<DATA_FILTER> = keyof {
Expand All @@ -22,7 +23,7 @@ interface PaginatorExtensions<DATA = any> {
page: State<number>
data: State<DATA>
useEndpoint<ROUTE extends PaginatedEndpointRoute, DATA extends ResponseData<EndpointResponse<Endpoint<ROUTE>>>> (endpoint: PreparedQueryOf<Endpoint<ROUTE>>, contentInitialiser: (slot: Slot, response: DATA, paginator: this) => any): Promise<this>
useInitial<DATA> (data: DATA, page: number, pageCount: number): PaginatorUseInitialFactory<DATA, this>
useInitial<DATA> (data: DATA, page: number, pageCount: number | true): PaginatorUseInitialFactory<DATA, this>
orElse (contentInitialiser: (slot: Slot) => any): this
}

Expand All @@ -32,7 +33,7 @@ type PageInitialiser<HOST extends Paginator> = (slot: Slot, response: ResponseDa

interface PaginatorUsing<HOST extends Paginator> {
endpoint: PreparedQueryOf<PaginatedEndpoint>
pageCount: number
pageCount: number | true
initialiser: PageInitialiser<HOST>
}

Expand Down Expand Up @@ -71,7 +72,7 @@ const Paginator = Component.Builder((component): Paginator => {

const buttonNext = Button()
.style("paginator-button", "paginator-button-next")
.event.subscribe("click", () => showPage(Math.min(cursor.value + 1, pages.length - 1)))
.event.subscribe("click", () => showPage(Math.min(cursor.value + 1, using?.pageCount === true ? Infinity : pages.length - 1)))
.appendTo(block.footer.right)

const buttonLast = Button()
Expand Down Expand Up @@ -161,12 +162,13 @@ const Paginator = Component.Builder((component): Paginator => {
cursor.value = 0
}

async function setup (initialData: ResponseData<EndpointResponse<PaginatedEndpoint>>, page: number, pageCount: number) {
if (pageCount > 1)
async function setup (initialData: ResponseData<EndpointResponse<PaginatedEndpoint>>, page: number, pageCount: number | true) {
if (pageCount === true || pageCount > 1)
block.footer.style.remove("paginator-footer--hidden")

while (pages.length < pageCount)
pages.push(Page())
if (pageCount !== true)
while (pages.length < pageCount)
pages.push(Page())

const pageComponent = pages[page]
.style("paginator-page--initial-load")
Expand Down Expand Up @@ -217,8 +219,9 @@ const Paginator = Component.Builder((component): Paginator => {
function updateButtons (page = cursor.value, pageCount = using?.pageCount ?? 0) {
buttonFirst.style.toggle(page <= 0, "paginator-button--disabled")
buttonPrev.style.toggle(page <= 0, "paginator-button--disabled")
buttonNext.style.toggle(page >= pageCount - 1, "paginator-button--disabled")
buttonLast.style.toggle(page >= pageCount - 1, "paginator-button--disabled")
buttonNext.style.toggle(pageCount !== true && page >= pageCount - 1, "paginator-button--disabled")
buttonLast.style.toggle(pageCount !== true && page >= pageCount - 1, "paginator-button--disabled")
buttonLast.style.toggle(pageCount === true, "paginator-button--hidden")
}

async function showPage (number: number) {
Expand All @@ -229,12 +232,15 @@ const Paginator = Component.Builder((component): Paginator => {
const direction = Math.sign(number - oldNumber)

pages[oldNumber]
.style.remove("paginator-page--initial-load")
.style.remove("paginator-page--initial-load", "paginator-page--bounce")
.style("paginator-page--hidden")
.style.setVariable("page-direction", direction)

const page = pages[number]
.style.setVariable("page-direction", direction)
let page = pages[number]
if (!page)
pages.push(page ??= Page())

page.style.setVariable("page-direction", direction)

updateButtons(number)

Expand All @@ -253,9 +259,17 @@ const Paginator = Component.Builder((component): Paginator => {
if (showingPage !== number)
return

const isError = result instanceof Error
if (!isError && !hasResults(result.data)) {
cursor.value = number
pages[oldNumber].style("paginator-page--bounce")
await Async.sleep(200)
return showPage(oldNumber)
}

page.style.remove("paginator-page--hidden")

if (result instanceof Error) {
if (isError) {
await new Promise<void>(resolve => {
RetryDialog(resolve).appendTo(page)
block.header.element.scrollIntoView()
Expand All @@ -282,4 +296,21 @@ const Paginator = Component.Builder((component): Paginator => {
}
})

function hasResults (result: unknown) {
if (result === null || result === undefined)
return false

if (typeof result !== "object")
return true

if (Array.isArray(result))
return result.length > 0

for (const sub of Object.values(result))
if (hasResults(sub))
return true

return false
}

export default Paginator
3 changes: 2 additions & 1 deletion src/ui/view/FeedView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export default ViewDefinition({
.type("flush")
.tweak(p => p.title.text.use("view/feed/main/title"))
.appendTo(view)
await paginator.useEndpoint(EndpointFeedGet, async (slot, { works, authors }) => {
const endpoint = EndpointFeedGet.prep().setPageSize(3)
await paginator.useEndpoint(endpoint, async (slot, { works, authors }) => {
for (const workData of works) {
const author = authors.find(author => author.vanity === workData.author)
const work = await Link(author && `/work/${author.vanity}/${workData.vanity}`)
Expand Down
16 changes: 15 additions & 1 deletion style/component/core/paginator.chiri
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@
inset: 1px

&--flush:
#after: .paginator-footer, .block-type-flush-footer
%relative
%margin-top-2
%background-none
%border-none
@before:
%hidden

&--hidden:
%hidden
Expand All @@ -67,6 +74,9 @@
%opacity-30
%no-pointer-events

&-button--hidden:
%hidden

&-button-first:
#after: .button
#icon name="angles-left
Expand All @@ -86,7 +96,6 @@
%margin-bottom-0
%grid
%stack
%overflow-hidden

&--or-else:
#after: .paginator-content
Expand Down Expand Up @@ -121,6 +130,11 @@
translate: calc($space-4 * $page-direction * -1)
$transition-duration: $transition-focus

&--bounce:
#after: .paginator-page--hidden
%flex
%opaque

&-error:
&-text:
&-retry-button:

0 comments on commit 54df633

Please sign in to comment.