diff --git a/frontend/documentation/components/Banner.stories.tsx b/frontend/documentation/components/Banner.stories.tsx new file mode 100644 index 000000000000..553919ec6586 --- /dev/null +++ b/frontend/documentation/components/Banner.stories.tsx @@ -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 = { + component: Banner, + parameters: { chromatic: { disableSnapshot: false }, layout: 'padded' }, + title: 'Components/Banner', +} + +export default meta + +type Story = StoryObj + +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: ( + <> + + Your organisation has exceeded its plan limit. + + + + ), + 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', + }, +} diff --git a/frontend/documentation/components/WarningMessage.stories.tsx b/frontend/documentation/components/WarningMessage.stories.tsx index 06d3dae6d36a..3f4bfc703478 100644 --- a/frontend/documentation/components/WarningMessage.stories.tsx +++ b/frontend/documentation/components/WarningMessage.stories.tsx @@ -24,12 +24,3 @@ export const Default: Story = { ), } - -export const WithCustomClass: Story = { - render: () => ( - - ), -} diff --git a/frontend/web/components/Banner/Banner.scss b/frontend/web/components/Banner/Banner.scss new file mode 100644 index 000000000000..f759771bada4 --- /dev/null +++ b/frontend/web/components/Banner/Banner.scss @@ -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); + } +} diff --git a/frontend/web/components/Banner/Banner.tsx b/frontend/web/components/Banner/Banner.tsx new file mode 100644 index 000000000000..4ff72c41120d --- /dev/null +++ b/frontend/web/components/Banner/Banner.tsx @@ -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 = { + 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 = ({ children, className, variant }) => ( +
+ + {children} +
+) + +export default Banner diff --git a/frontend/web/components/Banner/index.ts b/frontend/web/components/Banner/index.ts new file mode 100644 index 000000000000..644a4e9f8bb0 --- /dev/null +++ b/frontend/web/components/Banner/index.ts @@ -0,0 +1,2 @@ +export { default } from './Banner' +export type { BannerProps, BannerVariant } from './Banner' diff --git a/frontend/web/components/ErrorMessage.tsx b/frontend/web/components/ErrorMessage.tsx index af915dcf72e9..257e98f1d3fe 100644 --- a/frontend/web/components/ErrorMessage.tsx +++ b/frontend/web/components/ErrorMessage.tsx @@ -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 = ({ - 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) => + 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) => - // 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 ( -
- - - - {error instanceof Error ? ( - error.message - ) : typeof error === 'object' ? ( -
- `${Format.camelCase(Format.enumeration.get(v))}: ${ - error[v] - }`, - ) - .join('
'), - }} - /> - ) : ( - error - )} - {enabledButton && ( - - )} -
- ) + if (typeof message === 'object') { + return ( +
+ `${Format.camelCase(Format.enumeration.get(key))}: ${ + message[key] + }`, + ) + .join('
'), + }} + /> + ) + } + + return message } +const ErrorMessage: FC = ({ error }) => + error ? ( + {renderMessage(messageOf(error))} + ) : null + export default ErrorMessage diff --git a/frontend/web/components/OrganisationLimit.tsx b/frontend/web/components/OrganisationLimit.tsx index 36e74350305c..0134caf1e506 100644 --- a/frontend/web/components/OrganisationLimit.tsx +++ b/frontend/web/components/OrganisationLimit.tsx @@ -80,11 +80,7 @@ const OrganisationLimit: FC = ({ {Utils.getFlagsmithHasFeature('payments_enabled') && Utils.getFlagsmithHasFeature('max_api_calls_alert') && (maxApiCallsPercentage < 100 ? ( - + ) : ( maxApiCallsPercentage >= 100 && ))} diff --git a/frontend/web/components/TestWebhook.tsx b/frontend/web/components/TestWebhook.tsx index 680ae7caea07..1f4598103d5e 100644 --- a/frontend/web/components/TestWebhook.tsx +++ b/frontend/web/components/TestWebhook.tsx @@ -21,20 +21,10 @@ const TestWebhook: FC = ({ scope, secret, webhookUrl }) => { ] = useTestWebhookMutation() return ( <> - {backendError && ( - - )} + {backendError && } {isBackendSuccess && (
- + {'Your API returned with a successful 200 response.'}
diff --git a/frontend/web/components/WarningMessage.tsx b/frontend/web/components/WarningMessage.tsx index b730f13d3ea4..af6955b4975e 100644 --- a/frontend/web/components/WarningMessage.tsx +++ b/frontend/web/components/WarningMessage.tsx @@ -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 = (props) => { - const { warningMessage, warningMessageClass } = props - const warningMessageClassName = `alert alert-warning ${ - warningMessageClass || 'flex-1 align-items-center' - }` - if (!props.warningMessage) { - return null - } - return ( -
- - - - {warningMessage} -
- ) -} +const WarningMessage: FC = ({ warningMessage }) => + warningMessage ? {warningMessage} : null export default WarningMessage diff --git a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx index 454c377e8e15..5ee3ff1c7f27 100644 --- a/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx +++ b/frontend/web/components/experiments/RolloutSplitEditor/RolloutSplitEditor.tsx @@ -43,10 +43,9 @@ const RolloutSplitEditor: FC = ({ return (
{invalid && ( - +
+ +
)}
diff --git a/frontend/web/components/messages/ErrorMessage.tsx b/frontend/web/components/messages/ErrorMessage.tsx index abd5b442fc87..5df95f83cd1a 100644 --- a/frontend/web/components/messages/ErrorMessage.tsx +++ b/frontend/web/components/messages/ErrorMessage.tsx @@ -1,79 +1,2 @@ -import React from 'react' -import Icon from 'components/icons/Icon' -import Button from 'components/base/forms/Button' -import Format from 'common/utils/format' -import Constants from 'common/constants' - -interface ErrorMessageProps { - error?: any - errorMessageClass?: string - errorStyles?: React.CSSProperties - enabledButton?: boolean -} - -const ErrorMessage: React.FC = ({ - enabledButton, - error, - errorMessageClass, - errorStyles, -}) => { - const errorMessageClassName = `alert alert-danger ${ - errorMessageClass || 'flex-1 align-items-center' - }` - - const resolvedError = - error?.data?.metadata?.find((item: any) => - Object.prototype.hasOwnProperty.call(item, 'non_field_errors'), - )?.non_field_errors?.[0] ?? - error?.data ?? - error?.message ?? - error - - if (!error) return null - - return ( -
- - - - {resolvedError instanceof Error ? ( - resolvedError.message - ) : typeof resolvedError === 'object' ? ( -
- `${Format.camelCase(Format.enumeration.get(v))}: ${ - resolvedError[v] - }`, - ) - .join('
'), - }} - /> - ) : ( - resolvedError - )} - {enabledButton && ( - - )} -
- ) -} - -ErrorMessage.displayName = 'ErrorMessage' - -export default ErrorMessage +// Kept so existing imports keep working. One implementation, next door. +export { default } from 'components/ErrorMessage' diff --git a/frontend/web/components/messages/SuccessMessage.tsx b/frontend/web/components/messages/SuccessMessage.tsx index 5abb62d623a5..c47839115e43 100644 --- a/frontend/web/components/messages/SuccessMessage.tsx +++ b/frontend/web/components/messages/SuccessMessage.tsx @@ -1,65 +1,21 @@ -import React from 'react' -import Icon from 'components/icons/Icon' -import { close as closeIcon } from 'ionicons/icons' -import { IonIcon } from '@ionic/react' -import Button from 'components/base/forms/Button' +import { FC, ReactNode } from 'react' +import Banner from 'components/Banner' -interface SuccessMessageProps { - url?: string - buttonText?: string +type SuccessMessageProps = { + children?: ReactNode title?: string - children?: React.ReactNode - infoMessageClass?: string - successStyles?: React.CSSProperties - isClosable?: boolean - close?: () => void } -const SuccessMessage: React.FC = ({ - buttonText, +const SuccessMessage: FC = ({ children, - close, - infoMessageClass, - isClosable, - successStyles, title = 'SUCCESS', - url, -}) => { - const handleOpenNewWindow = () => { - if (url) window.open(url, '_blank') - } - - const infoMessageClassName = `alert alert-success ${ - infoMessageClass || 'flex-1' - }` - - const titleDescClass = infoMessageClass ? `${infoMessageClass} body mr-2` : '' - - return ( -
- - - -
-
{title}
- {children} -
- {url && ( - - )} - {isClosable && ( - - - - - - )} +}) => ( + +
+
{title}
+ {children}
- ) -} - -SuccessMessage.displayName = 'SuccessMessage' +
+) export default SuccessMessage diff --git a/frontend/web/components/mv/VariationOptions.tsx b/frontend/web/components/mv/VariationOptions.tsx index 8948ec8d68bd..45aec17423f5 100644 --- a/frontend/web/components/mv/VariationOptions.tsx +++ b/frontend/web/components/mv/VariationOptions.tsx @@ -79,10 +79,9 @@ export const VariationOptions: FC = ({ return ( <> {invalid && ( - +
+ +
)} {select && !!unmatchedOverride && (
diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx index cc2903714ff3..145dbedc20b4 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/ClickHouseConfigForm.tsx @@ -285,12 +285,7 @@ const ClickHouseConfigForm: FC = ({
)} {testState === 'errored' && ( -
- -
+ )}
diff --git a/frontend/web/components/pages/feature-lifecycle/components/StaleSection.tsx b/frontend/web/components/pages/feature-lifecycle/components/StaleSection.tsx index 2f345d07efe6..db4137b437b0 100644 --- a/frontend/web/components/pages/feature-lifecycle/components/StaleSection.tsx +++ b/frontend/web/components/pages/feature-lifecycle/components/StaleSection.tsx @@ -81,17 +81,18 @@ const StaleSection: FC = ({ This will create a GitHub issue in{' '} flagsmith/flagsmith to clean up{' '} {flag.name}. - - Cleaning up a feature flag means removing the flag checks from - code so that the code behaves as if the flag were{' '} - enabled for everyone, allowing you to then - delete the feature in Flagsmith. - - } - /> +
+ + Cleaning up a feature flag means removing the flag checks + from code so that the code behaves as if the flag were{' '} + enabled for everyone, allowing you to then + delete the feature in Flagsmith. + + } + /> +
), onYes: async () => {