From 8abf2688952ff0e69ba843ee6ccfc5d5574abb76 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 19 May 2023 09:48:23 -0500 Subject: [PATCH] feat(DataTable): add Table.ErrorDialog component (#3276) * feat(DataTable): add ErrorDialog component * chore: add changeset * chore: remove unused import * Update generated/components.json * chore: address eslint violations --------- Co-authored-by: Josh Black --- .changeset/brown-beds-cover.md | 5 + generated/components.json | 27 +++ src/DataTable/DataTable.docs.json | 27 +++ src/DataTable/DataTable.features.stories.tsx | 54 +++++- src/DataTable/ErrorDialog.tsx | 39 +++++ src/DataTable/Table.tsx | 5 +- src/DataTable/__tests__/ErrorDialog.test.tsx | 73 ++++++++ src/DataTable/index.ts | 2 + src/DataTable/storybook/data.ts | 174 ++++++++++++++----- 9 files changed, 362 insertions(+), 44 deletions(-) create mode 100644 .changeset/brown-beds-cover.md create mode 100644 src/DataTable/ErrorDialog.tsx create mode 100644 src/DataTable/__tests__/ErrorDialog.test.tsx 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) }