Skip to content

Commit b87f473

Browse files
committed
feat: Added spent time for other users
- Added spent time for other users - Improved create time entry modal layout - Optimized react-select component - Added formik wrapper for react-select - Added useProjectUsers hook
1 parent 1d0ad94 commit b87f473

File tree

14 files changed

+258
-47
lines changed

14 files changed

+258
-47
lines changed

src/api/redmine.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { formatISO } from "date-fns";
2-
import { TAccount, TCreateTimeEntry, TIssue, TIssuesPriority, TProject, TSearchResult, TTimeEntry, TTimeEntryActivity } from "../types/redmine";
2+
import { TAccount, TCreateTimeEntry, TIssue, TIssuesPriority, TMembership, TProject, TSearchResult, TTimeEntry, TTimeEntryActivity } from "../types/redmine";
33
import instance from "./axios.config";
44

55
export const getMyAccount = async (): Promise<TAccount> => {
@@ -44,6 +44,10 @@ export const searchProjects = async (query: string): Promise<TSearchResult[]> =>
4444
return instance.get(`/search.json?q=${query}&scope=my_project&titles_only=1&projects=1`).then((res) => res.data.results);
4545
};
4646

47+
export const getProjectMemberships = async (id: number, offset = 0, limit = 100): Promise<TMembership[]> => {
48+
return instance.get(`/projects/${id}/memberships.json?offset=${offset}&limit=${limit}`).then((res) => res.data.memberships);
49+
};
50+
4751
// Time entries
4852
export const getAllMyTimeEntries = async (from: Date, to: Date, offset = 0, limit = 100): Promise<TTimeEntry[]> => {
4953
return instance

src/components/general/LoadingSpinner.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const LoadingSpinner = () => {
22
return (
33
<div className="flex items-center justify-center">
44
<div role="status">
5-
<svg aria-hidden="true" className="mr-2 h-6 w-6 animate-spin fill-blue-600 text-gray-200 dark:text-gray-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
5+
<svg aria-hidden="true" className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 dark:text-gray-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
66
<path
77
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
88
fill="currentColor"
@@ -13,7 +13,6 @@ const LoadingSpinner = () => {
1313
/>
1414
</svg>
1515
</div>
16-
<span>Loading...</span>
1716
</div>
1817
);
1918
};

src/components/general/ReactSelect.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import Select, { GroupBase, Props } from "react-select";
55

66
type PropTypes = {
77
title?: string;
8+
error?: string;
89
};
910

10-
function ReactSelect<Option = unknown, IsMulti extends boolean = false, Group extends GroupBase<Option> = GroupBase<Option>>({ title, ...props }: Props<Option, IsMulti, Group> & PropTypes) {
11+
function ReactSelect<Option = unknown, IsMulti extends boolean = false, Group extends GroupBase<Option> = GroupBase<Option>>({ title, error, ...props }: Props<Option, IsMulti, Group> & PropTypes) {
1112
return (
1213
<div>
1314
{title && (
@@ -27,9 +28,12 @@ function ReactSelect<Option = unknown, IsMulti extends boolean = false, Group ex
2728
control: (state) =>
2829
clsx("block w-full rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-white", {
2930
"outline-none ring-2 ring-primary-300 dark:ring-primary-800": state.isFocused,
31+
"border-red-500 text-red-900 dark:border-red-500 dark:text-red-500": error !== undefined,
32+
}),
33+
placeholder: () =>
34+
clsx("text-gray-400", {
35+
"text-red-700 dark:text-red-500": error !== undefined,
3036
}),
31-
input: () => "focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-800",
32-
placeholder: () => "text-gray-400",
3337
menu: () => "mt-1 py-2 rounded-lg bg-gray-100 text-gray-900 dark:bg-gray-600 dark:text-white border border-gray-300 dark:border-gray-600",
3438
option: (state) =>
3539
clsx("truncate p-1.5", {
@@ -41,6 +45,7 @@ function ReactSelect<Option = unknown, IsMulti extends boolean = false, Group ex
4145
indicatorsContainer: () => "cursor-pointer",
4246
}}
4347
/>
48+
{error && <p className="text-sm text-red-600 dark:text-red-500">{error}</p>}
4449
</div>
4550
);
4651
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { FormikHandlers } from "formik";
2+
import { ComponentProps } from "react";
3+
import { GroupBase, OnChangeValue, PropsValue } from "react-select";
4+
import ReactSelect from "./ReactSelect";
5+
6+
type Option = { value: number; label: string };
7+
8+
function ReactSelectFormik<IsMulti extends boolean = false, Group extends GroupBase<Option> = GroupBase<Option>>({
9+
value: _value,
10+
onChange,
11+
onBlur,
12+
...props
13+
}: Omit<ComponentProps<typeof ReactSelect<Option, IsMulti, Group>>, "value" | "onChange"> & {
14+
value?: Option["value"] | Option["value"][];
15+
onChange?: FormikHandlers["handleChange"];
16+
}) {
17+
let value: PropsValue<Option>;
18+
if (Array.isArray(_value)) {
19+
value =
20+
props.options?.reduce((result, o) => {
21+
if (typeof o === "object" && "options" in o) {
22+
const opts = o.options.filter((o) => _value.includes(o.value));
23+
return [...result, ...opts];
24+
} else if (_value.includes(o.value)) {
25+
return [...result, o];
26+
}
27+
return result;
28+
}, [] as Option[]) ?? [];
29+
} else {
30+
value =
31+
(props.options &&
32+
[...props.options].reduce(
33+
(_, o, i, arr) => {
34+
if (typeof o === "object" && "options" in o) {
35+
const opt = o.options.find((o) => o.value === _value);
36+
if (opt) {
37+
arr.splice(i + 1); // break out reduce
38+
return opt;
39+
}
40+
} else if (o.value === _value) {
41+
arr.splice(i + 1); // break out reduce
42+
return o;
43+
}
44+
return undefined;
45+
},
46+
undefined as undefined | Option
47+
)) ??
48+
null;
49+
}
50+
51+
return (
52+
<ReactSelect
53+
{...props}
54+
value={value}
55+
onChange={(selected) => {
56+
if (Array.isArray(selected)) {
57+
onChange?.({
58+
target: {
59+
name: props.name,
60+
value: (selected as OnChangeValue<Option, true>).map((v) => v.value),
61+
},
62+
});
63+
} else {
64+
onChange?.({
65+
target: {
66+
name: props.name,
67+
value: (selected as OnChangeValue<Option, false>)?.value,
68+
},
69+
});
70+
}
71+
}}
72+
onBlur={(e) => {
73+
e.target.name = props.name!;
74+
onBlur?.(e);
75+
}}
76+
/>
77+
);
78+
}
79+
80+
export default ReactSelectFormik;

src/components/issues/CreateTimeEntryModal.tsx

Lines changed: 82 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ import { useEffect, useRef, useState } from "react";
66
import { FormattedMessage, useIntl } from "react-intl";
77
import * as Yup from "yup";
88
import { createTimeEntry, updateIssue } from "../../api/redmine";
9+
import useMyAccount from "../../hooks/useMyAccount";
10+
import useProjectUsers from "../../hooks/useProjectUsers";
911
import useSettings from "../../hooks/useSettings";
1012
import useTimeEntryActivities from "../../hooks/useTimeEntryActivities";
1113
import { TCreateTimeEntry, TIssue, TRedmineError } from "../../types/redmine";
1214
import { formatHours } from "../../utils/date";
1315
import Button from "../general/Button";
1416
import DateField from "../general/DateField";
1517
import InputField from "../general/InputField";
18+
import LoadingSpinner from "../general/LoadingSpinner";
1619
import Modal from "../general/Modal";
20+
import ReactSelectFormik from "../general/ReactSelectFormik";
1721
import SelectField from "../general/SelectField";
1822
import Toast from "../general/Toast";
1923
import TimeEntryPreview from "../time/TimeEntryPreview";
@@ -33,21 +37,26 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) =>
3337

3438
const formik = useRef<FormikProps<TCreateTimeEntry>>(null);
3539

40+
const myAccount = useMyAccount();
3641
const timeEntryActivities = useTimeEntryActivities();
42+
const users = useProjectUsers(issue.project.id, { enabled: settings.options.addSpentTimeForOtherUsers });
3743

3844
useEffect(() => {
39-
formik.current?.setFieldValue("activity_id", timeEntryActivities.find((entry) => entry.is_default)?.id ?? undefined);
40-
}, [timeEntryActivities]);
45+
formik.current?.setFieldValue("activity_id", timeEntryActivities.data.find((entry) => entry.is_default)?.id ?? undefined);
46+
}, [timeEntryActivities.data]);
4147

4248
const createTimeEntryMutation = useMutation({
4349
mutationFn: (entry: TCreateTimeEntry) => createTimeEntry(entry),
44-
onSuccess: () => {
45-
queryClient.invalidateQueries(["timeEntries"]);
46-
onSuccess();
50+
onSuccess: (_, entry) => {
51+
// if entry created for me => invalidate query
52+
if (!entry.user_id || entry.user_id === myAccount.data?.id) {
53+
queryClient.invalidateQueries(["timeEntries"]);
54+
}
4755
},
4856
});
4957

5058
const [doneRatio, setDoneRatio] = useState(issue.done_ratio);
59+
5160
const updateIssueMutation = useMutation({
5261
mutationFn: (data: { done_ratio: number }) => updateIssue(issue.id, data),
5362
onSuccess: () => {
@@ -63,12 +72,14 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) =>
6372
innerRef={formik}
6473
initialValues={{
6574
issue_id: issue.id,
75+
user_id: undefined,
6676
spent_on: new Date(),
6777
activity_id: undefined,
6878
hours: Number((time / 1000 / 60 / 60).toFixed(2)),
6979
comments: "",
7080
}}
7181
validationSchema={Yup.object({
82+
user_id: Yup.array(Yup.number()),
7283
spent_on: Yup.date().max(new Date(), formatMessage({ id: "issues.modal.add-spent-time.date.validation.in-future" })),
7384
hours: Yup.number()
7485
.required(formatMessage({ id: "issues.modal.add-spent-time.hours.validation.required" }))
@@ -81,8 +92,17 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) =>
8192
if (issue.done_ratio !== doneRatio) {
8293
await updateIssueMutation.mutateAsync({ done_ratio: doneRatio });
8394
}
84-
await createTimeEntryMutation.mutateAsync(values);
95+
if (values.user_id && Array.isArray(values.user_id) && values.user_id.length > 0) {
96+
// create for multiple users
97+
for (const userId of values.user_id as number[]) {
98+
await createTimeEntryMutation.mutateAsync({ ...values, user_id: userId });
99+
}
100+
} else {
101+
// create for me
102+
await createTimeEntryMutation.mutateAsync({ ...values, user_id: undefined });
103+
}
85104
setSubmitting(false);
105+
if (!createTimeEntryMutation.isError) onSuccess();
86106
}}
87107
>
88108
{({ isSubmitting, touched, errors, values }) => (
@@ -99,32 +119,59 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) =>
99119

100120
{values.spent_on && <TimeEntryPreview date={startOfDay(values.spent_on)} previewHours={values.hours} />}
101121

102-
<Field
103-
type="date"
104-
name="spent_on"
105-
title={formatMessage({ id: "issues.modal.add-spent-time.date" })}
106-
placeholder={formatMessage({ id: "issues.modal.add-spent-time.date" })}
107-
required
108-
as={DateField}
109-
size="sm"
110-
error={touched.spent_on && errors.spent_on}
111-
options={{ maxDate: new Date() }}
112-
/>
113-
<Field
114-
type="number"
115-
name="hours"
116-
title={formatMessage({ id: "issues.modal.add-spent-time.hours" })}
117-
placeholder={formatMessage({ id: "issues.modal.add-spent-time.hours" })}
118-
min="0"
119-
step="0.01"
120-
max="24"
121-
required
122-
as={InputField}
123-
size="sm"
124-
extraText={values.hours >= 0 && values.hours <= 24 ? formatHours(values.hours) + " h" : undefined}
125-
error={touched.hours && errors.hours}
126-
autoComplete="off"
127-
/>
122+
<div className="grid grid-cols-5 gap-x-2">
123+
<div className="col-span-3">
124+
<Field
125+
type="number"
126+
name="hours"
127+
title={formatMessage({ id: "issues.modal.add-spent-time.hours" })}
128+
placeholder={formatMessage({ id: "issues.modal.add-spent-time.hours" })}
129+
min="0"
130+
step="0.01"
131+
max="24"
132+
required
133+
as={InputField}
134+
size="sm"
135+
className="appearance-none"
136+
extraText={values.hours >= 0 && values.hours <= 24 ? formatHours(values.hours) + " h" : undefined}
137+
error={touched.hours && errors.hours}
138+
autoComplete="off"
139+
/>
140+
</div>
141+
<div className="col-span-2">
142+
<Field
143+
type="date"
144+
name="spent_on"
145+
title={formatMessage({ id: "issues.modal.add-spent-time.date" })}
146+
placeholder={formatMessage({ id: "issues.modal.add-spent-time.date" })}
147+
required
148+
as={DateField}
149+
size="sm"
150+
error={touched.spent_on && errors.spent_on}
151+
options={{ maxDate: new Date() }}
152+
/>
153+
</div>
154+
</div>
155+
156+
{settings.options.addSpentTimeForOtherUsers && (
157+
<Field
158+
type="select"
159+
name="user_id"
160+
title={formatMessage({ id: "issues.modal.add-spent-time.user" })}
161+
placeholder={formatMessage({ id: "issues.modal.add-spent-time.user" })}
162+
as={ReactSelectFormik}
163+
options={users.data.map((user) => ({
164+
value: user.id,
165+
label: user.id === myAccount.data?.id ? `${user.name} (${formatMessage({ id: "issues.modal.add-spent-time.user.me" })})` : user.name,
166+
}))}
167+
error={touched.user_id && errors.user_id}
168+
isClearable
169+
isMulti
170+
closeMenuOnSelect={false}
171+
isLoading={users.isLoading}
172+
/>
173+
)}
174+
128175
<Field
129176
type="text"
130177
name="comments"
@@ -145,7 +192,7 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) =>
145192
size="sm"
146193
error={touched.activity_id && errors.activity_id}
147194
>
148-
{timeEntryActivities.map((activity) => (
195+
{timeEntryActivities.data.map((activity) => (
149196
<>
150197
<option key={activity.id} value={activity.id}>
151198
{activity.name}
@@ -154,8 +201,9 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) =>
154201
))}
155202
</Field>
156203

157-
<Button type="submit" disabled={isSubmitting}>
204+
<Button type="submit" disabled={isSubmitting} className="flex items-center justify-center gap-x-2">
158205
<FormattedMessage id="issues.modal.add-spent-time.submit" />
206+
{isSubmitting && <LoadingSpinner />}
159207
</Button>
160208
</div>
161209
</Form>

src/components/issues/Filter.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ const Filter = ({ onChange }: PropTypes) => {
2727
// On "Escape" => close filter
2828
useHotKey(() => setShowFilter(false), { key: "Escape" });
2929

30-
const { data: projects, isLoading } = useMyProjects(showFilter);
30+
const { data: projects, isLoading } = useMyProjects({
31+
enabled: showFilter,
32+
});
3133

3234
const { data: filter, setData: setFilter } = useStorage<FilterQuery>("filter", defaultFilter);
3335

src/hooks/useMyProjects.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ import { useInfiniteQuery } from "@tanstack/react-query";
22
import { useEffect } from "react";
33
import { getAllMyProjects } from "../api/redmine";
44

5-
const AUTO_REFRESH_DATA_INTERVAL = 1000 * 60 * 5;
6-
const STALE_DATA_TIME = 1000 * 60;
5+
type Options = {
6+
enabled?: boolean;
7+
};
78

8-
const useMyProjects = (enabled = true) => {
9+
const useMyProjects = ({ enabled = true }: Options = {}) => {
910
const projectsQuery = useInfiniteQuery({
1011
queryKey: ["projects"],
1112
queryFn: ({ pageParam = 0 }) => getAllMyProjects(pageParam * 100, 100),
1213
getNextPageParam: (lastPage, allPages) => (lastPage.length === 100 ? allPages.length : undefined),
13-
staleTime: STALE_DATA_TIME,
14-
refetchInterval: AUTO_REFRESH_DATA_INTERVAL,
14+
staleTime: 1000 * 60 * 60,
1515
enabled: enabled,
1616
});
1717

0 commit comments

Comments
 (0)