github.com/tilt-dev/tilt@v0.36.0/web/src/ApiButton.tsx (about)

     1  import {
     2    Button,
     3    ButtonClassKey,
     4    ButtonGroup,
     5    ButtonProps,
     6    Dialog,
     7    DialogActions,
     8    DialogContent,
     9    DialogTitle,
    10    FormControlLabel,
    11    Icon,
    12    IconButton,
    13    InputLabel,
    14    MenuItem,
    15    Select,
    16    SvgIcon,
    17  } from "@material-ui/core"
    18  import { Close as CloseIcon } from "@material-ui/icons"
    19  import { ClassNameMap } from "@material-ui/styles"
    20  import moment from "moment"
    21  import { useSnackbar } from "notistack"
    22  import React, {
    23    PropsWithChildren,
    24    useLayoutEffect,
    25    useMemo,
    26    useRef,
    27    useState,
    28  } from "react"
    29  import { convertFromNode, convertFromString } from "react-from-dom"
    30  import { Link } from "react-router-dom"
    31  import styled from "styled-components"
    32  import { annotations } from "./annotations"
    33  import { ReactComponent as CloseSvg } from "./assets/svg/close.svg"
    34  import { usePersistentState } from "./BrowserStorage"
    35  import { useHudErrorContext } from "./HudErrorContext"
    36  import {
    37    InstrumentedButton,
    38    InstrumentedCheckbox,
    39    InstrumentedTextField,
    40  } from "./instrumentedComponents"
    41  import { usePathBuilder } from "./PathBuilder"
    42  import {
    43    AnimDuration,
    44    Color,
    45    Font,
    46    FontSize,
    47    SizeUnit,
    48    ZIndex,
    49  } from "./style-helpers"
    50  import { apiTimeFormat, tiltApiPut } from "./tiltApi"
    51  import { UIButton, UIInputSpec, UIInputStatus } from "./types"
    52  
    53  /**
    54   * Note on nomenclature: both `ApiButton` and `UIButton` are used to refer to
    55   * custom action buttons here. On the Tilt backend, these are generally called
    56   * `UIButton`s, but to avoid confusion on the frontend, (where there are many
    57   * UI buttons,) they're generally called `ApiButton`s.
    58   */
    59  
    60  // Types
    61  type ApiButtonProps = ButtonProps & {
    62    className?: string
    63    uiButton: UIButton
    64  }
    65  
    66  type ApiIconProps = { iconName?: string; iconSVG?: string }
    67  
    68  type ApiButtonInputProps = {
    69    spec: UIInputSpec
    70    status: UIInputStatus | undefined
    71    value: any | undefined
    72    setValue: (name: string, value: any) => void
    73  }
    74  
    75  type ApiButtonElementProps = ButtonProps & {
    76    text: string
    77    confirming: boolean
    78    disabled: boolean
    79    iconName?: string
    80    iconSVG?: string
    81  }
    82  
    83  // UIButtons for a location, sorted into types
    84  export type ButtonSet = {
    85    default: UIButton[]
    86    toggleDisable?: UIButton
    87    stopBuild?: UIButton
    88  }
    89  
    90  function newButtonSet(): ButtonSet {
    91    return { default: [] }
    92  }
    93  
    94  export enum ApiButtonType {
    95    Global = "Global",
    96    Resource = "Resource",
    97  }
    98  
    99  export enum ApiButtonToggleState {
   100    On = "on",
   101    Off = "off",
   102  }
   103  
   104  // Constants
   105  export const UIBUTTON_SPEC_HASH = "uibuttonspec-hash"
   106  export const UIBUTTON_ANNOTATION_TYPE = "tilt.dev/uibutton-type"
   107  export const UIBUTTON_GLOBAL_COMPONENT_ID = "nav"
   108  export const UIBUTTON_TOGGLE_DISABLE_TYPE = "DisableToggle"
   109  export const UIBUTTON_TOGGLE_INPUT_NAME = "action"
   110  export const UIBUTTON_STOP_BUILD_TYPE = "StopBuild"
   111  
   112  // Styles
   113  const ApiButtonFormRoot = styled.div`
   114    z-index: ${ZIndex.ApiButton};
   115  `
   116  const ApiIconRoot = styled.span``
   117  export const ApiButtonLabel = styled.span``
   118  // MUI makes it tricky to get cursor: not-allowed on disabled buttons
   119  // https://material-ui.com/components/buttons/#cursor-not-allowed
   120  export const ApiButtonRoot = styled(ButtonGroup)<{ disabled?: boolean }>`
   121    ${(props) =>
   122      props.disabled &&
   123      `
   124      cursor: not-allowed;
   125    `}
   126    ${ApiIconRoot} + ${ApiButtonLabel} {
   127      margin-left: ${SizeUnit(0.25)};
   128    }
   129  `
   130  export const LogLink = styled(Link)`
   131    font-size: ${FontSize.smallest};
   132    padding-left: ${SizeUnit(0.5)};
   133  `
   134  
   135  export const confirmingButtonStateMixin = `
   136  &.confirming {
   137    background-color: ${Color.red};
   138    border-color: ${Color.gray30};
   139    color: ${Color.black};
   140    height: 32px;
   141    margin: 12px 0 12px 0;
   142  
   143    &:hover,
   144    &:active,
   145    &:focus {
   146      background-color: ${Color.red};
   147      border-color: ${Color.redLight};
   148      color: ${Color.black};
   149    }
   150  
   151    .fillStd {
   152      fill: ${Color.black} !important; /* TODO (lizz): find this style source! */
   153    }
   154  }
   155  `
   156  
   157  /* Manually manage the border that both left and right
   158   * buttons share on the edge between them, so border
   159   * color changes work as expected
   160   */
   161  export const confirmingButtonGroupBorderMixin = `
   162  &.leftButtonInGroup {
   163    border-right: 0;
   164  
   165    &:active + .rightButtonInGroup,
   166    &:focus + .rightButtonInGroup,
   167    &:hover + .rightButtonInGroup {
   168      border-left-color: ${Color.redLight};
   169    }
   170  }
   171  `
   172  
   173  const ApiButtonElementRoot = styled(InstrumentedButton)`
   174    ${confirmingButtonStateMixin}
   175    ${confirmingButtonGroupBorderMixin}
   176  `
   177  
   178  const inputLabelMixin = `
   179  font-family: ${Font.monospace};
   180  font-size: ${FontSize.small};
   181  color: ${Color.gray10};
   182  `
   183  
   184  const ApiButtonInputLabel = styled(InputLabel)`
   185    ${inputLabelMixin}
   186    margin-top: ${SizeUnit(1 / 2)};
   187    margin-bottom: ${SizeUnit(1 / 4)};
   188  `
   189  
   190  const ApiButtonInputTextField = styled(InstrumentedTextField)`
   191    margin-bottom: ${SizeUnit(1 / 2)};
   192  
   193    .MuiOutlinedInput-root {
   194      background-color: ${Color.offWhite};
   195    }
   196  
   197    .MuiOutlinedInput-input {
   198      ${inputLabelMixin}
   199      border: 1px solid ${Color.gray70};
   200      border-radius: ${SizeUnit(0.125)};
   201      transition: border-color ${AnimDuration.default} ease;
   202      padding: ${SizeUnit(0.2)} ${SizeUnit(0.4)};
   203  
   204      &:hover {
   205        border-color: ${Color.gray40};
   206      }
   207  
   208      &:focus,
   209      &:active {
   210        border: 1px solid ${Color.gray20};
   211      }
   212    }
   213  `
   214  
   215  const ApiButtonInputFormControlLabel = styled(FormControlLabel)`
   216    ${inputLabelMixin}
   217    margin-left: unset;
   218    gap: ${SizeUnit(0.25)};
   219  `
   220  
   221  const ApiButtonInputCheckbox = styled(InstrumentedCheckbox)`
   222    &.MuiCheckbox-root,
   223    &.Mui-checked {
   224      color: ${Color.gray40};
   225    }
   226  `
   227  
   228  // Styled components for the input modal dialog
   229  const StyledDialog = styled(Dialog)`
   230    .MuiDialog-paper {
   231      min-width: 480px;
   232      max-width: 600px;
   233    }
   234  `
   235  
   236  const StyledDialogTitle = styled(DialogTitle)`
   237    display: flex;
   238    justify-content: space-between;
   239    align-items: center;
   240    font-family: ${Font.monospace};
   241    font-size: ${FontSize.default};
   242    background-color: ${Color.grayLightest};
   243    border-bottom: 1px solid ${Color.gray50};
   244  
   245    h2 {
   246      margin: 0;
   247      font-weight: normal;
   248      color: ${Color.gray10};
   249    }
   250  `
   251  
   252  const StyledDialogCloseButton = styled(IconButton)`
   253    position: absolute;
   254    top: 16px;
   255    right: 16px;
   256  `
   257  
   258  const StyledDialogContent = styled(DialogContent)`
   259    padding: 24px;
   260    background-color: white;
   261  `
   262  
   263  const StyledDialogActions = styled(DialogActions)`
   264    padding: 16px 24px;
   265    background-color: ${Color.grayLightest};
   266    border-top: 1px solid ${Color.gray50};
   267    gap: 12px;
   268  `
   269  
   270  const CancelButton = styled(Button)`
   271    color: ${Color.gray40};
   272    border: 1px solid ${Color.gray50};
   273  
   274    &:hover {
   275      background-color: ${Color.grayLightest};
   276      border-color: ${Color.gray40};
   277    }
   278  `
   279  
   280  const ConfirmButton = styled(Button)`
   281    background-color: ${Color.green};
   282    color: white;
   283  
   284    &:hover {
   285      background-color: ${Color.greenLight};
   286    }
   287  
   288    &:disabled {
   289      background-color: ${Color.gray50};
   290      color: ${Color.gray40};
   291    }
   292  `
   293  
   294  function buttonType(b: UIButton): string {
   295    return annotations(b)[UIBUTTON_ANNOTATION_TYPE]
   296  }
   297  
   298  const svgElement = (src: string): React.ReactElement => {
   299    const node = convertFromString(src, {
   300      selector: "svg",
   301      type: "image/svg+xml",
   302      nodeOnly: true,
   303    }) as SVGSVGElement
   304    return convertFromNode(node) as React.ReactElement
   305  }
   306  
   307  function ApiButtonInput(props: ApiButtonInputProps) {
   308    if (props.spec.text) {
   309      return (
   310        <>
   311          <ApiButtonInputLabel htmlFor={props.spec.name}>
   312            {props.spec.label ?? props.spec.name}
   313          </ApiButtonInputLabel>
   314          <ApiButtonInputTextField
   315            id={props.spec.name}
   316            placeholder={props.spec.text?.placeholder}
   317            value={props.value ?? props.spec.text?.defaultValue ?? ""}
   318            onChange={(e) => props.setValue(props.spec.name!, e.target.value)}
   319            variant="outlined"
   320            fullWidth
   321          />
   322        </>
   323      )
   324    } else if (props.spec.bool) {
   325      const isChecked = props.value ?? props.spec.bool.defaultValue ?? false
   326      return (
   327        <ApiButtonInputFormControlLabel
   328          control={
   329            <ApiButtonInputCheckbox id={props.spec.name} checked={isChecked} />
   330          }
   331          label={props.spec.label ?? props.spec.name}
   332          onChange={(_, checked) => props.setValue(props.spec.name!, checked)}
   333        />
   334      )
   335    } else if (props.spec.hidden) {
   336      return null
   337    } else if (props.spec.choice) {
   338      // @ts-ignore
   339      const currentChoice = props.value ?? props.spec.choice.choices?.at(0)
   340      const menuItems = []
   341      // @ts-ignore
   342      for (let choice of props.spec.choice?.choices) {
   343        menuItems.push(
   344          <MenuItem key={choice} value={choice}>
   345            {choice}
   346          </MenuItem>
   347        )
   348      }
   349      return (
   350        <>
   351          <ApiButtonInputFormControlLabel
   352            control={
   353              <Select
   354                id={props.spec.name}
   355                value={currentChoice}
   356                label={props.spec.label ?? props.spec.name}
   357              >
   358                {menuItems}
   359              </Select>
   360            }
   361            label={props.spec.label ?? props.spec.name}
   362            onChange={(e) => {
   363              // @ts-ignore
   364              props.setValue(props.spec.name!, e.target.value as string)
   365            }}
   366            aria-label={props.spec.label ?? props.spec.name}
   367          />
   368        </>
   369      )
   370    } else {
   371      return (
   372        <div>{`Error: button input ${props.spec.name} had unsupported type`}</div>
   373      )
   374    }
   375  }
   376  
   377  type ApiButtonFormProps = {
   378    uiButton: UIButton
   379    setInputValue: (name: string, value: any) => void
   380    getInputValue: (name: string) => any | undefined
   381  }
   382  
   383  export function ApiButtonForm(props: ApiButtonFormProps) {
   384    return (
   385      <ApiButtonFormRoot>
   386        {props.uiButton.spec?.inputs?.map((spec) => {
   387          const name = spec.name!
   388          const status = props.uiButton.status?.inputs?.find(
   389            (status) => status.name === name
   390          )
   391          const value = props.getInputValue(name)
   392          return (
   393            <ApiButtonInput
   394              key={name}
   395              spec={spec}
   396              status={status}
   397              value={value}
   398              setValue={props.setInputValue}
   399            />
   400          )
   401        })}
   402      </ApiButtonFormRoot>
   403    )
   404  }
   405  
   406  interface ApiButtonInputModalProps {
   407    open: boolean
   408    onClose: () => void
   409    onConfirm: (values: { [name: string]: any }) => void
   410    uiButton: UIButton
   411    initialValues: { [name: string]: any }
   412  }
   413  
   414  function ApiButtonInputModal(props: ApiButtonInputModalProps) {
   415    const [inputValues, setInputValues] = useState(props.initialValues)
   416  
   417    const setInputValue = (name: string, value: any) => {
   418      setInputValues({ ...inputValues, [name]: value })
   419    }
   420  
   421    const getInputValue = (name: string) => inputValues[name]
   422  
   423    const handleConfirm = () => {
   424      props.onConfirm(inputValues)
   425      props.onClose()
   426    }
   427  
   428    const buttonText = props.uiButton.spec?.text || "Button"
   429    const visibleInputs =
   430      props.uiButton.spec?.inputs?.filter((input) => !input.hidden) || []
   431  
   432    return (
   433      <StyledDialog
   434        open={props.open}
   435        onClose={props.onClose}
   436        maxWidth="md"
   437        fullWidth
   438        aria-labelledby="input-modal-title"
   439      >
   440        <StyledDialogTitle id="input-modal-title">
   441          <span>Configure {buttonText}</span>
   442          <StyledDialogCloseButton
   443            aria-label="Close dialog"
   444            onClick={props.onClose}
   445            size="small"
   446          >
   447            <CloseIcon />
   448          </StyledDialogCloseButton>
   449        </StyledDialogTitle>
   450  
   451        <StyledDialogContent>
   452          {visibleInputs.length > 0 ? (
   453            <>
   454              <p
   455                style={{
   456                  margin: "0 0 20px 0",
   457                  color: Color.gray40,
   458                  fontSize: FontSize.small,
   459                  fontFamily: Font.monospace,
   460                }}
   461              >
   462                Review and modify the input values, then confirm to execute the
   463                action.
   464              </p>
   465              <ApiButtonForm
   466                uiButton={props.uiButton}
   467                setInputValue={setInputValue}
   468                getInputValue={getInputValue}
   469              />
   470            </>
   471          ) : (
   472            <p
   473              style={{
   474                margin: 0,
   475                color: Color.gray40,
   476                fontSize: FontSize.small,
   477                fontFamily: Font.monospace,
   478              }}
   479            >
   480              Are you sure you want to execute "{buttonText}"?
   481            </p>
   482          )}
   483        </StyledDialogContent>
   484  
   485        <StyledDialogActions>
   486          <CancelButton onClick={props.onClose} variant="outlined">
   487            Cancel
   488          </CancelButton>
   489          <ConfirmButton
   490            onClick={handleConfirm}
   491            variant="contained"
   492            color="primary"
   493          >
   494            Confirm & Execute
   495          </ConfirmButton>
   496        </StyledDialogActions>
   497      </StyledDialog>
   498    )
   499  }
   500  
   501  export const ApiIcon = ({ iconName, iconSVG }: ApiIconProps) => {
   502    if (iconSVG) {
   503      // the material SvgIcon handles accessibility/sizing/colors well but can't accept a raw SVG string
   504      // create a ReactElement by parsing the source and then use that as the component, passing through
   505      // the props so that it's correctly styled
   506      const svgEl = svgElement(iconSVG)
   507      const svg = (props: React.PropsWithChildren<any>) => {
   508        // merge the props from material-ui while keeping the children of the actual SVG
   509        return React.cloneElement(svgEl, { ...props }, ...svgEl.props.children)
   510      }
   511      return (
   512        <ApiIconRoot>
   513          <SvgIcon component={svg} />
   514        </ApiIconRoot>
   515      )
   516    }
   517  
   518    if (iconName) {
   519      return (
   520        <ApiIconRoot>
   521          <Icon>{iconName}</Icon>
   522        </ApiIconRoot>
   523      )
   524    }
   525  
   526    return null
   527  }
   528  
   529  // returns metadata + button status w/ the specified input buttons
   530  function buttonStatusWithInputs(
   531    button: UIButton,
   532    inputValues: { [name: string]: any }
   533  ): UIButton {
   534    const result = {
   535      metadata: { ...button.metadata },
   536      status: { ...button.status },
   537    } as UIButton
   538  
   539    result.status!.lastClickedAt = apiTimeFormat(moment.utc())
   540  
   541    result.status!.inputs = []
   542    button.spec!.inputs?.forEach((spec) => {
   543      const value = inputValues[spec.name!]
   544      const defined = value !== undefined
   545      let status: UIInputStatus = { name: spec.name }
   546      // If the value isn't defined, use the default value
   547      // This is unfortunate duplication with the default value checks when initializing the
   548      // MUI managed input components. It might bee cleaner to initialize `inputValues` with
   549      // the default values. However, that breaks a bunch of stuff with persistence (e.g.,
   550      // if you modify one value, you get a cookie and then never get to see any default values
   551      // that get added/changed)
   552      if (spec.text) {
   553        status.text = { value: defined ? value : spec.text?.defaultValue }
   554      } else if (spec.bool) {
   555        status.bool = {
   556          value: (defined ? value : spec.bool.defaultValue) === true,
   557        }
   558      } else if (spec.hidden) {
   559        status.hidden = { value: spec.hidden.value }
   560      } else if (spec.choice) {
   561        status.choice = { value: defined ? value : spec.choice?.choices?.at(0) }
   562      }
   563      result.status!.inputs!.push(status)
   564    })
   565  
   566    return result
   567  }
   568  
   569  export async function updateButtonStatus(
   570    button: UIButton,
   571    inputValues: { [name: string]: any }
   572  ) {
   573    const toUpdate = buttonStatusWithInputs(button, inputValues)
   574  
   575    await tiltApiPut("uibuttons", "status", toUpdate)
   576  }
   577  
   578  export function ApiCancelButton(props: ApiButtonElementProps) {
   579    const { confirming, onClick, text, ...buttonProps } = props
   580  
   581    // Don't display the cancel confirmation button if the button
   582    // group's state isn't confirming
   583    if (!confirming) {
   584      return null
   585    }
   586  
   587    // To pass classes to a MUI component, it's necessary to use `classes`, instead of `className`
   588    const classes: Partial<ClassNameMap<ButtonClassKey>> = {
   589      root: "confirming rightButtonInGroup",
   590    }
   591  
   592    return (
   593      <ApiButtonElementRoot
   594        aria-label={`Cancel ${text}`}
   595        classes={classes}
   596        onClick={onClick}
   597        {...buttonProps}
   598      >
   599        <CloseSvg role="presentation" />
   600      </ApiButtonElementRoot>
   601    )
   602  }
   603  
   604  // The inner content of an ApiSubmitButton
   605  export function ApiSubmitButtonContent(
   606    props: PropsWithChildren<{
   607      confirming: boolean
   608      displayButtonText: string
   609      iconName?: string
   610      iconSVG?: string
   611    }>
   612  ) {
   613    if (props.confirming) {
   614      return <ApiButtonLabel>{props.displayButtonText}</ApiButtonLabel>
   615    }
   616  
   617    if (props.children && props.children !== true) {
   618      return <>{props.children}</>
   619    }
   620  
   621    return (
   622      <>
   623        <ApiIcon iconName={props.iconName} iconSVG={props.iconSVG} />
   624        <ApiButtonLabel>{props.displayButtonText}</ApiButtonLabel>
   625      </>
   626    )
   627  }
   628  
   629  // For a toggle button that requires confirmation to trigger a UIButton's
   630  // action, this component will render both the "submit" and the "confirm submit"
   631  // HTML buttons. For keyboard navigation and accessibility, this component
   632  // intentionally renders both buttons as the same element with different props.
   633  // This makes sure that keyboard focus is moved to (or rather, stays on)
   634  // the "confirm submit" button when the "submit" button is clicked. People
   635  // using assistive tech like screenreaders will know they need to confirm.
   636  // (Screenreaders should announce the "confirm submit" button to users because
   637  // the `aria-label` changes when the "submit" button is clicked.)
   638  export function ApiSubmitButton(
   639    props: PropsWithChildren<ApiButtonElementProps>
   640  ) {
   641    const {
   642      confirming,
   643      disabled,
   644      onClick,
   645      iconName,
   646      iconSVG,
   647      text,
   648      ...buttonProps
   649    } = props
   650  
   651    // Determine display text and accessible button label based on confirmation state
   652    const displayButtonText = confirming ? "Confirm" : text
   653    const ariaLabel = confirming ? `Confirm ${text}` : `Trigger ${text}`
   654  
   655    // To pass classes to a MUI component, it's necessary to use `classes`, instead of `className`
   656    const isConfirmingClass = confirming ? "confirming leftButtonInGroup" : ""
   657    const classes: Partial<ClassNameMap<ButtonClassKey>> = {
   658      root: isConfirmingClass,
   659    }
   660  
   661    // Note: button text is not included in analytics name since that can be user data
   662    return (
   663      <ApiButtonElementRoot
   664        aria-label={ariaLabel}
   665        classes={classes}
   666        disabled={disabled}
   667        onClick={onClick}
   668        {...buttonProps}
   669      >
   670        <ApiSubmitButtonContent
   671          confirming={confirming}
   672          displayButtonText={displayButtonText}
   673          iconName={iconName}
   674          iconSVG={iconSVG}
   675        >
   676          {props.children}
   677        </ApiSubmitButtonContent>
   678      </ApiButtonElementRoot>
   679    )
   680  }
   681  
   682  // Renders a UIButton.
   683  // NB: The `Button` in `ApiButton` refers to a UIButton, not an html <button>.
   684  // This can be confusing because each ApiButton consists of one or two <button>s:
   685  // 1. A submit <button>, which fires the button's action.
   686  // 2. Optionally, an options <button>, which allows the user to configure the
   687  //    options used on submit.
   688  export function ApiButton(props: PropsWithChildren<ApiButtonProps>) {
   689    const { className, uiButton, ...buttonProps } = props
   690    const buttonName = uiButton.metadata?.name || ""
   691  
   692    const [inputValues, setInputValues] = usePersistentState<{
   693      [name: string]: any
   694    }>(`apibutton-${buttonName}`, {})
   695    const { enqueueSnackbar } = useSnackbar()
   696    const pb = usePathBuilder()
   697    const { setError } = useHudErrorContext()
   698  
   699    const [loading, setLoading] = useState(false)
   700    const [confirming, setConfirming] = useState(false)
   701    const [showInputModal, setShowInputModal] = useState(false)
   702  
   703    // Reset the confirmation state when the button's name changes
   704    useLayoutEffect(() => setConfirming(false), [buttonName])
   705  
   706    const componentType = uiButton.spec?.location?.componentType as ApiButtonType
   707    const disabled = loading || uiButton.spec?.disabled || false
   708    const buttonText = uiButton.spec?.text || "Button"
   709  
   710    // Visible inputs (non-hidden) determine if we show a configuration modal before action
   711    const hasVisibleInputs = uiButton.spec?.inputs?.some((input) => !input.hidden)
   712  
   713    const onClick = async (e: React.MouseEvent<HTMLElement>) => {
   714      e.preventDefault()
   715      e.stopPropagation()
   716  
   717      // If there are visible inputs, always show modal to configure them before executing
   718      if (hasVisibleInputs) {
   719        setShowInputModal(true)
   720        return
   721      }
   722  
   723      if (uiButton.spec?.requiresConfirmation && !confirming) {
   724        setConfirming(true)
   725        return
   726      }
   727  
   728      if (confirming) {
   729        setConfirming(false)
   730      }
   731  
   732      // TODO(milas): currently the loading state just disables the button for the duration of
   733      //  the AJAX request to avoid duplicate clicks - there is no progress tracking at the
   734      //  moment, so there's no fancy spinner animation or propagation of result of action(s)
   735      //  that occur as a result of click right now
   736      setLoading(true)
   737      try {
   738        await updateButtonStatus(uiButton, inputValues)
   739      } catch (err) {
   740        setError(`Error submitting button click: ${err}`)
   741        return
   742      } finally {
   743        setLoading(false)
   744      }
   745  
   746      // skip snackbar notifications for special buttons (e.g., disable, stop build)
   747      if (!buttonType(uiButton)) {
   748        const snackbarLogsLink =
   749          componentType === ApiButtonType.Global ? (
   750            <LogLink to="/r/(all)/overview">Global Logs</LogLink>
   751          ) : (
   752            <LogLink
   753              to={pb.encpath`/r/${
   754                uiButton.spec?.location?.componentID || "(all)"
   755              }/overview`}
   756            >
   757              Resource Logs
   758            </LogLink>
   759          )
   760        enqueueSnackbar(
   761          <div>
   762            Triggered button: {uiButton.spec?.text || uiButton.metadata?.name}
   763            {snackbarLogsLink}
   764          </div>
   765        )
   766      }
   767    }
   768  
   769    const submitButton = (
   770      <ApiSubmitButton
   771        text={buttonText}
   772        confirming={confirming}
   773        disabled={disabled}
   774        iconName={uiButton.spec?.iconName}
   775        iconSVG={uiButton.spec?.iconSVG}
   776        onClick={onClick}
   777        {...buttonProps}
   778      >
   779        {props.children}
   780      </ApiSubmitButton>
   781    )
   782  
   783    if (hasVisibleInputs) {
   784      const setInputValue = (name: string, value: any) => {
   785        setInputValues({ ...inputValues, [name]: value })
   786      }
   787      const getInputValue = (name: string) => inputValues[name]
   788  
   789      return (
   790        <>
   791          <ApiButtonRoot
   792            className={className}
   793            disableRipple={true}
   794            aria-label={buttonText}
   795            disabled={disabled}
   796          >
   797            {submitButton}
   798            {/* Confirmation cancel button only shown if in confirming state and no inputs (handled in else) */}
   799          </ApiButtonRoot>
   800          <ApiButtonInputModal
   801            open={showInputModal}
   802            onClose={() => setShowInputModal(false)}
   803            onConfirm={async (values) => {
   804              setInputValues(values) // Persist values for next time
   805              try {
   806                setLoading(true)
   807                await updateButtonStatus(uiButton, values)
   808              } catch (error) {
   809                setError(`Error updating button: ${error}`)
   810              } finally {
   811                setLoading(false)
   812              }
   813            }}
   814            uiButton={uiButton}
   815            initialValues={inputValues}
   816          />
   817        </>
   818      )
   819    } else {
   820      return (
   821        <>
   822          <ApiButtonRoot
   823            className={className}
   824            disableRipple={true}
   825            aria-label={buttonText}
   826            disabled={disabled}
   827          >
   828            {submitButton}
   829            <ApiCancelButton
   830              text={buttonText}
   831              confirming={confirming}
   832              disabled={disabled}
   833              onClick={() => setConfirming(false)}
   834              {...buttonProps}
   835            />
   836          </ApiButtonRoot>
   837          {/* Modal for confirmation only (no inputs) */}
   838          <ApiButtonInputModal
   839            open={showInputModal}
   840            onClose={() => setShowInputModal(false)}
   841            onConfirm={async () => {
   842              try {
   843                setLoading(true)
   844                await updateButtonStatus(uiButton, inputValues)
   845              } catch (error) {
   846                setError(`Error updating button: ${error}`)
   847              } finally {
   848                setLoading(false)
   849              }
   850            }}
   851            uiButton={uiButton}
   852            initialValues={inputValues}
   853          />
   854        </>
   855      )
   856    }
   857  }
   858  
   859  function addButtonToSet(bs: ButtonSet, b: UIButton) {
   860    switch (buttonType(b)) {
   861      case UIBUTTON_TOGGLE_DISABLE_TYPE:
   862        bs.toggleDisable = b
   863        break
   864      case UIBUTTON_STOP_BUILD_TYPE:
   865        bs.stopBuild = b
   866        break
   867      default:
   868        bs.default.push(b)
   869        break
   870    }
   871  }
   872  
   873  export function buttonsForComponent(
   874    buttons: UIButton[] | undefined,
   875    componentType: ApiButtonType,
   876    componentID: string | undefined
   877  ): ButtonSet {
   878    let result = newButtonSet()
   879    if (!buttons) {
   880      return result
   881    }
   882  
   883    buttons.forEach((b) => {
   884      const buttonType = b.spec?.location?.componentType || ""
   885      const buttonID = b.spec?.location?.componentID || ""
   886  
   887      const buttonTypesMatch =
   888        buttonType.toUpperCase() === componentType.toUpperCase()
   889      const buttonIDsMatch = buttonID === componentID
   890  
   891      if (buttonTypesMatch && buttonIDsMatch) {
   892        addButtonToSet(result, b)
   893      }
   894    })
   895  
   896    return result
   897  }
   898  
   899  export function buttonsByComponent(
   900    buttons: UIButton[] | undefined
   901  ): Map<string, ButtonSet> {
   902    const result = new Map<string, ButtonSet>()
   903  
   904    if (buttons === undefined) {
   905      return result
   906    }
   907  
   908    buttons.forEach((b) => {
   909      const componentID = b.spec?.location?.componentID || ""
   910  
   911      // Disregard any buttons that aren't linked to a specific component or resource
   912      if (!componentID.length) {
   913        return
   914      }
   915  
   916      let buttonSet = result.get(componentID)
   917      if (!buttonSet) {
   918        buttonSet = newButtonSet()
   919        result.set(componentID, buttonSet)
   920      }
   921  
   922      addButtonToSet(buttonSet, b)
   923    })
   924  
   925    return result
   926  }