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