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>&mdash;</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      &#x26A0; { 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);