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

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