Skip to content

Commit

Permalink
Improve state surfacing support (#25)
Browse files Browse the repository at this point in the history
# Improve state surfacing support

## ♻️ Current situation & Problem
Design system needs to establish a consistent and easy way to support
operations states: loading, error, empty, success.

## ⚙️ Release Notes 
* Add Badge component
* Add ErrorState component
* Add Spinner component
* Add StateContainer component
* Add FormError component
* Add Async component
* Surface states on SignInForm
* Add dev utils
* Add query utils
* Migrate DataTable to use Async component and simplify it's
implementation

StanfordBDHG/ENGAGE-HF-Web-Frontend#95 uses all
the introduced elements.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).

---------

Co-authored-by: Nikolai Madlener <[email protected]>
  • Loading branch information
arkadiuszbachorski and NikolaiMadlener authored Jan 6, 2025
1 parent 4282aee commit bf01cf7
Show file tree
Hide file tree
Showing 47 changed files with 1,136 additions and 233 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 } },
}
132 changes: 132 additions & 0 deletions src/components/Async/Async.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// 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, queriesToAsyncProps } 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()
})

it('renders error', () => {
render(<Async error>Lorem</Async>)

const alert = screen.getByRole('alert')
expect(alert).toBeInTheDocument()
})

it('renders loading', () => {
render(<Async loading>Lorem</Async>)

const status = screen.getByRole('status')
expect(status).toBeInTheDocument()
})

it('renders empty', () => {
render(<Async empty>Lorem</Async>)

const status = screen.getByText(/No data found/)
expect(status).toBeInTheDocument()
})

it('prioritizes error', () => {
render(
<Async error loading empty>
Lorem
</Async>,
)

const alert = screen.getByRole('alert')
expect(alert).toBeInTheDocument()
})
})

describe('utils', () => {
describe('queriesToAsync', () => {
it('combines loading state', () => {
expect(
queriesToAsyncProps([{ isLoading: false }, { isLoading: true }]),
).toHaveProperty('loading', true)
expect(
queriesToAsyncProps([{ isLoading: false }, { isLoading: false }]),
).toHaveProperty('loading', false)
})

it('supports loading prop that can override result', () => {
expect(
queriesToAsyncProps([{ isLoading: false }, { isLoading: false }], {
loading: true,
}),
).toHaveProperty('loading', true)
expect(
queriesToAsyncProps([{ isLoading: true }, { isLoading: false }], {
loading: false,
}),
).toHaveProperty('loading', true)
expect(
queriesToAsyncProps([{ isLoading: false }, { isLoading: false }], {
loading: false,
}),
).toHaveProperty('loading', false)
})

it('combines error state', () => {
expect(
queriesToAsyncProps([{ isError: false }, { isError: true }]),
).toHaveProperty('error', true)
expect(
queriesToAsyncProps([{ isError: false }, { isError: false }]),
).toHaveProperty('error', false)
})

it('supports error prop that can override result', () => {
expect(
queriesToAsyncProps([{ isError: false }, { isError: false }], {
error: { children: 'Example', show: true },
}),
).toHaveProperty('error', { children: 'Example', show: true })

expect(
queriesToAsyncProps([{ isError: true }, { isError: false }], {
error: false,
}),
).toHaveProperty('error', true)
})
})
})
89 changes: 89 additions & 0 deletions src/components/Async/Async.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// 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 { 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>
)
}
33 changes: 33 additions & 0 deletions src/components/Async/Async.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// 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 { 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 {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
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' } }
Loading

0 comments on commit bf01cf7

Please sign in to comment.