diff --git a/package.json b/package.json index bf81a3c..de6322e 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,26 @@ "types": "./dist/components/Tooltip.d.ts", "default": "./dist/components/Tooltip.js" }, + "./components/ErrorState": { + "types": "./dist/components/ErrorState.d.ts", + "default": "./dist/components/ErrorState.js" + }, + "./components/Badge": { + "types": "./dist/components/Badge.d.ts", + "default": "./dist/components/Badge.js" + }, + "./components/StateContainer": { + "types": "./dist/components/StateContainer.d.ts", + "default": "./dist/components/StateContainer.js" + }, + "./components/Spinner": { + "types": "./dist/components/Spinner.d.ts", + "default": "./dist/components/Spinner.js" + }, + "./components/Async": { + "types": "./dist/components/Async.d.ts", + "default": "./dist/components/Async.js" + }, "./molecules/AsideBrandLayout": { "types": "./dist/molecules/AsideBrandLayout.d.ts", "default": "./dist/molecules/AsideBrandLayout.js" @@ -181,6 +201,14 @@ "./utils/navigator": { "types": "./dist/utils/navigator.d.ts", "default": "./dist/utils/navigator.js" + }, + "./utils/query": { + "types": "./dist/utils/query.d.ts", + "default": "./dist/utils/query.js" + }, + "./utils/dev": { + "types": "./dist/utils/dev.d.ts", + "default": "./dist/utils/dev.js" } }, "main": "dist/spezi-web-design-system.es.js", @@ -198,7 +226,8 @@ "lint:ci": "eslint --output-file eslint_report.json --format json .", "lint:fix": "eslint . --fix", "storybook": "storybook dev -p 6006", - "test": "vitest run --coverage" + "test": "vitest run --coverage", + "prepush": "npm run lint:fix && tsc --noEmit" }, "dependencies": { "@hookform/resolvers": "^3.9.0", diff --git a/src/components/Async/Async.stories.tsx b/src/components/Async/Async.stories.tsx new file mode 100644 index 0000000..e4f2236 --- /dev/null +++ b/src/components/Async/Async.stories.tsx @@ -0,0 +1,36 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { type Meta, type StoryObj } from '@storybook/react' +import { Async } from './Async' + +const meta: Meta = { + title: 'Components/Async', + component: Async, + args: { entityName: 'users' }, +} + +export default meta + +type Story = StoryObj + +export const Loading: Story = { + args: { loading: true }, +} + +export const Error: Story = { + args: { error: true }, +} + +export const CustomMessage: Story = { + args: { error: { show: true, children: 'Custom error message!' } }, +} + +export const Empty: Story = { + args: { empty: { show: true } }, +} diff --git a/src/components/Async/Async.test.tsx b/src/components/Async/Async.test.tsx new file mode 100644 index 0000000..b804ff2 --- /dev/null +++ b/src/components/Async/Async.test.tsx @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { render, screen } from '@testing-library/react' +import { Async } from '.' + +describe('Async', () => { + it('renders special state if any provided', () => { + render(Lorem) + const content = screen.queryByText('Lorem') + expect(content).not.toBeInTheDocument() + const loading = screen.getByLabelText(/Loading/) + expect(loading).toBeInTheDocument() + }) + + it('renders content if no special state', () => { + render(Lorem) + const loading = screen.queryByLabelText(/Loading/) + expect(loading).not.toBeInTheDocument() + const content = screen.getByText('Lorem') + expect(content).toBeInTheDocument() + }) + + it('allows wrapping special state with container', () => { + render( + ( +
+

Title

+ {state} +
+ )} + > + Lorem +
, + ) + const title = screen.getByText('Title') + expect(title).toBeInTheDocument() + }) +}) diff --git a/src/components/Async/Async.tsx b/src/components/Async/Async.tsx new file mode 100644 index 0000000..fab4a96 --- /dev/null +++ b/src/components/Async/Async.tsx @@ -0,0 +1,81 @@ +import { omit } from 'es-toolkit' +import { isBoolean, isUndefined } from 'lodash' +import { type ReactNode } from 'react' +import { ErrorState, type ErrorStateProps } from '@/components/ErrorState' +import { Spinner } from '@/components/Spinner' +import { StateContainer } from '@/components/StateContainer' +import { EmptyState, type EmptyStateProps } from '../EmptyState' + +const parseError = (error: AsyncProps['error']) => { + if (isUndefined(error)) + return { + show: false, + } + if (isBoolean(error)) return { show: error } + return error +} + +const parseEmpty = (empty: AsyncProps['empty']) => { + if (isBoolean(empty)) return { show: empty } + if (isUndefined(empty)) return { show: false } + return empty +} + +export type FullEmptyProps = { show: boolean } & EmptyStateProps + +export type FullErrorProps = { + show: boolean +} & ErrorStateProps + +export interface AsyncProps { + grow?: boolean + children?: ReactNode + className?: string + /** + * Name of the represented entity + * Provide pluralized + * @example "users" + * */ + entityName?: string + empty?: boolean | FullEmptyProps + error?: boolean | FullErrorProps + loading?: boolean + /** + * Can be used to wrap state with custom container + * */ + renderState?: (specialState: ReactNode) => ReactNode +} + +/** + * Generic async container + * Handles common data states: empty, error and loading + * */ +export const Async = ({ + entityName = 'data', + empty: emptyProp, + error: errorProp, + loading, + renderState, + children, + grow, + className, +}: AsyncProps) => { + const error = parseError(errorProp) + const empty = parseEmpty(emptyProp) + + const specialState = + error.show ? + + : loading ? + : empty.show ? + + : null + + return ( + !specialState ? children + : renderState ? renderState(specialState) + : + {specialState} + + ) +} diff --git a/src/components/Async/Async.utils.ts b/src/components/Async/Async.utils.ts new file mode 100644 index 0000000..06d5f69 --- /dev/null +++ b/src/components/Async/Async.utils.ts @@ -0,0 +1,24 @@ +import { isObject } from '@/utils/misc' +import { combineQueries, type Query } from '@/utils/query' +import { type FullErrorProps } from './Async' + +/** + * Parses queries to Async component props + * */ +export const queriesToAsyncProps = ( + queries: Query[], + props?: { + loading?: boolean + error?: boolean | Partial + }, +) => { + const combinedQueries = combineQueries(queries) + return { + loading: props?.loading ?? combinedQueries.isLoading, + error: + isObject(props?.error) ? + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + { ...props.error, show: props.error.show || combinedQueries.isError } + : combinedQueries.isError, + } +} diff --git a/src/components/Async/index.tsx b/src/components/Async/index.tsx new file mode 100644 index 0000000..a7b5daa --- /dev/null +++ b/src/components/Async/index.tsx @@ -0,0 +1,10 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +export * from './Async' +export * from './Async.utils' diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx new file mode 100644 index 0000000..0d0851e --- /dev/null +++ b/src/components/Badge/Badge.stories.tsx @@ -0,0 +1,31 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { type Meta, type StoryObj } from '@storybook/react' +import { Badge } from './Badge' + +const meta: Meta = { + title: 'Components/Badge', + component: Badge, + args: { + children: 'Lorem', + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { args: { variant: 'default' } } +export const Secondary: Story = { args: { variant: 'secondary' } } +export const Destructive: Story = { args: { variant: 'destructive' } } +export const DestructiveLight: Story = { args: { variant: 'destructiveLight' } } +export const Outline: Story = { args: { variant: 'outline' } } + +export const Sm: Story = { args: { size: 'sm' } } +export const Lg: Story = { args: { size: 'lg' } } diff --git a/src/components/Badge/Badge.test.tsx b/src/components/Badge/Badge.test.tsx new file mode 100644 index 0000000..0c4a2b7 --- /dev/null +++ b/src/components/Badge/Badge.test.tsx @@ -0,0 +1,20 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { render, screen } from '@testing-library/react' +import { Badge } from '.' + +describe('Badge', () => { + it('renders badge element', () => { + render(Lorem) + + const element = screen.getByText('Lorem') + + expect(element).toBeInTheDocument() + }) +}) diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx new file mode 100644 index 0000000..c7c3738 --- /dev/null +++ b/src/components/Badge/Badge.tsx @@ -0,0 +1,39 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' +import { type ComponentProps } from 'react' +import { cn } from '@/utils/className' + +const badgeVariants = cva( + 'inline-flex-center border transition-colors focus-ring', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + destructiveLight: + 'border-transparent bg-destructive/10 text-destructive', + outline: 'text-foreground', + }, + size: { + sm: 'px-2.5 py-0.5 text-xs rounded-md gap-1 font-semibold', + lg: 'text-sm px-3 py-2 rounded-2xl gap-3 font-medium', + }, + }, + defaultVariants: { + variant: 'default', + size: 'sm', + }, + }, +) + +export interface BadgeProps + extends ComponentProps<'div'>, + VariantProps {} + +export const Badge = ({ className, variant, size, ...props }: BadgeProps) => ( +
+) diff --git a/src/components/Table/TableEmptyState/index.tsx b/src/components/Badge/index.tsx similarity index 88% rename from src/components/Table/TableEmptyState/index.tsx rename to src/components/Badge/index.tsx index c582401..4f603fc 100644 --- a/src/components/Table/TableEmptyState/index.tsx +++ b/src/components/Badge/index.tsx @@ -6,4 +6,4 @@ // SPDX-License-Identifier: MIT // -export * from './TableEmptyState' +export * from './Badge' diff --git a/src/components/DataTable/DataTable.stories.tsx b/src/components/DataTable/DataTable.stories.tsx index c0fbcfc..e9ae962 100644 --- a/src/components/DataTable/DataTable.stories.tsx +++ b/src/components/DataTable/DataTable.stories.tsx @@ -16,7 +16,6 @@ import { peopleColumn, columnHelper, } from './DataTable.mocks' -import { DataTableBasicView } from './DataTableBasicView' import { Button } from '../Button' const meta: Meta = { @@ -99,24 +98,20 @@ export const CustomView = () => ( entityName="users" className="m-5" > - {(props) => ( - - {(rows) => ( -
- {rows.map((row) => { - const person = row.original - return ( -
-

{person.name}

- - {person.age} years old - -
- ) - })} -
- )} -
+ {({ rows }) => ( +
+ {rows.map((row) => { + const person = row.original + return ( +
+

{person.name}

+ + {person.age} years old + +
+ ) + })} +
)} ) diff --git a/src/components/DataTable/DataTable.test.tsx b/src/components/DataTable/DataTable.test.tsx index eb42449..51c63cd 100644 --- a/src/components/DataTable/DataTable.test.tsx +++ b/src/components/DataTable/DataTable.test.tsx @@ -8,8 +8,8 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' -import { peopleColumns, peopleData } from './DataTable.mocks' -import { DataTable, DataTableBasicView } from '.' +import { peopleColumn, peopleColumns, peopleData } from './DataTable.mocks' +import { DataTable } from '.' describe('DataTable', () => { const getTBody = () => { @@ -130,6 +130,40 @@ describe('DataTable', () => { expect(searchTextDisplayed).toBeInTheDocument() }) + it('shows correct filters empty state', () => { + render( + , + ) + + const emptyState = screen.getByText( + /No\sresults\sfound\sfor\syour\sselected\sfilters/, + ) + expect(emptyState).toBeInTheDocument() + + render( + , + ) + + // When there is no data at all, it shows regular message + const emptyStateForFilters = screen.queryByText( + /No\sresults\sfound\sfor\syour\sselected\sfilters/, + ) + expect(emptyStateForFilters).not.toBeInTheDocument() + }) + it('supports entityName for search', async () => { const user = userEvent.setup() render( @@ -181,19 +215,17 @@ describe('DataTable', () => { it('renders people using custom view', () => { render( - {(props) => ( - - {(rows) => - rows.map((row) => { - const person = row.original - return ( -
- Person name - {person.name} -
- ) - }) - } -
+ {({ rows }) => ( + <> + {rows.map((row) => { + const person = row.original + return ( +
+ Person name - {person.name} +
+ ) + })} + )}
, ) @@ -208,9 +240,7 @@ describe('DataTable', () => { it('shows empty state', () => { render( - {(props) => ( - {() => null} - )} + {() => null} , ) diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index c5c5535..1bfcb34 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -6,25 +6,29 @@ // SPDX-License-Identifier: MIT // -import { type Table as TableType } from '@tanstack/table-core' +import { type Row, type Table as TableType } from '@tanstack/table-core' import { type ReactNode } from 'react' +import { Async, type AsyncProps } from '@/components/Async/Async' import { DataTableTableView, type DataTableTableViewSpecificProps, } from '@/components/DataTable/DataTableTableView' +import { ensureString } from '@/utils/misc' import { useDataTable, type UseDataTableProps } from './DataTable.utils' import { DataTablePagination } from './DataTablePagination' import { GlobalFilterInput } from './GlobalFilterInput' import { cn } from '../../utils/className' -export type DataTableViewProps = { table: TableType } & Pick< - DataTableProps, - 'entityName' -> +export type DataTableViewProps = { + table: TableType + rows: Array> +} & Pick, 'entityName'> type ViewRenderProp = (props: DataTableViewProps) => ReactNode -export interface DataTableProps extends UseDataTableProps { +export interface DataTableProps + extends UseDataTableProps, + Pick { className?: string /** * Name of the presented data entity @@ -57,6 +61,8 @@ export const DataTable = ({ bordered = true, minimal, tableView, + loading = false, + error = false, ...props }: DataTableProps) => { const { table, setGlobalFilterDebounced } = useDataTable({ @@ -67,7 +73,8 @@ export const DataTable = ({ }) const rows = table.getRowModel().rows - const viewProps = { table, entityName } + const isEmpty = !rows.length + const viewProps = { table, entityName, rows } return (
({ {typeof header === 'function' ? header(viewProps) : header} )} - {children ? - children(viewProps) - : } - {(!minimal || table.getPageCount() > 1) && !!rows.length && ( + 0, + }} + > + {children ? + children(viewProps) + : } + + {(!minimal || table.getPageCount() > 1) && !isEmpty && (
diff --git a/src/components/DataTable/DataTableBasicView.tsx b/src/components/DataTable/DataTableBasicView.tsx deleted file mode 100644 index 967a2e4..0000000 --- a/src/components/DataTable/DataTableBasicView.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// -// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import { type Row } from '@tanstack/react-table' -import { type ReactNode } from 'react' -import { EmptyState } from '@/components/EmptyState' -import { ensureString } from '@/utils/misc' -import type { DataTableViewProps } from './DataTable' - -interface DataTableBasicViewProps extends DataTableViewProps { - children: (rows: Array>) => ReactNode -} - -/** - * Handles basic states for custom DataTable views - * - * @example See `DataTable.stories#CustomView` - * */ -export const DataTableBasicView = ({ - table, - entityName, - children, -}: DataTableBasicViewProps) => { - const rows = table.getRowModel().rows - return ( -
- {!rows.length ? - 0} - className="h-24" - /> - : children(rows)} -
- ) -} diff --git a/src/components/DataTable/DataTableTableView.tsx b/src/components/DataTable/DataTableTableView.tsx index db3ac09..0d53fc6 100644 --- a/src/components/DataTable/DataTableTableView.tsx +++ b/src/components/DataTable/DataTableTableView.tsx @@ -17,8 +17,6 @@ import { TableHeader, TableRow, } from '@/components/Table' -import { TableEmptyState } from '@/components/Table/TableEmptyState' -import { ensureString } from '@/utils/misc' import type { DataTableViewProps } from './DataTable' export interface DataTableTableViewSpecificProps { @@ -39,68 +37,54 @@ interface DataTableTableViewProps export const DataTableTableView = ({ table, - entityName, + rows, onRowClick, isRowClicked = isRowClickedDefault, -}: DataTableTableViewProps) => { - const rows = table.getRowModel().rows - return ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const columnContent = - header.isPlaceholder ? null : ( - flexRender( - header.column.columnDef.header, - header.getContext(), - ) - ) - return ( - - {header.column.getCanFilter() ? - - {columnContent} - - : columnContent} - +}: DataTableTableViewProps) => ( +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnContent = + header.isPlaceholder ? null : ( + flexRender(header.column.columnDef.header, header.getContext()) ) - })} - - ))} - - - {!rows.length ? - 0} - /> - : rows.map((row) => ( - { - if (isRowClicked(event)) { - onRowClick(row.original, event) - } - } - : undefined + return ( + + {header.column.getCanFilter() ? + + {columnContent} + + : columnContent} + + ) + })} + + ))} + + + {rows.map((row) => ( + { + if (isRowClicked(event)) { + onRowClick(row.original, event) + } } - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - } - -
- ) -} + : undefined + } + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + +) diff --git a/src/components/DataTable/index.tsx b/src/components/DataTable/index.tsx index 8080593..5c69d79 100644 --- a/src/components/DataTable/index.tsx +++ b/src/components/DataTable/index.tsx @@ -11,4 +11,3 @@ export * from './RowDropdownMenu' export * from './DataTable.columns' export * from './DataTable.utils' export * from './DataTableTableView' -export * from './DataTableBasicView' diff --git a/src/components/EmptyState/EmptyState.tsx b/src/components/EmptyState/EmptyState.tsx index eedfe35..4243a2a 100644 --- a/src/components/EmptyState/EmptyState.tsx +++ b/src/components/EmptyState/EmptyState.tsx @@ -35,10 +35,7 @@ export const EmptyState = ({ children, ...props }: EmptyStateProps) => ( -
+
{textFilter ? : } diff --git a/src/components/ErrorState/ErrorState.stories.tsx b/src/components/ErrorState/ErrorState.stories.tsx new file mode 100644 index 0000000..4eb7cac --- /dev/null +++ b/src/components/ErrorState/ErrorState.stories.tsx @@ -0,0 +1,25 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { type Meta, type StoryObj } from '@storybook/react' +import { ErrorState } from './ErrorState' + +const meta: Meta = { + title: 'Components/ErrorState', + component: ErrorState, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { args: { entityName: 'users' } } + +export const CustomMessage: Story = { + args: { children: 'Unknown error' }, +} diff --git a/src/components/ErrorState/ErrorState.test.tsx b/src/components/ErrorState/ErrorState.test.tsx new file mode 100644 index 0000000..9ef6fc3 --- /dev/null +++ b/src/components/ErrorState/ErrorState.test.tsx @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { render, screen } from '@testing-library/react' +import { ErrorState } from '.' + +describe('ErrorState', () => { + it('renders error state', () => { + render(Lorem) + + const element = screen.getByRole('alert', { name: 'Lorem' }) + + expect(element).toBeInTheDocument() + }) + + it('renders entityName', () => { + render() + + const element = screen.getByText(/Fetching\susers\sfailed/) + + expect(element).toBeInTheDocument() + }) +}) diff --git a/src/components/ErrorState/ErrorState.tsx b/src/components/ErrorState/ErrorState.tsx new file mode 100644 index 0000000..9fab71f --- /dev/null +++ b/src/components/ErrorState/ErrorState.tsx @@ -0,0 +1,36 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { CircleAlert } from 'lucide-react' +import { type ReactNode } from 'react' +import { Badge, type BadgeProps } from '@/components/Badge' + +export interface ErrorStateProps extends BadgeProps { + /** + * Name of the presented missing data entity + * Provide pluralized and lowercased + * @example "users" + * */ + entityName?: ReactNode +} + +/** + * Component for surfacing inline query errors + * */ +export const ErrorState = ({ + children, + entityName, + ...props +}: ErrorStateProps) => ( + + + + {children ?? <>Fetching {entityName} failed. Please try again later.} + + +) diff --git a/src/components/ErrorState/index.tsx b/src/components/ErrorState/index.tsx new file mode 100644 index 0000000..7e0d691 --- /dev/null +++ b/src/components/ErrorState/index.tsx @@ -0,0 +1,9 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +export * from './ErrorState' diff --git a/src/components/Spinner/Spinner.stories.tsx b/src/components/Spinner/Spinner.stories.tsx new file mode 100644 index 0000000..8b6815b --- /dev/null +++ b/src/components/Spinner/Spinner.stories.tsx @@ -0,0 +1,21 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { type Meta, type StoryObj } from '@storybook/react' +import { Spinner } from './Spinner' + +const meta: Meta = { + title: 'Components/Spinner', + component: Spinner, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = {} diff --git a/src/components/Spinner/Spinner.test.tsx b/src/components/Spinner/Spinner.test.tsx new file mode 100644 index 0000000..7181a58 --- /dev/null +++ b/src/components/Spinner/Spinner.test.tsx @@ -0,0 +1,19 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { render, screen } from '@testing-library/react' +import { Spinner } from '.' + +describe('Spinner', () => { + it('renders accessible loader', () => { + render() + + const element = screen.getByRole('status') + expect(element).toBeInTheDocument() + }) +}) diff --git a/src/components/Spinner/Spinner.tsx b/src/components/Spinner/Spinner.tsx new file mode 100644 index 0000000..4b9ea33 --- /dev/null +++ b/src/components/Spinner/Spinner.tsx @@ -0,0 +1,26 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { Loader2 } from 'lucide-react' +import { type ComponentProps } from 'react' +import { cn } from '@/utils/className' + +export interface SpinnerProps extends ComponentProps {} + +/** + * Loading indicator icon + * */ +export const Spinner = ({ className, ...props }: SpinnerProps) => ( + +) diff --git a/src/components/Spinner/index.tsx b/src/components/Spinner/index.tsx new file mode 100644 index 0000000..9110d71 --- /dev/null +++ b/src/components/Spinner/index.tsx @@ -0,0 +1,9 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +export * from './Spinner' diff --git a/src/components/StateContainer/StateContainer.stories.tsx b/src/components/StateContainer/StateContainer.stories.tsx new file mode 100644 index 0000000..f305eea --- /dev/null +++ b/src/components/StateContainer/StateContainer.stories.tsx @@ -0,0 +1,29 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { type Meta } from '@storybook/react' +import { StateContainer } from './StateContainer' + +const meta: Meta = { + title: 'Components/StateContainer', + component: StateContainer, +} + +export default meta + +export const Default = () => ( + ... +) + +export const Grow = () => ( +
+ + ... + +
+) diff --git a/src/components/StateContainer/StateContainer.test.tsx b/src/components/StateContainer/StateContainer.test.tsx new file mode 100644 index 0000000..a0a862c --- /dev/null +++ b/src/components/StateContainer/StateContainer.test.tsx @@ -0,0 +1,17 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { render, screen } from '@testing-library/react' +import { StateContainer } from '.' + +describe('StateContainer', () => { + it('renders div element', () => { + render() + expect(screen.getByTestId('element')).toBeInTheDocument() + }) +}) diff --git a/src/components/StateContainer/StateContainer.tsx b/src/components/StateContainer/StateContainer.tsx new file mode 100644 index 0000000..ae174e5 --- /dev/null +++ b/src/components/StateContainer/StateContainer.tsx @@ -0,0 +1,28 @@ +import { type ComponentProps } from 'react' +import { cn } from '@/utils/className' + +export interface StateContainerProps extends ComponentProps<'div'> { + grow?: boolean + padding?: boolean +} + +/** + * Standard container for state components, like Spinner, EmptyState, ErrorState + * Guarantees consistent spacing + * */ +export const StateContainer = ({ + className, + grow, + padding = true, + ...props +}: StateContainerProps) => ( +
+) diff --git a/src/components/StateContainer/index.tsx b/src/components/StateContainer/index.tsx new file mode 100644 index 0000000..87aed71 --- /dev/null +++ b/src/components/StateContainer/index.tsx @@ -0,0 +1,9 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +export * from './StateContainer' diff --git a/src/components/Table/TableEmptyState/TableEmptyState.tsx b/src/components/Table/TableEmptyState/TableEmptyState.tsx deleted file mode 100644 index 3b57494..0000000 --- a/src/components/Table/TableEmptyState/TableEmptyState.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// -// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import { EmptyState, type EmptyStateProps } from '../../EmptyState' -import { TableCell, TableRow } from '../Table' - -interface TableEmptyStateProps extends EmptyStateProps { - colSpan: number -} - -export const TableEmptyState = ({ - colSpan, - ...props -}: TableEmptyStateProps) => ( - - - - - -) diff --git a/src/forms/FormError/FormError.stories.tsx b/src/forms/FormError/FormError.stories.tsx new file mode 100644 index 0000000..0781d81 --- /dev/null +++ b/src/forms/FormError/FormError.stories.tsx @@ -0,0 +1,32 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { type Meta, type StoryObj } from '@storybook/react' +import { FormError } from './FormError' + +const meta: Meta = { + title: 'Forms/FormError', + component: FormError, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { formError: { message: 'User already exists' } }, +} + +export const Prefixed: Story = { + args: { + prefix: 'Form invitation error. ', + formError: { + message: 'User already exists', + }, + }, +} diff --git a/src/forms/FormError/FormError.test.tsx b/src/forms/FormError/FormError.test.tsx new file mode 100644 index 0000000..535f7e1 --- /dev/null +++ b/src/forms/FormError/FormError.test.tsx @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { render, screen } from '@testing-library/react' +import { FormError } from '.' + +describe('ErrorState', () => { + it('renders error state', () => { + render(Lorem) + + const element = screen.getByRole('alert', { name: 'Lorem' }) + + expect(element).toBeInTheDocument() + }) + + it('renders entityName', () => { + render() + + const element = screen.getByText(/Fetching\susers\sfailed/) + + expect(element).toBeInTheDocument() + }) +}) diff --git a/src/forms/FormError/FormError.tsx b/src/forms/FormError/FormError.tsx new file mode 100644 index 0000000..f1e8f0a --- /dev/null +++ b/src/forms/FormError/FormError.tsx @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { isString } from 'es-toolkit' +import { type ReactNode } from 'react' +import { type ErrorOption } from 'react-hook-form' +import { ErrorState, type ErrorStateProps } from '@/components/ErrorState' +import { StateContainer } from '@/components/StateContainer' +import { cn } from '@/utils/className' +import { isObject } from '@/utils/misc' + +export interface FormErrorProps extends Omit { + formError: ErrorOption | ReactNode + prefix?: ReactNode +} + +/** + * Exposes form error if exists + * + * Use it for exposing form-wide errors, like server errors + * */ +export const FormError = ({ + formError, + className, + prefix, + ...props +}: FormErrorProps) => + formError ? + + + {prefix} + {( + isObject(formError) && + 'message' in formError && + isString(formError.message) + ) ? + formError.message + : (formError as ReactNode)} + + + : null diff --git a/src/forms/FormError/index.tsx b/src/forms/FormError/index.tsx new file mode 100644 index 0000000..073b88f --- /dev/null +++ b/src/forms/FormError/index.tsx @@ -0,0 +1,9 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +export * from './FormError' diff --git a/src/forms/index.tsx b/src/forms/index.tsx index e34e71a..79fcb7d 100644 --- a/src/forms/index.tsx +++ b/src/forms/index.tsx @@ -7,3 +7,4 @@ export * from './useForm' export * from './Field' +export * from './FormError' diff --git a/src/forms/useForm/useForm.ts b/src/forms/useForm/useForm.ts index 1c111da..d592c8d 100644 --- a/src/forms/useForm/useForm.ts +++ b/src/forms/useForm/useForm.ts @@ -17,7 +17,7 @@ import { type UseFormProps, } from 'react-hook-form' import { type z } from 'zod' -import { isObject } from '@/utils/misc' +import { parseUnknownError } from '@/utils/query' type FieldValues = Record @@ -83,11 +83,7 @@ export const useForm = ({ const setFormError = useCallback( (error: unknown, options?: Parameters[2]) => { const errorValue = { - message: - isObject(error) && 'message' in error && isString(error.message) ? - error.message - : isString(error) ? error - : 'Unknown error happened', + message: parseUnknownError(error), } setError( // @ts-expect-error Form error is special key, so error here is fine @@ -99,6 +95,18 @@ export const useForm = ({ [setError], ) + const handleSubmit: (typeof form)['handleSubmit'] = ( + successHandler, + negativeHandler, + ) => + form.handleSubmit(async (...args) => { + try { + await successHandler(...args) + } catch (error) { + setFormError(error) + } + }, negativeHandler) + const formError = errors[FORM_ERROR_KEY] as ErrorOption | undefined const isSubmitDisabled = !isValid || !isDirty @@ -109,5 +117,6 @@ export const useForm = ({ formError, setFormError, isSubmitDisabled, + handleSubmit, } } diff --git a/src/index.ts b/src/index.ts index d32295c..803c641 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ export * from './utils/misc' export * from './utils/tailwind' export * from './utils/useOpenState' export * from './utils/navigator' +export * from './utils/query' +export * from './utils/dev' export * from './theme/light' export * from './theme/utils' export * from './components/Avatar' @@ -42,6 +44,11 @@ export * from './components/Tabs' export * from './components/Textarea' export * from './components/Toaster' export * from './components/Tooltip' +export * from './components/ErrorState' +export * from './components/Badge' +export * from './components/Spinner' +export * from './components/Async' +export * from './components/StateContainer' export * from './forms/useForm' export * from './forms/Field' export * from './molecules/AsideBrandLayout' diff --git a/src/modules/auth/SignInForm/EmailPasswordForm/EmailPasswordForm.tsx b/src/modules/auth/SignInForm/EmailPasswordForm/EmailPasswordForm.tsx index fe6acdf..92e35c0 100644 --- a/src/modules/auth/SignInForm/EmailPasswordForm/EmailPasswordForm.tsx +++ b/src/modules/auth/SignInForm/EmailPasswordForm/EmailPasswordForm.tsx @@ -6,13 +6,13 @@ // SPDX-License-Identifier: MIT // +import { FirebaseError } from '@firebase/app' import { type Auth, type signInWithEmailAndPassword } from 'firebase/auth' import { useTranslations } from 'next-intl' import { z } from 'zod' -import { Button } from '../../../../components/Button' -import { Input } from '../../../../components/Input' -import { Field } from '../../../../forms/Field' -import { useForm } from '../../../../forms/useForm' +import { Button } from '@/components/Button' +import { Input } from '@/components/Input' +import { Field, useForm, FormError } from '@/forms' const formSchema = z.object({ email: z.string().min(1, 'Email is required'), @@ -39,11 +39,16 @@ export const EmailPasswordForm = ({ await signInWithEmailAndPassword(auth, email, password) } catch (error) { if ( - error instanceof Error && - 'code' in error && - error.code === 'auth/invalid-credential' + error instanceof FirebaseError && + [ + 'auth/invalid-credential', + 'auth/user-not-found', + 'auth/wrong-password', + ].includes(error.code) ) { form.setFormError(t('signIn_formError_invalidCredentials')) + } else if (error instanceof FirebaseError) { + form.setFormError(t('signIn_formError_firebase', { code: error.code })) } else { form.setFormError(t('signIn_formError_unknown')) } @@ -52,6 +57,7 @@ export const EmailPasswordForm = ({ return (
+ } - error={form.formError} /> diff --git a/src/molecules/DashboardLayout/DashboardLayout.tsx b/src/molecules/DashboardLayout/DashboardLayout.tsx index 47ac9f1..1c48c41 100644 --- a/src/molecules/DashboardLayout/DashboardLayout.tsx +++ b/src/molecules/DashboardLayout/DashboardLayout.tsx @@ -30,20 +30,30 @@ export const DashboardLayout = ({ const menu = useOpenState() return ( -
-
- {title} -
- {actions} - -
-
+
+ {title && ( +
+ {title} +
+ {actions} + +
+
+ )} + diff --git a/src/utils/dev/dev.ts b/src/utils/dev/dev.ts new file mode 100644 index 0000000..50acf18 --- /dev/null +++ b/src/utils/dev/dev.ts @@ -0,0 +1,27 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(() => resolve(undefined), ms)) + +export const logPerformance = (name: string, callback: () => T) => { + performance.mark(`mark-${name}`) + const res = callback() + performance.measure(name, `mark-${name}`) + console.info(performance.getEntriesByName(name)[0]) + return res +} + +export const notImplementedError: any = () => { + throw new Error('Not implemented') +} + +export const notImplementedAlert: any = () => { + alert('Not implemented') +} diff --git a/src/utils/dev/index.ts b/src/utils/dev/index.ts new file mode 100644 index 0000000..b889d68 --- /dev/null +++ b/src/utils/dev/index.ts @@ -0,0 +1,9 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +export * from './dev' diff --git a/src/utils/misc/misc.ts b/src/utils/misc/misc.ts index 7bb3ef3..a8a7242 100644 --- a/src/utils/misc/misc.ts +++ b/src/utils/misc/misc.ts @@ -30,6 +30,15 @@ export type Url = string | UrlObject * */ export type PartialSome = Omit & Partial> +/** + * Make provided fields in the object required, rest of them partial + * + * @example + * RequiredSome<{ a: string, b: string, c: string }, 'a'> => { a: string, b?: string, c?: string } + * */ +export type RequiredSome = Partial> & + Required> + /** * Handles copying to clipboard and show confirmation toast * */ diff --git a/src/utils/query/index.ts b/src/utils/query/index.ts new file mode 100644 index 0000000..61c1259 --- /dev/null +++ b/src/utils/query/index.ts @@ -0,0 +1,9 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +export * from './query' diff --git a/src/utils/query/query.ts b/src/utils/query/query.ts new file mode 100644 index 0000000..18af532 --- /dev/null +++ b/src/utils/query/query.ts @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Biodesign Digital Health Spezi Web Design System open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import { isString } from 'es-toolkit' +import { isObject } from '@/utils/misc' + +export interface Query { + isLoading?: boolean + isError?: boolean + isSuccess?: boolean +} + +export const combineQueries = (queries: Query[]) => ({ + isLoading: queries.some((query) => query.isLoading), + isError: queries.some((query) => query.isError), + isSuccess: queries.every((query) => query.isSuccess), +}) + +export const parseUnknownError = (error: unknown) => + isObject(error) && 'message' in error && isString(error.message) ? + error.message + : isString(error) ? error + : 'Unknown error happened'