LabelMenu.tsx

  1import { useEffect, useRef, useState } from 'react';
  2import * as React from 'react';
  3
  4import { IconButton } from '@material-ui/core';
  5import Menu from '@material-ui/core/Menu';
  6import MenuItem from '@material-ui/core/MenuItem';
  7import TextField from '@material-ui/core/TextField';
  8import { makeStyles, withStyles } from '@material-ui/core/styles';
  9import { darken } from '@material-ui/core/styles/colorManipulator';
 10import CheckIcon from '@material-ui/icons/Check';
 11import SettingsIcon from '@material-ui/icons/Settings';
 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<HTMLButtonElement>(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        >
129          <SettingsIcon fontSize={'small'} />
130        </IconButton>
131      </div>
132
133      <Menu
134        className={classes.menu}
135        getContentAnchorEl={null}
136        ref={searchRef}
137        anchorOrigin={{
138          vertical: 'bottom',
139          horizontal: 'left',
140        }}
141        transformOrigin={{
142          vertical: 'top',
143          horizontal: 'left',
144        }}
145        open={open}
146        onClose={() => {
147          setOpen(false);
148          onClose();
149        }}
150        onExited={() => setFilter('')}
151        anchorEl={buttonRef.current}
152        PaperProps={{
153          style: {
154            maxHeight: ITEM_HEIGHT * 4.5,
155            width: '25ch',
156          },
157        }}
158      >
159        {hasFilter && (
160          <CustomTextField
161            onChange={(e) => {
162              const { value } = e.target;
163              setFilter(value);
164            }}
165            onKeyDown={(e) => e.stopPropagation()}
166            value={filter}
167            label={`Filter ${children}`}
168          />
169        )}
170        {filter !== '' &&
171          dropdown.filter((d) => d[1].toLowerCase() === filter.toLowerCase())
172            .length <= 0 && (
173            <MenuItem
174              style={{ whiteSpace: 'normal', wordBreak: 'break-all' }}
175              onClick={() => {
176                onNewItem(filter);
177                setFilter('');
178                setOpen(false);
179              }}
180            >
181              Create new label '{filter}'
182            </MenuItem>
183          )}
184        {dropdown
185          .sort(function (x, y) {
186            // true values first
187            return itemActive(x[1]) === itemActive(y[1]) ? 0 : x ? -1 : 1;
188          })
189          .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase()))
190          .map(([key, value, color]) => (
191            <MenuItem
192              style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}
193              onClick={() => {
194                toggleLabel(key, itemActive(key));
195              }}
196              key={key}
197              selected={itemActive(key)}
198            >
199              <div className={classes.menuRow}>
200                {itemActive(key) && <CheckIcon />}
201                <div
202                  className={classes.labelcolor}
203                  style={createStyle(color)}
204                />
205                {value}
206              </div>
207            </MenuItem>
208          ))}
209      </Menu>
210    </>
211  );
212}
213
214type Props = {
215  bug: BugFragment;
216};
217function LabelMenu({ bug }: Props) {
218  const { data: labelsData } = useListLabelsQuery();
219  const [bugLabelNames, setBugLabelNames] = useState(
220    bug.labels.map((l) => l.name)
221  );
222  const [selectedLabels, setSelectedLabels] = useState(
223    bug.labels.map((l) => l.name)
224  );
225
226  const [setLabelMutation] = useSetLabelMutation();
227
228  function toggleLabel(key: string, active: boolean) {
229    const labels: string[] = active
230      ? selectedLabels.filter((label) => label !== key)
231      : selectedLabels.concat([key]);
232    setSelectedLabels(labels);
233  }
234
235  function diff(oldState: string[], newState: string[]) {
236    const added = newState.filter((x) => !oldState.includes(x));
237    const removed = oldState.filter((x) => !newState.includes(x));
238    return {
239      added: added,
240      removed: removed,
241    };
242  }
243
244  const changeBugLabels = (selectedLabels: string[]) => {
245    const labels = diff(bugLabelNames, selectedLabels);
246    if (labels.added.length > 0 || labels.removed.length > 0) {
247      setLabelMutation({
248        variables: {
249          input: {
250            prefix: bug.id,
251            added: labels.added,
252            Removed: labels.removed,
253          },
254        },
255        refetchQueries: [
256          // TODO: update the cache instead of refetching
257          {
258            query: GetBugDocument,
259            variables: { id: bug.id },
260          },
261          {
262            query: ListLabelsDocument,
263          },
264        ],
265        awaitRefetchQueries: true,
266      })
267        .then((res) => {
268          setSelectedLabels(selectedLabels);
269          setBugLabelNames(selectedLabels);
270        })
271        .catch((e) => console.log(e));
272    }
273  };
274
275  function isActive(key: string) {
276    return selectedLabels.includes(key);
277  }
278
279  function createNewLabel(name: string) {
280    changeBugLabels(selectedLabels.concat([name]));
281  }
282
283  let labels: any = [];
284  if (
285    labelsData?.repository &&
286    labelsData.repository.validLabels &&
287    labelsData.repository.validLabels.nodes
288  ) {
289    labels = labelsData.repository.validLabels.nodes.map((node) => [
290      node.name,
291      node.name,
292      node.color,
293    ]);
294  }
295
296  return (
297    <FilterDropdown
298      onClose={() => changeBugLabels(selectedLabels)}
299      itemActive={isActive}
300      toggleLabel={toggleLabel}
301      dropdown={labels}
302      onNewItem={createNewLabel}
303      hasFilter
304    >
305      Labels
306    </FilterDropdown>
307  );
308}
309
310export default LabelMenu;