aboutsummaryrefslogblamecommitdiffstats
path: root/webui/src/pages/list/Filter.tsx
blob: 6b3422becacb58534f0ee442ff78804d6865cc51 (plain) (tree)
1
2
3
4
5
6
7
8
9








                                                              
                        
                               
                                                    
                                                  
 

                                       
                                                



                             






                                               

      
               


                       
                                                

                                      
                           
                                                                       




                                               

                                          
            
                                  
     



                
                                       
                                        

                                               

                                                                       
 



                                                                            

   




                                                                           

   
               

 

                                                                           


                                                       
     
                                                        

 
                                          

                              
                                        
                                 
                    

                           


                       

                    
                                      
    


                                     











                                                                 
               
                       
                    



                            
    







                                                         
 
                                              





                                           
                                
                      







                                                  
            

                         
                                          
                                                   
                                                    
                                                   

                                               



                                                                





                                                                          


          






                                      


                                          
                                     










                                      


                                         
                          

            
       

                          
                                



                                         
                                                  





                                                                           
                                         


                              

                                               


                                            
                                                





                                                


                       




             

                           
                                      


                                           
 

                                                                    
 

                   

                                                                          


       
           
            
           
               
                                                                       

                 



             




                                                                         


                      
                                                           
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import CheckIcon from '@mui/icons-material/Check';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { SvgIconProps } from '@mui/material/SvgIcon';
import TextField from '@mui/material/TextField';
import { darken } from '@mui/material/styles';
import makeStyles from '@mui/styles/makeStyles';
import withStyles from '@mui/styles/withStyles';
import clsx from 'clsx';
import * as React from 'react';
import { useRef, useState, useEffect } from 'react';
import { Location, Link } from 'react-router-dom';

import { Color } from '../../gqlTypes';

const CustomTextField = withStyles((theme) => ({
  root: {
    margin: '0 8px 12px 8px',
    '& label.Mui-focused': {
      margin: '0 2px',
      color: theme.palette.text.secondary,
    },
    '& .MuiInput-underline::before': {
      borderBottomColor: theme.palette.divider,
    },
    '& .MuiInput-underline::after': {
      borderBottomColor: theme.palette.divider,
    },
  },
}))(TextField);

const ITEM_HEIGHT = 48;

export type Query = { [key: string]: string[] };

function parse(query: string): Query {
  const params: Query = {};
  let re = new RegExp(/([^:\s]+)(:('[^']*'\S*|"[^"]*"\S*|\S*))?/, 'g');
  let matches;
  while ((matches = re.exec(query)) !== null) {
    if (!params[matches[1]]) {
      params[matches[1]] = [];
    }
    if (matches[3] !== undefined) {
      params[matches[1]].push(matches[3]);
    } else {
      params[matches[1]].push('');
    }
  }
  return params;
}

function quote(value: string): string {
  const hasSpaces = value.includes(' ');
  const isSingleQuotedRegEx = RegExp(/^'.*'$/);
  const isDoubleQuotedRegEx = RegExp(/^".*"$/);
  const isQuoted = () =>
    isDoubleQuotedRegEx.test(value) || isSingleQuotedRegEx.test(value);

  //Test if label name contains whitespace between quotes. If no quoates but
  //whitespace, then quote string.
  if (!isQuoted() && hasSpaces) {
    value = `"${value}"`;
  }

  //Convert single quote (tick) to double quote. This way quoting is always
  //uniform and can be relied upon by the label menu
  const hasSingle = value.includes(`'`);
  if (hasSingle) {
    value = value.replace(/'/g, `"`);
  }

  return value;
}

function stringify(params: Query): string {
  const parts: string[][] = Object.entries(params).map(([key, values]) => {
    return values.map((value) =>
      value.length > 0 ? `${key}:${quote(value)}` : key
    );
  });
  return new Array<string>().concat(...parts).join(' ');
}

const useStyles = makeStyles((theme) => ({
  element: {
    ...theme.typography.body2,
    color: theme.palette.text.secondary,
    padding: theme.spacing(0, 1),
    fontWeight: 400,
    textDecoration: 'none',
    display: 'flex',
    background: 'none',
    border: 'none',
  },
  itemActive: {
    fontWeight: 600,
    color: theme.palette.text.primary,
  },
  icon: {
    paddingRight: theme.spacing(0.5),
  },
  labelMenu: {
    '& .MuiMenu-paper': {
      //somehow using "width" won't override the default width...
      minWidth: '35ch',
    },
  },
  labelMenuItem: {
    whiteSpace: 'normal',
    wordBreak: 'break-word',
    display: 'flex',
    alignItems: 'initial',
  },
  labelcolor: {
    minWidth: '0.5rem',
    display: 'flex',
    borderRadius: '0.25rem',
    marginRight: '5px',
    marginLeft: '3px',
  },
}));
const _rgb = (color: Color) =>
  'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';

// Create a style object from the label RGB colors
const createStyle = (color: Color) => ({
  backgroundColor: _rgb(color),
  borderBottomColor: darken(_rgb(color), 0.2),
});

type DropdownTuple = [string, string, Color?];

type FilterDropdownProps = {
  children: React.ReactNode;
  dropdown: DropdownTuple[];
  itemActive: (key: string) => boolean;
  icon?: React.ComponentType<SvgIconProps>;
  to: (key: string) => Location;
  hasFilter?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

function FilterDropdown({
  children,
  dropdown,
  itemActive,
  icon: Icon,
  to,
  hasFilter,
  ...props
}: FilterDropdownProps) {
  const [open, setOpen] = useState(false);
  const [filter, setFilter] = useState<string>('');
  const buttonRef = useRef<HTMLButtonElement>(null);
  const searchRef = useRef<HTMLInputElement>(null);
  const classes = useStyles({ active: false });

  useEffect(() => {
    searchRef && searchRef.current && searchRef.current.focus();
  }, [filter]);

  const content = (
    <>
      {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
      <div>{children}</div>
    </>
  );

  return (
    <>
      <button
        ref={buttonRef}
        onClick={() => setOpen(!open)}
        className={classes.element}
        {...props}
      >
        {content}
        <ArrowDropDown fontSize="small" />
      </button>
      <Menu
        className={classes.labelMenu}
        anchorOrigin={{
          vertical: 'bottom',
          horizontal: 'left',
        }}
        transformOrigin={{
          vertical: 'top',
          horizontal: 'left',
        }}
        open={open}
        onClose={() => setOpen(false)}
        anchorEl={buttonRef.current}
        PaperProps={{
          style: {
            maxHeight: ITEM_HEIGHT * 4.5,
            width: '25ch',
          },
        }}
      >
        {hasFilter && (
          <CustomTextField
            inputRef={searchRef}
            onChange={(e) => {
              const { value } = e.target;
              setFilter(value);
            }}
            onKeyDown={(e) => e.stopPropagation()}
            value={filter}
            label={`Filter ${children}`}
          />
        )}
        {dropdown
          .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase()))
          .map(([key, value, color]) => (
            <MenuItem
              component={Link}
              to={to(key)}
              className={classes.labelMenuItem}
              selected={itemActive(key)}
              onClick={() => setOpen(false)}
              key={key}
            >
              {itemActive(key) && <CheckIcon />}
              {color && (
                <div
                  className={classes.labelcolor}
                  style={createStyle(color)}
                />
              )}
              {value}
            </MenuItem>
          ))}
      </Menu>
    </>
  );
}

export type FilterProps = {
  active: boolean;
  to: Location; // the target on click
  icon?: React.ComponentType<SvgIconProps>;
  children: React.ReactNode;
};

function Filter({ active, to, children, icon: Icon }: FilterProps) {
  const classes = useStyles();

  const content = (
    <>
      {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
      <div>{children}</div>
    </>
  );

  if (to) {
    return (
      <Link
        to={to}
        className={clsx(classes.element, active && classes.itemActive)}
      >
        {content}
      </Link>
    );
  }

  return (
    <div className={clsx(classes.element, active && classes.itemActive)}>
      {content}
    </div>
  );
}

export default Filter;
export { parse, stringify, quote, FilterDropdown, Filter };