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