diff --git a/.changeset/brown-beds-cover.md b/.changeset/brown-beds-cover.md
new file mode 100644
index 00000000000..97185a9653b
--- /dev/null
+++ b/.changeset/brown-beds-cover.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Add experimental Table.ErrorDialog component
diff --git a/generated/components.json b/generated/components.json
index 4b95f4f5eb8..b6e0488b3fe 100644
--- a/generated/components.json
+++ b/generated/components.json
@@ -1545,6 +1545,33 @@
"required": true
}
]
+ },
+ {
+ "name": "Table.ErrorDialog",
+ "props": [
+ {
+ "name": "children",
+ "required": true,
+ "type": "React.ReactNode",
+ "description": "The content of the dialog. This is usually a message explaining the error."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "defaultValue": "'Error'",
+ "description": "The title of the dialog. This is usually a short description of the error."
+ },
+ {
+ "name": "onRetry",
+ "type": "() => void",
+ "description": "Event handler called when the user clicks the retry button."
+ },
+ {
+ "name": "onDismiss",
+ "type": "() => void",
+ "description": "Event handler called when the dialog is dismissed."
+ }
+ ]
}
]
},
diff --git a/src/DataTable/DataTable.docs.json b/src/DataTable/DataTable.docs.json
index 41d235fa3a0..6f163ce4381 100644
--- a/src/DataTable/DataTable.docs.json
+++ b/src/DataTable/DataTable.docs.json
@@ -229,6 +229,33 @@
"required": true
}
]
+ },
+ {
+ "name": "Table.ErrorDialog",
+ "props": [
+ {
+ "name": "children",
+ "required": true,
+ "type": "React.ReactNode",
+ "description": "The content of the dialog. This is usually a message explaining the error."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "defaultValue": "'Error'",
+ "description": "The title of the dialog. This is usually a short description of the error."
+ },
+ {
+ "name": "onRetry",
+ "type": "() => void",
+ "description": "Event handler called when the user clicks the retry button."
+ },
+ {
+ "name": "onDismiss",
+ "type": "() => void",
+ "description": "Event handler called when the dialog is dismissed."
+ }
+ ]
}
]
}
diff --git a/src/DataTable/DataTable.features.stories.tsx b/src/DataTable/DataTable.features.stories.tsx
index dc8ce965afb..b2286a17742 100644
--- a/src/DataTable/DataTable.features.stories.tsx
+++ b/src/DataTable/DataTable.features.stories.tsx
@@ -22,7 +22,7 @@ import LabelGroup from '../LabelGroup'
import RelativeTime from '../RelativeTime'
import VisuallyHidden from '../_VisuallyHidden'
import {createColumnHelper} from './column'
-import {repos} from './storybook/data'
+import {fetchRepos, repos, useFlakeyQuery} from './storybook/data'
export default {
title: 'Components/DataTable/Features',
@@ -1524,3 +1524,55 @@ export const WithPagination = () => {
)
}
+
+export const WithNetworkError = () => {
+ const pageSize = 10
+ const [pageIndex, setPageIndex] = React.useState(0)
+ const {error, loading, data} = useFlakeyQuery({
+ queryKey: ['repos', pageSize, pageIndex],
+ queryFn: () => {
+ return fetchRepos({
+ page: pageIndex,
+ perPage: pageSize,
+ })
+ },
+ })
+
+ return (
+
+
+ Repositories
+
+
+ A subtitle could appear here to give extra context to the data.
+
+ {loading || error ? : null}
+ {error ? (
+ {
+ action('onDismiss')
+ }}
+ onRetry={() => {
+ action('onRetry')
+ }}
+ />
+ ) : null}
+ {data ? (
+
+ ) : null}
+ {
+ setPageIndex(pageIndex)
+ }}
+ />
+
+ )
+}
diff --git a/src/DataTable/ErrorDialog.tsx b/src/DataTable/ErrorDialog.tsx
new file mode 100644
index 00000000000..2ff534249b4
--- /dev/null
+++ b/src/DataTable/ErrorDialog.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import {ConfirmationDialog} from '../Dialog/ConfirmationDialog'
+
+export type TableErrorDialogProps = React.PropsWithChildren<{
+ /**
+ * Provide an optional title for the dialog
+ * @default 'Error'
+ */
+ title?: string
+
+ /**
+ * Provide an optional handler to be called when the user confirms to retry
+ */
+ onRetry?: () => void
+
+ /**
+ * Provide an optional handler to be called when the user dismisses the dialog
+ */
+ onDismiss?: () => void
+}>
+
+export function ErrorDialog({title = 'Error', children, onRetry, onDismiss}: TableErrorDialogProps) {
+ return (
+ {
+ if (gesture === 'confirm') {
+ onRetry?.()
+ } else {
+ onDismiss?.()
+ }
+ }}
+ confirmButtonContent="Retry"
+ cancelButtonContent="Dismiss"
+ >
+ {children}
+
+ )
+}
diff --git a/src/DataTable/Table.tsx b/src/DataTable/Table.tsx
index e5f1437f514..b7ec31b4ebf 100644
--- a/src/DataTable/Table.tsx
+++ b/src/DataTable/Table.tsx
@@ -501,12 +501,13 @@ export type TableTitleProps = React.PropsWithChildren<{
id: string
}>
-function TableTitle({as = 'h2', children, id}: TableTitleProps) {
+const TableTitle = React.forwardRef(function TableTitle({as = 'h2', children, id}, ref) {
return (
)
-}
+})
export type TableSubtitleProps = React.PropsWithChildren<{
/**
diff --git a/src/DataTable/__tests__/ErrorDialog.test.tsx b/src/DataTable/__tests__/ErrorDialog.test.tsx
new file mode 100644
index 00000000000..f15164e4748
--- /dev/null
+++ b/src/DataTable/__tests__/ErrorDialog.test.tsx
@@ -0,0 +1,73 @@
+import userEvent from '@testing-library/user-event'
+import {render, screen} from '@testing-library/react'
+import React from 'react'
+import {ErrorDialog} from '../ErrorDialog'
+
+describe('Table.ErrorDialog', () => {
+ it('should use a default title of "Error" if `title` is not provided', () => {
+ render()
+ expect(
+ screen.getByRole('alertdialog', {
+ name: 'Error',
+ }),
+ ).toBeInTheDocument()
+ })
+
+ it('should allow customizing the title of the dialog through `title`', () => {
+ const customTitle = 'custom-title'
+ render()
+ expect(
+ screen.getByRole('alertdialog', {
+ name: customTitle,
+ }),
+ ).toBeInTheDocument()
+ })
+
+ it('should use "Retry" as the confirm text of the dialog', () => {
+ render()
+ expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument()
+ })
+
+ it('should call `onRetry` if the confirm button is interacted with', async () => {
+ const user = userEvent.setup()
+ const onRetry = jest.fn()
+
+ render()
+ await user.click(screen.getByText('Retry'))
+ expect(onRetry).toHaveBeenCalledTimes(1)
+
+ onRetry.mockClear()
+
+ await user.keyboard('{Enter}')
+ expect(onRetry).toHaveBeenCalledTimes(1)
+ })
+
+ it('should set "Dismiss" as the cancel text of the dialog', () => {
+ render()
+ expect(screen.getByRole('button', {name: 'Dismiss'})).toBeInTheDocument()
+ })
+
+ it('should call `onDismiss` if the cancel button is interacted with', async () => {
+ const user = userEvent.setup()
+ const onDismiss = jest.fn()
+
+ render()
+ await user.click(screen.getByText('Dismiss'))
+ expect(onDismiss).toHaveBeenCalledTimes(1)
+
+ onDismiss.mockClear()
+
+ await user.keyboard('{Enter}')
+ expect(onDismiss).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render `children` as the content of the dialog', () => {
+ render(
+
+ children
+ ,
+ )
+
+ expect(screen.getByRole('alertdialog', {name: 'Error'})).toContainElement(screen.getByTestId('children'))
+ })
+})
diff --git a/src/DataTable/index.ts b/src/DataTable/index.ts
index 5ca274b0040..982608fc418 100644
--- a/src/DataTable/index.ts
+++ b/src/DataTable/index.ts
@@ -1,4 +1,5 @@
import {DataTable} from './DataTable'
+import {ErrorDialog} from './ErrorDialog'
import {
Table as TableImpl,
TableHead,
@@ -30,6 +31,7 @@ const Table = Object.assign(TableImpl, {
Cell: TableCell,
CellPlaceholder: TableCellPlaceholder,
Pagination,
+ ErrorDialog,
})
export {DataTable, Table}
diff --git a/src/DataTable/storybook/data.ts b/src/DataTable/storybook/data.ts
index f7ef9fd37be..5d799f42896 100644
--- a/src/DataTable/storybook/data.ts
+++ b/src/DataTable/storybook/data.ts
@@ -1,3 +1,6 @@
+import React from 'react'
+import {alphanumeric, datetime} from '../sorting'
+
interface Repo {
id: number
name: string
@@ -35,55 +38,144 @@ function random(floor: number, ceiling: number): number {
return Math.floor(Math.random() * ceiling) + floor
}
-const Repo = {
- async all() {
- await sleep(random(2500, 3500))
- return repos
- },
- async create() {
- await sleep(random(1000, 5000))
- },
- async delete() {
- await sleep(random(1000, 5000))
- },
- async paginate(offset: number, pageSize: number) {
- await sleep(random(2500, 3500))
- return repos.slice(offset * pageSize, offset * pageSize + pageSize)
- },
- async pageInfo(pageSize: number) {
- return {
- totalCount: repos.length,
- totalPages: repos.length / pageSize,
- }
- },
+type SortOptions = {
+ sort: 'name' | 'updatedAt'
+ direction: 'asc' | 'desc'
+}
+
+type PaginationOptions = {
+ page: number
+ perPage: number
}
-const cache = new Map()
+type ListRepoOptions = Partial & Partial
+
+export async function fetchRepos({sort = 'name', direction = 'asc', page, perPage = 30}: ListRepoOptions = {}): Promise<
+ Array
+> {
+ await sleep(random(2500, 3500))
+
+ const collection = repos.slice().sort((a, b) => {
+ if (sort === 'name') {
+ if (direction === 'asc') {
+ return alphanumeric(a.name, b.name)
+ }
+ return alphanumeric(b.name, a.name)
+ }
+
+ // sort === 'updatedAt'
+ if (direction === 'asc') {
+ return datetime(a.updatedAt, b.updatedAt)
+ }
+ return datetime(b.updatedAt, a.updatedAt)
+ })
+
+ if (page !== undefined) {
+ const offset = page * perPage
+ return collection.slice(offset, offset + perPage)
+ }
+
+ return collection
+}
-export function fetchRepos(): Promise> {
- if (!cache.has('/repos')) {
- cache.set('/repos', Repo.all())
+export async function fetchRepoPageInfo(perPage: number): Promise<{totalCount: number; totalPages: number}> {
+ await sleep(random(1000, 2000))
+ return {
+ totalCount: repos.length,
+ totalPages: Math.ceil(repos.length / perPage),
}
- return cache.get('/repos')
}
-export function fetchRepoPage(offset: number, pageSize: number): Promise> {
- const url = new URL('/repos', 'https://api.dev')
- url.searchParams.set('offset', `${offset}`)
- url.searchParams.set('pageSize', `${pageSize}`)
- const id = url.toString()
- if (!cache.has(id)) {
- cache.set(id, Repo.paginate(offset, pageSize))
+type Result = {
+ loading: boolean
+ error: Error | null
+ data: T | null
+}
+
+export function useQuery(
+ queryKey: string | Array,
+ queryFn: ({signal}: {signal: AbortSignal}) => Promise,
+): Result {
+ const [loading, setLoading] = React.useState(true)
+ const [error, setError] = React.useState(null)
+ const [data, setData] = React.useState(null)
+ const savedQueryFn = React.useRef(queryFn)
+ const key = Array.isArray(queryKey) ? queryKey.join('.') : queryKey
+
+ React.useEffect(() => {
+ savedQueryFn.current = queryFn
+ })
+
+ React.useEffect(() => {
+ const controller = new AbortController()
+
+ setLoading(true)
+ setError(null)
+ setData(null)
+
+ savedQueryFn
+ .current({signal: controller.signal})
+ // eslint-disable-next-line github/no-then
+ .then(data => {
+ if (!controller.signal.aborted) {
+ setLoading(false)
+ setData(data)
+ setError(null)
+ }
+ })
+ // eslint-disable-next-line github/no-then
+ .catch(error => {
+ if (!controller.signal.aborted) {
+ setLoading(false)
+ setData(null)
+ setError(error)
+ }
+ })
+ return () => {
+ controller.abort()
+ }
+ }, [key])
+
+ return {
+ data,
+ error,
+ loading,
}
- return cache.get(id)
}
-export function fetchRepoPageInfo(pageSize: number): Promise<{totalCount: number; totalPages: number}> {
- const url = new URL('/repos/page-info', 'https://api.dev')
- url.searchParams.set('pageSize', `${pageSize}`)
- const id = url.toString()
- if (!cache.has(id)) {
- cache.set(id, Repo.pageInfo(pageSize))
+export function useFlakeyQuery({
+ queryKey,
+ queryFn,
+}: {
+ queryKey: string | Array
+ queryFn: ({signal}: {signal: AbortSignal}) => Promise
+}): Result {
+ const {error, loading, data} = useQuery(queryKey, queryFn)
+ const [previousLoading, setPreviousLoading] = React.useState(loading)
+ const [previouslyHadError, setPreviouslyHadError] = React.useState(false)
+ const [wrappedError, setWrappedError] = React.useState(null)
+
+ if (loading !== previousLoading) {
+ setPreviousLoading(loading)
+
+ if (loading === false) {
+ if (error) {
+ setWrappedError(error)
+ } else if (previouslyHadError) {
+ setWrappedError(null)
+ setPreviouslyHadError(false)
+ } else {
+ setWrappedError(new Error('Flakey error'))
+ setPreviouslyHadError(true)
+ }
+ } else {
+ setWrappedError(null)
+ }
+ }
+
+ return {
+ error: wrappedError,
+ loading,
+ data: wrappedError === null ? data : null,
}
- return cache.get(id)
}