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;