Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
559 changes: 348 additions & 211 deletions kassiber/core/ui_snapshot.py

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions tests/test_review_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1466,6 +1466,27 @@ def test_ui_snapshots_show_reviewed_swap_movement_with_fee(self):
[row["id"] for row in limited["txs"]],
["swap-in-leg", "older-income"],
)
first_page = build_transactions_snapshot(conn, {"limit": 1})
self.assertEqual([row["id"] for row in first_page["txs"]], ["swap-in-leg"])
self.assertTrue(first_page["hasMore"])
self.assertTrue(first_page["nextCursor"])
second_page = build_transactions_snapshot(
conn,
{"limit": 1, "cursor": first_page["nextCursor"]},
)
self.assertEqual([row["id"] for row in second_page["txs"]], ["older-income"])
self.assertFalse(second_page["hasMore"])
self.assertIsNone(second_page["nextCursor"])
with self.assertRaises(AppError) as changed_cursor_filter:
build_transactions_snapshot(
conn,
{
"limit": 1,
"cursor": first_page["nextCursor"],
"asset": "BTC",
},
)
self.assertEqual(changed_cursor_filter.exception.code, "validation")

outbound = build_transactions_snapshot(
conn,
Expand All @@ -1484,6 +1505,82 @@ def test_ui_snapshots_show_reviewed_swap_movement_with_fee(self):
self.assertEqual(len(outbound_search["txs"]), 1)
self.assertEqual(outbound_search["txs"][0]["id"], "swap-out-leg")
self.assertEqual(outbound_search["txs"][0]["type"], "Swap")
list_search = build_transactions_snapshot(
conn,
{"query": "older income", "limit": 10},
)
self.assertEqual([row["id"] for row in list_search["txs"]], ["older-income"])
self.assertEqual(list_search["filters"]["query"], "older income")

def test_ui_transactions_snapshot_cursor_roundtrips_sort_ties(self):
self._bootstrap_wallet(label="Cursor Sort")
for index, tx_id in enumerate(
[
"cursor-sort-a",
"cursor-sort-b",
"cursor-sort-c",
"cursor-sort-d",
],
):
self._insert_transaction(
wallet_label="Cursor Sort",
tx_id=tx_id,
occurred_at=f"2024-01-01T00:00:0{index}Z",
amount_msat=100_000_000,
)

db_path = self.data_root / "kassiber.sqlite3"
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute(
"""
UPDATE transactions
SET fee = ?, fiat_value = ?
WHERE id LIKE 'cursor-sort-%'
""",
(1_000, 42.0),
)
conn.commit()
self.addCleanup(conn.close)

for sort in ("occurred-at", "amount", "fee", "fiat-value"):
for order in ("asc", "desc"):
with self.subTest(sort=sort, order=order):
first_page = build_transactions_snapshot(
conn,
{
"query": "cursor-sort",
"limit": 2,
"sort": sort,
"order": order,
},
)
self.assertTrue(first_page["hasMore"])
self.assertTrue(first_page["nextCursor"])
second_page = build_transactions_snapshot(
conn,
{
"query": "cursor-sort",
"limit": 2,
"sort": sort,
"order": order,
"cursor": first_page["nextCursor"],
},
)
ids = [
row["id"]
for row in [*first_page["txs"], *second_page["txs"]]
]
self.assertEqual(len(ids), 4)
self.assertEqual(len(set(ids)), 4)
self.assertEqual(set(ids), {
"cursor-sort-a",
"cursor-sort-b",
"cursor-sort-c",
"cursor-sort-d",
})
self.assertFalse(second_page["hasMore"])
self.assertIsNone(second_page["nextCursor"])

def test_report_transfer_rows_derive_missing_swap_fee(self):
context = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,28 @@ import {
const TransactionsDashboard = ({
className,
transactions = MOCK_TRANSACTIONS,
tableTransactions,
nowRate = MOCK_OVERVIEW.priceEur,
swapCandidates,
swapCandidateTotal,
isDataRefreshing = false,
hasMoreTransactions = false,
isLoadingMoreTransactions = false,
onLoadMoreTransactions,
focusedTransaction,
deepLinkedTransactionId,
deepLinkedTransactionTab,
}: {
className?: string;
transactions?: TransactionsList;
tableTransactions?: TransactionsList;
nowRate?: number | null;
swapCandidates?: SwapCandidateReference[];
swapCandidateTotal?: number | null;
isDataRefreshing?: boolean;
hasMoreTransactions?: boolean;
isLoadingMoreTransactions?: boolean;
onLoadMoreTransactions?: () => void;
focusedTransaction?: TransactionsList["txs"][number] | null;
deepLinkedTransactionId?: string | null;
deepLinkedTransactionTab?: string;
Expand Down Expand Up @@ -90,6 +98,25 @@ const TransactionsDashboard = ({
},
[focusedTransaction, transactions.txs],
);
const tableSourceRecords = React.useMemo(() => {
const txs = (tableTransactions ?? transactions).txs.length
? [...(tableTransactions ?? transactions).txs]
: [];
if (
focusedTransaction &&
!txs.some(
(tx) =>
tx.id === focusedTransaction.id ||
(Boolean(tx.externalId) &&
tx.externalId === focusedTransaction.externalId) ||
(Boolean(tx.explorerId) &&
tx.explorerId === focusedTransaction.explorerId),
)
) {
txs.unshift(focusedTransaction);
}
return txs.length ? txs.map(toDashboardTransaction) : transactionRecords;
}, [focusedTransaction, tableTransactions, transactions]);
const allPeriodRecords = React.useMemo(
() => sortTransactionsByDateDesc(records),
[records],
Expand All @@ -103,33 +130,39 @@ const TransactionsDashboard = ({
);
const focusedRecord = React.useMemo(() => {
if (!focusedTransaction) return null;
return records.find(
return tableSourceRecords.find(
(record) =>
record.id === focusedTransaction.id ||
(Boolean(focusedTransaction.externalId) &&
record.txnId === focusedTransaction.externalId) ||
(Boolean(focusedTransaction.explorerId) &&
record.explorerId === focusedTransaction.explorerId),
) ?? null;
}, [focusedTransaction, records]);
}, [focusedTransaction, tableSourceRecords]);
const tablePeriodRecords = React.useMemo(
() =>
period === "all"
? sortTransactionsByDateDesc(tableSourceRecords)
: recordsForPeriod(tableSourceRecords, period),
[period, tableSourceRecords],
);
const tableRecords = React.useMemo(() => {
if (
!focusedRecord ||
periodRecords.some((record) => record.id === focusedRecord.id)
tablePeriodRecords.some((record) => record.id === focusedRecord.id)
) {
return periodRecords;
return tablePeriodRecords;
}
return [focusedRecord, ...periodRecords];
}, [focusedRecord, periodRecords]);
const periodSwapCandidateIds = React.useMemo(
return [focusedRecord, ...tablePeriodRecords];
}, [focusedRecord, tablePeriodRecords]);
const tableSwapCandidateIds = React.useMemo(
() =>
new Set(
buildSwapCandidates(periodRecords, swapCandidates).flatMap((candidate) => [
candidate.in.id,
candidate.out.id,
]),
buildSwapCandidates(tablePeriodRecords, swapCandidates).flatMap(
(candidate) => [candidate.in.id, candidate.out.id],
),
),
[periodRecords, swapCandidates],
[tablePeriodRecords, swapCandidates],
);
const handlePeriodChange = React.useCallback((nextPeriod: PeriodKey) => {
setPeriod(nextPeriod);
Expand Down Expand Up @@ -212,7 +245,7 @@ const TransactionsDashboard = ({
currency={currency}
nowRate={nowRate}
explorerSettings={explorerSettings}
swapCandidateIds={periodSwapCandidateIds}
swapCandidateIds={tableSwapCandidateIds}
chartSelection={flowChartSelection}
quickFilter={quickFilter}
breakdownSelection={breakdownSelection}
Expand All @@ -221,6 +254,9 @@ const TransactionsDashboard = ({
onBreakdownSelectionChange={setBreakdownSelection}
resetTableFiltersToken={resetTableFiltersToken}
isRefreshing={showRefreshSkeleton}
hasMoreRecords={hasMoreTransactions}
isLoadingMoreRecords={isLoadingMoreTransactions}
onLoadMoreRecords={onLoadMoreTransactions}
deepLinkedTransactionId={deepLinkedTransactionId}
deepLinkedTransactionTab={deepLinkedTransactionTab}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ const TransactionsTable = ({
onBreakdownSelectionChange,
resetTableFiltersToken,
isRefreshing,
hasMoreRecords = false,
isLoadingMoreRecords = false,
onLoadMoreRecords,
deepLinkedTransactionId,
deepLinkedTransactionTab = "details",
}: {
Expand All @@ -144,6 +147,9 @@ const TransactionsTable = ({
onBreakdownSelectionChange: (selection: BreakdownSelection | null) => void;
resetTableFiltersToken: number;
isRefreshing?: boolean;
hasMoreRecords?: boolean;
isLoadingMoreRecords?: boolean;
onLoadMoreRecords?: () => void;
deepLinkedTransactionId?: string | null;
deepLinkedTransactionTab?: string;
}) => {
Expand Down Expand Up @@ -1273,6 +1279,18 @@ const TransactionsTable = ({
</span>
</div>

{hasMoreRecords && onLoadMoreRecords ? (
<Button
variant="outline"
size="sm"
className="h-8 gap-2"
onClick={onLoadMoreRecords}
disabled={isLoadingMoreRecords}
>
{isLoadingMoreRecords ? "Loading" : "Load more"}
</Button>
) : null}

<div className="flex items-center gap-1">
<Button
variant="outline"
Expand Down
10 changes: 10 additions & 0 deletions ui-tauri/src/daemon/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";

import {
daemonMutationKey,
mutationAdvancesDaemonSession,
parseDaemonAuthRequiredEventDetail,
shouldHandleDaemonAuthRequiredEvent,
} from "./client";
Expand Down Expand Up @@ -73,3 +74,12 @@ describe("daemon mutation key", () => {
);
});
});

describe("daemon session advancing mutations", () => {
it("advances after profile switches so cached pages cannot cross books", () => {
expect(mutationAdvancesDaemonSession("ui.profiles.switch")).toBe(true);
expect(mutationAdvancesDaemonSession("ui.transactions.metadata.update")).toBe(
false,
);
});
});
68 changes: 65 additions & 3 deletions ui-tauri/src/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
*/

import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type UseQueryOptions,
type UseQueryResult,
type UseInfiniteQueryOptions,
type UseInfiniteQueryResult,
type InfiniteData,
} from "@tanstack/react-query";
import { getTransport, type DaemonEnvelope } from "./transport";
import { useUiStore, type DataMode } from "@/store/ui";
Expand Down Expand Up @@ -184,10 +188,64 @@ export function useDaemon<T = unknown>(
});
}

export function useDaemonInfinite<T = unknown>(
kind: string,
args: Record<string, unknown> | undefined,
getNextPageParam: (lastPage: DaemonEnvelope<T>) => unknown,
options?: Omit<
UseInfiniteQueryOptions<
DaemonEnvelope<T>,
Error,
InfiniteData<DaemonEnvelope<T>>,
readonly unknown[],
unknown
>,
"queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam"
>,
): UseInfiniteQueryResult<InfiniteData<DaemonEnvelope<T>>, Error> {
const dataMode = useUiStore((state) => state.dataMode);
const daemonSession = useUiStore((state) => state.daemonSession);
return useInfiniteQuery<
DaemonEnvelope<T>,
Error,
InfiniteData<DaemonEnvelope<T>>,
readonly unknown[],
unknown
>({
queryKey: daemonQueryKey(dataMode, daemonSession, kind, args),
initialPageParam: null,
queryFn: async ({ pageParam }) => {
const envelope = await getTransport(dataMode).invoke<T>({
kind,
args: {
...(args ?? {}),
...(typeof pageParam === "string" ? { cursor: pageParam } : {}),
},
});
if (envelope.kind === "auth_required") {
handleAuthRequired(envelope, daemonSession);
}
if (envelope.kind === "error" || envelope.error) {
throw new DaemonRequestError(kind, envelope);
}
return envelope;
},
getNextPageParam,
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) =>
error instanceof DaemonAuthRequiredError ? false : failureCount < 3,
...options,
});
}

export function daemonMutationKey(dataMode: DataMode, kind: string) {
return ["daemon-mutation", dataMode, kind] as const;
}

export function mutationAdvancesDaemonSession(kind: string) {
return kind === "ui.profiles.switch";
}

export function useDaemonMutation<T = unknown>(
kind: string,
options?: { dataMode?: DataMode },
Expand All @@ -212,10 +270,14 @@ export function useDaemonMutation<T = unknown>(
}
return envelope;
},
onSuccess: () =>
queryClient.invalidateQueries({
onSuccess: () => {
if (mutationAdvancesDaemonSession(kind)) {
useUiStore.getState().bumpDaemonSession();
}
return queryClient.invalidateQueries({
queryKey: ["daemon", dataMode],
}),
});
},
});
}

Expand Down
Loading
Loading