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  },
 63  menu: {
 64    '& .MuiMenu-paper': {
 65      //somehow using "width" won't override the default width...
 66      minWidth: '35ch',
 67    },
 68  },
 69  labelcolor: {
 70    minWidth: '1rem',
 71    minHeight: '1rem',
 72    display: 'flex',
 73    backgroundColor: 'blue',
 74    borderRadius: '0.25rem',
 75    marginRight: '5px',
 76    marginLeft: '3px',
 77  },
 78  labelsheader: {
 79    display: 'flex',
 80    flexDirection: 'row',
 81  },
 82  menuRow: {
 83    display: 'flex',
 84    alignItems: 'center',
 85    flexWrap: 'wrap',
 86  },
 87}));
 88
 89const _rgb = (color: Color) =>
 90  'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
 91
 92// Create a style object from the label RGB colors
 93const createStyle = (color: Color) => ({
 94  backgroundColor: _rgb(color),
 95  borderBottomColor: darken(_rgb(color), 0.2),
 96});
 97
 98function FilterDropdown({
 99  children,
100  dropdown,
101  hasFilter,
102  itemActive,
103  onClose,
104  toggleLabel,
105  onNewItem,
106}: FilterDropdownProps) {
107  const [open, setOpen] = useState(false);
108  const [filter, setFilter] = useState<string>('');
109  const buttonRef = useRef<HTMLButtonElement>(null);
110  const searchRef = useRef<HTMLButtonElement>(null);
111  const classes = useStyles({ active: false });
112
113  useEffect(() => {
114    searchRef && searchRef.current && searchRef.current.focus();
115  }, [filter]);
116
117  return (
118    <>
119      <div className={classes.labelsheader}>
120        Labels
121        <IconButton
122          ref={buttonRef}
123          onClick={() => setOpen(!open)}
124          className={classes.gearBtn}
125        >
126          <SettingsIcon fontSize={'small'} />
127        </IconButton>
128      </div>
129
130      <Menu
131        className={classes.menu}
132        getContentAnchorEl={null}
133        ref={searchRef}
134        anchorOrigin={{
135          vertical: 'bottom',
136          horizontal: 'left',
137        }}
138        transformOrigin={{
139          vertical: 'top',
140          horizontal: 'left',
141        }}
142        open={open}
143        onClose={() => {
144          setOpen(false);
145          onClose();
146        }}
147        onExited={() => setFilter('')}
148        anchorEl={buttonRef.current}
149        PaperProps={{
150          style: {
151            maxHeight: ITEM_HEIGHT * 4.5,
152            width: '25ch',
153          },
154        }}
155      >
156        {hasFilter && (
157          <CustomTextField
158            onChange={(e) => {
159              const { value } = e.target;
160              setFilter(value);
161            }}
162            onKeyDown={(e) => e.stopPropagation()}
163            value={filter}
164            label={`Filter ${children}`}
165          />
166        )}
167        {filter !== '' &&
168          dropdown.filter((d) => d[1].toLowerCase() === filter.toLowerCase())
169            .length <= 0 && (
170            <MenuItem
171              style={{ whiteSpace: 'normal', wordBreak: 'break-all' }}
172              onClick={() => {
173                onNewItem(filter);
174                setFilter('');
175                setOpen(false);
176              }}
177            >
178              Create new label '{filter}'
179            </MenuItem>
180          )}
181        {dropdown
182          .sort(function (x, y) {
183            // true values first
184            return itemActive(x[1]) === itemActive(y[1]) ? 0 : x ? -1 : 1;
185          })
186          .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase()))
187          .map(([key, value, color]) => (
188            <MenuItem
189              style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}
190              onClick={() => {
191                toggleLabel(key, itemActive(key));
192              }}
193              key={key}
194              selected={itemActive(key)}
195            >
196              <div className={classes.menuRow}>
197                {itemActive(key) && <CheckIcon fontSize={'small'} />}
198                <div
199                  className={classes.labelcolor}
200                  style={createStyle(color)}
201                />
202                {value}
203              </div>
204            </MenuItem>
205          ))}
206      </Menu>
207    </>
208  );
209}
210
211type Props = {
212  bug: BugFragment;
213};
214function LabelMenu({ bug }: Props) {
215  const { data: labelsData } = useListLabelsQuery();
216  const [bugLabelNames, setBugLabelNames] = useState(
217    bug.labels.map((l) => l.name)
218  );
219  const [selectedLabels, setSelectedLabels] = useState(
220    bug.labels.map((l) => l.name)
221  );
222
223  const [setLabelMutation] = useSetLabelMutation();
224
225  function toggleLabel(key: string, active: boolean) {
226    const labels: string[] = active
227      ? selectedLabels.filter((label) => label !== key)
228      : selectedLabels.concat([key]);
229    setSelectedLabels(labels);
230    console.log('toggle (selected)');
231    console.log(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    console.log('CBL');
245    console.log('selected labels');
246    console.log(selectedLabels);
247    console.log('buglabels');
248    console.log(bugLabelNames);
249    const labels = diff(bugLabelNames, selectedLabels);
250    console.log(labels);
251    if (labels.added.length > 0 || labels.removed.length > 0) {
252      setLabelMutation({
253        variables: {
254          input: {
255            prefix: bug.id,
256            added: labels.added,
257            Removed: labels.removed,
258          },
259        },
260        refetchQueries: [
261          // TODO: update the cache instead of refetching
262          {
263            query: GetBugDocument,
264            variables: { id: bug.id },
265          },
266          {
267            query: ListLabelsDocument,
268          },
269        ],
270        awaitRefetchQueries: true,
271      })
272        .then((res) => {
273          console.log(res);
274          setSelectedLabels(selectedLabels);
275          setBugLabelNames(selectedLabels);
276        })
277        .catch((e) => console.log(e));
278    }
279  };
280
281  function isActive(key: string) {
282    return selectedLabels.includes(key);
283  }
284
285  //TODO label wont removed, if a filter hides it!
286  function createNewLabel(name: string) {
287    console.log('CREATE NEW LABEL: ' + name);
288    changeBugLabels(selectedLabels.concat([name]));
289  }
290
291  let labels: any = [];
292  if (
293    labelsData?.repository &&
294    labelsData.repository.validLabels &&
295    labelsData.repository.validLabels.nodes
296  ) {
297    labels = labelsData.repository.validLabels.nodes.map((node) => [
298      node.name,
299      node.name,
300      node.color,
301    ]);
302  }
303
304  return (
305    <FilterDropdown
306      onClose={() => changeBugLabels(selectedLabels)}
307      itemActive={isActive}
308      toggleLabel={toggleLabel}
309      dropdown={labels}
310      onNewItem={createNewLabel}
311      hasFilter
312    >
313      Labels
314    </FilterDropdown>
315  );
316}
317
318export default LabelMenu;