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