Skip to content

Commit 99e331f

Browse files
committed
feat: extended search, remember/forgot issue & context menu
- added extended search feature - added remember/forgot issue action - added right click context menu - fix axios config - fixed assigned_to is undefined - fixed settings merge - restructured source code
1 parent 9bd4ef6 commit 99e331f

File tree

14 files changed

+534
-165
lines changed

14 files changed

+534
-165
lines changed

src/App.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ function App() {
1010
const location = useLocation();
1111

1212
return (
13-
<>
13+
<div
14+
// disable context menu
15+
onContextMenu={(e) => {
16+
e.preventDefault();
17+
}}
18+
>
1419
<nav className="sticky top-0 z-10 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-1">
1520
<ul className="flex flex-wrap gap-x-2 -mb-px text-sm font-medium text-center text-gray-500 dark:text-gray-400">
1621
<li>
@@ -51,7 +56,7 @@ function App() {
5156
<Route path="*" element={<Toast type="error" message="Page not found!" allowClose={false} />} />
5257
</Routes>
5358
</main>
54-
</>
59+
</div>
5560
);
5661
}
5762

src/api/axios.config.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
1-
import axios from "axios";
1+
import axios, { InternalAxiosRequestConfig } from "axios";
22
import { getSettings } from "../hooks/useSettings";
33

44
const instance = axios.create();
55

6-
export const loadRedmineConfig = async () => {
6+
instance.interceptors.request.use(
7+
async (config) => {
8+
if (!config.baseURL) {
9+
await loadRedmineConfig(config);
10+
}
11+
return config;
12+
},
13+
(error) => {
14+
return Promise.reject(error);
15+
}
16+
);
17+
18+
export const loadRedmineConfig = async (config?: InternalAxiosRequestConfig) => {
719
const settings = await getSettings();
20+
21+
// set instance config
822
instance.defaults.baseURL = settings.redmineURL;
923
instance.defaults.headers["X-Redmine-API-Key"] = settings.redmineApiKey;
10-
};
1124

12-
loadRedmineConfig();
25+
// set current config
26+
if (config) {
27+
config.baseURL = settings.redmineURL;
28+
config.headers["X-Redmine-API-Key"] = settings.redmineApiKey;
29+
}
30+
};
1331

1432
export default instance;

src/api/redmine.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { TCreateTimeEntry, TIssue, TTimeEntryActivity } from "../types/redmine";
1+
import { TAccount, TCreateTimeEntry, TIssue, TSearchResult, TTimeEntryActivity } from "../types/redmine";
22
import instance from "./axios.config";
33

4+
export const getMyAccount = async (): Promise<TAccount> => {
5+
return instance.get("/my/account.json").then((res) => res.data.user);
6+
};
7+
48
export const getAllMyOpenIssues = async (offset = 0, limit = 100): Promise<TIssue[]> => {
59
return instance.get(`/issues.json?offset=${offset}&limit=${limit}&status_id=open&assigned_to_id=me&sort=updated_on:desc`).then((res) => res.data.issues);
610
};
711

12+
export const getOpenIssues = async (ids: number[], offset = 0, limit = 100): Promise<TIssue[]> => {
13+
return instance.get(`/issues.json?offset=${offset}&limit=${limit}&status_id=open&issue_id=${ids.join(",")}&sort=updated_on:desc`).then((res) => res.data.issues);
14+
};
15+
816
export const createTimeEntry = async (entry: TCreateTimeEntry) => {
917
return instance
1018
.post("/time_entries.json", {
@@ -16,3 +24,7 @@ export const createTimeEntry = async (entry: TCreateTimeEntry) => {
1624
export const getTimeEntryActivities = async (): Promise<TTimeEntryActivity[]> => {
1725
return instance.get("/enumerations/time_entry_activities.json").then((res) => res.data.time_entry_activities);
1826
};
27+
28+
export const searchOpenIssues = async (query: string): Promise<TSearchResult[]> => {
29+
return instance.get(`/search.json?q=${query}&scope=my_project&titles_only=1&issues=1&open_issues=1`).then((res) => res.data.results);
30+
};

src/components/general/CheckBox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ const CheckBox = ({ title, description, ...props }: PropTypes) => {
1010
const id = useId();
1111

1212
return (
13-
<div className="flex items-center pl-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700">
14-
<div className={clsx("flex items-center", description ? "h-12" : "h-8")}>
13+
<div className="flex items-center pl-2 p-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700">
14+
<div className="flex items-center">
1515
<input
1616
{...props}
1717
id={id}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import clsx from "clsx";
2+
import { useLayoutEffect, useRef } from "react";
3+
import useOnClickOutside from "../../hooks/useOnClickOutside";
4+
5+
type MenuItem = {
6+
name: string;
7+
icon: React.ReactNode;
8+
disabled?: boolean;
9+
onClick: () => void;
10+
};
11+
type PropTypes = {
12+
x: number;
13+
y: number;
14+
menu: MenuItem[] | MenuItem[][];
15+
onClose: () => void;
16+
};
17+
18+
const ContextMenu = ({ x, y, menu, onClose }: PropTypes) => {
19+
const ref = useRef<HTMLDivElement>(null);
20+
21+
// close on click outside
22+
useOnClickOutside(ref, onClose);
23+
24+
// set the correct position
25+
useLayoutEffect(() => {
26+
if (ref.current) {
27+
const { width, height } = ref.current.getBoundingClientRect();
28+
if (y + height > window.innerHeight) {
29+
ref.current.style.top = `${y - height}px`;
30+
}
31+
if (x + width > window.innerWidth && x - width > 0) {
32+
ref.current.style.left = `${x - width}px`;
33+
}
34+
}
35+
}, []);
36+
37+
return (
38+
<div ref={ref} className="absolute z-20 bg-white border dark:border-0 border-gray-200 divide-y divide-gray-200 dark:divide-gray-600 rounded-lg shadow w-40 dark:bg-gray-700" style={{ top: `${y}px`, left: `${x}px` }} onClick={onClose}>
39+
{(menu.length > 0 && Array.isArray(menu[0]) && (
40+
<>
41+
{(menu as MenuItem[][]).map((group) => (
42+
<ul className="py-1 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownDefaultButton">
43+
{group.map((item) => (
44+
<li className={clsx("flex px-2 py-1", item.disabled ? "text-gray-300 dark:text-gray-500" : "hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white")} onClick={!item.disabled ? item.onClick : undefined}>
45+
<span className="flex justify-center items-center w-4 me-2">{item.icon}</span>
46+
{item.name}
47+
</li>
48+
))}
49+
</ul>
50+
))}
51+
</>
52+
)) || (
53+
<ul className="py-1 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownDefaultButton">
54+
{(menu as MenuItem[]).map((item) => (
55+
<li className={clsx("flex px-2 py-1", item.disabled ? "text-gray-300 dark:text-gray-500" : "hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white")} onClick={!item.disabled ? item.onClick : undefined}>
56+
<span className="flex justify-center items-center w-4 me-2">{item.icon}</span>
57+
{item.name}
58+
</li>
59+
))}
60+
</ul>
61+
)}
62+
</div>
63+
);
64+
};
65+
66+
export default ContextMenu;

src/components/issues/Issue.tsx

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,54 @@
1-
import { faCheck, faPause, faPlay, faStop } from "@fortawesome/free-solid-svg-icons";
1+
import { faArrowUpRightFromSquare, faBan, faBookmark, faCheck, faCircleUser, faEdit, faPause, faPlay, faStop } from "@fortawesome/free-solid-svg-icons";
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
33
import clsx from "clsx";
44
import { useEffect, useState } from "react";
55
import { Tooltip } from "react-tooltip";
66
import useSettings from "../../hooks/useSettings";
77
import { TIssue } from "../../types/redmine";
8+
import ContextMenu from "../general/ContextMenu";
89
import KBD from "../general/KBD";
910
import CreateTimeEntryModal from "./CreateTimeEntryModal";
1011
import EditTime from "./EditTime";
1112

13+
export type IssueData = {
14+
active: boolean;
15+
start?: number;
16+
time: number;
17+
remember: boolean;
18+
};
19+
1220
type PropTypes = {
1321
issue: TIssue;
14-
isActive: boolean;
15-
time: number;
16-
start?: number;
22+
data: IssueData;
23+
assignedToMe: boolean;
1724
onStart: () => void;
1825
onPause: (time: number) => void;
1926
onStop: () => void;
20-
//onDone: (time: number) => void;
2127
onOverrideTime: (time: number) => void;
28+
onRemember: () => void;
29+
onForgot: () => void;
2230
};
2331

24-
const Issue = ({ issue, isActive, time, start, onStart, onPause, onStop, onOverrideTime }: PropTypes) => {
32+
const Issue = ({ issue, data: { active, time, start, remember }, assignedToMe, onStart, onPause, onStop, onOverrideTime, onRemember, onForgot }: PropTypes) => {
2533
const { settings } = useSettings();
2634

2735
const [timer, setTimer] = useState(calcTime(time, start));
2836

2937
useEffect(() => {
3038
setTimer(calcTime(time, start));
31-
if (isActive && start) {
39+
if (active && start) {
3240
const timerInterval = setInterval(() => {
3341
setTimer(calcTime(time, start));
3442
}, 1000);
3543
return () => clearInterval(timerInterval);
3644
}
37-
}, [isActive, time, start]);
45+
}, [active, time, start]);
3846

3947
const [editTime, setEditTime] = useState<number | undefined>(undefined);
4048
const [createTimeEntry, setCreateTimeEntry] = useState<number | undefined>(undefined);
49+
50+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | undefined>(undefined);
51+
4152
return (
4253
<>
4354
<div
@@ -55,7 +66,7 @@ const Issue = ({ issue, isActive, time, start, onStart, onPause, onStop, onOverr
5566
if (editTime !== undefined) {
5667
return;
5768
}
58-
if (isActive) {
69+
if (active) {
5970
onPause(timer);
6071
} else {
6172
onStart();
@@ -64,8 +75,12 @@ const Issue = ({ issue, isActive, time, start, onStart, onPause, onStop, onOverr
6475
}
6576
}}
6677
data-tooltip-id="tooltip-toggle-timer"
78+
onContextMenu={(e) => {
79+
e.preventDefault();
80+
setContextMenu({ x: e.pageX, y: e.pageY });
81+
}}
6782
>
68-
<h1 className="mb-1 truncate">
83+
<h1 className="mb-1 truncate me-4">
6984
<a href={`${settings.redmineURL}/issues/${issue.id}`} target="_blank" className="text-blue-500 hover:underline" data-tooltip-id={`tooltip-issue-${issue.id}`} tabIndex={-1}>
7085
#{issue.id}
7186
</a>{" "}
@@ -87,10 +102,12 @@ const Issue = ({ issue, isActive, time, start, onStart, onPause, onStop, onOverr
87102
<th className="pr-2 font-medium whitespace-nowrap">Priority:</th>
88103
<td>{issue.priority.name}</td>
89104
</tr>
90-
<tr>
91-
<th className="pr-2 font-medium whitespace-nowrap">Assignee:</th>
92-
<td>{issue.assigned_to.name}</td>
93-
</tr>
105+
{issue.assigned_to && (
106+
<tr>
107+
<th className="pr-2 font-medium whitespace-nowrap">Assignee:</th>
108+
<td>{issue.assigned_to.name}</td>
109+
</tr>
110+
)}
94111
{issue.estimated_hours && (
95112
<tr>
96113
<th className="pr-2 font-medium whitespace-nowrap">Estimated time:</th>
@@ -123,12 +140,12 @@ const Issue = ({ issue, isActive, time, start, onStart, onPause, onStop, onOverr
123140
onCancel={() => setEditTime(undefined)}
124141
/>
125142
)) || (
126-
<span className={clsx("text-lg", timer > 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500", isActive && "font-semibold")} onDoubleClick={() => setEditTime(timer)} data-tooltip-id="tooltip-edit-timer">
143+
<span className={clsx("text-lg", timer > 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500", active && "font-semibold")} onDoubleClick={() => setEditTime(timer)} data-tooltip-id="tooltip-edit-timer">
127144
{formatTime(timer)}
128145
</span>
129146
)}
130147
<Tooltip id="tooltip-edit-timer" place="top" delayShow={700} content="Double-click to edit" className="italic" />
131-
{!isActive ? (
148+
{!active ? (
132149
<FontAwesomeIcon icon={faPlay} size="2x" className="text-green-500 cursor-pointer focus:outline-none" onClick={onStart} data-tooltip-id="tooltip-start-timer" tabIndex={-1} />
133150
) : (
134151
<FontAwesomeIcon icon={faPause} size="2x" className="text-red-500 cursor-pointer focus:outline-none" onClick={() => onPause(timer)} data-tooltip-id="tooltip-pause-timer" tabIndex={-1} />
@@ -147,13 +164,19 @@ const Issue = ({ issue, isActive, time, start, onStart, onPause, onStop, onOverr
147164
<Tooltip id="tooltip-stop-timer" place="top" delayShow={700} content="Click to stop timer" className="italic" />
148165
<Tooltip id={`tooltip-done-timer-${issue.id}`} place="bottom" delayShow={700} className="z-10 italic opacity-100">
149166
Click to transfer{" "}
150-
<span className={clsx("text-xs", timer > 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500", isActive && "font-semibold")}>
167+
<span className={clsx("text-xs", timer > 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500", active && "font-semibold")}>
151168
{formatTime(settings.options.roundTimeNearestQuarterHour ? roundTimeNearestQuarterHour(timer) : timer)}
152169
</span>{" "}
153170
to Redmine issue
154171
</Tooltip>
155172
</div>
156173
</div>
174+
{!assignedToMe && (
175+
<>
176+
<Tooltip id="tooltip-not-assigned-to-me" place="left" delayShow={700} content="Issue is not assigned to you" className="italic" />
177+
<FontAwesomeIcon icon={faCircleUser} className="absolute top-2 right-2 text-gray-300 dark:text-gray-600" data-tooltip-id="tooltip-not-assigned-to-me" />
178+
</>
179+
)}
157180
</div>
158181
<Tooltip id="tooltip-toggle-timer" place="bottom" delayShow={4000} className="italic max-w-[275px]">
159182
If selected, press <KBD text="Ctrl" /> + <KBD text="Spacebar" space="xl" /> to toggle timer
@@ -169,6 +192,64 @@ const Issue = ({ issue, isActive, time, start, onStart, onPause, onStop, onOverr
169192
}}
170193
/>
171194
)}
195+
{contextMenu && (
196+
<ContextMenu
197+
x={contextMenu.x}
198+
y={contextMenu.y}
199+
onClose={() => setContextMenu(undefined)}
200+
menu={[
201+
[
202+
{
203+
name: "Open in Redmine",
204+
icon: <FontAwesomeIcon icon={faArrowUpRightFromSquare} />,
205+
onClick: () => {
206+
window.open(`${settings.redmineURL}/issues/${issue.id}`, "_blank");
207+
},
208+
},
209+
],
210+
[
211+
{
212+
name: "Start timer",
213+
icon: <FontAwesomeIcon icon={faPlay} />,
214+
disabled: active,
215+
onClick: onStart,
216+
},
217+
{
218+
name: "Pause timer",
219+
icon: <FontAwesomeIcon icon={faPause} />,
220+
disabled: !active,
221+
onClick: () => onPause(timer),
222+
},
223+
{
224+
name: "Stop timer",
225+
icon: <FontAwesomeIcon icon={faStop} />,
226+
disabled: timer === 0,
227+
onClick: onStop,
228+
},
229+
{
230+
name: "Edit timer",
231+
icon: <FontAwesomeIcon icon={faEdit} />,
232+
disabled: timer === 0,
233+
onClick: () => setEditTime(timer),
234+
},
235+
],
236+
[
237+
{
238+
name: "Remember issue",
239+
icon: <FontAwesomeIcon icon={faBookmark} />,
240+
disabled: assignedToMe || remember,
241+
onClick: onRemember,
242+
},
243+
{
244+
name: "Forgot issue",
245+
icon: <FontAwesomeIcon icon={faBan} />,
246+
disabled: assignedToMe || !remember,
247+
onClick: onForgot,
248+
},
249+
],
250+
]}
251+
/>
252+
)}
172253
</>
173254
);
174255
};

0 commit comments

Comments
 (0)