LabelMenu.tsx

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