web/satellite: add pagination to billing history

This change adds pagination to the billing history table. It uses the
new invoice-history endpoint since we only list invoices in this table.

Issue: https://github.com/storj/storj/issues/5479

Change-Id: I192d58503434203808a23a7c18e8d1feb6afc73f
This commit is contained in:
Wilfred Asomani 2023-08-28 17:29:32 +00:00 committed by Storj Robot
parent 614d213432
commit 6e3da022e0
8 changed files with 159 additions and 50 deletions

View File

@ -15,7 +15,7 @@ import {
TokenAmount,
NativePaymentHistoryItem,
Wallet,
PaymentWithConfirmations,
PaymentWithConfirmations, PaymentHistoryParam, PaymentHistoryPage,
} from '@/types/payments';
import { HttpClient } from '@/utils/httpClient';
import { Time } from '@/utils/time';
@ -215,8 +215,13 @@ export class PaymentsHttpApi implements PaymentsApi {
* @returns list of payments history items
* @throws Error
*/
public async paymentsHistory(): Promise<PaymentsHistoryItem[]> {
const path = `${this.ROOT_PATH}/billing-history`;
public async paymentsHistory(param: PaymentHistoryParam): Promise<PaymentHistoryPage> {
let path = `${this.ROOT_PATH}/invoice-history?limit=${param.limit}`;
if (param.startingAfter) {
path = `${path}&starting_after=${param.startingAfter}`;
} else if (param.endingBefore) {
path = `${path}&ending_before=${param.endingBefore}`;
}
const response = await this.client.get(path);
if (!response.ok) {
@ -227,9 +232,10 @@ export class PaymentsHttpApi implements PaymentsApi {
});
}
const paymentsHistoryItems = await response.json();
if (paymentsHistoryItems) {
return paymentsHistoryItems.map(item =>
const pageJson = await response.json();
let items: PaymentsHistoryItem[] = [];
if (pageJson.items) {
items = pageJson.items.map(item =>
new PaymentsHistoryItem(
item.id,
item.description,
@ -245,7 +251,11 @@ export class PaymentsHttpApi implements PaymentsApi {
);
}
return [];
return new PaymentHistoryPage(
items,
pageJson.next,
pageJson.previous,
);
}
/**

View File

@ -7,7 +7,14 @@
Billing History
</h1>
<v-table :total-items-count="historyItems.length" class="billing-history__table">
<v-table
simple-pagination
:total-items-count="historyItems.length"
class="billing-history__table"
:on-next-clicked="nextClicked"
:on-previous-clicked="previousClicked"
:on-page-size-changed="sizeChanged"
>
<template #head>
<BillingHistoryHeader />
</template>
@ -23,16 +30,17 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
PaymentsHistoryItem,
PaymentsHistoryItemStatus,
PaymentsHistoryItemType,
PaymentHistoryPage,
} from '@/types/payments';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { useNotify } from '@/utils/hooks';
import { useBillingStore } from '@/store/modules/billingStore';
import { DEFAULT_PAGE_LIMIT } from '@/types/pagination';
import BillingHistoryHeader
from '@/components/account/billing/billingTabs/BillingHistoryHeader.vue';
@ -43,20 +51,49 @@ import VTable from '@/components/common/VTable.vue';
const billingStore = useBillingStore();
const notify = useNotify();
async function fetchHistory(): Promise<void> {
const limit = ref(DEFAULT_PAGE_LIMIT);
const historyPage = computed((): PaymentHistoryPage => {
return billingStore.state.paymentsHistory;
});
const historyItems = computed((): PaymentsHistoryItem[] => {
return historyPage.value.items.filter((item: PaymentsHistoryItem) => {
return item.status !== PaymentsHistoryItemStatus.Draft;
});
});
async function fetchHistory(endingBefore = '', startingAfter = ''): Promise<void> {
try {
await billingStore.getPaymentsHistory();
await billingStore.getPaymentsHistory({
limit: limit.value,
startingAfter,
endingBefore,
});
} catch (error) {
notify.notifyError(error, AnalyticsErrorEventSource.BILLING_HISTORY_TAB);
}
}
const historyItems = computed((): PaymentsHistoryItem[] => {
return billingStore.state.paymentsHistory.filter((item: PaymentsHistoryItem) => {
return item.status !== PaymentsHistoryItemStatus.Draft && item.status !== PaymentsHistoryItemStatus.Empty
&& (item.type === PaymentsHistoryItemType.Invoice || item.type === PaymentsHistoryItemType.Charge);
});
});
async function sizeChanged(size: number) {
limit.value = size;
await fetchHistory();
}
async function nextClicked(): Promise<void> {
const length = historyPage.value.items.length;
if (!historyPage.value.hasNext || !length) {
return;
}
await fetchHistory('', historyPage.value.items[length - 1].id);
}
async function previousClicked(): Promise<void> {
if (!historyPage.value.hasPrevious || !historyPage.value.items.length) {
return;
}
await fetchHistory(historyPage.value.items[0].id, '');
}
onMounted(() => {
fetchHistory();

View File

@ -6,7 +6,26 @@
<span v-if="totalItemsCount > 0" class="pagination-container__label">{{ totalItemsCount }} {{ itemsLabel }}</span>
<span v-else class="pagination-container__label">No {{ itemsLabel }}</span>
<div v-if="totalPageCount > 1" class="pagination-container__pages">
<div v-if="simplePagination" class="pagination-container__pages">
<span
tabindex="0"
class="pagination-container__pages__button"
@click="prevPage"
@keyup.enter="prevPage"
>
<PaginationRightIcon class="pagination-container__pages__button__image reversed" />
</span>
<span
tabindex="0"
class="pagination-container__pages__button"
@click="nextPage"
@keyup.enter="nextPage"
>
<PaginationRightIcon class="pagination-container__pages__button__image" />
</span>
</div>
<div v-else-if="totalPageCount > 1" class="pagination-container__pages">
<template v-for="page of pageItems">
<span
v-if="page.type === 'prev'"
@ -58,7 +77,8 @@
<div v-else class="pagination-container__pages-placeholder" />
<table-size-changer
v-if="limit && totalPageCount && totalItemsCount > 10"
v-if="(limit && totalPageCount && totalItemsCount > 10) || simplePagination"
simple-pagination
:item-count="totalItemsCount"
:selected="pageSize"
@change="sizeChanged"
@ -89,13 +109,21 @@ const props = withDefaults(defineProps<{
totalPageCount?: number;
limit?: number;
totalItemsCount?: number;
simplePagination?: boolean;
onPageChange?: PageChangeCallback | null;
onNextClicked?: (() => Promise<void>) | null;
onPreviousClicked?: (() => Promise<void>) | null;
onPageSizeChanged?: ((size: number) => Promise<void>) | null;
}>(), {
itemsLabel: 'items',
totalPageCount: 0,
limit: 0,
totalItemsCount: 0,
simplePagination: false,
onPageChange: null,
onNextClicked: null,
onPreviousClicked: null,
onPageSizeChanged: null,
});
const currentPageNumber = ref<number>(1);
@ -160,11 +188,18 @@ const isLastPage = computed((): boolean => {
});
function sizeChanged(size: number) {
// if the new size is large enough to cause the page index to be out of range
// we calculate an appropriate new page index.
const maxPage = Math.ceil(Math.ceil(props.totalItemsCount / size));
const page = currentPageNumber.value > maxPage ? maxPage : currentPageNumber.value;
withLoading(async () => {
if (props.simplePagination) {
if (!props.onPageSizeChanged) {
return;
}
await props.onPageSizeChanged(size);
pageSize.value = size;
}
// if the new size is large enough to cause the page index to be out of range
// we calculate an appropriate new page index.
const maxPage = Math.ceil(Math.ceil(props.totalItemsCount / size));
const page = currentPageNumber.value > maxPage ? maxPage : currentPageNumber.value;
if (!props.onPageChange) {
return;
}
@ -197,6 +232,10 @@ async function goToPage(index: number) {
*/
async function nextPage(): Promise<void> {
await withLoading(async () => {
if (props.simplePagination && props.onNextClicked) {
await props.onNextClicked();
return;
}
if (isLastPage.value || !props.onPageChange) {
return;
}
@ -210,6 +249,10 @@ async function nextPage(): Promise<void> {
*/
async function prevPage(): Promise<void> {
await withLoading(async () => {
if (props.simplePagination && props.onPreviousClicked) {
await props.onPreviousClicked();
return;
}
if (isFirstPage.value || !props.onPageChange) {
return;
}

View File

@ -40,6 +40,7 @@ const appStore = useAppStore();
const props = defineProps<{
selected: number | null;
itemCount: number;
simplePagination?: boolean;
}>();
const emit = defineEmits<{
@ -53,7 +54,7 @@ const options = computed((): {label:string, value:number}[] => {
{ label: '50', value: 50 },
{ label: '100', value: 100 },
];
if (props.itemCount < 1000) {
if (props.itemCount < 1000 && !props.simplePagination) {
return [{ label: 'All', value: props.itemCount }, ...opts];
}
return opts;

View File

@ -19,11 +19,15 @@
<div class="table-footer">
<table-pagination
class="table-footer__pagination"
:simple-pagination="simplePagination"
:total-page-count="totalPageCount"
:total-items-count="totalItemsCount"
:items-label="itemsLabel"
:limit="limit"
:on-page-size-changed="onPageSizeChanged"
:on-page-change="onPageChange"
:on-next-clicked="onNextClicked"
:on-previous-clicked="onPreviousClicked"
/>
</div>
</div>
@ -40,19 +44,27 @@ const props = withDefaults(defineProps<{
limit?: number,
totalItemsCount?: number,
onPageChange?: PageChangeCallback | null;
onNextClicked?: (() => Promise<void>) | null;
onPreviousClicked?: (() => Promise<void>) | null;
onPageSizeChanged?: ((size: number) => Promise<void>) | null;
totalPageCount?: number,
selectable?: boolean,
selected?: boolean,
showSelect?: boolean,
simplePagination?: boolean,
}>(), {
selectable: false,
selected: false,
showSelect: false,
simplePagination: false,
totalPageCount: 0,
itemsLabel: 'items',
limit: 0,
totalItemsCount: 0,
onPageChange: null,
onNextClicked: null,
onPreviousClicked: null,
onPageSizeChanged: null,
});
const emit = defineEmits(['selectAllClicked']);

View File

@ -10,10 +10,9 @@ import {
CreditCard,
DateRange,
NativePaymentHistoryItem,
PaymentHistoryPage,
PaymentHistoryParam,
PaymentsApi,
PaymentsHistoryItem,
PaymentsHistoryItemStatus,
PaymentsHistoryItemType,
PaymentStatus,
PaymentWithConfirmations,
ProjectCharges,
@ -25,7 +24,7 @@ import { PaymentsHttpApi } from '@/api/payments';
export class PaymentsState {
public balance: AccountBalance = new AccountBalance();
public creditCards: CreditCard[] = [];
public paymentsHistory: PaymentsHistoryItem[] = [];
public paymentsHistory: PaymentHistoryPage = new PaymentHistoryPage([]);
public pendingPaymentsWithConfirmations: PaymentWithConfirmations[] = [];
public nativePaymentsHistory: NativePaymentHistoryItem[] = [];
public projectCharges: ProjectCharges = new ProjectCharges();
@ -121,8 +120,8 @@ export const useBillingStore = defineStore('billing', () => {
state.creditCards = state.creditCards.filter(card => card.id !== cardId);
}
async function getPaymentsHistory(): Promise<void> {
state.paymentsHistory = await api.paymentsHistory();
async function getPaymentsHistory(params: PaymentHistoryParam): Promise<void> {
state.paymentsHistory = await api.paymentsHistory(params);
}
async function getNativePaymentsHistory(): Promise<void> {
@ -191,7 +190,7 @@ export const useBillingStore = defineStore('billing', () => {
function clear(): void {
state.balance = new AccountBalance();
state.creditCards = [];
state.paymentsHistory = [];
state.paymentsHistory = new PaymentHistoryPage([]);
state.nativePaymentsHistory = [];
state.projectCharges = new ProjectCharges();
state.usagePriceModel = new ProjectUsagePriceModel();
@ -206,18 +205,6 @@ export const useBillingStore = defineStore('billing', () => {
return state.balance.sum > 0 || state.creditCards.length > 0;
});
const isTransactionProcessing = computed((): boolean => {
return state.balance.sum === 0 &&
state.paymentsHistory.some((item: PaymentsHistoryItem) => {
return item.amount >= 50 && item.type === PaymentsHistoryItemType.Transaction &&
(
item.status === PaymentsHistoryItemStatus.Pending ||
item.status === PaymentsHistoryItemStatus.Paid ||
item.status === PaymentsHistoryItemStatus.Completed
);
});
});
const isBalancePositive = computed((): boolean => {
return state.balance.sum > 0;
});
@ -225,7 +212,6 @@ export const useBillingStore = defineStore('billing', () => {
return {
state,
canUserCreateFirstProject,
isTransactionProcessing,
isBalancePositive,
getBalance,
getWallet,

View File

@ -4,6 +4,15 @@
import { formatPrice, decimalShift } from '@/utils/strings';
import { JSONRepresentable } from '@/types/json';
/**
* Page parameters for listing payments history.
*/
export interface PaymentHistoryParam {
limit: number;
startingAfter: string;
endingBefore: string;
}
/**
* Exposes all payments-related functionality
*/
@ -68,7 +77,7 @@ export interface PaymentsApi {
* @returns list of payments history items
* @throws Error
*/
paymentsHistory(): Promise<PaymentsHistoryItem[]>;
paymentsHistory(param: PaymentHistoryParam): Promise<PaymentHistoryPage>;
/**
* Returns a list of invoices, transactions and all others payments history items for payment account.
@ -186,6 +195,18 @@ export class PaymentAmountOption {
) { }
}
/**
* PaymentHistoryPage holds a paged list of PaymentsHistoryItem.
*/
export class PaymentHistoryPage {
public constructor(
public readonly items: PaymentsHistoryItem[],
public readonly hasNext = false,
public readonly hasPrevious = false,
) {
}
}
/**
* PaymentsHistoryItem holds all public information about payments history line.
*/

View File

@ -6,12 +6,11 @@ import {
Coupon,
CreditCard,
PaymentsApi,
PaymentsHistoryItem,
ProjectUsagePriceModel,
TokenDeposit,
NativePaymentHistoryItem,
Wallet,
ProjectCharges,
ProjectCharges, PaymentHistoryParam, PaymentHistoryPage,
} from '@/types/payments';
/**
@ -56,8 +55,8 @@ export class PaymentsMock implements PaymentsApi {
throw new Error('Method not implemented');
}
paymentsHistory(): Promise<PaymentsHistoryItem[]> {
return Promise.resolve([]);
paymentsHistory(param: PaymentHistoryParam): Promise<PaymentHistoryPage> {
return Promise.resolve(new PaymentHistoryPage([]));
}
nativePaymentsHistory(): Promise<NativePaymentHistoryItem[]> {