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;