github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/webui/src/lib/components/controls.jsx (about) 1 import React, {useCallback, useEffect, useRef, useState} from 'react'; 2 import dayjs from "dayjs"; 3 4 import Form from "react-bootstrap/Form"; 5 import Alert from "react-bootstrap/Alert"; 6 import Button from "react-bootstrap/Button"; 7 import Tooltip from "react-bootstrap/Tooltip"; 8 import Overlay from "react-bootstrap/Overlay"; 9 import Table from "react-bootstrap/Table"; 10 import {OverlayTrigger} from "react-bootstrap"; 11 import {CheckIcon, PasteIcon, SearchIcon, SyncIcon} from "@primer/octicons-react"; 12 import {Link} from "./nav"; 13 import { 14 Box, 15 Button as MuiButton, 16 CircularProgress, 17 Dialog, 18 DialogActions, 19 DialogContent, 20 DialogContentText, 21 DialogTitle, 22 Typography 23 } from "@mui/material"; 24 import InputGroup from "react-bootstrap/InputGroup"; 25 26 27 const defaultDebounceMs = 300; 28 29 export const debounce = (func, wait, immediate) => { 30 let timeout; 31 return function() { 32 let args = arguments; 33 let later = function() { 34 timeout = null; 35 if (!immediate) func.apply(null, args); 36 }; 37 let callNow = immediate && !timeout; 38 clearTimeout(timeout); 39 timeout = setTimeout(later, wait); 40 if (callNow) func.apply(null, args); 41 }; 42 } 43 44 export const useDebounce = (func, wait = defaultDebounceMs) => { 45 const debouncedRef = useRef(debounce(func, wait)) 46 return debouncedRef.current; 47 }; 48 49 export const useDebouncedState = (dependsOn, debounceFn, wait = 300) => { 50 const [state, setState] = useState(dependsOn); 51 useEffect(() => setState(dependsOn), [dependsOn]); 52 const dfn = useDebounce(debounceFn, wait); 53 54 return [state, newState => { 55 setState(newState) 56 dfn(newState) 57 }]; 58 } 59 60 export const DebouncedFormControl = React.forwardRef((props, ref) => { 61 const onChange = debounce(props.onChange, (props.debounce !== undefined) ? props.debounce : defaultDebounceMs) 62 return (<Form.Control ref={ref} {...{...props, onChange}}/>); 63 }); 64 DebouncedFormControl.displayName = "DebouncedFormControl"; 65 66 export const Loading = ({message = "Loading..."}) => { 67 return ( 68 <Alert variant={"info"}>{message}</Alert> 69 ); 70 }; 71 72 export const Na = () => { 73 return ( 74 <span>—</span> 75 ); 76 }; 77 78 export const AlertError = ({error, onDismiss = null, className = null}) => { 79 let content = React.isValidElement(error) ? error : error.toString(); 80 // handle wrapped errors 81 let err = error; 82 while (err.error) err = err.error; 83 if (err.message) content = err.message; 84 if (onDismiss !== null) { 85 return <Alert className={className} variant="danger" dismissible onClose={onDismiss}>{content}</Alert>; 86 } 87 88 return ( 89 <Alert className={className} variant="danger">{content}</Alert> 90 ); 91 }; 92 93 export const FormattedDate = ({ dateValue, format = "MM/DD/YYYY HH:mm:ss" }) => { 94 if (typeof dateValue === 'number') { 95 return ( 96 <span>{dayjs.unix(dateValue).format(format)}</span> 97 ); 98 } 99 100 return ( 101 <OverlayTrigger placement="bottom" overlay={<Tooltip>{dateValue}</Tooltip>}> 102 <span>{dayjs(dateValue).format(format)}</span> 103 </OverlayTrigger> 104 ); 105 }; 106 107 108 export const ActionGroup = ({ children, orientation = "left" }) => { 109 const side = (orientation === 'right') ? 'ms-auto' : ''; 110 return ( 111 <div role="toolbar" className={`${side} mb-2 btn-toolbar action-group-${orientation}`}> 112 {children} 113 </div> 114 ); 115 }; 116 117 export const ActionsBar = ({ children }) => { 118 return ( 119 <div className="action-bar d-flex mb-3"> 120 {children} 121 </div> 122 ); 123 }; 124 125 export const copyTextToClipboard = async (text, onSuccess, onError) => { 126 const textArea = document.createElement('textarea'); 127 128 // 129 // *** This styling is an extra step which is likely not required. *** 130 // 131 // Why is it here? To ensure: 132 // 1. the element is able to have focus and selection. 133 // 2. if element was to flash render it has minimal visual impact. 134 // 3. less flakyness with selection and copying which **might** occur if 135 // the textarea element is not visible. 136 // 137 // The likelihood is the element won't even render, not even a 138 // flash, so some of these are just precautions. However in 139 // Internet Explorer the element is visible whilst the popup 140 // box asking the user for permission for the web page to 141 // copy to the clipboard. 142 // 143 144 // Place in top-left corner of screen regardless of scroll position. 145 textArea.style.position = 'fixed'; 146 textArea.style.top = 0; 147 textArea.style.left = 0; 148 149 // Ensure it has a small width and height. Setting to 1px / 1em 150 // doesn't work as this gives a negative w/h on some browsers. 151 textArea.style.width = '2em'; 152 textArea.style.height = '2em'; 153 154 // We don't need padding, reducing the size if it does flash render. 155 textArea.style.padding = 0; 156 157 // Clean up any borders. 158 textArea.style.border = 'none'; 159 textArea.style.outline = 'none'; 160 textArea.style.boxShadow = 'none'; 161 162 // Avoid flash of white box if rendered for any reason. 163 textArea.style.background = 'transparent'; 164 165 166 textArea.value = text; 167 168 document.body.appendChild(textArea); 169 textArea.focus(); 170 textArea.select(); 171 172 let err = null; 173 try { 174 if ('clipboard' in navigator) { 175 await navigator.clipboard.writeText(text); 176 } else { 177 document.execCommand('copy', true, text); 178 } 179 } catch (e) { 180 err = e; 181 } 182 183 if (!!onSuccess && err === null) { 184 onSuccess(); 185 } 186 if (!!onError && err !== null) { 187 onError(err); 188 } 189 190 document.body.removeChild(textArea); 191 }; 192 193 export const useHover = () => { 194 const [value, setValue] = useState(false); 195 196 const ref = useRef(null); 197 198 const handleMouseOver = () => setValue(true); 199 const handleMouseOut = () => setValue(false); 200 201 useEffect( 202 () => { 203 const node = ref.current; 204 if (node) { 205 node.addEventListener('mouseover', handleMouseOver); 206 node.addEventListener('mouseout', handleMouseOut); 207 208 return () => { 209 node.removeEventListener('mouseover', handleMouseOver); 210 node.removeEventListener('mouseout', handleMouseOut); 211 }; 212 } 213 }, 214 [ref] // Recall only if ref changes 215 ); 216 217 return [ref, value]; 218 }; 219 220 export const LinkButton = ({ href, children, buttonVariant, tooltip = null }) => { 221 if (tooltip === null) { 222 return <Link href={href} component={Button} variant={buttonVariant}>{children}</Link> 223 } 224 return ( 225 <Link href={href} component={TooltipButton} tooltip={tooltip} variant={buttonVariant}>{children}</Link> 226 ); 227 }; 228 229 export const TooltipButton = ({ onClick, variant, children, tooltip, className="", size = "sm" }) => { 230 return ( 231 <OverlayTrigger placement="bottom" overlay={<Tooltip>{tooltip}</Tooltip>}> 232 <Button variant={variant} onClick={onClick} className={className} size={size}> 233 {children} 234 </Button> 235 </OverlayTrigger> 236 ); 237 }; 238 239 export const ClipboardButton = ({ text, variant, onSuccess, icon = <PasteIcon/>, onError, tooltip = "Copy to clipboard", ...rest}) => { 240 241 const [show, setShow] = useState(false); 242 const [copied, setCopied] = useState(false); 243 const [target, isHovered] = useHover(); 244 245 const currentIcon = (!copied) ? icon : <CheckIcon/>; 246 247 let updater = null; 248 249 return ( 250 <> 251 <Overlay 252 placement="bottom" 253 show={show || isHovered} 254 target={target.current}> 255 {props => { 256 updater = props.popper && props.popper.scheduleUpdate; 257 props.show = undefined 258 return (<Tooltip {...props}>{tooltip}</Tooltip>) 259 }} 260 </Overlay> 261 <Button variant={variant} ref={target} onClick={() => { 262 setShow(false) 263 setCopied(true) 264 if (updater !== null) updater() 265 setTimeout(() => { 266 if (target.current !== null) setCopied(false) 267 }, 1000); 268 copyTextToClipboard(text, onSuccess, onError); 269 }} {...rest}> 270 {currentIcon} 271 </Button> 272 </> 273 ); 274 }; 275 276 export const PrefixSearchWidget = ({ onFilter, text = "Search by Prefix", defaultValue = "" }) => { 277 278 const [expanded, setExpanded] = useState(!!defaultValue) 279 280 const toggle = useCallback((e) => { 281 e.preventDefault() 282 setExpanded((prev) => { 283 return !prev 284 }) 285 }, [setExpanded]) 286 287 const ref = useRef(null); 288 289 const handleSubmit = useCallback((e) => { 290 e.preventDefault() 291 onFilter(ref.current.value) 292 }, [ref]) 293 294 if (expanded) { 295 return ( 296 <Form onSubmit={handleSubmit}> 297 <InputGroup> 298 <Form.Control 299 ref={ref} 300 autoFocus 301 defaultValue={defaultValue} 302 placeholder={text} 303 aria-label={text} 304 /> 305 <Button variant="light" onClick={toggle}> 306 <SearchIcon/> 307 </Button> 308 </InputGroup> 309 </Form> 310 ) 311 } 312 313 return ( 314 <OverlayTrigger placement="bottom" overlay={ 315 <Tooltip> 316 {text} 317 </Tooltip> 318 }> 319 <Button variant="light" onClick={toggle}> 320 <SearchIcon/> 321 </Button> 322 </OverlayTrigger> 323 ) 324 } 325 326 export const RefreshButton = ({ onClick, size = "md", variant = "light", tooltip = "Refresh", icon = <SyncIcon/> }) => { 327 return ( 328 <TooltipButton 329 tooltip={tooltip} 330 variant={variant} 331 onClick={onClick} 332 size={size}> 333 {icon} 334 </TooltipButton> 335 ); 336 }; 337 338 export const DataTable = ({ headers, results, rowFn, keyFn = (row) => row[0], actions = [], emptyState = null }) => { 339 340 if ((!results || results.length === 0) && emptyState !== null) { 341 return <Alert variant="warning">{emptyState}</Alert>; 342 } 343 344 return ( 345 <Table> 346 <thead> 347 <tr> 348 {headers.map(header => ( 349 <th key={header}>{header}</th> 350 ))} 351 {(!!actions && actions.length > 0) && <th/>} 352 </tr> 353 </thead> 354 <tbody> 355 {results.map(row => ( 356 <tr key={keyFn(row)}> 357 {rowFn(row).map((cell, i) => ( 358 <td key={`${keyFn(row)}-${i}`}> 359 {cell} 360 </td> 361 ))} 362 {(!!actions && actions.length > 0) && ( 363 <td> 364 <span className="row-hover"> 365 {actions.map(action => ( 366 <span key={`${keyFn(row)}-${action.key}`}> 367 {action.buttonFn(row)} 368 </span> 369 ))} 370 </span> 371 </td> 372 )} 373 </tr> 374 ))} 375 </tbody> 376 </Table> 377 ); 378 }; 379 380 export const Checkbox = ({ name, onAdd, onRemove, disabled = false, defaultChecked = false }) => { 381 return ( 382 <Form.Group> 383 <Form.Check defaultChecked={defaultChecked} disabled={disabled} type="checkbox" name={name} onChange={(e) => { 384 if (e.currentTarget.checked) { 385 onAdd(name) 386 } else { 387 onRemove(name) 388 } 389 }}/> 390 </Form.Group> 391 ); 392 }; 393 394 export const ToggleSwitch = ({ label, id, defaultChecked, onChange }) => { 395 return ( 396 <Form> 397 <Form.Switch 398 label={label} 399 id={id} 400 defaultChecked={defaultChecked} 401 onChange={(e) => onChange(e.target.checked)} 402 /> 403 </Form> 404 ) 405 }; 406 407 export const Warning = (props) => 408 <> 409 <Alert variant="warning"> 410 ⚠ { props.children } 411 </Alert> 412 </>; 413 414 export const Warnings = ({ warnings = [] }) => { 415 return <ul className="pl-0 ms-0 warnings"> 416 {warnings.map((warning, i) => 417 <Warning key={i}>{warning}</Warning> 418 )} 419 </ul>; 420 }; 421 422 export const ProgressSpinner = ({text, changingElement =''}) => { 423 return ( 424 <Box sx={{display: 'flex', alignItems: 'center'}}> 425 <Box> 426 <CircularProgress size={50}/> 427 </Box> 428 <Box sx={{p: 4}}> 429 <Typography>{text}{changingElement}</Typography> 430 </Box> 431 </Box> 432 ); 433 } 434 435 export const ExitConfirmationDialog = ({dialogAlert, dialogDescription, onExit, onContinue, isOpen=false}) => { 436 return ( 437 <Dialog 438 open={isOpen} 439 aria-labelledby="alert-dialog-title" 440 aria-describedby="alert-dialog-description" 441 > 442 <DialogTitle id="alert-dialog-title"> 443 {dialogAlert} 444 </DialogTitle> 445 <DialogContent> 446 <DialogContentText id="alert-dialog-description"> 447 {dialogDescription} 448 </DialogContentText> 449 </DialogContent> 450 <DialogActions> 451 <MuiButton onClick={onContinue} autoFocus>Cancel</MuiButton> 452 <MuiButton onClick={onExit}> 453 Exit 454 </MuiButton> 455 </DialogActions> 456 </Dialog> 457 ); 458 }; 459 460 461 export const ExperimentalOverlayTooltip = ({children, show = true, placement="auto"}) => { 462 const experimentalTooltip = () => ( 463 <Tooltip id="button-tooltip" > 464 Experimental 465 </Tooltip> 466 ); 467 return show ? ( 468 <OverlayTrigger 469 placement={placement} 470 overlay={experimentalTooltip()} 471 > 472 {children} 473 </OverlayTrigger> 474 ) : <></>; 475 }; 476 477 export const GrayOut = ({children}) => 478 <div style={{position: 'relative'}}> 479 <div> 480 <div className={'gray-out overlay'}/> 481 {children} 482 </div> 483 </div>; 484 485 486 export const WrapIf = ({enabled, Component, children}) => ( 487 enabled ? <Component>{children}</Component> : children);