Skip to content

Commit

Permalink
Improve state surfacing support
Browse files Browse the repository at this point in the history
  • Loading branch information
arkadiuszbachorski committed Dec 3, 2024
1 parent 4282aee commit 65422b9
Show file tree
Hide file tree
Showing 45 changed files with 998 additions and 218 deletions.
31 changes: 30 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions src/components/Async/Async.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Async> = {
title: 'Components/Async',
component: Async,
args: { entityName: 'users' },
}

export default meta

type Story = StoryObj<typeof Async>

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 } },
}
46 changes: 46 additions & 0 deletions src/components/Async/Async.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Async loading>Lorem</Async>)
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(<Async loading={false}>Lorem</Async>)
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(
<Async
loading
renderState={(state) => (
<div>
<p>Title</p>
{state}
</div>
)}
>
Lorem
</Async>,
)
const title = screen.getByText('Title')
expect(title).toBeInTheDocument()
})
})
81 changes: 81 additions & 0 deletions src/components/Async/Async.tsx
Original file line number Diff line number Diff line change
@@ -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 ?
<ErrorState entityName={entityName} {...omit(error, ['show'])} />
: loading ? <Spinner />
: empty.show ?
<EmptyState entityName={entityName} {...omit(empty, ['show'])} />
: null

return (
!specialState ? children
: renderState ? renderState(specialState)
: <StateContainer grow={grow} className={className}>
{specialState}
</StateContainer>
)
}
24 changes: 24 additions & 0 deletions src/components/Async/Async.utils.ts
Original file line number Diff line number Diff line change
@@ -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<FullErrorProps>
},
) => {
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,
}
}
10 changes: 10 additions & 0 deletions src/components/Async/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
31 changes: 31 additions & 0 deletions src/components/Badge/Badge.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Badge> = {
title: 'Components/Badge',
component: Badge,
args: {
children: 'Lorem',
},
}

export default meta

type Story = StoryObj<typeof Badge>

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' } }
20 changes: 20 additions & 0 deletions src/components/Badge/Badge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Badge>Lorem</Badge>)

const element = screen.getByText('Lorem')

expect(element).toBeInTheDocument()
})
})
39 changes: 39 additions & 0 deletions src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof badgeVariants> {}

export const Badge = ({ className, variant, size, ...props }: BadgeProps) => (
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
// SPDX-License-Identifier: MIT
//

export * from './TableEmptyState'
export * from './Badge'
33 changes: 14 additions & 19 deletions src/components/DataTable/DataTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
peopleColumn,
columnHelper,
} from './DataTable.mocks'
import { DataTableBasicView } from './DataTableBasicView'
import { Button } from '../Button'

const meta: Meta<typeof DataTable> = {
Expand Down Expand Up @@ -99,24 +98,20 @@ export const CustomView = () => (
entityName="users"
className="m-5"
>
{(props) => (
<DataTableBasicView {...props}>
{(rows) => (
<div className="grid grid-cols-3 gap-4 p-4">
{rows.map((row) => {
const person = row.original
return (
<div key={row.id} className="flex flex-col border p-6">
<h4 className="text-lg font-medium">{person.name}</h4>
<span className="text-sm text-muted-foreground">
{person.age} years old
</span>
</div>
)
})}
</div>
)}
</DataTableBasicView>
{({ rows }) => (
<div className="grid grid-cols-3 gap-4 p-4">
{rows.map((row) => {
const person = row.original
return (
<div key={row.id} className="flex flex-col border p-6">
<h4 className="text-lg font-medium">{person.name}</h4>
<span className="text-sm text-muted-foreground">
{person.age} years old
</span>
</div>
)
})}
</div>
)}
</DataTable>
)
Expand Down
Loading

0 comments on commit 65422b9

Please sign in to comment.