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 }