Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/ui/src/components/CubeStatus/CubeStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react'
import { axe } from 'jest-axe'
import { CubeStatus } from './CubeStatus'

describe('CubeStatus', () => {
it.each([
['neutral', 'Neutral'],
['success', 'Success'],
['warning', 'Warning'],
])('renders the translated text for the %s status', (status, text) => {
render(<CubeStatus status={status} />)

expect(screen.getByText(text)).toBeInTheDocument()
})

it('formats an unrecognized status into display text', () => {
render(<CubeStatus status="anything-else" />)

expect(screen.getByText('Anything-else')).toBeInTheDocument()
})

it('renders the custom message instead of the translated status text', () => {
render(<CubeStatus status="success" message="Custom message" />)

expect(screen.getByText('Custom message')).toBeInTheDocument()
expect(screen.queryByText('Success')).not.toBeInTheDocument()
})

it('renders its skeleton via the compound API', () => {
const { container } = render(<CubeStatus.Skeleton />)

expect(container.querySelector('span')).not.toBeInTheDocument()
})

it('has no accessibility violations', async () => {
const { container } = render(<CubeStatus status="success" />)

expect(await axe(container)).toHaveNoViolations()
})
})
43 changes: 43 additions & 0 deletions packages/ui/src/components/CubeStatus/CubeStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { twMerge } from 'tailwind-merge'
import {
computeStatusType,
formatOtherStatusText,
type CubeStatusValue,
} from './cubeStatusUtils'
import { status as statusStyles } from './cubeStatusStyles'
import { useCubeStatusTranslation } from './useCubeStatusTranslation'
import { CubeStatusSkeleton } from './CubeStatusSkeleton'

export type CubeStatusProps = {
status: CubeStatusValue
/**
* Custom display text for the status text.
* If not specified, the text will be automatically generated
* based on the built-in logic and i18n translations.
*/
message?: string
}

export const CubeStatus = (props: CubeStatusProps) => {
const { status, message } = props

const type = computeStatusType(status)

const translationMap = useCubeStatusTranslation()

const getMessage = () => {
if (message) {
return message
}

if (status in translationMap) {
return translationMap[status]
}

return formatOtherStatusText(status)
}

return <span className={twMerge(statusStyles({ type }))}>{getMessage()}</span>
}

CubeStatus.Skeleton = CubeStatusSkeleton
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CubeSkeleton } from '../CubeSkeleton'

export const CubeStatusSkeleton = () => {
return <CubeSkeleton className="h-[17px] w-[49px] rounded-[20px]" />
}
20 changes: 20 additions & 0 deletions packages/ui/src/components/CubeStatus/cubeStatusStyles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { cva } from 'class-variance-authority'
import type { ClassValue } from 'class-variance-authority/types'
import type { CubeStatusType } from './cubeStatusUtils'

export const status = cva(
[
'flex h-[19px] w-fit cursor-default items-center rounded-[20px] border px-2.5',
'secondary-body6 whitespace-nowrap font-semibold',
],
{
variants: {
type: {
neutral: 'border-cosmos-primary text-cosmos-primary',
success: 'border-status-positive text-status-positive',
warning: 'border-status-negative text-status-negative',
others: 'border-functional-text-light text-functional-text-light',
} satisfies Record<CubeStatusType, ClassValue>,
},
},
)
67 changes: 67 additions & 0 deletions packages/ui/src/components/CubeStatus/cubeStatusUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const neutralStatuses = ['neutral', 'in-use', 'finished'] as const

const successStatuses = [
'ok',
'success',
'active',
'available',
'powering on',
] as const

const warningStatuses = [
'warning',
'error',
'fail',
'failed',
'stopped',
'powering off',
] as const

export const cubeStatusKnownValues = [
...neutralStatuses,
...successStatuses,
...warningStatuses,
] as const

type CubeStatusKnownValue = (typeof cubeStatusKnownValues)[number]

// Use `string & {}` alongside the known literals to allow those specific
// strings to autocomplete while still accepting any string.
// Reference: https://stackoverflow.com/a/61048124/19772349
export type CubeStatusValue = (string & {}) | CubeStatusKnownValue

export type CubeStatusType = 'neutral' | 'success' | 'warning' | 'others'

const neutralStatusSet = new Set<string>(neutralStatuses)
const successStatusSet = new Set<string>(successStatuses)
const warningStatusSet = new Set<string>(warningStatuses)

export const computeStatusType = (status: string): CubeStatusType => {
if (neutralStatusSet.has(status)) {
return 'neutral'
} else if (successStatusSet.has(status)) {
return 'success'
} else if (warningStatusSet.has(status)) {
return 'warning'
}
return 'others'
}

/**
* Split a string using the `-` character and capitalize the first substring.
*/
export const formatOtherStatusText = (status: string): string => {
const substrings = status.split('-').filter((text) => !!text)
if (!substrings.length) {
// This should not happen.
return status
}

// Capitalize the first substring. `substrings.length` is checked above, so
// the first element is always defined here.
const [firstSubstring = '', ...otherSubstrings] = substrings
const capitalizedFirstSubstring =
firstSubstring.charAt(0).toUpperCase() + firstSubstring.substring(1)

return [capitalizedFirstSubstring, ...otherSubstrings].join('-')
}
3 changes: 3 additions & 0 deletions packages/ui/src/components/CubeStatus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type { CubeStatusValue } from './cubeStatusUtils'

export { type CubeStatusProps, CubeStatus } from './CubeStatus'
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useCubeUiTranslation } from '@i18n/useCubeUiTranslation'

export const useCubeStatusTranslation = (): Record<string, string> => {
const { t } = useCubeUiTranslation()

return {
neutral: t('component.status.host.neutral'),
'in-use': t('component.status.host.inUse'),
finished: t('component.status.host.finished'),
ok: t('component.status.host.ok'),
success: t('component.status.host.success'),
active: t('component.status.host.active'),
available: t('component.status.host.available'),
'powering on': t('component.status.host.poweringOn'),
warning: t('component.status.host.warning'),
error: t('component.status.host.error'),
fail: t('component.status.host.fail'),
failed: t('component.status.host.failed'),
stopped: t('component.status.host.stopped'),
'powering off': t('component.status.host.poweringOff'),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react'
import { axe } from 'jest-axe'
import { CubeStatusReaction } from './CubeStatusReaction'
import {
cubeStatusReactionStatuses,
type CubeStatusReactionStatus,
} from './cubeStatusReactionUtils'

describe('CubeStatusReaction', () => {
it.each(cubeStatusReactionStatuses)(
'renders the %s status without crashing',
(status: CubeStatusReactionStatus) => {
const { container } = render(<CubeStatusReaction status={status} />)

expect(container.querySelector('svg')).toBeInTheDocument()
},
)

it('renders the translated text for a status', () => {
render(<CubeStatusReaction status="success" />)

expect(screen.getByText('Success')).toBeInTheDocument()
})

it('renders the custom message instead of the translated status text', () => {
render(<CubeStatusReaction status="success" message="Custom message" />)

expect(screen.getByText('Custom message')).toBeInTheDocument()
expect(screen.queryByText('Success')).not.toBeInTheDocument()
})

it('renders its skeleton via the compound API', () => {
const { container } = render(<CubeStatusReaction.Skeleton />)

expect(container.querySelector('svg')).not.toBeInTheDocument()
})

it('has no accessibility violations', async () => {
const { container } = render(<CubeStatusReaction status="success" />)

expect(await axe(container)).toHaveNoViolations()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { upperFirst } from 'lodash'
import { twMerge } from 'tailwind-merge'
import {
MonochromeCheckmarkBold,
MonochromeCheckmarkCircleFill,
MonochromeCrossFill,
} from '@icons'
import type { SvgComponent } from '../CubeIcon/cubeIconTypes'
import type {
CubeStatusReactionStatus,
CubeStatusReactionType,
} from './cubeStatusReactionUtils'
import { computeStatusType } from './cubeStatusReactionUtils'
import { statusReaction } from './cubeStatusReactionStyles'
import { useCubeStatusReactionTranslation } from './useCubeStatusReactionTranslation'
import { CubeStatusReactionSkeleton } from './CubeStatusReactionSkeleton'

const iconMap: Record<CubeStatusReactionType, SvgComponent> = {
neutral: MonochromeCheckmarkBold,
success: MonochromeCheckmarkCircleFill,
warning: MonochromeCrossFill,
}

export type CubeStatusReactionProps = {
status: CubeStatusReactionStatus
message?: string
}

export const CubeStatusReaction = (props: CubeStatusReactionProps) => {
const { status, message } = props

const type = computeStatusType(status)
const Icon = iconMap[type]

const translationMap = useCubeStatusReactionTranslation()

const getMessage = () => {
if (message) {
return message
}

if (status in translationMap) {
return translationMap[status]
}

return upperFirst(status)
}

return (
<div className={twMerge(statusReaction({ type }))}>
<Icon className="icon-md-sm shrink-0" />
<span>{getMessage()}</span>
</div>
)
}

CubeStatusReaction.Skeleton = CubeStatusReactionSkeleton
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { CubeSkeleton } from '../CubeSkeleton'
import { baseClass } from './cubeStatusReactionStyles'

export const CubeStatusReactionSkeleton = () => {
return (
<div className={baseClass}>
<CubeSkeleton className="size-4" />
<CubeSkeleton className="h-4 w-11" />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cva } from 'class-variance-authority'
import type { ClassValue } from 'class-variance-authority/types'
import type { CubeStatusReactionType } from './cubeStatusReactionUtils'

export const baseClass = 'inline-flex w-fit items-center gap-x-2 px-2 py-[5px]'

export const statusReaction = cva(
[baseClass, 'secondary-body3 whitespace-nowrap font-semibold'],
{
variants: {
type: {
neutral: 'text-status-neutral',
success: 'text-status-positive',
warning: 'text-status-negative',
} satisfies Record<CubeStatusReactionType, ClassValue>,
},
},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const neutralStatuses = ['neutral'] as const

const successStatuses = ['success', 'available', 'done'] as const

const warningStatuses = ['error', 'duplicate', 'failed'] as const

export const cubeStatusReactionStatuses = [
...neutralStatuses,
...successStatuses,
...warningStatuses,
] as const

export type CubeStatusReactionStatus =
(typeof cubeStatusReactionStatuses)[number]

export type CubeStatusReactionType = 'neutral' | 'success' | 'warning'

const neutralStatusSet = new Set<string>(neutralStatuses)
const successStatusSet = new Set<string>(successStatuses)
const warningStatusSet = new Set<string>(warningStatuses)

export const computeStatusType = (
status: CubeStatusReactionStatus,
): CubeStatusReactionType => {
if (neutralStatusSet.has(status)) {
return 'neutral'
} else if (successStatusSet.has(status)) {
return 'success'
} else if (warningStatusSet.has(status)) {
return 'warning'
}
throw new Error(`Status ${status} is not defined in CubeStatusReaction`)
}
6 changes: 6 additions & 0 deletions packages/ui/src/components/CubeStatusReaction/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type { CubeStatusReactionStatus } from './cubeStatusReactionUtils'

export {
type CubeStatusReactionProps,
CubeStatusReaction,
} from './CubeStatusReaction'
Loading