commit 2bd48c4d3e6619f3c06468706bb4174d67dc78c1 parent 832f4cc8dbdc9ab000ab281f74b39cce42586e86 Author: Florian Dold <dold@taler.net> Date: Mon, 31 Aug 2026 20:56:30 +0200 web-util: remove faked HTTP response shape from pagination Issue: https://bugs.taler.net/n/9829 Diffstat:
20 files changed, 484 insertions(+), 415 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/components/Transactions/state.ts b/packages/libeufin-bank-webui/src/components/Transactions/state.ts @@ -48,24 +48,26 @@ export function useComponentState({ error: undefined, }; } - if (result instanceof TalerError) { - return { - status: "loading-error", - error: result, - title: i18n.str`Failed to load transactions.`, - onRetry: () => void revalidateTransactions(), - }; - } - if (result.type === "fail") { + if (result.tag === "error") { + const failure = result.error; + if (failure instanceof TalerError) { + return { + status: "loading-error", + error: failure, + title: i18n.str`Failed to load transactions.`, + onRetry: () => void revalidateTransactions(), + }; + } return { status: "failed", - error: result, + error: failure, title: i18n.str`Failed to load transactions.`, onRetry: () => void revalidateTransactions(), }; } - const transactions = result.body + const page = result.value; + const transactions = page.items .map((tx) => { const negative = tx.direction === "debit"; const cp = Paytos.fromString( @@ -99,8 +101,8 @@ export function useComponentState({ routeFullHistory, routeBack, transactions, - onGoNext: result.loadNext, - onGoStart: result.loadFirst, - onGoPrevious: result.loadPrev, + onGoNext: page.loadNext, + onGoStart: page.loadFirst, + onGoPrevious: page.loadPrev, }; } diff --git a/packages/libeufin-bank-webui/src/hooks/account.test.ts b/packages/libeufin-bank-webui/src/hooks/account.test.ts @@ -1,58 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2026 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. -*/ - -import assert from "node:assert/strict"; -import test from "node:test"; -import { StateUpdater } from "preact/hooks"; -import { PAGINATED_LIST_REQUEST } from "../utils.js"; -import { buildPaginatedResult } from "./account.js"; - -test("forward pagination reports and updates the current page", () => { - let offset: number | undefined = 1; - let currentPage = 2; - const setOffset = (next: number | undefined) => { - offset = next; - }; - const setCurrentPage: StateUpdater<number> = (next) => { - currentPage = typeof next === "function" ? next(currentPage) : next; - }; - const rows = Array.from({ length: PAGINATED_LIST_REQUEST }, (_, index) => ({ - id: index + 2, - })); - - const middlePage = buildPaginatedResult( - rows, - offset, - setOffset, - (row) => row.id, - currentPage, - setCurrentPage, - ); - - assert.equal(middlePage.currentPage, 2); - assert.equal(middlePage.body.length, PAGINATED_LIST_REQUEST - 1); - middlePage.loadNext?.(); - assert.equal(offset, PAGINATED_LIST_REQUEST); - assert.equal(currentPage, 3); - - const lastPage = buildPaginatedResult( - [{ id: PAGINATED_LIST_REQUEST + 1 }], - offset, - setOffset, - (row) => row.id, - currentPage, - setCurrentPage, - ); - - assert.equal(lastPage.currentPage, 3); - assert.equal(lastPage.loadNext, undefined); - lastPage.loadFirst?.(); - assert.equal(offset, undefined); - assert.equal(currentPage, 1); -}); diff --git a/packages/libeufin-bank-webui/src/hooks/account.ts b/packages/libeufin-bank-webui/src/hooks/account.ts @@ -18,20 +18,19 @@ import { AccessToken, HttpStatusCode, OperationFail, + Result, TalerCorebankApi, TalerCoreBankResultByMethod, TalerError, TalerHttpError, } from "@gnu-taler/taler-util"; -import { StateUpdater, useEffect, useState } from "preact/hooks"; +import { useEffect } from "preact/hooks"; import { useSessionState } from "./session.js"; -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { LONG_POLL_DELAY, - PaginatedResult, - buildPaginatedResult as buildBidirectionalPaginatedResult, + PaginatedList, + buildPaginatedResult, useBankCoreApiContext, useListPointer, useLongPolling, @@ -122,28 +121,30 @@ export function usePublicAccounts( filterAccount: string | undefined, initial?: number, ) { - const [offset, setOffset] = useState<number | undefined>(initial); - const [currentPage, setCurrentPage] = useState(1); - - useEffect(() => { - setOffset(initial); - setCurrentPage(1); - }, [filterAccount, initial]); + const [pointer, setPointer] = useListPointer( + (row: TalerCorebankApi.PublicAccount) => String(row.row_id ?? 0), + filterAccount, + { + id: initial === undefined ? undefined : String(initial), + displayOrder: "asc", + }, + ); const { lib: { bank: api }, } = useBankCoreApiContext(); - async function fetcher([account, txid]: [ + async function fetcher([account, txid, order]: [ string | undefined, - number | undefined, + string | undefined, + "asc" | "dec", ]) { return await api.getPublicAccounts( { account }, { limit: PAGINATED_LIST_REQUEST, - offset: txid ? String(txid) : undefined, - order: "asc", + offset: txid, + order, }, ); } @@ -151,7 +152,7 @@ export function usePublicAccounts( const { data, error } = useSWR< TalerCoreBankResultByMethod<"getPublicAccounts">, TalerHttpError - >([filterAccount, offset, "getPublicAccounts"], fetcher, { + >([filterAccount, pointer.id, pointer.order, "getPublicAccounts"], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -163,61 +164,20 @@ export function usePublicAccounts( keepPreviousData: true, }); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; + if (data.type !== "ok") return Result.error(data); - //TODO: row_id should not be optional - return buildPaginatedResult( - data.body.public_accounts, - offset, - setOffset, - (d) => d.row_id ?? 0, - currentPage, - setCurrentPage, + return Result.of( + buildPaginatedResult( + data.body.public_accounts, + pointer, + setPointer, + PAGINATED_LIST_REQUEST, + ), ); } -// TODO: consider sending this to web-util -export function buildPaginatedResult<DataType, OffsetId>( - data: readonly DataType[], - offset: OffsetId | undefined, - setOffset: (o: OffsetId | undefined) => void, - getId: (r: DataType) => OffsetId, - currentPage: number, - setCurrentPage: StateUpdater<number>, -): PaginatedResult<DataType[]> { - const isLastPage = data.length < PAGINATED_LIST_REQUEST; - const isFirstPage = offset === undefined; - - const result = structuredClone(data as DataType[]); - if (result.length == PAGINATED_LIST_REQUEST) { - //do now show the last element, used to know if this is the last page - result.pop(); - } - return { - type: "ok", - case: "ok", - body: result, - response: dummyHttpResponse, - currentPage, - loadNext: isLastPage - ? undefined - : () => { - if (!result.length) return; - const id = getId(result[result.length - 1]); - setOffset(id); - setCurrentPage((page) => page + 1); - }, - loadFirst: isFirstPage - ? undefined - : () => { - setOffset(undefined); - setCurrentPage(1); - }, - }; -} - export function revalidateTransactions() { return mutate( (key) => Array.isArray(key) && key[key.length - 1] === "getTransactions", @@ -237,13 +197,15 @@ export function useTransactions( anonymous = false, pageSize = 20, ): - | TalerError<{ - requestUrl: string; - requestMethod: string; - }> - | OperationFail<HttpStatusCode.NotFound> - | OperationFail<HttpStatusCode.Unauthorized> - | PaginatedResult<TalerCorebankApi.BankAccountTransactionInfo[]> + | Result< + PaginatedList<TalerCorebankApi.BankAccountTransactionInfo>, + | TalerError<{ + requestUrl: string; + requestMethod: string; + }> + | OperationFail<HttpStatusCode.NotFound> + | OperationFail<HttpStatusCode.Unauthorized> + > | undefined { const { state: credentials } = useSessionState(); const token = @@ -299,14 +261,16 @@ export function useTransactions( shouldRetryOnError: true, }, ); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; + if (data.type !== "ok") return Result.error(data); - return buildBidirectionalPaginatedResult( - data.body.transactions, - pointer, - setPointer, - pageSize + 1, + return Result.of( + buildPaginatedResult( + data.body.transactions, + pointer, + setPointer, + pageSize + 1, + ), ); } diff --git a/packages/libeufin-bank-webui/src/hooks/regional.ts b/packages/libeufin-bank-webui/src/hooks/regional.ts @@ -22,6 +22,7 @@ import { AmountJson, Amounts, OperationOk, + Result, TalerBankConversionErrorsByMethod, TalerBankConversionHttpClient, TalerBankConversionResultByMethod, @@ -35,14 +36,12 @@ import { } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { - buildPaginatedResult as buildBidirectionalPaginatedResult, + buildPaginatedResult, useBankCoreApiContext, useListPointer, } from "@gnu-taler/web-util/browser"; -import { useEffect, useState } from "preact/hooks"; import _useSWR, { SWRHook, mutate } from "swr"; import { PAGINATED_LIST_REQUEST } from "../utils.js"; -import { buildPaginatedResult } from "./account.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 const useSWR = _useSWR as unknown as SWRHook; @@ -303,8 +302,10 @@ export function useBusinessAccounts(filterName?: string) { } = useBankCoreApiContext(); const [pointer, setPointer] = - useListPointer<TalerCorebankApi.AccountMinimalData>(accountRowId); - useEffect(() => setPointer(undefined, "dec"), [filterName, setPointer]); + useListPointer<TalerCorebankApi.AccountMinimalData>( + accountRowId, + filterName, + ); function fetcher([token, offset, order, account]: [ AccessToken, @@ -335,16 +336,18 @@ export function useBusinessAccounts(filterName?: string) { keepPreviousData: true, }); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; + if (data.type !== "ok") return Result.error(data); //TODO: row_id should not be optional - return buildBidirectionalPaginatedResult( - data.body.accounts, - pointer, - setPointer, - PAGINATED_LIST_REQUEST, + return Result.of( + buildPaginatedResult( + data.body.accounts, + pointer, + setPointer, + PAGINATED_LIST_REQUEST, + ), ); } @@ -568,21 +571,29 @@ export function useConversionRateClasses() { lib: { bank: api }, } = useBankCoreApiContext(); - const [offset, setOffset] = useState<number | undefined>(); - const [currentPage, setCurrentPage] = useState(1); + const [pointer, setPointer] = useListPointer( + (row: TalerCorebankApi.ConversionRateClass) => + String(row.conversion_rate_class_id), + undefined, + { displayOrder: "asc" }, + ); - function fetcher([token, aid]: [AccessToken, number]) { + function fetcher([token, offset, order]: [ + AccessToken, + string | undefined, + "asc" | "dec", + ]) { return api.listConversionRateClasses(token, { limit: PAGINATED_LIST_REQUEST, - offset: aid ? String(aid) : undefined, - order: "asc", + offset, + order, }); } const { data, error } = useSWR< TalerCoreBankResultByMethod<"listConversionRateClasses">, TalerHttpError - >([token, offset ?? 0, "useConversionRateClasses"], fetcher, { + >([token, pointer.id, pointer.order, "useConversionRateClasses"], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -594,17 +605,17 @@ export function useConversionRateClasses() { keepPreviousData: true, }); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; - - return buildPaginatedResult( - data.body.classes, - offset, - setOffset, - (d) => d.conversion_rate_class_id, - currentPage, - setCurrentPage, + if (data.type !== "ok") return Result.error(data); + + return Result.of( + buildPaginatedResult( + data.body.classes, + pointer, + setPointer, + PAGINATED_LIST_REQUEST, + ), ); } @@ -661,24 +672,23 @@ export function useConversionRateClassUsers( lib: { bank: api }, } = useBankCoreApiContext(); - const [offset, setOffset] = useState<number | undefined>(); - const [currentPage, setCurrentPage] = useState(1); - - useEffect(() => { - setOffset(undefined); - setCurrentPage(1); - }, [classId, username]); + const [pointer, setPointer] = useListPointer( + (row: TalerCorebankApi.AccountMinimalData) => String(row.row_id ?? 0), + `${classId ?? "none"}:${username ?? ""}`, + { displayOrder: "asc" }, + ); - function fetcher([token, aid, username, classId]: [ + function fetcher([token, offset, order, username, classId]: [ AccessToken, - number, + string | undefined, + "asc" | "dec", string, number, ]) { return api.listAccounts(token, { limit: PAGINATED_LIST_REQUEST, - offset: aid ? String(aid) : undefined, - order: "asc", + offset, + order, account: username, conversionRateId: classId, }); @@ -688,7 +698,14 @@ export function useConversionRateClassUsers( TalerCoreBankResultByMethod<"listAccounts">, TalerHttpError >( - [token, offset ?? 0, username, classId, "useConversionRateClassUsers"], + [ + token, + pointer.id, + pointer.order, + username, + classId, + "useConversionRateClassUsers", + ], fetcher, { refreshInterval: 0, @@ -703,16 +720,16 @@ export function useConversionRateClassUsers( }, ); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; - - return buildPaginatedResult( - data.body.accounts, - offset, - setOffset, - (d) => d.row_id!, - currentPage, - setCurrentPage, + if (data.type !== "ok") return Result.error(data); + + return Result.of( + buildPaginatedResult( + data.body.accounts, + pointer, + setPointer, + PAGINATED_LIST_REQUEST, + ), ); } diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx @@ -1069,15 +1069,6 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { if (!userListResult || !resultInfo) { return <Loading />; } - if (userListResult instanceof TalerError) { - return ( - <RetryableError - error={userListResult} - title={i18n.str`Failed to load users in conversion class.`} - onRetry={() => revalidateConversionRateClassUsers()} - /> - ); - } if (resultInfo instanceof TalerError) { return ( <RetryableError @@ -1097,8 +1088,18 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { ); } const convInfo = resultInfo.body; - if (userListResult.type === "fail") { - switch (userListResult.case) { + if (userListResult.tag === "error") { + const failure = userListResult.error; + if (failure instanceof TalerError) { + return ( + <RetryableError + error={failure} + title={i18n.str`Failed to load users in conversion class.`} + onRetry={() => revalidateConversionRateClassUsers()} + /> + ); + } + switch (failure.case) { case HttpStatusCode.Unauthorized: return ( <Attention type="danger" title={i18n.str`Conversion is disabled`}> @@ -1109,9 +1110,10 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { </Attention> ); default: - assertUnreachable(userListResult); + assertUnreachable(failure.case); } } + const userListPage = userListResult.value; return ( <Fragment> <div class="px-4 mt-4"> @@ -1179,7 +1181,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <div class="mt-4 flow-root"> <div class="overflow-x-auto"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> - {!userListResult.body.length ? ( + {!userListPage.items.length ? ( <div class="py-3.5 pl-4 pr-3 "> <i18n.Translate> No users in this conversion rate class @@ -1212,7 +1214,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { </tr> </thead> <tbody class="divide-y divide-gray-200"> - {userListResult.body.map((item) => { + {userListPage.items.map((item) => { return ( <tr key={item.username} @@ -1301,7 +1303,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { </table> )} </div> - {!userListResult.loadFirst && !userListResult.loadNext ? undefined : ( + {!userListPage.loadFirst && !userListPage.loadNext ? undefined : ( <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" aria-label={i18n.str`Pagination`} @@ -1311,8 +1313,8 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { type="button" name="first page" class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!userListResult.loadFirst} - onClick={userListResult.loadFirst} + disabled={!userListPage.loadFirst} + onClick={userListPage.loadFirst} > <i18n.Translate>First page</i18n.Translate> </button> @@ -1320,8 +1322,8 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { type="button" name="next page" class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!userListResult.loadNext} - onClick={userListResult.loadNext} + disabled={!userListPage.loadNext} + onClick={userListPage.loadNext} > <i18n.Translate>Next</i18n.Translate> </button> diff --git a/packages/libeufin-bank-webui/src/pages/PublicHistoriesPage.tsx b/packages/libeufin-bank-webui/src/pages/PublicHistoriesPage.tsx @@ -14,7 +14,6 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { TalerError } from "@gnu-taler/taler-util"; import { Attention, Loading, @@ -46,7 +45,7 @@ export function PublicHistoriesPage({ if (!result) { return <Loading />; } - if (result instanceof TalerError || result.type === "fail") { + if (result.tag === "error") { return ( <Attention type="danger" @@ -63,7 +62,7 @@ export function PublicHistoriesPage({ ); } - const { body: accountList } = result; + const accountList = result.value.items; const selectedAccount = accountList.some( ({ username }) => username === showAccount, ) diff --git a/packages/libeufin-bank-webui/src/pages/admin/AccountList.tsx b/packages/libeufin-bank-webui/src/pages/admin/AccountList.tsx @@ -62,25 +62,27 @@ export function AccountList({ if (!result) { return <Loading />; } - if (result instanceof TalerError) { - return ( - <RetryableError - error={result} - title={i18n.str`Failed to load the account list.`} - onRetry={() => void revalidateBusinessAccounts()} - /> - ); - } - switch (result.case) { - case "ok": - break; - case HttpStatusCode.Unauthorized: - return <Fragment />; - default: - assertUnreachable(result); + if (result.tag === "error") { + const failure = result.error; + if (failure instanceof TalerError) { + return ( + <RetryableError + error={failure} + title={i18n.str`Failed to load the account list.`} + onRetry={() => void revalidateBusinessAccounts()} + /> + ); + } + switch (failure.case) { + case HttpStatusCode.Unauthorized: + return <Fragment />; + default: + assertUnreachable(failure.case); + } } - const accounts = result.body; + const page = result.value; + const accounts = page.items; return ( <Fragment> <div class="px-4 sm:px-6 lg:px-8 mt-8"> @@ -277,8 +279,8 @@ export function AccountList({ type="button" name="previous page" class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!result.loadPrev} - onClick={result.loadPrev} + disabled={!page.loadPrev} + onClick={page.loadPrev} > <i18n.Translate>Previous</i18n.Translate> </button> @@ -286,8 +288,8 @@ export function AccountList({ type="button" name="next page" class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!result.loadNext} - onClick={result.loadNext} + disabled={!page.loadNext} + onClick={page.loadNext} > <i18n.Translate>Next</i18n.Translate> </button> diff --git a/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx b/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx @@ -65,18 +65,18 @@ export function ConversionClassList({ if (!result) { return <Loading />; } - if (result instanceof TalerError) { - return ( - <RetryableError - error={result} - title={i18n.str`Failed to load conversion rate.`} - onRetry={() => void revalidateConversionRateClasses()} - /> - ); - } - - if (result.type !== "ok") { - switch (result.case) { + if (result.tag === "error") { + const failure = result.error; + if (failure instanceof TalerError) { + return ( + <RetryableError + error={failure} + title={i18n.str`Failed to load conversion rate.`} + onRetry={() => void revalidateConversionRateClasses()} + /> + ); + } + switch (failure.case) { case HttpStatusCode.Forbidden: return ( <Attention @@ -106,11 +106,12 @@ export function ConversionClassList({ ></Attention> ); default: - assertUnreachable(result); + assertUnreachable(failure); } } - const classes = result.body; + const page = result.value; + const classes = page.items; return ( <Fragment> @@ -268,7 +269,7 @@ export function ConversionClassList({ </table> )} </div> - {result.loadFirst || result.loadNext ? ( + {page.loadFirst || page.loadNext ? ( <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" aria-label={i18n.str`Pagination`} @@ -278,8 +279,8 @@ export function ConversionClassList({ type="button" name="first page" class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!result.loadFirst} - onClick={result.loadFirst} + disabled={!page.loadFirst} + onClick={page.loadFirst} > <i18n.Translate>First page</i18n.Translate> </button> @@ -287,8 +288,8 @@ export function ConversionClassList({ type="button" name="next page" class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-onBackground ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!result.loadNext} - onClick={result.loadNext} + disabled={!page.loadNext} + onClick={page.loadNext} > <i18n.Translate>Next</i18n.Translate> </button> diff --git a/packages/taler-exchange-aml-webui/src/hooks/account.ts b/packages/taler-exchange-aml-webui/src/hooks/account.ts @@ -18,6 +18,7 @@ import { OfficerSession, opFixedSuccess, PaytoString, + Result, TalerExchangeResultByMethod2, TalerHttpError, } from "@gnu-taler/taler-util"; @@ -91,10 +92,12 @@ export function useAccountInformation(paytoHash?: string) { ); if (data?.type === "ok") { - return buildPaginatedResult(data.body.details, pointer, updatePointer, 11); + return Result.of( + buildPaginatedResult(data.body.details, pointer, updatePointer, 11), + ); } - if (data) return data; - if (error) return error; + if (data) return Result.error(data); + if (error) return Result.error(error); return undefined; } diff --git a/packages/taler-exchange-aml-webui/src/hooks/decisions.ts b/packages/taler-exchange-aml-webui/src/hooks/decisions.ts @@ -23,6 +23,7 @@ import { OperationFail, OperationOk, PaytoHash, + Result, opFixedSuccess, TalerError, TalerExchangeResultByMethod2, @@ -98,15 +99,17 @@ export function useAmlAccounts({ fetcher, ); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; + if (data.type !== "ok") return Result.error(data); - return buildPaginatedResult( - data.body.accounts, - pointer, - updatePointer, - AML_ACCOUNTS_LIST_REQUEST, + return Result.of( + buildPaginatedResult( + data.body.accounts, + pointer, + updatePointer, + AML_ACCOUNTS_LIST_REQUEST, + ), ); } @@ -218,15 +221,17 @@ export function useAccountDecisions(accountStr: string, active?: boolean) { fetcher, ); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; + if (data.type !== "ok") return Result.error(data); - return buildPaginatedResult( - data.body.records, - pointer, - updatePointer, - PAGINATED_LIST_REQUEST, + return Result.of( + buildPaginatedResult( + data.body.records, + pointer, + updatePointer, + PAGINATED_LIST_REQUEST, + ), ); } diff --git a/packages/taler-exchange-aml-webui/src/hooks/legitimizations.ts b/packages/taler-exchange-aml-webui/src/hooks/legitimizations.ts @@ -18,6 +18,7 @@ import { LegitimizationMeasureDetails, OfficerSession, + Result, TalerExchangeResultByMethod2, TalerHttpError, } from "@gnu-taler/taler-util"; @@ -86,14 +87,16 @@ export function useCurrentLegitimizations(accoutnStr: string) { fetcher, ); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; + if (data.type !== "ok") return Result.error(data); - return buildPaginatedResult( - data.body.measures, - pointer, - updatePointer, - PAGINATED_LIST_REQUEST, + return Result.of( + buildPaginatedResult( + data.body.measures, + pointer, + updatePointer, + PAGINATED_LIST_REQUEST, + ), ); } diff --git a/packages/taler-exchange-aml-webui/src/hooks/transfers.test.ts b/packages/taler-exchange-aml-webui/src/hooks/transfers.test.ts @@ -54,7 +54,7 @@ test("wallet credit category dispatches only to the wallet endpoint", async () = await requestTransferList( api, officer, - { id: "99", order: "dec", page: 2 }, + { id: "99", order: "dec", displayOrder: "dec", page: 2 }, "wallet-credit", { currency: "CHF", value: 5, fraction: 0 }, "1111111111111111111111111111111111111111111111111111" as never, diff --git a/packages/taler-exchange-aml-webui/src/hooks/transfers.ts b/packages/taler-exchange-aml-webui/src/hooks/transfers.ts @@ -20,6 +20,7 @@ import { ExchangeTransferListEntry, OfficerSession, PaytoHash, + Result, TalerExchangeHttpClient, TalerExchangeResultByMethod2, TalerHttpError, @@ -137,14 +138,16 @@ export function useTransferList({ fetcher, ); - if (error) return error; + if (error) return Result.error(error); if (data === undefined) return undefined; - if (data.type !== "ok") return data; + if (data.type !== "ok") return Result.error(data); - return buildPaginatedResult( - data.body.transfers, - pointer, - updatePointer, - PAGINATED_LIST_REQUEST, + return Result.of( + buildPaginatedResult( + data.body.transfers, + pointer, + updatePointer, + PAGINATED_LIST_REQUEST, + ), ); } diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountDecisionHistory.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountDecisionHistory.tsx @@ -43,13 +43,9 @@ export function AccountDecisionHistory({ const dialect = useAmlSpaDialect(config.config.aml_spa_dialect); const history = useAccountDecisions(account, false); const measures = useServerMeasures(); - const historyOk = - history && !(history instanceof TalerError) && history.type === "ok" - ? history - : undefined; - const historyFailed = - history instanceof TalerError || history?.type === "fail"; - const decisions = historyOk?.body ?? []; + const historyOk = history?.tag === "ok" ? history.value : undefined; + const historyFailed = history?.tag === "error"; + const decisions = historyOk?.items ?? []; const measuresOk = measures && !(measures instanceof TalerError) && measures.type === "ok" ? measures.body diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx @@ -73,20 +73,13 @@ export function AccountDetails({ const legitimizations = useCurrentLegitimizations(account); const measures = useServerMeasures(); - const detailsOk = - details && !(details instanceof TalerError) && details.type === "ok" - ? details - : undefined; + const detailsOk = details?.tag === "ok" ? details.value : undefined; const activeOk = active && !(active instanceof TalerError) && active.type === "ok" ? active : undefined; const legitimizationsOk = - legitimizations && - !(legitimizations instanceof TalerError) && - legitimizations.type === "ok" - ? legitimizations - : undefined; + legitimizations?.tag === "ok" ? legitimizations.value : undefined; const measuresOk = measures && !(measures instanceof TalerError) && measures.type === "ok" ? measures @@ -100,8 +93,8 @@ export function AccountDetails({ const targetIsWallet = activeDecision?.is_wallet ?? (parsedTarget ? isWalletPaytoType(parsedTarget.targetType) : undefined); - const collectionEvents = detailsOk?.body ?? []; - const activeRequirements = legitimizationsOk?.body ?? []; + const collectionEvents = detailsOk?.items ?? []; + const activeRequirements = legitimizationsOk?.items ?? []; const requirementCounts = activeRequirements.reduce( (counts, requirement) => { if (requirement.measures.verboten) counts.forbidden += 1; @@ -122,10 +115,8 @@ export function AccountDetails({ ); const activeFailed = active instanceof TalerError || active?.type === "fail"; - const detailsFailed = - details instanceof TalerError || details?.type === "fail"; - const legitimizationsFailed = - legitimizations instanceof TalerError || legitimizations?.type === "fail"; + const detailsFailed = details?.tag === "error"; + const legitimizationsFailed = legitimizations?.tag === "error"; const measuresFailed = measures instanceof TalerError || measures?.type === "fail"; diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx @@ -158,26 +158,30 @@ export function AccountList({ if (!list) return <Loading />; - if (list instanceof TalerError) { - return ( - <div class="space-y-4"> - <ErrorLoading title={i18n.str`Failed to load accounts`} error={list} /> - <RetryButton /> - </div> - ); - } - - if (list.type === "fail") { + if (list.tag === "error") { + const failure = list.error; + if (failure instanceof TalerError) { + return ( + <div class="space-y-4"> + <ErrorLoading + title={i18n.str`Failed to load accounts`} + error={failure} + /> + <RetryButton /> + </div> + ); + } return ( <AccountsFailure title={i18n.str`Failed to load accounts`} - message={accountFailureMessage(list.case, i18n)} - technical={list} + message={accountFailureMessage(failure.case, i18n)} + technical={failure} /> ); } - const records = list.body; + const page = list.value; + const records = page.items; const hasFilters = Object.values(filters).some( (value) => value !== undefined, ); @@ -326,10 +330,10 @@ export function AccountList({ ))} </div> <Pagination - currentPage={list.currentPage} - onFirstPage={list.loadFirst} - onPrevious={list.loadPrev} - onNext={list.loadNext} + currentPage={page.currentPage} + onFirstPage={page.loadFirst} + onPrevious={page.loadPrev} + onNext={page.loadNext} /> </div> )} diff --git a/packages/taler-exchange-aml-webui/src/pages/Search.tsx b/packages/taler-exchange-aml-webui/src/pages/Search.tsx @@ -208,18 +208,19 @@ function ShowResult({ if (!history) { return <Loading />; } - if (history instanceof TalerError) { - return ( - <ErrorLoading - title={i18n.str`Failed to load account decisions.`} - error={history} - /> - ); - } - if (history.type === "fail") { + if (history.tag === "error") { + const failure = history.error; + if (failure instanceof TalerError) { + return ( + <ErrorLoading + title={i18n.str`Failed to load account decisions.`} + error={failure} + /> + ); + } return ( <FailLoading - operation={history} + operation={failure} title={i18n.str`Failed to load the account history.`} translate={(d) => { switch (d.case) { @@ -252,8 +253,8 @@ function ShowResult({ ); } - if (history.body.length) { - const latest = history.body[0]; + if (history.value.items.length) { + const latest = history.value.items[0]; return ( <section aria-labelledby="account-found-heading" diff --git a/packages/taler-exchange-aml-webui/src/pages/Transfers.tsx b/packages/taler-exchange-aml-webui/src/pages/Transfers.tsx @@ -248,18 +248,19 @@ export function Transfers({ if (!resp) { return renderPage(<Loading />); } - if (resp instanceof Error) { - return renderPage( - <ErrorLoading - title={i18n.str`Failed to load transfer list.`} - error={resp} - />, - ); - } - if (resp.type === "fail") { + if (resp.tag === "error") { + const failure = resp.error; + if (failure instanceof Error) { + return renderPage( + <ErrorLoading + title={i18n.str`Failed to load transfer list.`} + error={failure} + />, + ); + } return renderPage( <FailLoading - operation={resp} + operation={failure} title={i18n.str`Failed to load the transfer list.`} translate={(d) => { switch (d.case) { @@ -291,7 +292,8 @@ export function Transfers({ />, ); } - const transactions = resp.body; + const page = resp.value; + const transactions = page.items; if (!direction) { return renderPage( @@ -489,10 +491,10 @@ export function Transfers({ </table> <Pagination - currentPage={resp.currentPage} - onFirstPage={resp.loadFirst} - onPrevious={resp.loadPrev} - onNext={resp.loadNext} + currentPage={page.currentPage} + onFirstPage={page.loadFirst} + onPrevious={page.loadPrev} + onNext={page.loadNext} /> </div>, ); diff --git a/packages/web-util/src/utils/buildPaginatedResult.test.tsx b/packages/web-util/src/utils/buildPaginatedResult.test.tsx @@ -12,7 +12,67 @@ import test from "node:test"; import { Window } from "happy-dom"; import { h, render } from "preact"; import { act } from "preact/test-utils"; -import { ListPointer, useListPointer } from "./buildPaginatedResult.js"; +import { + buildPaginatedResult, + ListPointer, + useListPointer, +} from "./buildPaginatedResult.js"; + +test("builds descending pages and navigation controls", () => { + const moves: Array<{ id?: number; order: "asc" | "dec" }> = []; + const firstPage = buildPaginatedResult( + [5, 4, 3], + { order: "dec", displayOrder: "dec", page: 1 }, + (item, order) => moves.push({ id: item, order }), + 3, + ); + + assert.deepEqual(firstPage.items, [5, 4]); + assert.equal("response" in firstPage, false); + assert.equal("type" in firstPage, false); + assert.equal(firstPage.currentPage, 1); + assert.equal(firstPage.loadPrev, undefined); + assert.equal(firstPage.loadFirst, undefined); + firstPage.loadNext?.(); + assert.deepEqual(moves.pop(), { id: 4, order: "dec" }); + + const middlePage = buildPaginatedResult( + [4, 5, 6], + { id: "3", order: "asc", displayOrder: "dec", page: 2 }, + (item, order) => moves.push({ id: item, order }), + 3, + ); + assert.deepEqual(middlePage.items, [5, 4]); + middlePage.loadPrev?.(); + assert.deepEqual(moves.pop(), { id: 5, order: "asc" }); + middlePage.loadFirst?.(); + assert.deepEqual(moves.pop(), { id: undefined, order: "dec" }); +}); + +test("builds ascending pages and preserves their display order", () => { + const moves: Array<{ id?: number; order: "asc" | "dec" }> = []; + const firstPage = buildPaginatedResult( + [1, 2, 3], + { order: "asc", displayOrder: "asc", page: 1 }, + (item, order) => moves.push({ id: item, order }), + 3, + ); + + assert.deepEqual(firstPage.items, [1, 2]); + firstPage.loadNext?.(); + assert.deepEqual(moves.pop(), { id: 2, order: "asc" }); + + const lastPage = buildPaginatedResult( + [3, 4], + { id: "2", order: "asc", displayOrder: "asc", page: 2 }, + (item, order) => moves.push({ id: item, order }), + 3, + ); + assert.deepEqual(lastPage.items, [3, 4]); + assert.equal(lastPage.loadNext, undefined); + lastPage.loadPrev?.(); + assert.deepEqual(moves.pop(), { id: 3, order: "dec" }); +}); test("list pointers track the current page", () => { const window = new Window({ url: "https://pagination.example/" }); @@ -39,6 +99,7 @@ test("list pointers track the current page", () => { try { act(() => render(<Harness />, container)); assert.equal(pointer?.page, 1); + assert.equal(pointer?.displayOrder, "dec"); act(() => move?.({ id: "20" }, "dec")); assert.equal(pointer?.page, 2); @@ -57,3 +118,57 @@ test("list pointers track the current page", () => { window.close(); } }); + +test("list pointers support ascending lists and an initial cursor", () => { + const window = new Window({ url: "https://pagination.example/" }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: window, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: window.document, + }); + const container = document.createElement("div"); + document.body.append(container); + let pointer: ListPointer | undefined; + let move: + | ((row: { id: string } | undefined, order: "asc" | "dec") => void) + | undefined; + + function Harness() { + [pointer, move] = useListPointer( + (row: { id: string }) => row.id, + "ascending-list", + { id: "10", displayOrder: "asc" }, + ); + return <span>{pointer.page}</span>; + } + + try { + act(() => render(<Harness />, container)); + assert.deepEqual(pointer, { + id: "10", + order: "asc", + displayOrder: "asc", + page: 1, + }); + + act(() => move?.({ id: "20" }, "asc")); + assert.equal(pointer?.page, 2); + + act(() => move?.({ id: "15" }, "dec")); + assert.equal(pointer?.page, 1); + + act(() => move?.(undefined, "asc")); + assert.deepEqual(pointer, { + order: "asc", + displayOrder: "asc", + page: 1, + }); + } finally { + render(null, container); + container.remove(); + window.close(); + } +}); diff --git a/packages/web-util/src/utils/buildPaginatedResult.ts b/packages/web-util/src/utils/buildPaginatedResult.ts @@ -1,4 +1,3 @@ -import { assertUnreachable, OperationOk } from "@gnu-taler/taler-util"; import { useCallback, useEffect, useState } from "preact/hooks"; export type PaginationControl = { @@ -7,57 +6,60 @@ export type PaginationControl = { loadPrev?(): void; loadFirst?(): void; }; -export type PaginatedResult<T> = OperationOk<T> & PaginationControl; +export type PaginatedList<T> = { + items: T[]; +} & PaginationControl; /** * * @param data the result of the requested list * @param offset offset id or index * @param setOffset function to be call on loadNext or loadFirst to specify the new offset - * @param getId return the offset id of a row - * @param PAGINATED_LIST_REQUEST the limit of the request, the UI is expted to show N -1 elements - * @returns an OperationOk with two function. If the function is missing is because the offset is on the limit + * @param requestLimit number of requested rows; the last row is a pagination sentinel + * @returns the visible items and the controls available for the current page */ export function buildPaginatedResult<R>( - data: R[], + data: readonly R[], offset: ListPointer, setOffset: (o: R | undefined, direction: "asc" | "dec") => void, - PAGINATED_LIST_REQUEST: number, -): PaginatedResult<R[]> { + requestLimit: number, +): PaginatedList<R> { const { - list: body, + list: items, isLastPage, isFirstPage, - } = __compute_for_ui(data, offset, PAGINATED_LIST_REQUEST); - // FIXME: This should *not* be an HTTP result. + } = computePage(data, offset, requestLimit); + const forwardOrder = offset.displayOrder; + const backwardOrder = oppositeOrder(forwardOrder); return { - type: "ok", - case: "ok", - body, - response: {} as any, + items, currentPage: offset.page, loadNext: isLastPage ? undefined : () => { - if (!body.length) return; - const id = body[body.length - 1]; - setOffset(id, "dec"); + if (!items.length) return; + const id = items[items.length - 1]; + setOffset(id, forwardOrder); }, loadPrev: isFirstPage ? undefined : () => { - if (!body.length) return; - const id = body[0]; - setOffset(id, "asc"); + if (!items.length) return; + const id = items[0]; + setOffset(id, backwardOrder); }, loadFirst: isFirstPage ? undefined : () => { - setOffset(undefined, "dec"); + setOffset(undefined, forwardOrder); }, }; } +function oppositeOrder(order: ListOrder): ListOrder { + return order === "asc" ? "dec" : "asc"; +} + /** * based on the pointer and the max result set * @@ -67,35 +69,22 @@ export function buildPaginatedResult<R>( * @param max * @returns */ -function __compute_for_ui<R>(data: Array<R>, offset: ListPointer, max: number) { +function computePage<R>(data: readonly R[], offset: ListPointer, max: number) { // we ask for N but show N-1 // the last element on the list is to signal if // we have more pages after it const thereIsMore = data.length == max; - const result: R[] = structuredClone(data); + const result = data.slice(); if (thereIsMore) { result.pop(); } - // we assume the UI always show on DEC order - // so what the offset.order is telling is - // in which direction the last request was. - switch (offset.order) { - case "asc": { - const isFirstPage = !thereIsMore; - const isLastPage = offset.id === undefined; - const list = result.reverse(); - return { list, isFirstPage, isLastPage }; - } - case "dec": { - const isLastPage = !thereIsMore; - const isFirstPage = offset.id === undefined; - const list = result; - return { list, isFirstPage, isLastPage }; - } - default: { - assertUnreachable(offset.order); - } + const isForward = offset.order === offset.displayOrder; + if (!isForward) { + result.reverse(); } + const isFirstPage = isForward ? offset.id === undefined : !thereIsMore; + const isLastPage = isForward ? !thereIsMore : offset.id === undefined; + return { list: result, isFirstPage, isLastPage }; } /** @@ -103,12 +92,21 @@ function __compute_for_ui<R>(data: Array<R>, offset: ListPointer, max: number) { * * If id is not set then assume the top most entry point. */ +export type ListOrder = "asc" | "dec"; + export type ListPointer = { id?: string; - order: "asc" | "dec"; + order: ListOrder; + displayOrder: ListOrder; page: number; }; -const INITIAL_LIST_POINTER: ListPointer = { order: "dec", page: 1 }; + +export type InitialListPointer = { + /** Start from this cursor when the query identity changes. */ + id?: string; + /** Order in which pages and their items are displayed. */ + displayOrder?: ListOrder; +}; /** * @@ -118,25 +116,44 @@ const INITIAL_LIST_POINTER: ListPointer = { order: "dec", page: 1 }; export function useListPointer<T>( getId: (d: T) => string, queryIdentity?: string, + initialPointer: InitialListPointer = {}, ): [ListPointer, (p: T | undefined, order: ListPointer["order"]) => void] { - const [pointer, setPointer] = useState<ListPointer>(INITIAL_LIST_POINTER); + const initialId = initialPointer.id; + const displayOrder = initialPointer.displayOrder ?? "dec"; + const makeInitialPointer = useCallback( + (): ListPointer => ({ + id: initialId, + order: displayOrder, + displayOrder, + page: 1, + }), + [displayOrder, initialId], + ); + const [pointer, setPointer] = useState<ListPointer>(makeInitialPointer); useEffect(() => { - setPointer(INITIAL_LIST_POINTER); - }, [queryIdentity]); + setPointer(makeInitialPointer()); + }, [makeInitialPointer, queryIdentity]); const movePointer = useCallback( (d: T | undefined, order: ListPointer["order"]) => { if (!d) { - setPointer(INITIAL_LIST_POINTER); + setPointer({ + order: displayOrder, + displayOrder, + page: 1, + }); } else { setPointer((current) => ({ order, + displayOrder: current.displayOrder, id: getId(d), page: - order === "dec" ? current.page + 1 : Math.max(1, current.page - 1), + order === current.displayOrder + ? current.page + 1 + : Math.max(1, current.page - 1), })); } }, - [getId], + [displayOrder, getId], ); return [pointer, movePointer]; }