From 54df633e037aa46d6b01eb42505455fb9ae659d5 Mon Sep 17 00:00:00 2001 From: Chiri Vulpes Date: Fri, 27 Dec 2024 23:23:01 +1300 Subject: [PATCH] Add support for unknown-length pagination --- src/endpoint/Endpoint.ts | 36 +++++++++++++++--- src/ui/component/core/Paginator.ts | 57 +++++++++++++++++++++------- src/ui/view/FeedView.ts | 3 +- style/component/core/paginator.chiri | 16 +++++++- 4 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/endpoint/Endpoint.ts b/src/endpoint/Endpoint.ts index 9026c8d..f6260db 100644 --- a/src/endpoint/Endpoint.ts +++ b/src/endpoint/Endpoint.ts @@ -60,7 +60,12 @@ interface Endpoint removeHeader (header: string): this noResponse (): this query: QUERY - prep: (...parameters: Parameters) => PreparedEndpointQuery + prep: (...parameters: Parameters) => ConfigurablePreparedEndpointQuery +} + +interface ConfigurablePreparedEndpointQuery> extends PreparedEndpointQuery { + getPageSize (): number | undefined + setPageSize (size: number): this } interface PreparedEndpointQuery> extends Omit, "query"> { @@ -70,7 +75,8 @@ interface PreparedEndpointQuery> = ENDPOINT extends Endpoint ? PreparedEndpointQuery : never function Endpoint (route: ROUTE, method: Paths[ROUTE]["method"], headers?: Record) { - const endpoint: Endpoint = { + let pageSize: number | undefined + const endpoint: ConfigurablePreparedEndpointQuery = { header (header, value) { headers ??= {} headers[header] = value @@ -84,30 +90,48 @@ function Endpoint (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["query"], prep: (...parameters) => { - return Object.assign(Endpoint(route, method, headers), { + const endpoint = Endpoint(route, method, headers) as any as ConfigurablePreparedEndpointQuery + 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 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).toString() : "" + + const params = new URLSearchParams(data?.query as Record) + if (pageSize) + params.set("page_size", `${pageSize}`) + + const qs = params.size ? "?" + params.toString() : "" let error: ErrorResponse | undefined const response = await fetch(`${Env.API_ORIGIN}${url}${qs}`, { diff --git a/src/ui/component/core/Paginator.ts b/src/ui/component/core/Paginator.ts index e53cf0e..c21d421 100644 --- a/src/ui/component/core/Paginator.ts +++ b/src/ui/component/core/Paginator.ts @@ -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 = keyof { @@ -22,7 +23,7 @@ interface PaginatorExtensions { page: State data: State useEndpoint>>> (endpoint: PreparedQueryOf>, contentInitialiser: (slot: Slot, response: DATA, paginator: this) => any): Promise - useInitial (data: DATA, page: number, pageCount: number): PaginatorUseInitialFactory + useInitial (data: DATA, page: number, pageCount: number | true): PaginatorUseInitialFactory orElse (contentInitialiser: (slot: Slot) => any): this } @@ -32,7 +33,7 @@ type PageInitialiser = (slot: Slot, response: ResponseDa interface PaginatorUsing { endpoint: PreparedQueryOf - pageCount: number + pageCount: number | true initialiser: PageInitialiser } @@ -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() @@ -161,12 +162,13 @@ const Paginator = Component.Builder((component): Paginator => { cursor.value = 0 } - async function setup (initialData: ResponseData>, page: number, pageCount: number) { - if (pageCount > 1) + async function setup (initialData: ResponseData>, 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") @@ -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) { @@ -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) @@ -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(resolve => { RetryDialog(resolve).appendTo(page) block.header.element.scrollIntoView() @@ -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 diff --git a/src/ui/view/FeedView.ts b/src/ui/view/FeedView.ts index e472225..97ea28f 100644 --- a/src/ui/view/FeedView.ts +++ b/src/ui/view/FeedView.ts @@ -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}`) diff --git a/style/component/core/paginator.chiri b/style/component/core/paginator.chiri index 4f632d0..ffcef8d 100644 --- a/style/component/core/paginator.chiri +++ b/style/component/core/paginator.chiri @@ -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 @@ -67,6 +74,9 @@ %opacity-30 %no-pointer-events + &-button--hidden: + %hidden + &-button-first: #after: .button #icon name="angles-left @@ -86,7 +96,6 @@ %margin-bottom-0 %grid %stack - %overflow-hidden &--or-else: #after: .paginator-content @@ -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: