Skip to content
Draft
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
65 changes: 65 additions & 0 deletions frontend/documentation/components/Banner.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from 'storybook'
import Banner, { BannerProps } from 'components/Banner'
import { Button } from 'components/base/forms/Button'

const meta: Meta<BannerProps> = {
component: Banner,
parameters: { chromatic: { disableSnapshot: false }, layout: 'padded' },
title: 'Components/Banner',
}

export default meta

type Story = StoryObj<BannerProps>

export const Info: Story = {
args: {
children: 'Your changes will apply on the next deployment.',
variant: 'info',
},
}

export const Success: Story = {
args: { children: 'Webhook saved.', variant: 'success' },
}

export const Warning: Story = {
args: {
children: 'You have used 41.2K of your 50K allowed requests.',
variant: 'warning',
},
}

// Only danger carries role='alert', so a screen reader is interrupted by the
// states worth interrupting for and left alone by the rest.
export const Danger: Story = {
args: {
children: 'Your organisation has exceeded its plan limit.',
variant: 'danger',
},
}

export const WithAnAction: Story = {
args: {
children: (
<>
<span className='flex-fill'>
Your organisation has exceeded its plan limit.
</span>
<Button className='flex-shrink-0'>Upgrade plan</Button>
</>
),
variant: 'danger',
},
}

// Long bodies wrap against the icon rather than under it.
export const Wrapping: Story = {
args: {
children:
'We could not fetch usage for this period. This is usually temporary, ' +
'so try again in a moment. If it keeps happening, get in touch with ' +
'support and quote your organisation ID.',
variant: 'danger',
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,3 @@ export const Default: Story = {
<WarningMessage warningMessage='This feature is deprecated and will be removed.' />
),
}

export const WithCustomClass: Story = {
render: () => (
<WarningMessage
warningMessage='You have reached 80% of your identity limit.'
warningMessageClass='text-center'
/>
),
}
28 changes: 28 additions & 0 deletions frontend/web/components/Banner/Banner.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.banner {
align-items: center;
border-radius: 8px;
color: var(--color-text-default);
display: flex;
gap: 12px;
padding: 12px 16px;

&--success {
background: var(--color-surface-success);
border: 1px solid var(--color-border-success);
}

&--warning {
background: var(--color-surface-warning);
border: 1px solid var(--color-border-warning);
}

&--danger {
background: var(--color-surface-danger);
border: 1px solid var(--color-border-danger);
}

&--info {
background: var(--color-surface-info);
border: 1px solid var(--color-border-info);
}
}
39 changes: 39 additions & 0 deletions frontend/web/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FC, ReactNode } from 'react'
import cn from 'classnames'
import Icon, { IconName } from 'components/icons/Icon'
import './Banner.scss'

export type BannerVariant = 'success' | 'warning' | 'danger' | 'info'

export type BannerProps = {
variant: BannerVariant
children: ReactNode
className?: string
}

const variantIcons: Record<BannerVariant, IconName> = {
danger: 'close-circle',
info: 'info',
success: 'checkmark-circle',
warning: 'warning',
}

// Only danger interrupts. The rest are read in place, so announcing them would
// talk over whatever the user was doing.
const isUrgent = (variant: BannerVariant) => variant === 'danger'

const Banner: FC<BannerProps> = ({ children, className, variant }) => (
<div
role={isUrgent(variant) ? 'alert' : undefined}
className={cn('banner', `banner--${variant}`, className)}
>
<Icon
aria-hidden
name={variantIcons[variant]}
fill={`var(--color-icon-${variant})`}
/>
{children}
</div>
)

export default Banner
2 changes: 2 additions & 0 deletions frontend/web/components/Banner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './Banner'
export type { BannerProps, BannerVariant } from './Banner'
104 changes: 38 additions & 66 deletions frontend/web/components/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,49 @@
import React, { FC } from 'react'
import Icon from './icons/Icon'
import Button from './base/forms/Button'
import { FC } from 'react'
import Banner from './Banner'
import Format from 'common/utils/format'
import Constants from 'common/constants'

type ErrorMessageProps = {
enabledButton?: boolean
error?: any
errorMessageClass?: string
errorStyles?: React.CSSProperties
}

const ErrorMessage: FC<ErrorMessageProps> = ({
enabledButton,
error: errorProp,
errorMessageClass,
errorStyles,
}) => {
if (!errorProp) return null
// The API answers in several shapes: DRF field errors nested under metadata,
// a plain data payload, an Error, or a bare string.
const messageOf = (error: any) =>
error?.data?.metadata?.find((item: Record<string, unknown>) =>
Object.prototype.hasOwnProperty.call(item, 'non_field_errors'),
)?.non_field_errors?.[0] ??
error?.data ??
error?.message ??
error

const errorMessageClassName = `alert alert-danger ${
errorMessageClass || 'flex-1 align-items-center'
}`
const error =
errorProp?.data?.metadata?.find((item: Record<string, unknown>) =>
// eslint-disable-next-line no-prototype-builtins
item.hasOwnProperty('non_field_errors'),
)?.non_field_errors[0] ||
errorProp?.data ||
errorProp?.message ||
errorProp
const renderMessage = (message: any) => {
if (message instanceof Error) {
return message.message
}

return (
<div
className={errorMessageClassName}
style={{
display: errorMessageClass ? 'initial' : '',
...errorStyles,
}}
>
<span className='icon-alert'>
<Icon name='close-circle' />
</span>
{error instanceof Error ? (
error.message
) : typeof error === 'object' ? (
<div
dangerouslySetInnerHTML={{
__html: Object.keys(error)
.map(
(v) =>
`${Format.camelCase(Format.enumeration.get(v))}: ${
error[v]
}`,
)
.join('<br/>'),
}}
/>
) : (
error
)}
{enabledButton && (
<Button
className='btn ml-3'
onClick={() => {
document.location.replace(Constants.getUpgradeUrl())
}}
>
Upgrade plan
</Button>
)}
</div>
)
if (typeof message === 'object') {
return (
<div
dangerouslySetInnerHTML={{
__html: Object.keys(message)
.map(
(key) =>
`${Format.camelCase(Format.enumeration.get(key))}: ${
message[key]
}`,
)
.join('<br/>'),
}}
/>
)
}

return message
}

const ErrorMessage: FC<ErrorMessageProps> = ({ error }) =>
error ? (
<Banner variant='danger'>{renderMessage(messageOf(error))}</Banner>
) : null

export default ErrorMessage
6 changes: 1 addition & 5 deletions frontend/web/components/OrganisationLimit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,7 @@ const OrganisationLimit: FC<OrganisationLimitType> = ({
{Utils.getFlagsmithHasFeature('payments_enabled') &&
Utils.getFlagsmithHasFeature('max_api_calls_alert') &&
(maxApiCallsPercentage < 100 ? (
<WarningMessage
warningMessage={alertMaxApiCallsText}
warningMessageClass={'announcement'}
enabledButton
/>
<WarningMessage warningMessage={alertMaxApiCallsText} />
) : (
maxApiCallsPercentage >= 100 && <QuotaExceededMessage />
))}
Expand Down
14 changes: 2 additions & 12 deletions frontend/web/components/TestWebhook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,10 @@ const TestWebhook: FC<TestWebhookType> = ({ scope, secret, webhookUrl }) => {
] = useTestWebhookMutation()
return (
<>
{backendError && (
<ErrorMessage
error={backendError}
errorStyles={{ marginBottom: '0' }}
/>
)}
{backendError && <ErrorMessage error={backendError} />}
{isBackendSuccess && (
<div style={{ maxWidth: 'fit-content' }}>
<SuccessMessage
successStyles={{
marginBottom: '0',
width: 'fit-content !important',
}}
>
<SuccessMessage>
{'Your API returned with a successful 200 response.'}
</SuccessMessage>
</div>
Expand Down
27 changes: 4 additions & 23 deletions frontend/web/components/WarningMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,11 @@
import React, { FC, ReactNode } from 'react'
import Icon from './icons/Icon'
import { FC, ReactNode } from 'react'
import Banner from './Banner'

type WarningMessageType = {
warningMessage: ReactNode
warningMessageClass?: string
}

const WarningMessage: FC<WarningMessageType> = (props) => {
const { warningMessage, warningMessageClass } = props
const warningMessageClassName = `alert alert-warning ${
warningMessageClass || 'flex-1 align-items-center'
}`
if (!props.warningMessage) {
return null
}
return (
<div
className={warningMessageClassName}
style={{ display: warningMessageClass ? 'initial' : '' }}
>
<span className='icon-alert'>
<Icon name='warning' />
</span>
{warningMessage}
</div>
)
}
const WarningMessage: FC<WarningMessageType> = ({ warningMessage }) =>
warningMessage ? <Banner variant='warning'>{warningMessage}</Banner> : null

export default WarningMessage
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ const RolloutSplitEditor: FC<RolloutSplitEditorProps> = ({
return (
<div className='rollout-split'>
{invalid && (
<ErrorMessage
errorMessageClass='mb-2'
error='Your variation percentage splits total to over 100%'
/>
<div className='mb-2'>
<ErrorMessage error='Your variation percentage splits total to over 100%' />
</div>
)}

<div className='rollout-split__rows'>
Expand Down
Loading
Loading