diff --git a/frontend/libs/console/legacy-ce/.storybook/preview.tsx b/frontend/libs/console/legacy-ce/.storybook/preview.tsx index 6bd458faa42..759f7b04e72 100644 --- a/frontend/libs/console/legacy-ce/.storybook/preview.tsx +++ b/frontend/libs/console/legacy-ce/.storybook/preview.tsx @@ -12,6 +12,7 @@ import '../src/lib/theme/tailwind.css'; import { store } from '../src/lib/store'; import '../src/lib/components/Common/Common.module.scss'; import { ToastsHub } from '../src/lib/new-components/Toasts'; +import { AlertProvider } from '../src/lib/new-components/Alert/AlertProvider'; const channel = addons.getChannel(); initialize(); @@ -74,10 +75,14 @@ export const decorators = [ Story => { document.body.classList.add('hasura-tailwind-on'); return ( -
- -
{Story()}
-
+ +
+ +
+ +
+
+
); }, ]; diff --git a/frontend/libs/console/legacy-ce/src/lib/components/App/App.js b/frontend/libs/console/legacy-ce/src/lib/components/App/App.js index 5d5749d4ea3..1535b38fc23 100644 --- a/frontend/libs/console/legacy-ce/src/lib/components/App/App.js +++ b/frontend/libs/console/legacy-ce/src/lib/components/App/App.js @@ -9,6 +9,7 @@ import ErrorBoundary from '../Error/ErrorBoundary'; import globals from '../../Globals'; import styles from './App.module.scss'; import { ToastsHub } from '../../new-components/Toasts'; +import { AlertProvider } from '../../new-components/Alert/AlertProvider'; import { theme } from '../UIKit/theme'; import { trackCustomEvent } from '../../features/Analytics'; @@ -60,19 +61,21 @@ const App = ({ -
- {connectionFailMsg} - {ongoingRequest && ( - - )} -
{children}
- -
+ +
+ {connectionFailMsg} + {ongoingRequest && ( + + )} +
{children}
+ +
+
diff --git a/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/Alert.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/Alert.stories.tsx new file mode 100644 index 00000000000..ee063f1287b --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/Alert.stories.tsx @@ -0,0 +1,622 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import { expect } from '@storybook/jest'; +import { screen, userEvent, within } from '@storybook/testing-library'; +import { useHasuraAlert } from '.'; +import { Button } from '../Button'; +import { useDestructiveAlert } from './AlertProvider'; + +export default { + title: 'components/Alert Dialog ๐Ÿงฌ', + decorators: [ + Story => ( +
{Story()}
+ ), + ], +} as ComponentMeta; + +/** + * + * Basic Alert + * + */ + +export const Alert: ComponentStory = () => { + const { hasuraAlert } = useHasuraAlert(); + return ( +
+ +
+ ); +}; +Alert.storyName = '๐Ÿงฐ Alert'; +Alert.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Some Title')).toBeInTheDocument(); +}; + +/** + * + * Basic Confirm + * + */ + +export const Confirm: ComponentStory = () => { + const { hasuraConfirm } = useHasuraAlert(); + + return ( +
+ +
+ ); +}; + +Confirm.storyName = '๐Ÿงฐ Confirm'; +Confirm.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Some Title')).toBeInTheDocument(); +}; + +/** + * + * Confirm Interaction Test + * + * - tests if user selection of confirm/cancel is set correctly + * + */ + +export const ConfirmTest: ComponentStory = () => { + const { hasuraConfirm } = useHasuraAlert(); + const [choice, setChoice] = React.useState<'cancelled' | 'confirmed'>(); + return ( +
+ +
+ Your selection: {choice ?? ''} +
+
+ ); +}; + +ConfirmTest.storyName = '๐Ÿงช Confirm'; +ConfirmTest.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Some Title')).toBeInTheDocument(); + + await userEvent.click(await screen.findByText('Ok')); + await expect(await screen.findByTestId('choice')).toHaveTextContent( + 'confirmed' + ); + + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Some Title')).toBeInTheDocument(); + + await userEvent.click(await screen.findByText('Cancel')); + await expect(await screen.findByTestId('choice')).toHaveTextContent( + 'cancelled' + ); +}; + +/** + * + * Basic Prompt + * + */ + +export const Prompt: ComponentStory = () => { + const { hasuraPrompt } = useHasuraAlert(); + const [choice, setChoice] = React.useState(''); + return ( +
+ +
Your value: {choice}
+
+ ); +}; +Prompt.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Some Title')).toBeInTheDocument(); +}; +Prompt.storyName = '๐Ÿงฐ Prompt'; + +/** + * + * Prompt Interaction Test + * + * - Tests if value that user enters, and is passed to callback matches when set to component state + * + */ +export const PromptTest: ComponentStory = () => { + const { hasuraPrompt } = useHasuraAlert(); + const [value, setValue] = React.useState(''); + return ( +
+ +
+ Your value: {value} +
+
+ ); +}; +PromptTest.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Some Title')).toBeInTheDocument(); + await userEvent.type(await screen.findByLabelText('Input Label'), 'blah'); + await userEvent.click(await screen.findByText('Ok')); + await expect(await screen.findByTestId('prompt-value')).toHaveTextContent( + 'blah' + ); +}; +PromptTest.storyName = '๐Ÿงช Prompt'; + +/** + * + * Confirm with custom text + * + */ +export const CustomText: ComponentStory = () => { + const { hasuraConfirm } = useHasuraAlert(); + + const [choice, setChoice] = React.useState(''); + return ( +
+ +
You chose: {choice}
+
+ ); +}; + +CustomText.storyName = '๐ŸŽญ Variant - Custom Button Text'; +CustomText.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Choose Wisely!')).toBeInTheDocument(); + + await expect(await screen.findByText('Good')).toBeInTheDocument(); + await expect(await screen.findByText('Evil')).toBeInTheDocument(); +}; + +/** + * + * Confirm - with destructive flag + * + */ + +export const Destructive: ComponentStory = () => { + const { hasuraConfirm } = useHasuraAlert(); + + return ( +
+ +
+ ); +}; + +Destructive.storyName = '๐ŸŽญ Variant - Destructive'; + +Destructive.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(await screen.findByText('Are you sure?')).toBeInTheDocument(); + + await expect( + screen.getByRole('button', { + name: /Ok/i, + }) + ).toHaveClass('text-red-600'); +}; +const doAsyncAction = () => { + return new Promise(res => { + setTimeout(() => { + res(); + }, 3000); + }); +}; + +/** + * + * Confirm - demonstrates Async Mode + * + */ + +export const AsyncMode: ComponentStory = () => { + const { hasuraConfirm } = useHasuraAlert(); + + return ( +
+ +
+ ); +}; + +AsyncMode.storyName = '๐Ÿช„ Async Confirm'; +AsyncMode.parameters = { + docs: { + description: { + story: `#### ๐Ÿšฆ Usage +- Use \`onCloseAsync\` instead of \`onClose\` and return a \`Promise\`. Loading spinner will show until Promise is resolved.`, + }, + }, +}; + +/** + * + * Confirm - demonstrates Async Mode w/ optional success state + * + */ + +export const AsyncModeWithSuccess: ComponentStory = () => { + const { hasuraConfirm } = useHasuraAlert(); + + return ( +
+ +
+ ); +}; + +AsyncModeWithSuccess.storyName = '๐Ÿช„ Async Confirm - with success indicator'; + +AsyncModeWithSuccess.parameters = { + docs: { + description: { + story: `#### ๐Ÿšฆ Usage +- Use \`onCloseAsync\` instead of \`onClose\` and return a \`Promise\`. Loading spinner will show until Promise is resolved. +- To enable a success indication, return an object from your Promise like this: \`{ withSuccess: true, successText: 'Saved!' }\``, + }, + }, +}; + +/** + * + * Prompt - demonstrates Async Mode w/ success state + * + */ + +export const AsyncPrompt: ComponentStory = () => { + const { hasuraPrompt } = useHasuraAlert(); + const [value, setValue] = React.useState(''); + return ( +
+ +
+ Your value: {value} +
+
+ ); +}; + +AsyncPrompt.storyName = '๐Ÿช„ Async Prompt - with success indicator'; + +/** + * + * Alert - demonstrates Async Mode w/ success state + * + */ +export const AsyncAlert: ComponentStory = () => { + const { hasuraAlert } = useHasuraAlert(); + + return ( +
+ +
+ ); +}; + +AsyncAlert.storyName = '๐Ÿช„ Async Alert - with success indicator'; + +/** + * + * Async Error Handling - demonstrates built-in error handling + * + */ +export const ErrorHandling: ComponentStory = () => { + const { hasuraAlert } = useHasuraAlert(); + + return ( +
+ +
+ ); +}; + +ErrorHandling.storyName = '๐Ÿช„ Error Handling'; + +/** + * + * Async Error Handling - demonstrates built-in error handling + * + */ +export const AsyncErrorHandling: ComponentStory = () => { + const { hasuraAlert } = useHasuraAlert(); + + return ( +
+ +
+ ); +}; + +AsyncErrorHandling.storyName = '๐Ÿช„ Error Handling - Async Mode'; + +/** + * + * useDestructiveConfirm - demonstrates wrapper function + * + */ +export const DestructiveConfirm: ComponentStory = () => { + const { destructiveConfirm } = useDestructiveAlert(); + + return ( +
+ +
+ ); +}; + +DestructiveConfirm.storyName = '๐Ÿ’ฅ Destructive Confirm'; + +DestructiveConfirm.parameters = { + docs: { + description: { + story: `#### ๐Ÿšฆ Usage +- When needing a confirm to delete a resource, this hook standardizes the UI/UX and language.`, + }, + }, +}; + +/** + * + * useDestructivePrompt - demonstrates wrapper function + * + */ +export const DestructivePrompt: ComponentStory = () => { + const { destructivePrompt } = useDestructiveAlert(); + + return ( +
+ +
+ ); +}; + +DestructivePrompt.storyName = '๐Ÿ’ฅ Destructive Prompt'; +DestructivePrompt.parameters = { + docs: { + description: { + story: `#### ๐Ÿšฆ Usage +- When needing a prompt to delete a resource, this hook standardizes the UI/UX and language.`, + }, + }, +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/Alert.tsx b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/Alert.tsx new file mode 100644 index 00000000000..2fdf083f4b7 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/Alert.tsx @@ -0,0 +1,148 @@ +import * as AlertDialog from '@radix-ui/react-alert-dialog'; +import clsx from 'clsx'; +import React from 'react'; +import { BsCheckCircleFill } from 'react-icons/bs'; +import useUpdateEffect from '../../hooks/useUpdateEffect'; +import { sanitizeGraphQLFieldNames } from '../../utils'; +import { Button } from '../Button'; +import { Input } from '../Form'; +import { AlertComponentProps } from './component-types'; + +const buttonMode = (props: AlertComponentProps) => { + return props.success + ? 'success' + : props.mode !== 'alert' && props.destructive + ? 'destructive' + : 'primary'; +}; + +function Buttons(props: AlertComponentProps & { promptValue: string }) { + const { + confirmText, + onClose, + onCloseAsync, + promptValue, + mode, + isLoading, + success, + } = props; + + return ( +
+ {(mode === 'confirm' || mode === 'prompt') && !success && ( + + {/* CANCEL BUTTON: */} + + + )} + + {/* CONFIRM BUTTON: */} + + +
+ ); +} + +export const Alert = (props: AlertComponentProps) => { + const { title, message, mode, open, isLoading, success } = props; + // we only want to apply an open prop if it's supplied as true/false, but NOT undefined. which likely means the trigger element is in use. + const openProps = React.useMemo( + () => ({ + ...(typeof open !== 'undefined' && { open: open }), + }), + [open] + ); + + // makes sure the prompt values are cleared after close + useUpdateEffect(() => { + if (open === false) { + setPromptValue(''); + } + }, [open]); + + const [promptValue, setPromptValue] = React.useState(''); + return ( + + + + + + {title} + + + {message} + + {mode === 'prompt' && ( +
+ {!!props.promptLabel && ( + + )} + { + let value = e.target.value; + + if (props.sanitizeGraphQL) { + value = sanitizeGraphQLFieldNames(e.target.value); + } + + setPromptValue(value); + }} + /> +
+ )} + +
+
+
+ ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/AlertProvider.tsx b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/AlertProvider.tsx new file mode 100644 index 00000000000..441f3e2a771 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/AlertProvider.tsx @@ -0,0 +1,294 @@ +import capitalize from 'lodash/capitalize'; +import React, { createContext } from 'react'; +import { hasuraToast } from '../Toasts'; +import { Alert } from './Alert'; +import { AlertComponentProps } from './component-types'; +import { + AlertMode, + AlertParams, + ConfirmParams, + DismissAlertParams, + Params, + PromptParams, +} from './types'; + +interface AlertContextType { + hasuraAlert: (props: Params<'alert'>) => void; + hasuraConfirm: (props: Params<'confirm'>) => void; + hasuraPrompt: (props: Params<'prompt'>) => void; +} + +const AlertContext = createContext({ + hasuraAlert: async () => { + // init + }, + hasuraConfirm: async () => { + // init + }, + hasuraPrompt: async () => { + // init + }, +}); + +export const useHasuraAlert = () => { + const ctx = React.useContext(AlertContext); + + return ctx; +}; + +export const AlertProvider: React.FC = ({ children }) => { + const [showAlert, setShowAlert] = React.useState(false); + + const [componentProps, setComponentProps] = + React.useState(null); + const [loading, setLoading] = React.useState(false); + const [success, setSuccess] = React.useState(false); + const [successText, setSuccessText] = React.useState(null); + + const closeAndCleanup = React.useCallback(() => { + setShowAlert(false); + + //reset success state + setSuccess(false); + setSuccessText(null); + setLoading(false); + setComponentProps(null); + }, []); + + const dismissAlert = React.useCallback( + (props?: DismissAlertParams) => { + const { withSuccess, successText, successDelay = 1500 } = props ?? {}; + + if (withSuccess) { + if (successText) { + setSuccessText(successText); + } + + //show a success state + setSuccess(true); + + setTimeout(() => { + closeAndCleanup(); + }, successDelay); + } else { + closeAndCleanup(); + } + }, + [closeAndCleanup] + ); + + // ideally errors should be handled elsewhere + // this is a failsafe to prevent an error state from freezing an alert on screen + const handleUnhandledError = React.useCallback( + (e: unknown, mode: AlertMode) => { + setLoading(false); + closeAndCleanup(); + hasuraToast({ + type: 'error', + title: `Unhandled error occurred while executing ${mode} dialog.`, + message: e?.toString() ?? JSON.stringify(e), + }); + }, + [closeAndCleanup] + ); + + // function that will fire an alert: + const fireAlert = React.useCallback( + (params: AlertParams | ConfirmParams | PromptParams) => { + // just in case so we don't display a broken alert + if (!params) throw Error('Invalid state passed to fireAlert()'); + + const extendedProps = { ...params }; + + if ( + extendedProps?.onClose || + //handle when alert has no onClose or onCloseAsync passed + (params.mode === 'alert' && !params?.onClose && !params?.onCloseAsync) + ) { + //SYNC MODE: + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extendedProps.onClose = async (args: any) => { + closeAndCleanup(); + // call original callback + try { + params.onClose?.(args); + } catch (e) { + handleUnhandledError(e, params.mode); + } + }; + } else { + //ASYNC MODE: + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extendedProps.onCloseAsync = async (args: any) => { + setLoading(true); + + try { + const result = await params.onCloseAsync?.(args); + + setLoading(false); + + dismissAlert({ + withSuccess: result?.withSuccess, + successText: result?.successText, + }); + } catch (e) { + handleUnhandledError(e, params.mode); + } + }; + } + + // this is to prevent an issue where if moving from other radix components that involve overlays, it will get confused and leave the overlay pointer-events:none property on the body. + // the timeout prevents overlapping css calls to modify the pointer events on the body + setTimeout(() => { + setComponentProps({ ...extendedProps }); + setShowAlert(true); + }, 0); + }, + [closeAndCleanup, dismissAlert] + ); + + return ( + fireAlert({ ...params, mode: 'alert' }), + hasuraConfirm: params => fireAlert({ ...params, mode: 'confirm' }), + hasuraPrompt: params => fireAlert({ ...params, mode: 'prompt' }), + }} + > + {children} + { + // init + }, + })} + isLoading={loading} + open={showAlert} + success={success} + confirmText={ + success && !!successText ? successText : componentProps?.confirmText + } + /> + + ); +}; + +/** + * + * These are wrapper hooks that simplify and standardize the API/UI/UX for remove/delete operations + * + */ + +const useDestructiveConfirm = () => { + const { hasuraConfirm } = useHasuraAlert(); + return ({ + resourceName, + resourceType, + destroyTerm = 'remove', + onConfirm, + }: { + resourceName: string; + resourceType: string; + destroyTerm?: 'delete' | 'remove'; + onConfirm: () => Promise; + }) => { + if (!onConfirm) throw new Error('onCloseAsync() is required.'); + + hasuraConfirm({ + title: `${capitalize(destroyTerm)} ${resourceType}`, + message: ( +
+ Are you sure you want to {destroyTerm} {resourceType}:{' '} + {resourceName}? +
+ ), + confirmText: 'Remove', + destructive: true, + onCloseAsync: async ({ confirmed }) => { + if (!confirmed) return; + + const success = await onConfirm(); + + if (success) { + return { + withSuccess: success, + successText: `${capitalize(destroyTerm)}d ${resourceName}`, + }; + } else { + return; + } + }, + }); + }; +}; + +const useDestructivePrompt = () => { + const { hasuraPrompt } = useHasuraAlert(); + + return ({ + resourceName, + resourceType, + destroyTerm = 'remove', + onConfirm, + }: { + resourceName: string; + resourceType: string; + destroyTerm?: 'delete' | 'remove'; + onConfirm: () => Promise; + }) => { + if (!onConfirm) throw new Error('onCloseAsync() is required.'); + + hasuraPrompt({ + title: `${capitalize(destroyTerm)} ${resourceType}`, + message: ( +
+ Are you sure you want to {destroyTerm} {resourceType}:{' '} + {resourceName}? +
+ ), + confirmText: 'Remove', + destructive: true, + promptLabel: ( +
+ Type {resourceName} to confirm this action. +
+ ), + onCloseAsync: async result => { + if (!result.confirmed) return; + if (result.promptValue !== resourceName) { + hasuraToast({ + type: 'error', + title: `Entry Not Matching`, + message: `Your entry "${result.promptValue}" does not match "${resourceName}".`, + }); + return; + } else { + const success = await onConfirm(); + + if (success) { + return { + withSuccess: success, + successText: `${capitalize(destroyTerm)}d ${resourceName}`, + }; + } else { + return; + } + } + }, + }); + }; +}; + +export const useDestructiveAlert = () => { + const destructiveConfirm = useDestructiveConfirm(); + const destructivePrompt = useDestructivePrompt(); + return { + destructiveConfirm, + destructivePrompt, + }; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/component-types.ts b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/component-types.ts new file mode 100644 index 00000000000..1d5cdd91ec5 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/component-types.ts @@ -0,0 +1,19 @@ +import { + AlertMode, + AlertParams, + CommonAlertBase, + ConfirmParams, + PromptParams, +} from './types'; + +export type AlertComponentProps = AlertProps | ConfirmProps | PromptProps; + +type AlertProps = PropsBase<'alert'> & AlertParams; +type ConfirmProps = PropsBase<'confirm'> & ConfirmParams; +type PromptProps = PropsBase<'prompt'> & PromptParams; + +export type PropsBase = CommonAlertBase & { + open?: boolean; + isLoading?: boolean; + success?: boolean; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/index.ts b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/index.ts new file mode 100644 index 00000000000..92d82cf83be --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/index.ts @@ -0,0 +1 @@ +export { useHasuraAlert, useDestructiveAlert } from './AlertProvider'; diff --git a/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/types.ts b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/types.ts new file mode 100644 index 00000000000..08e71a947a2 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/new-components/Alert/types.ts @@ -0,0 +1,87 @@ +// see: https://stackoverflow.com/questions/57103834/typescript-omit-a-property-from-all-interfaces-in-a-union-but-keep-the-union-s +// TS docs: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types +type DistributiveOmit = T extends any + ? Omit + : never; + +export type AlertMode = 'alert' | 'confirm' | 'prompt'; + +export type onCloseAsyncPromiseReturn = DismissAlertParams | void; + +export type CommonAlertBase = { + title: string; + message: React.ReactNode; + mode: Mode; + confirmText?: string; +}; + +export type ConfirmableBase = { + cancelText?: string; + destructive?: boolean; +}; + +/** + * + * For all alert modes types, onClose/onCloseAsync are added with an XOR (exclusive or) -- that is, only one or the other can be present on the type + * + */ + +export type AlertParams = CommonAlertBase<'alert'> & + ( + | { onClose?: () => void; onCloseAsync?: never } + | { + onClose?: never; + onCloseAsync?: () => Promise; + } + ); + +export type ConfirmParams = CommonAlertBase<'confirm'> & + ConfirmableBase & + ( + | { onClose: (args: onCloseProps) => void; onCloseAsync?: never } + | { + onClose?: never; + onCloseAsync: ( + args: onCloseProps + ) => Promise; + } + ); + +export type PromptParams = CommonAlertBase<'prompt'> & + ConfirmableBase & { + promptLabel?: React.ReactNode; + promptPlaceholder?: string; + sanitizeGraphQL?: boolean; + } & ( + | { onClose: (args: onClosePromptProps) => void; onCloseAsync?: never } + | { + onClose?: never; + onCloseAsync: ( + args: onClosePromptProps + ) => Promise; + } + ); + +export type Params = DistributiveOmit< + Extract, + 'mode' +>; + +// alert/confirm +export type onCloseProps = { confirmed: boolean }; + +// prompt: +export type onClosePromptProps = + | { + confirmed: true; + promptValue: string; + } + | { + confirmed: false; + }; + +export type DismissAlertParams = { + withSuccess?: boolean; + successText?: string; + successDelay?: number; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/new-components/Button/Button.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/new-components/Button/Button.stories.tsx index 393516355cc..2b68a035c97 100644 --- a/frontend/libs/console/legacy-ce/src/lib/new-components/Button/Button.stories.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/new-components/Button/Button.stories.tsx @@ -55,6 +55,9 @@ export const VariantMode: ComponentStory = () => ( + ); VariantMode.storyName = '๐ŸŽญ Variant - Mode'; @@ -144,6 +147,14 @@ export const StateLoading: ComponentStory = () => ( > Loading +
+