LabelMenu.tsx

  1import CheckIcon from '@mui/icons-material/Check';
  2import SettingsIcon from '@mui/icons-material/Settings';
  3import { IconButton } from '@mui/material';
  4import Menu from '@mui/material/Menu';
  5import MenuItem from '@mui/material/MenuItem';
  6import TextField from '@mui/material/TextField';
  7import { darken } from '@mui/material/styles';
  8import makeStyles from '@mui/styles/makeStyles';
  9import withStyles from '@mui/styles/withStyles';
 10import * as React from 'react';
 11import { useEffect, useRef, useState } from 'react';
 12
 13import { Color } from '../../../gqlTypes';
 14import {
 15  ListLabelsDocument,
 16  useListLabelsQuery,
 17} from '../../list/ListLabels.generated';
 18import { BugFragment } from '../Bug.generated';
 19import { GetBugDocument } from '../BugQuery.generated';
 20
 21import { useSetLabelMutation } from './SetLabel.generated';
 22
 23type DropdownTuple = [string, string, Color];
 24
 25type FilterDropdownProps = {
 26  children: React.ReactNode;
 27  dropdown: DropdownTuple[];
 28  hasFilter?: boolean;
 29  itemActive: (key: string) => boolean;
 30  onClose: () => void;
 31  toggleLabel: (key: string, active: boolean) => void;
 32  onNewItem: (name: string) => void;
 33} & React.ButtonHTMLAttributes<HTMLButtonElement>;
 34
 35const CustomTextField = withStyles((theme) => ({
 36  root: {
 37    margin: '0 8px 12px 8px',
 38    '& label.Mui-focused': {
 39      margin: '0 2px',
 40      color: theme.palette.text.secondary,
 41    },
 42    '& .MuiInput-underline::before': {
 43      borderBottomColor: theme.palette.divider,
 44    },
 45    '& .MuiInput-underline::after': {
 46      borderBottomColor: theme.palette.divider,
 47    },
 48  },
 49}))(TextField);
 50
 51const ITEM_HEIGHT = 48;
 52
 53const useStyles = makeStyles((theme) => ({
 54  gearBtn: {
 55    ...theme.typography.body2,
 56    color: theme.palette.text.secondary,
 57    padding: theme.spacing(0, 1),
 58    fontWeight: 400,
 59    textDecoration: 'none',
 60    display: 'flex',
 61    background: 'none',
 62    border: 'none',
 63    '&:hover': {
 64      backgroundColor: 'transparent',
 65      color: theme.palette.text.primary,
 66    },
 67  },
 68  menu: {
 69    '& .MuiMenu-paper': {
 70      //somehow using "width" won't override the default width...
 71      minWidth: '35ch',
 72    },
 73  },
 74  labelcolor: {
 75    minWidth: '0.5rem',
 76    display: 'flex',
 77    borderRadius: '0.25rem',
 78    marginRight: '5px',
 79    marginLeft: '3px',
 80  },
 81  labelsheader: {
 82    display: 'flex',
 83    flexDirection: 'row',
 84  },
 85  menuRow: {
 86    display: 'flex',
 87    alignItems: 'initial',
 88  },
 89}));
 90
 91const _rgb = (color: Color) =>
 92  'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
 93
 94// Create a style object from the label RGB colors
 95const createStyle = (color: Color) => ({
 96  backgroundColor: _rgb(color),
 97  borderBottomColor: darken(_rgb(color), 0.2),
 98});
 99
100function FilterDropdown({
101  children,
102  dropdown,
103  hasFilter,
104  itemActive,
105  onClose,
106  toggleLabel,
107  onNewItem,
108}: FilterDropdownProps) {
109  const [open, setOpen] = useState(false);
110  const [filter, setFilter] = useState<string>('');
111  const buttonRef = useRef<HTMLButtonElement>(null);
112  const searchRef = useRef<HTMLInputElement>(null);
113  const classes = useStyles({ active: false });
114
115  useEffect(() => {
116    searchRef && searchRef.current && searchRef.current.focus();
117  }, [filter]);
118
119  return (
120    <>
121      <div className={classes.labelsheader}>
122        Labels
123        <IconButton
124          ref={buttonRef}
125          onClick={() => setOpen(!open)}
126          className={classes.gearBtn}
127          disableRipple
128          size="large"
129        >
130          <SettingsIcon fontSize={'small'} />
131        </IconButton>
132      </div>
133
134      <Menu
135        className={classes.menu}
136        anchorOrigin={{
137          vertical: 'bottom',
138          horizontal: 'left',
139        }}
140        transformOrigin={{
141          vertical: 'top',
142          horizontal: 'left',
143        }}
144        open={open}
145        onClose={() => {
146          setOpen(false);
147          onClose();
148        }}
149        anchorEl={buttonRef.current}
150        PaperProps={{
151          style: {
152            maxHeight: ITEM_HEIGHT * 4.5,
153            width: '25ch',
154          },
155        }}
156        TransitionProps={{
157          onExited: () => setFilter(''),
158        }}
159      >
160        {hasFilter && (
161          <CustomTextField
162            inputRef={searchRef}
163            onChange={(e) => {
164              const { value } = e.target;
165              setFilter(value);
166            }}
167            onKeyDown={(e) => e.stopPropagation()}
168            value={filter}
169            label={`Filter ${children}`}
170          />
171        )}
172        {filter !== '' &&
173          dropdown.filter((d) => d[1].toLowerCase() === filter.toLowerCase())
174            .length <= 0 && (
175            <MenuItem
176              style={{ whiteSpace: 'normal', wordBreak: 'break-all' }}
177              onClick={() => {
178                onNewItem(filter);
179                setFilter('');
180                setOpen(false);
181              }}
182            >
183              Create new label '{filter}'
184            </MenuItem>
185          )}
186        {dropdown
187          .sort(function (x, y) {
188            // true values first
189            return itemActive(x[1]) === itemActive(y[1]) ? 0 : x ? -1 : 1;
190          })
191          .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase()))
192          .map(([key, value, color]) => (
193            <MenuItem
194              style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}
195              onClick={() => {
196                toggleLabel(key, itemActive(key));
197              }}
198              key={key}
199              selected={itemActive(key)}
200            >
201              <div className={classes.menuRow}>
202                {itemActive(key) && <CheckIcon />}
203                <div
204                  className={classes.labelcolor}
205                  style={createStyle(color)}
206                />
207                {value}
208              </div>
209            </MenuItem>
210          ))}
211      </Menu>
212    </>
213  );
214}
215
216type Props = {
217  bug: BugFragment;
218};
219function LabelMenu({ bug }: Props) {
220  const { data: labelsData } = useListLabelsQuery();
221  const [bugLabelNames, setBugLabelNames] = useState(
222    bug.labels.map((l) => l.name)
223  );
224  const [selectedLabels, setSelectedLabels] = useState(
225    bug.labels.map((l) => l.name)
226  );
227
228  const [setLabelMutation] = useSetLabelMutation();
229
230  function toggleLabel(key: string, active: boolean) {
231    const labels: string[] = active
232      ? selectedLabels.filter((label) => label !== key)
233      : selectedLabels.concat([key]);
234    setSelectedLabels(labels);
235  }
236
237  function diff(oldState: string[], newState: string[]) {
238    const added = newState.filter((x) => !oldState.includes(x));
239    const removed = oldState.filter((x) => !newState.includes(x));
240    return {
241      added: added,
242      removed: removed,
243    };
244  }
245
246  const changeBugLabels = (selectedLabels: string[]) => {
247    const labels = diff(bugLabelNames, selectedLabels);
248    if (labels.added.length > 0 || labels.removed.length > 0) {
249      setLabelMutation({
250        variables: {
251          input: {
252            prefix: bug.id,
253            added: labels.added,
254            Removed: labels.removed,
255          },
256        },
257        refetchQueries: [
258          // TODO: update the cache instead of refetching
259          {
260            query: GetBugDocument,
261            variables: { id: bug.id },
262          },
263          {
264            query: ListLabelsDocument,
265          },
266        ],
267        awaitRefetchQueries: true,
268      })
269        .then((res) => {
270          setSelectedLabels(selectedLabels);
271          setBugLabelNames(selectedLabels);
272        })
273        .catch((e) => console.log(e));
274    }
275  };
276
277  function isActive(key: string) {
278    return selectedLabels.includes(key);
279  }
280
281  function createNewLabel(name: string) {
282    changeBugLabels(selectedLabels.concat([name]));
283  }
284
285  let labels: any = [];
286  if (
287    labelsData?.repository &&
288    labelsData.repository.validLabels &&
289    labelsData.repository.validLabels.nodes
290  ) {
291    labels = labelsData.repository.validLabels.nodes.map((node) => [
292      node.name,
293      node.name,
294      node.color,
295    ]);
296  }
297
298  return (
299    <FilterDropdown
300      onClose={() => changeBugLabels(selectedLabels)}
301      itemActive={isActive}
302      toggleLabel={toggleLabel}
303      dropdown={labels}
304      onNewItem={createNewLabel}
305      hasFilter
306    >
307      Labels
308    </FilterDropdown>
309  );
310}
311
312export default LabelMenu;