From 39bfac245cb5b2e53db84d48e2a3d3149ddfc795 Mon Sep 17 00:00:00 2001 From: Ricky Smith Date: Mon, 30 Dec 2024 10:49:26 +0100 Subject: [PATCH] Implement feedback behavior of button --- .../admin/admin/src/common/buttons/Button.tsx | 150 ++++++++++++++---- packages/admin/admin/src/index.ts | 2 +- .../docs/components/Button/Button.stories.tsx | 133 ++++++++++++++-- 3 files changed, 243 insertions(+), 42 deletions(-) diff --git a/packages/admin/admin/src/common/buttons/Button.tsx b/packages/admin/admin/src/common/buttons/Button.tsx index 76c81f58ff..b138ca4ba3 100644 --- a/packages/admin/admin/src/common/buttons/Button.tsx +++ b/packages/admin/admin/src/common/buttons/Button.tsx @@ -1,3 +1,4 @@ +import { ThreeDotSaving } from "@comet/admin-icons"; import { Breakpoint, Button as MuiButton, @@ -5,24 +6,27 @@ import { ComponentsOverrides, css, Theme, - Tooltip, useTheme, useThemeProps, } from "@mui/material"; -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; +import { FormattedMessage } from "react-intl"; import { createComponentSlot } from "../../helpers/createComponentSlot"; import { ThemedComponentBaseProps } from "../../helpers/ThemedComponentBaseProps"; import { useWindowSize } from "../../helpers/useWindowSize"; +import { Tooltip } from "../Tooltip"; -type StateClassKey = "usingResponsiveBehavior"; -type SlotClassKey = "root" | "mobileTooltip"; +type StateClassKey = "usingResponsiveBehavior" | "feedbackStateLoading" | "feedbackStateSuccess" | "feedbackStateError"; +type SlotClassKey = "root" | "mobileTooltip" | "successFeedback" | "errorFeedback"; export type ButtonClassKey = StateClassKey | SlotClassKey; type ButtonThemeProps = ThemedComponentBaseProps<{ root: typeof MuiButton; mobileTooltip: typeof Tooltip; + successFeedback: typeof Tooltip; + errorFeedback: typeof Tooltip; }>; type ResponsiveBehaviorSettings = { @@ -35,39 +39,31 @@ type ResponsiveBehaviorOptions = Omit, "mobi }; type FeedbackBehaviorOptions = { + state?: ButtonFeedbackState | null; successMessage?: ReactNode; errorMessage?: ReactNode; - loading?: boolean; - hasError?: boolean; - tooltipDuration?: { - success?: number; - error?: number; - }; + successDuration?: number; + errorDuration?: number; }; +export type ButtonFeedbackState = "none" | "loading" | "success" | "error"; + type OwnerState = { usingResponsiveBehavior: boolean; + feedbackState: ButtonFeedbackState; }; -/** - * TODO: - * - Restrice imports from MuiButton - * - Consider if we should also use this for IconButtons (and restrict imports from MuiIconButton) - */ - type CustomButtonProps = { responsiveBehavior?: boolean | ResponsiveBehaviorOptions; - - // TODO: Implement feedback on click behavior - // TODO: Figure out if we only need the controlled or uncontrolled version feedbackBehavior?: boolean | FeedbackBehaviorOptions; - iconMapping?: { loading?: ReactNode; }; }; -export type ButtonProps = CustomButtonProps & ButtonThemeProps & MuiButtonProps; +type OnClick = ((event: React.MouseEvent) => void) | ((event: React.MouseEvent) => Promise); + +export type ButtonProps = CustomButtonProps & ButtonThemeProps & MuiButtonProps & { onClick?: OnClick }; const getResponsiveBehaviorSettings = (propValue: ButtonProps["responsiveBehavior"], startIcon: ReactNode, endIcon: ReactNode) => { let settings: ResponsiveBehaviorSettings = { @@ -93,8 +89,26 @@ const getResponsiveBehaviorSettings = (propValue: ButtonProps["responsiveBehavio return settings; }; +const defaultFeedbackBehaviorSettings = { + successMessage: , + errorMessage: , + successDuration: 2000, + errorDuration: 5000, + state: null, +}; + export const Button = (props: ButtonProps) => { - const { slotProps, responsiveBehavior, feedbackBehavior, children, startIcon, endIcon, ...restProps } = useThemeProps({ + const { + iconMapping = {}, + slotProps, + responsiveBehavior, + feedbackBehavior, + children, + onClick, + startIcon, + endIcon, + ...restProps + } = useThemeProps({ props, name: "CometAdminButton", }); @@ -102,18 +116,58 @@ export const Button = (props: ButtonProps) => { const windowSize = useWindowSize(); const theme = useTheme(); + const { loading: loadingIcon = } = iconMapping; + const responsiveBehaviorSettings = getResponsiveBehaviorSettings(responsiveBehavior, startIcon, endIcon); const usingResponsiveBehavior = Boolean(responsiveBehavior) && windowSize.width < theme.breakpoints.values[responsiveBehaviorSettings.breakpoint]; + const [uncontrolledFeedbackState, setUncontrolledFeedbackState] = useState("none"); + const feedbackBehaviorSettings: Required = + typeof feedbackBehavior === "object" ? { ...defaultFeedbackBehaviorSettings, ...feedbackBehavior } : defaultFeedbackBehaviorSettings; + const feedbackStateIsControlledByProp = feedbackBehaviorSettings.state !== null; + const feedbackState = feedbackBehaviorSettings.state ?? uncontrolledFeedbackState; + + const handleClick: OnClick = async (event) => { + if (feedbackStateIsControlledByProp) { + onClick?.(event); + return; + } + + try { + setUncontrolledFeedbackState("loading"); + await onClick?.(event); + setUncontrolledFeedbackState("success"); + setTimeout(() => { + setUncontrolledFeedbackState("none"); + }, feedbackBehaviorSettings.successDuration); + } catch (error) { + setUncontrolledFeedbackState("error"); + setTimeout(() => { + setUncontrolledFeedbackState("none"); + }, feedbackBehaviorSettings.errorDuration); + } + }; + + const loadingFeedbackStateProps = + feedbackState === "loading" + ? { + disabled: true, + startIcon: loadingIcon, + } + : {}; + const ownerState: OwnerState = { usingResponsiveBehavior, + feedbackState, }; const buttonNode = ( @@ -121,28 +175,50 @@ export const Button = (props: ButtonProps) => { ); - if (usingResponsiveBehavior) { - return ( - - {buttonNode} - - ); - } + const buttonNodeWithResponsiveBehavior = usingResponsiveBehavior ? ( + + {buttonNode} + + ) : ( + buttonNode + ); - return buttonNode; + return ( + + + {buttonNodeWithResponsiveBehavior} + + + ); }; const Root = createComponentSlot(MuiButton)({ componentName: "Button", slotName: "root", classesResolver(ownerState) { - return [ownerState.usingResponsiveBehavior && "usingResponsiveBehavior"]; + return [ + ownerState.usingResponsiveBehavior && "usingResponsiveBehavior", + ownerState.feedbackState === "loading" && "feedbackStateLoading", + ownerState.feedbackState === "success" && "feedbackStateSuccess", + ownerState.feedbackState === "error" && "feedbackStateError", + ]; }, })( ({ ownerState }) => css` ${ownerState.usingResponsiveBehavior && css` - // TODO: Should the size be smaller than in the standard button design? Like the 'ToolbarActionButton' min-width: 0; `} `, @@ -153,6 +229,16 @@ const MobileTooltip = createComponentSlot(Tooltip)({ slotName: "mobileTooltip", })(); +const SuccessFeedback = createComponentSlot(Tooltip)({ + componentName: "Button", + slotName: "successFeedback", +})(); + +const ErrorFeedback = createComponentSlot(Tooltip)({ + componentName: "Button", + slotName: "errorFeedback", +})(); + declare module "@mui/material/styles" { interface ComponentNameToClassKey { CometAdminButton: ButtonClassKey; diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 8c301464d8..7b2c266af8 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -10,7 +10,7 @@ export { AppHeaderMenuButton, AppHeaderMenuButtonClassKey, AppHeaderMenuButtonPr export { buildCreateRestMutation, buildDeleteRestMutation, buildUpdateRestMutation } from "./buildRestMutation"; export { readClipboardText } from "./clipboard/readClipboardText"; export { writeClipboardText } from "./clipboard/writeClipboardText"; -export { Button, ButtonClassKey, ButtonProps } from "./common/buttons/Button"; +export { Button, ButtonClassKey, ButtonFeedbackState, ButtonProps } from "./common/buttons/Button"; export { CancelButton, CancelButtonClassKey, CancelButtonProps } from "./common/buttons/cancel/CancelButton"; export { ClearInputButton, ClearInputButtonClassKey, ClearInputButtonProps } from "./common/buttons/clearinput/ClearInputButton"; export { CopyToClipboardButton, CopyToClipboardButtonClassKey, CopyToClipboardButtonProps } from "./common/buttons/CopyToClipboardButton"; diff --git a/storybook/src/docs/components/Button/Button.stories.tsx b/storybook/src/docs/components/Button/Button.stories.tsx index cfb3272c36..19d098d82a 100644 --- a/storybook/src/docs/components/Button/Button.stories.tsx +++ b/storybook/src/docs/components/Button/Button.stories.tsx @@ -1,7 +1,7 @@ -import { Button, FeedbackButton, ToolbarActionButton } from "@comet/admin"; +import { Button, ButtonFeedbackState, FeedbackButton, ToolbarActionButton } from "@comet/admin"; import { Add, ArrowRight, Favorite } from "@comet/admin-icons"; -import { Box, Card, CardContent, CardHeader, Chip, Stack, SxProps, Theme } from "@mui/material"; -import { Children, cloneElement, ReactElement, ReactNode } from "react"; +import { Box, Card, CardContent, CardHeader, Chip, Stack, SxProps, Theme, Typography } from "@mui/material"; +import { Children, cloneElement, ReactElement, ReactNode, useState } from "react"; export default { title: "Docs/Components/Button", @@ -287,12 +287,24 @@ export const ResponsiveBehavior = { export const FeedbackBehavior = { // TODO render: () => { + const [firstButtonLoading, setFirstButtonLoading] = useState(false); + const [secondButtonLoading, setSecondButtonLoading] = useState(false); + + const [firstButtonHasErrors, setFirstButtonHasErrors] = useState(false); + const [secondButtonHasErrors, setSecondButtonHasErrors] = useState(false); + + const [thirdButtonState, setThirdButtonState] = useState("none"); + const [fourthButtonState, setFourthButtonState] = useState("none"); + return ( - + + Uncontrolled (feedback state depends on the promise) + + } onClick={() => { @@ -309,8 +321,51 @@ export const FeedbackBehavior = { > This will fail + + + Controlled (feedback state depends on the props) + + } + onClick={() => { + setFirstButtonLoading(true); + + setTimeout(() => { + setFirstButtonLoading(false); + setFirstButtonHasErrors(false); + }, 1000); + }} + > + This will succeed + + } + onClick={() => { + setSecondButtonLoading(true); + + setTimeout(() => { + setSecondButtonLoading(false); + setSecondButtonHasErrors(true); + + setTimeout(() => { + setSecondButtonHasErrors(false); + }, 1000); + }, 1000); + }} + > + This will fail + + + + With custom messages and no icon + + + { return new Promise((resolve) => setTimeout(resolve, 500)); }} @@ -320,7 +375,6 @@ export const FeedbackBehavior = { Custom message (succeeds) } onClick={() => { return new Promise((_, reject) => setTimeout(reject, 500)); }} @@ -329,15 +383,19 @@ export const FeedbackBehavior = { > Custom message (fails) - + - + + Uncontrolled (feedback state depends on the promise) + + + + + Controlled (feedback state depends on the props) + + + + + + With custom messages and no icon + + + - +