diff --git a/apps/walletd/components/WalletsList/index.tsx b/apps/walletd/components/WalletsList/index.tsx
index 16b0cb4b7..03defdad1 100644
--- a/apps/walletd/components/WalletsList/index.tsx
+++ b/apps/walletd/components/WalletsList/index.tsx
@@ -1,4 +1,4 @@
-import { Table } from '@siafoundation/design-system'
+import { EmptyState, Table } from '@siafoundation/design-system'
import { useWallets } from '../../contexts/wallets'
import { StateNoneYet } from './StateNoneYet'
import { StateNoneMatching } from './StateNoneMatching'
@@ -6,7 +6,7 @@ import { StateError } from './StateError'
export function WalletsList() {
const {
- dataset,
+ datasetPage,
dataState,
context,
columns,
@@ -24,14 +24,14 @@ export function WalletsList() {
testId="walletsTable"
isLoading={dataState === 'loading'}
emptyState={
- dataState === 'noneMatchingFilters' ? (
-
- ) : dataState === 'error' ? (
-
- ) : null
+
}
+ error={
}
+ />
}
pageSize={6}
- data={dataset}
+ data={datasetPage}
context={context}
columns={columns}
sortableColumns={sortableColumns}
diff --git a/apps/walletd/contexts/addresses/dataset.tsx b/apps/walletd/contexts/addresses/dataset.tsx
index 2e2a4fa4a..65c087779 100644
--- a/apps/walletd/contexts/addresses/dataset.tsx
+++ b/apps/walletd/contexts/addresses/dataset.tsx
@@ -1,5 +1,5 @@
import {
- useDatasetEmptyState,
+ useDataEmptyState,
ClientFilterItem,
} from '@siafoundation/design-system'
import {
@@ -10,6 +10,7 @@ import { useWalletAddresses } from '@siafoundation/walletd-react'
import { useMemo } from 'react'
import { AddressData } from './types'
import { OpenDialog, useDialog } from '../dialog'
+import { Maybe } from '@siafoundation/types'
export function transformAddressesResponse(
response: WalletAddressesResponse,
@@ -47,18 +48,20 @@ export function useDataset({
filters: ClientFilterItem
[]
}) {
const { openDialog } = useDialog()
- const dataset = useMemo(() => {
+ const dataset = useMemo>(() => {
if (!response.data) {
- return null
+ return undefined
}
return transformAddressesResponse(response.data, walletId, openDialog)
}, [response.data, openDialog, walletId])
- const dataState = useDatasetEmptyState(
+ const dataState = useDataEmptyState(
dataset,
response.isValidating,
response.error,
- filters
+ {
+ filters,
+ }
)
const lastIndex = (dataset || []).reduce(
diff --git a/apps/walletd/contexts/events/index.tsx b/apps/walletd/contexts/events/index.tsx
index 64d5df722..357cc509f 100644
--- a/apps/walletd/contexts/events/index.tsx
+++ b/apps/walletd/contexts/events/index.tsx
@@ -1,7 +1,7 @@
import {
useTableState,
- useDatasetEmptyState,
useServerFilters,
+ useDataEmptyState,
} from '@siafoundation/design-system'
import {
useWalletEvents,
@@ -27,6 +27,7 @@ import { useRouter } from 'next/router'
import { useSiascanUrl } from '../../hooks/useSiascanUrl'
import { defaultDatasetRefreshInterval } from '../../config/swr'
import { useSyncStatus } from '../../hooks/useSyncStatus'
+import { Maybe } from '@siafoundation/types'
const defaultLimit = 100
@@ -63,9 +64,9 @@ export function useEventsMain() {
})
const syncStatus = useSyncStatus()
- const dataset = useMemo(() => {
+ const datasetPage = useMemo>(() => {
if (!responseEvents.data || !responseTxPool.data) {
- return null
+ return undefined
}
const dataTxPool: EventData[] = responseTxPool.data.map((e) => {
const amountSc = calculateScValue(e)
@@ -106,8 +107,17 @@ export function useEventsMain() {
}
return res
})
- return [...dataTxPool.reverse(), ...dataEvents]
- }, [responseEvents.data, responseTxPool.data, syncStatus.nodeBlockHeight])
+ if (offset === 0) {
+ return [...dataTxPool.reverse(), ...dataEvents]
+ } else {
+ return [...dataEvents]
+ }
+ }, [
+ responseEvents.data,
+ responseTxPool.data,
+ syncStatus.nodeBlockHeight,
+ offset,
+ ])
const {
configurableColumns,
@@ -141,7 +151,10 @@ export function useEventsMain() {
responseEvents.isValidating || responseTxPool.isValidating
const error = responseEvents.error || responseTxPool.error
- const dataState = useDatasetEmptyState(dataset, isValidating, error, filters)
+ const dataState = useDataEmptyState(datasetPage, isValidating, error, {
+ filters,
+ offset,
+ })
const siascanUrl = useSiascanUrl()
const cellContext = useMemo(
@@ -154,9 +167,9 @@ export function useEventsMain() {
return {
dataState,
error: responseEvents.error,
- pageCount: dataset?.length || 0,
+ pageCount: datasetPage?.length || 0,
columns: filteredTableColumns,
- dataset,
+ dataset: datasetPage,
cellContext,
configurableColumns,
enabledColumns,
diff --git a/apps/walletd/contexts/wallets/index.tsx b/apps/walletd/contexts/wallets/index.tsx
index 2c450c906..c67ddf144 100644
--- a/apps/walletd/contexts/wallets/index.tsx
+++ b/apps/walletd/contexts/wallets/index.tsx
@@ -1,6 +1,6 @@
import {
useTableState,
- useDatasetEmptyState,
+ useDataEmptyState,
useClientFilters,
useClientFilteredDataset,
} from '@siafoundation/design-system'
@@ -20,6 +20,7 @@ import { useWalletSeedCache } from './useWalletSeedCache'
import { useDialog } from '../dialog'
import { useAppSettings } from '@siafoundation/react-core'
import { defaultDatasetRefreshInterval } from '../../config/swr'
+import { Maybe } from '@siafoundation/types'
function useWalletsMain() {
const response = useWalletsData({
@@ -51,9 +52,9 @@ function useWalletsMain() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
- const dataset = useMemo(() => {
+ const dataset = useMemo>(() => {
if (!response.data) {
- return null
+ return undefined
}
const data: WalletData[] = response.data.map((wallet) => {
const { id, name, description, dateCreated, lastUpdated, metadata } =
@@ -126,7 +127,7 @@ function useWalletsMain() {
defaultSortField,
})
- const datasetFiltered = useClientFilteredDataset({
+ const datasetPage = useClientFilteredDataset({
dataset,
filters,
sortField,
@@ -141,11 +142,13 @@ function useWalletsMain() {
[enabledColumns]
)
- const dataState = useDatasetEmptyState(
- dataset,
+ const dataState = useDataEmptyState(
+ datasetPage,
response.isValidating,
response.error,
- filters
+ {
+ filters,
+ }
)
const context = useMemo(
@@ -159,10 +162,10 @@ function useWalletsMain() {
return {
dataState,
error: response.error,
- datasetCount: datasetFiltered?.length || 0,
+ pageCount: datasetPage?.length || 0,
unlockedCount: cachedMnemonicCount,
columns: filteredTableColumns,
- dataset: datasetFiltered,
+ datasetPage,
context,
wallet,
configurableColumns,
diff --git a/libs/design-system/src/app/AlertsDialog/index.tsx b/libs/design-system/src/app/AlertsDialog/index.tsx
index 762b45f5c..8ac03c20d 100644
--- a/libs/design-system/src/app/AlertsDialog/index.tsx
+++ b/libs/design-system/src/app/AlertsDialog/index.tsx
@@ -6,7 +6,7 @@ import { Heading } from '../../core/Heading'
import { Checkmark16 } from '@siafoundation/react-icons'
import { Skeleton } from '../../core/Skeleton'
import { Text } from '../../core/Text'
-import { useDatasetEmptyState } from '../../hooks/useDatasetEmptyState'
+import { useDataEmptyState } from '../../hooks/useDataEmptyState'
import { humanDate } from '@siafoundation/units'
import { cx } from 'class-variance-authority'
import { times } from '@technically/lodash'
@@ -47,7 +47,7 @@ export function AlertsDialog({
dataFieldOrder,
dataFields,
}: Props) {
- const loadingState = useDatasetEmptyState(
+ const loadingState = useDataEmptyState(
alerts.data,
alerts.isValidating,
alerts.error,
diff --git a/libs/design-system/src/components/EmptyState/StateError.tsx b/libs/design-system/src/components/EmptyState/StateError.tsx
new file mode 100644
index 000000000..7f35eb623
--- /dev/null
+++ b/libs/design-system/src/components/EmptyState/StateError.tsx
@@ -0,0 +1,20 @@
+import { Text } from '@siafoundation/design-system'
+import { MisuseOutline32 } from '@siafoundation/react-icons'
+import { SWRError } from '@siafoundation/react-core'
+
+type Props = {
+ error?: SWRError
+}
+
+export function StateError({ error }: Props) {
+ return (
+
+
+
+
+
+ Error loading data. Please try again later.
+
+
+ )
+}
diff --git a/libs/design-system/src/components/EmptyState/StateNoData.tsx b/libs/design-system/src/components/EmptyState/StateNoData.tsx
new file mode 100644
index 000000000..80c6aa8b2
--- /dev/null
+++ b/libs/design-system/src/components/EmptyState/StateNoData.tsx
@@ -0,0 +1,15 @@
+import { Text } from '@siafoundation/design-system'
+import { ChartArea32 } from '@siafoundation/react-icons'
+
+export function StateNoData() {
+ return (
+
+
+
+
+
+ No data available.
+
+
+ )
+}
diff --git a/libs/design-system/src/components/EmptyState/StateNoneMatching.tsx b/libs/design-system/src/components/EmptyState/StateNoneMatching.tsx
new file mode 100644
index 000000000..28451c2d2
--- /dev/null
+++ b/libs/design-system/src/components/EmptyState/StateNoneMatching.tsx
@@ -0,0 +1,15 @@
+import { Text } from '@siafoundation/design-system'
+import { Filter32 } from '@siafoundation/react-icons'
+
+export function StateNoneMatching() {
+ return (
+
+
+
+
+
+ No data matching filters.
+
+
+ )
+}
diff --git a/libs/design-system/src/components/EmptyState/StateNoneOnPage.tsx b/libs/design-system/src/components/EmptyState/StateNoneOnPage.tsx
new file mode 100644
index 000000000..bccd71c2e
--- /dev/null
+++ b/libs/design-system/src/components/EmptyState/StateNoneOnPage.tsx
@@ -0,0 +1,28 @@
+import { Button, Text } from '@siafoundation/design-system'
+import { usePagesRouter } from '@siafoundation/next'
+import { Reset32 } from '@siafoundation/react-icons'
+import { useCallback } from 'react'
+
+export function StateNoneOnPage() {
+ const router = usePagesRouter()
+ const back = useCallback(() => {
+ router.push({
+ query: {
+ ...router.query,
+ offset: 0,
+ marker: undefined,
+ },
+ })
+ }, [router])
+ return (
+
+
+
+
+
+ No data on this page, reset pagination to continue.
+
+
+
+ )
+}
diff --git a/libs/design-system/src/components/EmptyState/StateNoneYet.tsx b/libs/design-system/src/components/EmptyState/StateNoneYet.tsx
new file mode 100644
index 000000000..14a9970fa
--- /dev/null
+++ b/libs/design-system/src/components/EmptyState/StateNoneYet.tsx
@@ -0,0 +1,15 @@
+import { Text } from '@siafoundation/design-system'
+import { DataBase32 } from '@siafoundation/react-icons'
+
+export function StateNoneYet() {
+ return (
+
+
+
+
+
+ There is no data yet.
+
+
+ )
+}
diff --git a/libs/design-system/src/components/EmptyState/index.tsx b/libs/design-system/src/components/EmptyState/index.tsx
new file mode 100644
index 000000000..047e2d603
--- /dev/null
+++ b/libs/design-system/src/components/EmptyState/index.tsx
@@ -0,0 +1,30 @@
+import { DataEmptyState } from '@siafoundation/design-system'
+import { StateNoneMatching } from './StateNoneMatching'
+import { StateNoneYet } from './StateNoneYet'
+import { StateError } from './StateError'
+import { StateNoneOnPage } from './StateNoneOnPage'
+import React from 'react'
+
+export function EmptyState({
+ datasetState,
+ noneOnPage,
+ noneMatching,
+ noneYet,
+ error,
+}: {
+ datasetState: DataEmptyState
+ noneOnPage?: React.ReactNode
+ noneMatching?: React.ReactNode
+ noneYet?: React.ReactNode
+ error?: React.ReactNode
+}) {
+ return datasetState === 'noneOnPage'
+ ? noneOnPage ||
+ : datasetState === 'noneMatchingFilters'
+ ? noneMatching ||
+ : datasetState === 'noneYet'
+ ? noneYet ||
+ : datasetState === 'error'
+ ? error ||
+ : null
+}
diff --git a/libs/design-system/src/components/PaginatorMarker.tsx b/libs/design-system/src/components/PaginatorMarker.tsx
index 63fb1b6b3..f6995331e 100644
--- a/libs/design-system/src/components/PaginatorMarker.tsx
+++ b/libs/design-system/src/components/PaginatorMarker.tsx
@@ -7,7 +7,7 @@ import { usePagesRouter } from '@siafoundation/next'
import { LoadingDots } from './LoadingDots'
type Props = {
- marker?: string
+ nextMarker: string | null
isMore: boolean
limit: number
pageTotal: number
@@ -15,7 +15,7 @@ type Props = {
}
export function PaginatorMarker({
- marker,
+ nextMarker,
isMore,
pageTotal,
isLoading,
@@ -28,7 +28,6 @@ export function PaginatorMarker({
size="small"
variant="gray"
className="rounded-r-none"
- disabled={!marker}
onClick={() =>
router.push({
query: {
@@ -61,14 +60,17 @@ export function PaginatorMarker({
size="small"
variant="gray"
className="rounded-none"
- onClick={() =>
+ onClick={() => {
+ console.log('xxx', {
+ marker: nextMarker,
+ })
router.push({
query: {
...router.query,
- marker,
+ marker: nextMarker,
},
})
- }
+ }}
>
diff --git a/libs/design-system/src/hooks/useClientFilteredDataset.ts b/libs/design-system/src/hooks/useClientFilteredDataset.ts
index b69d7c63d..f6a28443f 100644
--- a/libs/design-system/src/hooks/useClientFilteredDataset.ts
+++ b/libs/design-system/src/hooks/useClientFilteredDataset.ts
@@ -1,6 +1,7 @@
import BigNumber from 'bignumber.js'
import { useMemo } from 'react'
import { ClientFilterItem } from './useClientFilters'
+import { Maybe } from '@siafoundation/types'
type DatumValue =
| BigNumber
@@ -12,7 +13,7 @@ type DatumValue =
| object
type Props> = {
- dataset: Datum[] | undefined
+ dataset: Maybe
filters: ClientFilterItem[]
sortField: string
sortDirection: 'asc' | 'desc'
@@ -21,7 +22,7 @@ type Props> = {
export function useClientFilteredDataset<
Datum extends Record
>({ dataset, filters, sortField, sortDirection }: Props) {
- return useMemo(() => {
+ return useMemo>(() => {
if (!dataset) {
return undefined
}
diff --git a/libs/design-system/src/hooks/useDataEmptyState.tsx b/libs/design-system/src/hooks/useDataEmptyState.tsx
new file mode 100644
index 000000000..9925013e1
--- /dev/null
+++ b/libs/design-system/src/hooks/useDataEmptyState.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+
+export type DataEmptyState =
+ | 'loading'
+ | 'noneYet'
+ | 'noneMatchingFilters'
+ | 'noneOnPage'
+ | 'error'
+ | undefined
+
+type Pagination = {
+ offset?: number
+ marker?: string | null
+ filters?: unknown[]
+}
+
+/**
+ * Returns the current sate of the dataset. Note that an empty dataset should
+ * be an empty array. An undefined value represents data that has not finished
+ * initial fetch or fetch after a key change.
+ **/
+export function useDataEmptyState(
+ datasetPage: unknown[] | undefined,
+ isFetching: boolean,
+ error: Error | undefined,
+ pagination?: Pagination
+): DataEmptyState {
+ const { filters } = pagination || {}
+ const isOnFirstPage = getIsOnFirstPage(pagination || {})
+ const [lastDatasetSize, setLastDatasetSize] = useState()
+ useEffect(() => {
+ // Update last dataset size every time refetching completes.
+ if (!isFetching && datasetPage) {
+ setLastDatasetSize(datasetPage.length)
+ }
+ }, [isFetching, datasetPage, setLastDatasetSize])
+
+ return useMemo(() => {
+ if (error) {
+ return 'error'
+ }
+ // No previous dataset, initialize in loading state.
+ if (lastDatasetSize === undefined) {
+ return 'loading'
+ }
+ // Previous dataset not empty and loading.
+ // If a loading state between dataset is not desired, turn on
+ // swr keepPreviousData on the dataset.
+ // Note that dataset will be defined if revalidating same key.
+ if (lastDatasetSize > 0 && !datasetPage) {
+ return 'loading'
+ }
+ // Previous dataset was empty, show none state until results.
+ // This sticks to none state even when new data is loading to avoid a
+ // flickering skeleton loader.
+ if (lastDatasetSize === 0) {
+ if (!isOnFirstPage) {
+ return 'noneOnPage'
+ }
+ return !filters || filters.length === 0
+ ? 'noneYet'
+ : 'noneMatchingFilters'
+ }
+ return undefined
+ }, [datasetPage, lastDatasetSize, error, filters, isOnFirstPage])
+}
+
+function getIsOnFirstPage({ offset, marker }: Pagination): boolean {
+ // If marker is undefined it is not in use.
+ if (marker !== undefined) {
+ // Page marker.
+ if (marker) {
+ return false
+ }
+ // If marker is null, its the first page.
+ if (marker === null) {
+ return true
+ }
+ }
+ // If both marker and offset are undefined, there is no paging.
+ if (offset === undefined) {
+ return true
+ }
+ // Offset based pagination.
+ if (offset > 0) {
+ return false
+ }
+ return true
+}
diff --git a/libs/design-system/src/hooks/useDatasetEmptyState.tsx b/libs/design-system/src/hooks/useDatasetEmptyState.tsx
deleted file mode 100644
index ee86e61e8..000000000
--- a/libs/design-system/src/hooks/useDatasetEmptyState.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-'use client'
-
-import { useEffect, useMemo, useState } from 'react'
-
-type EmptyState =
- | 'loading'
- | 'noneYet'
- | 'noneMatchingFilters'
- | 'error'
- | undefined
-
-export function useDatasetEmptyState(
- dataset: unknown[] | undefined,
- isFetching: boolean,
- error: Error | undefined,
- filters: unknown[]
-): EmptyState {
- const [lastDatasetSize, setLastDatasetSize] = useState()
- useEffect(() => {
- // Update last dataset size every time refetching completes
- if (!isFetching && dataset) {
- setLastDatasetSize(dataset.length)
- }
- }, [isFetching, dataset, setLastDatasetSize])
-
- return useMemo(() => {
- if (error) {
- return 'error'
- }
- // No previous dataset, initialize in loading state.
- if (lastDatasetSize === undefined) {
- return 'loading'
- }
- // Previous dataset not empty and loading.
- // If a loading state between dataset is not desired, turn on
- // swr keepPreviousData on the dataset.
- // Note that dataset will be defined if revalidating same key.
- if (lastDatasetSize > 0 && !dataset) {
- return 'loading'
- }
- // Previous dataset was empty, show none state until results.
- // This sticks to none state even when new data is loading to avoid a
- // flickering skeleton loader.
- if (lastDatasetSize === 0) {
- return filters.length === 0 ? 'noneYet' : 'noneMatchingFilters'
- }
- return undefined
- }, [dataset, lastDatasetSize, error, filters])
-}
diff --git a/libs/design-system/src/index.ts b/libs/design-system/src/index.ts
index 7e7926b99..7fd5cdcd7 100644
--- a/libs/design-system/src/index.ts
+++ b/libs/design-system/src/index.ts
@@ -77,6 +77,12 @@ export * from './components/PaginatorUnknownTotal'
export * from './components/PaginatorMarker'
export * from './components/ListWithSeparators'
export * from './components/ClientSideOnly'
+export * from './components/EmptyState'
+export * from './components/EmptyState/StateNoneOnPage'
+export * from './components/EmptyState/StateNoneYet'
+export * from './components/EmptyState/StateNoData'
+export * from './components/EmptyState/StateNoneMatching'
+export * from './components/EmptyState/StateError'
// app
export * from './app/AppPublicLayout'
@@ -163,7 +169,7 @@ export * from './hooks/useClientFilters'
export * from './hooks/useClientFilteredDataset'
export * from './hooks/useServerFilters'
export * from './hooks/useFormChanged'
-export * from './hooks/useDatasetEmptyState'
+export * from './hooks/useDataEmptyState'
export * from './hooks/useSiacoinFiat'
export * from './hooks/useOS'
diff --git a/libs/react-core/src/arrayResponse.tsx b/libs/react-core/src/arrayResponse.tsx
new file mode 100644
index 000000000..39bb8f49d
--- /dev/null
+++ b/libs/react-core/src/arrayResponse.tsx
@@ -0,0 +1,20 @@
+import { Maybe, Nullish } from '@siafoundation/types'
+
+/**
+ * Ensure the data is an empty array if the response returns null.
+ * This makes responses consistent and allows other methods to distinguish
+ * between a loading state and an empty state.
+ * @param data Nullish
+ * @returns Maybe
+ */
+export function maybeFromNullishArrayResponse(
+ data: Nullish
+): Maybe {
+ if (data) {
+ return data
+ }
+ if (data === null) {
+ return []
+ }
+ return undefined
+}
diff --git a/libs/react-core/src/index.ts b/libs/react-core/src/index.ts
index abae5f095..aa2e9b4a2 100644
--- a/libs/react-core/src/index.ts
+++ b/libs/react-core/src/index.ts
@@ -13,6 +13,7 @@ export * from './useExchangeRate'
export * from './useTryUntil'
export * from './userPrefersReducedMotion'
export * from './mutate'
+export * from './arrayResponse'
export * from './workflows'
export * from './coreProvider'
diff --git a/libs/renterd-types/src/bus.ts b/libs/renterd-types/src/bus.ts
index 9614b7578..c9c5b6a96 100644
--- a/libs/renterd-types/src/bus.ts
+++ b/libs/renterd-types/src/bus.ts
@@ -9,7 +9,7 @@ import {
Transaction,
TransactionID,
WalletEvent,
- Maybe,
+ Nullable,
} from '@siafoundation/types'
import {
ConsensusState,
@@ -232,7 +232,7 @@ export type HostsPayload = {
limit?: number
maxLastScan?: string
}
-export type HostsResponse = Maybe
+export type HostsResponse = Nullable
export type HostParams = { hostkey: string }
export type HostPayload = Host
@@ -291,7 +291,7 @@ export type HostScanResponse = {
export type ContractsParams = void
export type ContractsPayload = void
-export type ContractsResponse = Maybe
+export type ContractsResponse = Nullable
export type ContractAcquireParams = {
id: string
diff --git a/libs/types/src/utils.ts b/libs/types/src/utils.ts
index 4ada1062b..44d529936 100644
--- a/libs/types/src/utils.ts
+++ b/libs/types/src/utils.ts
@@ -1,4 +1,6 @@
export type Maybe = T | undefined
+export type Nullable = T | null
+export type Nullish = T | null | undefined
export type NoUndefined = {
[K in keyof T]: Exclude