github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/BulkApiButton.tsx (about)

     1  import { ButtonClassKey, ButtonGroup, ButtonProps } from "@material-ui/core"
     2  import { ClassNameMap } from "@material-ui/styles"
     3  import React, { useLayoutEffect, useMemo, useState } from "react"
     4  import styled from "styled-components"
     5  import { AnalyticsType, Tags } from "./analytics"
     6  import {
     7    ApiButtonToggleState,
     8    ApiButtonType,
     9    confirmingButtonGroupBorderMixin,
    10    confirmingButtonStateMixin,
    11    UIBUTTON_TOGGLE_INPUT_NAME,
    12    updateButtonStatus,
    13  } from "./ApiButton"
    14  import { ReactComponent as CloseSvg } from "./assets/svg/close.svg"
    15  import { useHudErrorContext } from "./HudErrorContext"
    16  import { InstrumentedButton } from "./instrumentedComponents"
    17  import { BulkAction } from "./OverviewTableBulkActions"
    18  import { AnimDuration, Color, Font, FontSize, SizeUnit } from "./style-helpers"
    19  import { UIButton } from "./types"
    20  
    21  /**
    22   * The BulkApiButton is used to update multiple UIButtons with a single
    23   * user action. It follows similar patterns as the core ApiButton component,
    24   * but most of the data it receives, and its styling, is different.
    25   * The BulkApiButton supports toggle and non-toggle buttons that may require
    26   * confirmation.
    27   *
    28   * In the future, it may need to be expanded to share more of the UIButton
    29   * options (like specifying an icon svg or having a form with inputs), or
    30   * it may need to support non-UIButton bulk actions.
    31   */
    32  
    33  // Types
    34  type BulkApiButtonProps = ButtonProps & {
    35    bulkAction: BulkAction
    36    buttonText: string
    37    onClickCallback?: () => void
    38    requiresConfirmation: boolean
    39    targetToggleState?: ApiButtonToggleState
    40    uiButtons: UIButton[]
    41  }
    42  
    43  type BulkApiButtonElementProps = ButtonProps & {
    44    text: string
    45    confirming: boolean
    46    disabled: boolean
    47    analyticsTags: Tags
    48    analyticsName: string
    49  }
    50  
    51  // Styles
    52  const BulkButtonElementRoot = styled(InstrumentedButton)`
    53    border: 1px solid ${Color.gray50};
    54    border-radius: 4px;
    55    background-color: ${Color.gray40};
    56    color: ${Color.white};
    57    font-family: ${Font.monospace};
    58    font-size: ${FontSize.small};
    59    padding: 0 ${SizeUnit(1 / 4)};
    60    text-transform: capitalize;
    61    transition: color ${AnimDuration.default} ease,
    62      border ${AnimDuration.default} ease;
    63  
    64    &:hover,
    65    &:active,
    66    &:focus {
    67      background-color: ${Color.gray40};
    68      color: ${Color.blue};
    69    }
    70  
    71    &.Mui-disabled {
    72      border-color: ${Color.gray50};
    73      color: ${Color.gray60};
    74    }
    75  
    76    /* Use shared styles with ApiButton */
    77    ${confirmingButtonStateMixin}
    78    ${confirmingButtonGroupBorderMixin}
    79  `
    80  
    81  const BulkButtonGroup = styled(ButtonGroup)<{ disabled?: boolean }>`
    82    ${(props) =>
    83      props.disabled &&
    84      `
    85      cursor: not-allowed;
    86    `}
    87  
    88    & + &:not(.isConfirming) {
    89      margin-left: -4px;
    90      ${BulkButtonElementRoot} {
    91        border-top-left-radius: 0;
    92        border-bottom-left-radius: 0;
    93      }
    94    }
    95  
    96    & + &.isConfirming {
    97      margin-left: 4px;
    98    }
    99  `
   100  
   101  // Helpers
   102  export function canButtonBeToggled(
   103    uiButton: UIButton,
   104    targetToggleState?: ApiButtonToggleState
   105  ) {
   106    const toggleInput = uiButton.spec?.inputs?.find(
   107      (input) => input.name === UIBUTTON_TOGGLE_INPUT_NAME
   108    )
   109  
   110    if (!toggleInput) {
   111      return false
   112    }
   113  
   114    if (!targetToggleState) {
   115      return true
   116    }
   117  
   118    const toggleValue = toggleInput.hidden?.value
   119  
   120    // A button can be toggled if it's state doesn't match the target state
   121    return toggleValue !== undefined && toggleValue !== targetToggleState
   122  }
   123  
   124  /**
   125   * A bulk button can be toggled if some UIButtons have values that don't
   126   * match the target toggle state.
   127   * ex: some buttons are off and target toggle is on => bulk button can be toggled
   128   * ex: all buttons are on and target toggle is on   => bulk button cannot be toggled
   129   * ex: all buttons are not toggle buttons           => bulk button cannot be toggled
   130   */
   131  export function canBulkButtonBeToggled(
   132    uiButtons: UIButton[],
   133    targetToggleState?: ApiButtonToggleState
   134  ) {
   135    // Bulk button cannot be toggled if there are no UIButtons
   136    if (uiButtons.length === 0) {
   137      return false
   138    }
   139  
   140    // Bulk button can always be toggled if there's no target toggle state
   141    if (!targetToggleState) {
   142      return true
   143    }
   144  
   145    const individualButtonsCanBeToggled = uiButtons.map((b) =>
   146      canButtonBeToggled(b, targetToggleState)
   147    )
   148  
   149    return individualButtonsCanBeToggled.some(
   150      (canBeToggled) => canBeToggled === true
   151    )
   152  }
   153  
   154  async function bulkUpdateButtonStatus(uiButtons: UIButton[]) {
   155    try {
   156      await Promise.all(uiButtons.map((button) => updateButtonStatus(button, {})))
   157    } catch (err) {
   158      // Expect that errors will be handled in the component caller
   159      throw err
   160    }
   161  }
   162  
   163  function BulkSubmitButton(props: BulkApiButtonElementProps) {
   164    const {
   165      analyticsName,
   166      analyticsTags,
   167      confirming,
   168      disabled,
   169      onClick,
   170      text,
   171      ...buttonProps
   172    } = props
   173  
   174    // Determine display text and accessible button label based on confirmation state
   175    const displayButtonText = confirming ? "Confirm" : text
   176    const ariaLabel = confirming ? `Confirm ${text}` : `Trigger ${text}`
   177  
   178    const tags = { ...analyticsTags }
   179    if (confirming) {
   180      tags.confirm = "true"
   181    }
   182  
   183    const isConfirmingClass = confirming ? "confirming leftButtonInGroup" : ""
   184    const classes: Partial<ClassNameMap<ButtonClassKey>> = {
   185      root: isConfirmingClass,
   186    }
   187  
   188    return (
   189      <BulkButtonElementRoot
   190        analyticsName={analyticsName}
   191        analyticsTags={tags}
   192        aria-label={ariaLabel}
   193        classes={classes}
   194        disabled={disabled}
   195        onClick={onClick}
   196        {...buttonProps}
   197      >
   198        {displayButtonText}
   199      </BulkButtonElementRoot>
   200    )
   201  }
   202  
   203  function BulkCancelButton(props: BulkApiButtonElementProps) {
   204    const {
   205      analyticsName,
   206      analyticsTags,
   207      confirming,
   208      onClick,
   209      text,
   210      ...buttonProps
   211    } = props
   212  
   213    // Don't display the cancel confirmation button if the button
   214    // group's state isn't confirming
   215    if (!confirming) {
   216      return null
   217    }
   218  
   219    const classes: Partial<ClassNameMap<ButtonClassKey>> = {
   220      root: "confirming rightButtonInGroup",
   221    }
   222  
   223    return (
   224      <BulkButtonElementRoot
   225        analyticsName={analyticsName}
   226        aria-label={`Cancel ${text}`}
   227        analyticsTags={{ confirm: "false", ...analyticsTags }}
   228        classes={classes}
   229        onClick={onClick}
   230        {...buttonProps}
   231      >
   232        <CloseSvg role="presentation" />
   233      </BulkButtonElementRoot>
   234    )
   235  }
   236  
   237  export function BulkApiButton(props: BulkApiButtonProps) {
   238    const {
   239      bulkAction,
   240      buttonText,
   241      targetToggleState,
   242      requiresConfirmation,
   243      onClickCallback,
   244      uiButtons,
   245      ...buttonProps
   246    } = props
   247  
   248    const { setError } = useHudErrorContext()
   249  
   250    const [loading, setLoading] = useState(false)
   251    const [confirming, setConfirming] = useState(false)
   252  
   253    let buttonCount = String(uiButtons.length)
   254    const analyticsTags: Tags = useMemo(() => {
   255      let tags: Tags = {
   256        component: ApiButtonType.Global,
   257        type: AnalyticsType.Grid,
   258        bulkCount: buttonCount,
   259        bulkAction,
   260      }
   261  
   262      if (targetToggleState) {
   263        // The `toggleValue` reflects the value of the buttons
   264        // when they are clicked, not their updated values
   265        tags.toggleValue =
   266          targetToggleState === ApiButtonToggleState.On
   267            ? ApiButtonToggleState.Off
   268            : ApiButtonToggleState.On
   269      }
   270  
   271      return tags
   272    }, [buttonCount, bulkAction, targetToggleState])
   273  
   274    const bulkActionDisabled = !canBulkButtonBeToggled(
   275      uiButtons,
   276      targetToggleState
   277    )
   278    const disabled = loading || bulkActionDisabled || false
   279    const buttonGroupClassName = `${disabled ? "isDisabled" : "isEnabled"} ${
   280      confirming ? "isConfirming" : ""
   281    }`
   282  
   283    // If the bulk action isn't available while the bulk button
   284    // is in a confirmation state, reset the confirmation state
   285    useLayoutEffect(() => {
   286      if (bulkActionDisabled && confirming) {
   287        setConfirming(false)
   288      }
   289    }, [bulkActionDisabled, confirming])
   290  
   291    const onClick = async () => {
   292      if (requiresConfirmation && !confirming) {
   293        setConfirming(true)
   294        return
   295      }
   296  
   297      if (confirming) {
   298        setConfirming(false)
   299      }
   300  
   301      setLoading(true)
   302  
   303      try {
   304        // If there's a target toggle state, filter out buttons that
   305        // already have that toggle state. If they're not filtered out
   306        // updating them will toggle them to an unintended state.
   307        const buttonsToUpdate = uiButtons.filter((button) =>
   308          canButtonBeToggled(button, targetToggleState)
   309        )
   310        await bulkUpdateButtonStatus(buttonsToUpdate)
   311      } catch (err) {
   312        setError(`Error triggering ${bulkAction} action: ${err}`)
   313        return
   314      } finally {
   315        setLoading(false)
   316  
   317        if (onClickCallback) {
   318          onClickCallback()
   319        }
   320      }
   321    }
   322  
   323    return (
   324      <BulkButtonGroup
   325        className={buttonGroupClassName}
   326        disableRipple={true}
   327        aria-label={buttonText}
   328        disabled={disabled}
   329      >
   330        <BulkSubmitButton
   331          analyticsName="ui.web.bulkButton"
   332          analyticsTags={analyticsTags}
   333          confirming={confirming}
   334          disabled={disabled}
   335          onClick={onClick}
   336          text={buttonText}
   337          {...buttonProps}
   338        ></BulkSubmitButton>
   339        <BulkCancelButton
   340          analyticsName="ui.web.bulkButton"
   341          analyticsTags={analyticsTags}
   342          confirming={confirming}
   343          disabled={disabled}
   344          onClick={() => setConfirming(false)}
   345          text={buttonText}
   346          {...buttonProps}
   347        />
   348      </BulkButtonGroup>
   349    )
   350  }