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 }