From b31c12f9f2e1c2ec868da4f391bb26cee9f823d8 Mon Sep 17 00:00:00 2001 From: CrawlerCode <41094392+CrawlerCode@users.noreply.github.com> Date: Thu, 29 May 2025 18:13:56 +0200 Subject: [PATCH 01/55] refactor: Use only project id for permission check function --- src/components/issues/CreateTimeEntryModal.tsx | 6 +++--- src/components/issues/CurrentIssueTimer.tsx | 2 +- src/components/issues/IssuesList.tsx | 16 ++++++++-------- src/components/time/TimeEntryContextMenu.tsx | 2 +- src/hooks/useMyProjectRoles.ts | 7 ++++--- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/components/issues/CreateTimeEntryModal.tsx b/src/components/issues/CreateTimeEntryModal.tsx index 7e06989..dcaed7e 100644 --- a/src/components/issues/CreateTimeEntryModal.tsx +++ b/src/components/issues/CreateTimeEntryModal.tsx @@ -56,7 +56,7 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => const myUser = useMyUser(); const project = useProject(issue.project.id); - const projectRoles = useMyProjectRoles([issue.project.id]); + const projectRoles = useMyProjectRoles([issue.project.id], project.data ? [project.data] : undefined); const timeEntryActivities = useTimeEntryActivities(issue.project.id); const cachedComments = useStorage>("cachedComments", _defaultCachedComments); @@ -255,7 +255,7 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => /> - {projectRoles.hasProjectPermission(project.data ?? issue.project, "log_time_for_other_users") && ( + {projectRoles.hasProjectPermission(issue.project.id, "log_time_for_other_users") && ( {settings.features.addNotes && - projectRoles.hasProjectPermission(project.data ?? issue.project, "add_issue_notes") && + projectRoles.hasProjectPermission(issue.project.id, "add_issue_notes") && (!values.add_notes ? ( ) : ( diff --git a/src/components/issues/CurrentIssueTimer.tsx b/src/components/issues/CurrentIssueTimer.tsx index 4624ad8..78b31e5 100644 --- a/src/components/issues/CurrentIssueTimer.tsx +++ b/src/components/issues/CurrentIssueTimer.tsx @@ -13,7 +13,7 @@ const CurrentIssueTimer = ({ issueId }: PropTypes) => { const { data: issue } = useIssue(issueId); const projectRoles = useMyProjectRoles(issue ? [issue.project.id] : []); - if (!issue || !projectRoles.hasProjectPermission(issue.project, "log_time")) return; + if (!issue || !projectRoles.hasProjectPermission(issue.project.id, "log_time")) return; const timer = timers.getTimer(issue.id); diff --git a/src/components/issues/IssuesList.tsx b/src/components/issues/IssuesList.tsx index cd138aa..15bfb51 100644 --- a/src/components/issues/IssuesList.tsx +++ b/src/components/issues/IssuesList.tsx @@ -11,7 +11,7 @@ import useMyUser from "../../hooks/useMyUser"; import useProjectVersions from "../../hooks/useProjectVersions"; import useTimers from "../../hooks/useTimers"; import { useSettings } from "../../provider/SettingsProvider"; -import { TIssue, TProject, TReference } from "../../types/redmine"; +import { TIssue, TReference } from "../../types/redmine"; import { getGroupedIssues, getSortedIssues } from "../../utils/issue"; import CreateIssueModal from "./CreateIssueModal"; import Issue from "./Issue"; @@ -32,7 +32,7 @@ const IssuesList = ({ issues: rawIssues, issuePriorities, projectVersions, timer const myUser = useMyUser(); const projects = useMyProjects(); - const projectRoles = useMyProjectRoles([...new Set(rawIssues.map((i) => i.project.id))]); + const projectRoles = useMyProjectRoles([...new Set(rawIssues.map((i) => i.project.id))], projects.data); const groupedIssues = getGroupedIssues({ issues: getSortedIssues(rawIssues, settings.style.sortIssuesByPriority ? issuePriorities.data : [], timers.timers), projectVersions: projectVersions?.data ?? {}, @@ -45,8 +45,7 @@ const IssuesList = ({ issues: rawIssues, issuePriorities, projectVersions, timer return ( <> - {groupedIssues.map(({ id, project: projectRef, versions, groups }) => { - const project: TProject | TReference | undefined = projects.data?.find((p) => p.id === projectRef?.id) ?? projectRef; + {groupedIssues.map(({ id, project, versions, groups }) => { return ( {project && ( @@ -60,7 +59,7 @@ const IssuesList = ({ issues: rawIssues, issuePriorities, projectVersions, timer
- {projectRoles?.hasProjectPermission(project, "add_issues") && ( + {projectRoles?.hasProjectPermission(project.id, "add_issues") && ( @@ -105,10 +104,11 @@ const IssuesList = ({ issues: rawIssues, issuePriorities, projectVersions, timer priorityType={issuePriorities.getPriorityType(issue)} assignedToMe={myUser.data ? myUser.data.id === issue.assigned_to?.id : true} canEdit={ - projectRoles.hasProjectPermission(issue.project, "edit_issues") || (projectRoles.hasProjectPermission(issue.project, "edit_own_issues") && issue.author.id === myUser.data?.id) + projectRoles.hasProjectPermission(issue.project.id, "edit_issues") || + (projectRoles.hasProjectPermission(issue.project.id, "edit_own_issues") && issue.author.id === myUser.data?.id) } - canLogTime={projectRoles.hasProjectPermission(issue.project, "log_time")} - canAddNotes={projectRoles.hasProjectPermission(issue.project, "add_issue_notes")} + canLogTime={projectRoles.hasProjectPermission(issue.project.id, "log_time")} + canAddNotes={projectRoles.hasProjectPermission(issue.project.id, "add_issue_notes")} timer={timer} /> ); diff --git a/src/components/time/TimeEntryContextMenu.tsx b/src/components/time/TimeEntryContextMenu.tsx index 8eaad3f..9493fce 100644 --- a/src/components/time/TimeEntryContextMenu.tsx +++ b/src/components/time/TimeEntryContextMenu.tsx @@ -38,7 +38,7 @@ function TimeEntryContextMenu({ entry, projectRoles, children, ...props }: PropT { name: formatMessage({ id: "time.time-entry.context-menu.edit" }), icon: , - disabled: !projectRoles.hasProjectPermission(entry.project, "edit_own_time_entries"), + disabled: !projectRoles.hasProjectPermission(entry.project.id, "edit_own_time_entries"), onClick: () => setEdit(true), }, ], diff --git a/src/hooks/useMyProjectRoles.ts b/src/hooks/useMyProjectRoles.ts index 94fb288..4c821d4 100644 --- a/src/hooks/useMyProjectRoles.ts +++ b/src/hooks/useMyProjectRoles.ts @@ -4,7 +4,7 @@ import { useRedmineApi } from "../provider/RedmineApiProvider"; import { TProject, TReference, TRole } from "../types/redmine"; import useMyUser from "./useMyUser"; -const useMyProjectRoles = (projectIds: number[]) => { +const useMyProjectRoles = (projectIds: number[], projects?: TProject[]) => { const redmineApi = useRedmineApi(); const myUser = useMyUser(); @@ -60,9 +60,10 @@ const useMyProjectRoles = (projectIds: number[]) => { data: projectRoles, isLoading: myUser.isLoading || rolesQueries.isLoading, isError: myUser.isError || rolesQueries.isError, - hasProjectPermission: (project: TProject | TReference, permission: TRole["permissions"][number]) => + hasProjectPermission: (projectId: number, permission: TRole["permissions"][number]) => // First check if user is admin, then check if user has the permission in the project roles or if the project is public and the non member role has the permission - myUser.data?.admin || (projectRoles[project.id] ?? ("is_public" in project && project.is_public && nonMemberRole ? [nonMemberRole] : undefined))?.some((r) => r.permissions.includes(permission)), + myUser.data?.admin || + (projectRoles[projectId] ?? (projects?.find((p) => p.id === projectId)?.is_public && nonMemberRole ? [nonMemberRole] : undefined))?.some((r) => r.permissions.includes(permission)), }; }; From 49f6eb6cec9bb43337fce87abd6407465e4011d4 Mon Sep 17 00:00:00 2001 From: CrawlerCode <41094392+CrawlerCode@users.noreply.github.com> Date: Thu, 29 May 2025 18:19:21 +0200 Subject: [PATCH 02/55] refactor: Improve create time entry modal - Move cached comments logic into own hook - Create separate activity field - Improve auto focus --- .../issues/CreateTimeEntryModal.tsx | 102 +++++++++--------- src/components/issues/Issue.tsx | 5 +- .../issues/fields/ActivityField.tsx | 37 +++++++ src/hooks/useCachedComments.ts | 39 +++++++ 4 files changed, 128 insertions(+), 55 deletions(-) create mode 100644 src/components/issues/fields/ActivityField.tsx create mode 100644 src/hooks/useCachedComments.ts diff --git a/src/components/issues/CreateTimeEntryModal.tsx b/src/components/issues/CreateTimeEntryModal.tsx index dcaed7e..d090e20 100644 --- a/src/components/issues/CreateTimeEntryModal.tsx +++ b/src/components/issues/CreateTimeEntryModal.tsx @@ -5,11 +5,10 @@ import { FastField, Form, Formik, FormikProps } from "formik"; import { useEffect, useRef } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as Yup from "yup"; +import useCachedComments from "../../hooks/useCachedComments"; import useMyProjectRoles from "../../hooks/useMyProjectRoles"; import useMyUser from "../../hooks/useMyUser"; import useProject from "../../hooks/useProject"; -import useStorage from "../../hooks/useStorage"; -import useTimeEntryActivities from "../../hooks/useTimeEntryActivities"; import { useRedmineApi } from "../../provider/RedmineApiProvider"; import { useSettings } from "../../provider/SettingsProvider"; import { TCreateTimeEntry, TIssue, TRedmineError, TUpdateIssue } from "../../types/redmine"; @@ -21,7 +20,7 @@ import Fieldset from "../general/Fieldset"; import InputField from "../general/InputField"; import LoadingSpinner from "../general/LoadingSpinner"; import Modal from "../general/Modal"; -import ReactSelectFormik, { shouldUpdate } from "../general/ReactSelectFormik"; +import { shouldUpdate as shouldUpdateReactSelect } from "../general/ReactSelectFormik"; import TextareaField from "../general/TextareaField"; import TimeField from "../general/TimeField"; import Toast from "../general/Toast"; @@ -29,11 +28,12 @@ import Toggle from "../general/Toggle"; import TimeEntryPreview from "../time/TimeEntryPreview"; import DoneSlider from "./DoneSlider"; import SpentVsEstimatedTime from "./SpentVsEstimatedTime"; +import ActivityField from "./fields/ActivityField"; import TimeEntryUsersField from "./fields/TimeEntryUsersField"; type PropTypes = { issue: TIssue; - time: number; + initialValues?: Partial; onClose: () => void; onSuccess: () => void; }; @@ -44,9 +44,7 @@ type TCreateTimeEntryForm = Omit & add_notes?: boolean; }; -const _defaultCachedComments = {}; - -const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => { +const CreateTimeEntryModal = ({ issue, initialValues, onClose, onSuccess }: PropTypes) => { const { formatMessage } = useIntl(); const { settings } = useSettings(); const redmineApi = useRedmineApi(); @@ -57,13 +55,6 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => const myUser = useMyUser(); const project = useProject(issue.project.id); const projectRoles = useMyProjectRoles([issue.project.id], project.data ? [project.data] : undefined); - const timeEntryActivities = useTimeEntryActivities(issue.project.id); - - const cachedComments = useStorage>("cachedComments", _defaultCachedComments); - - useEffect(() => { - formik.current?.setFieldValue("activity_id", timeEntryActivities.defaultActivity?.id); - }, [timeEntryActivities.defaultActivity]); useEffect(() => { if (myUser.data?.id) { @@ -71,14 +62,13 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => } }, [myUser.data?.id]); - useEffect(() => { - if (!settings.features.cacheComments) return; - // load cached comment to formik - const comments = cachedComments.data[issue.id]; - if (comments) { - formik.current?.setFieldValue("comments", comments); - } - }, [settings.features.cacheComments, issue.id, cachedComments.data]); + const cachedComments = useCachedComments({ + identifier: issue.id, + enabled: settings.features.cacheComments, + onLoad: (comment) => { + formik.current?.setFieldValue("comments", comment); + }, + }); const createTimeEntryMutation = useMutation({ mutationFn: (entry: TCreateTimeEntry) => redmineApi.createTimeEntry(entry), @@ -104,13 +94,12 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => { - if (settings.features.cacheComments) { - // if comment or already cached => save/update comment - const comments = formik.current?.values.comments; - if (comments || cachedComments.data[issue.id]) { - cachedComments.setData({ ...cachedComments.data, [issue.id]: comments }); - } + // if comment exist => save/update comment + const comment = formik.current?.values.comments; + if (cachedComments.isEnabled && comment) { + cachedComments.saveComment(comment); } + onClose(); }} > @@ -119,17 +108,19 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => initialValues={ { issue_id: issue.id, - done_ratio: issue.done_ratio, - hours: Number((time / 1000 / 60 / 60).toFixed(2)), + done_ratio: 0, + hours: 0, spent_on: new Date(), user_id: undefined, comments: "", activity_id: undefined, add_notes: false, notes: "", + ...initialValues, } satisfies Partial as unknown as TCreateTimeEntryForm } validationSchema={Yup.object({ + issue_id: Yup.number(), done_ratio: Yup.number().min(0).max(100), hours: Yup.number() .required(formatMessage({ id: "time.time-entry.field.hours.validation.required" })) @@ -145,13 +136,19 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => notes: Yup.string(), })} onSubmit={async (originalValues, { setSubmitting }) => { - const values = { ...originalValues }; - if (values.done_ratio !== issue.done_ratio || (values.add_notes && values.notes)) { - await updateIssueMutation.mutateAsync({ done_ratio: values.done_ratio !== issue.done_ratio ? values.done_ratio : undefined, notes: values.add_notes ? values.notes : undefined }); + const { done_ratio, add_notes, notes, ...values } = { ...originalValues }; + + // Update issue done_ratio and notes + const updatedDoneRatio = done_ratio && done_ratio !== issue.done_ratio ? done_ratio : undefined; + const addNotes = add_notes && notes ? notes : undefined; + if (updatedDoneRatio || addNotes) { + await updateIssueMutation.mutateAsync({ + done_ratio: updatedDoneRatio, + notes: addNotes, + }); } - delete values.done_ratio; - delete values.add_notes; - delete values.notes; + + // Create time entry if (values.user_id && Array.isArray(values.user_id) && values.user_id.length > 0) { // create for multiple users for (const userId of values.user_id) { @@ -161,19 +158,20 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => // create for me await createTimeEntryMutation.mutateAsync({ ...values, user_id: undefined as never }); } + setSubmitting(false); + if (!createTimeEntryMutation.isError) { - if (settings.features.cacheComments) { - // if has cached comment => remove it - if (cachedComments.data[issue.id]) { - cachedComments.setData({ ...cachedComments.data, [issue.id]: undefined }); - } + // if has cached comment => remove it + if (cachedComments.isEnabled && cachedComments.isCached) { + cachedComments.removeComment(); } + onSuccess(); } }} > - {({ isSubmitting, touched, errors, values }) => ( + {({ isSubmitting, touched, errors, values, setFieldValue }) => (

@@ -223,6 +221,7 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => } autoComplete="off" className="col-span-3" + autoFocus={values.hours === 0} /> ) : ( size="sm" autoComplete="off" className="col-span-2" + autoFocus={values.hours === 0} /> )} @@ -265,7 +265,7 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => size="sm" isMulti closeMenuOnSelect={false} - shouldUpdate={shouldUpdate} + shouldUpdate={shouldUpdateReactSelect} /> )} @@ -277,25 +277,19 @@ const CreateTimeEntryModal = ({ issue, time, onClose, onSuccess }: PropTypes) => error={touched.comments && errors.comments} as={InputField} size="sm" - autoFocus + autoFocus={values.hours > 0} /> formatMessage({ id: "general.no-options" })} error={touched.activity_id && errors.activity_id} required - as={ReactSelectFormik} + as={ActivityField} + projectId={issue.project.id} + onDefaultActivityChange={(activityId: number) => setFieldValue("activity_id", activityId)} size="sm" - options={timeEntryActivities.data?.map((activity) => ({ - label: activity.name, - value: activity.id, - }))} - isLoading={timeEntryActivities.isLoading} - shouldUpdate={shouldUpdate} + shouldUpdate={shouldUpdateReactSelect} /> diff --git a/src/components/issues/Issue.tsx b/src/components/issues/Issue.tsx index c06bd3c..b6db976 100644 --- a/src/components/issues/Issue.tsx +++ b/src/components/issues/Issue.tsx @@ -246,7 +246,10 @@ const Issue = ({ issue, priorityType, assignedToMe, canEdit, canLogTime, canAddN {createTimeEntry !== undefined && ( setCreateTimeEntry(undefined)} onSuccess={() => { setCreateTimeEntry(undefined); diff --git a/src/components/issues/fields/ActivityField.tsx b/src/components/issues/fields/ActivityField.tsx new file mode 100644 index 0000000..810be30 --- /dev/null +++ b/src/components/issues/fields/ActivityField.tsx @@ -0,0 +1,37 @@ +import { ComponentProps, useEffect } from "react"; +import { useIntl } from "react-intl"; +import useTimeEntryActivities from "../../../hooks/useTimeEntryActivities"; +import ReactSelectFormik from "../../general/ReactSelectFormik"; + +type Props = { + projectId: number; + onDefaultActivityChange?: (activityId: number) => void; +}; + +const ActivityField = ({ projectId, onDefaultActivityChange, ...props }: ComponentProps & Props) => { + const { formatMessage } = useIntl(); + + const timeEntryActivities = useTimeEntryActivities(projectId); + + useEffect(() => { + if (!timeEntryActivities.defaultActivity) return; + + onDefaultActivityChange?.(timeEntryActivities.defaultActivity.id); + }, [timeEntryActivities.defaultActivity, onDefaultActivityChange]); + + return ( + formatMessage({ id: "general.no-options" })} + options={timeEntryActivities.data?.map((activity) => ({ + label: activity.name, + value: activity.id, + }))} + isLoading={timeEntryActivities.isLoading} + /> + ); +}; + +export default ActivityField; diff --git a/src/hooks/useCachedComments.ts b/src/hooks/useCachedComments.ts new file mode 100644 index 0000000..0c3dd91 --- /dev/null +++ b/src/hooks/useCachedComments.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import useStorage from "./useStorage"; + +const _defaultCachedComments = {}; + +type Options = { + identifier: number; + enabled?: boolean; + onLoad: (comment: string) => void; +}; + +const useCachedComments = ({ identifier, enabled = true, onLoad }: Options) => { + const cachedComments = useStorage>("cachedComments", _defaultCachedComments); + + useEffect(() => { + if (!enabled) return; + const comment = cachedComments.data[identifier]; + if (comment) { + onLoad(comment); + } + }, [enabled, identifier, onLoad, cachedComments.data]); + + return { + isEnabled: enabled, + isCached: !!cachedComments.data[identifier], + saveComment: (comment: string) => + cachedComments.setData({ + ...cachedComments.data, + [identifier]: comment, + }), + removeComment: () => + cachedComments.setData({ + ...cachedComments.data, + [identifier]: undefined, + }), + }; +}; + +export default useCachedComments; From bd55541bf7ccaed7cbb4668ec92a847e0d3e6db8 Mon Sep 17 00:00:00 2001 From: CrawlerCode <41094392+CrawlerCode@users.noreply.github.com> Date: Fri, 30 May 2025 23:09:23 +0200 Subject: [PATCH 03/55] feat: Multiple timers & timer tab - Add separate timer tab - Allow multiple timers per issue - Move context menu into separate component - Add IssueTitle component - Allow to log time directly in redmine (#39) --- src/App.tsx | 22 +- src/api/redmine.ts | 6 +- src/components/general/ContextMenu.tsx | 3 +- src/components/general/Navbar.tsx | 6 +- src/components/issues/AddIssueNotesModal.tsx | 11 +- .../issues/CreateTimeEntryModal.tsx | 10 +- src/components/issues/CurrentIssueTimer.tsx | 15 +- src/components/issues/EditIssueModal.tsx | 8 +- src/components/issues/EditTimer.tsx | 3 + src/components/issues/Issue.tsx | 241 +++-------- src/components/issues/IssueContextMenu.tsx | 144 +++++++ src/components/issues/IssueInfoTooltip.tsx | 5 +- src/components/issues/IssueTimer.tsx | 184 --------- src/components/issues/IssueTitle.tsx | 50 +++ src/components/issues/IssuesList.tsx | 49 ++- src/components/time/TimeEntryContextMenu.tsx | 2 +- src/components/timers/Timer.tsx | 301 ++++++++++++++ src/components/timers/TimerContextMenu.tsx | 48 +++ src/components/timers/TimersBadge.tsx | 11 + src/content.tsx | 1 + src/hooks/useIssues.ts | 48 +++ src/hooks/useLocalIssues.ts | 52 +++ src/hooks/useMyIssues.ts | 10 +- src/hooks/useOnClickOutside.ts | 3 + src/hooks/useSearch.ts | 5 +- src/hooks/useTimers.ts | 375 +++++++++++------- src/lang/de.json | 16 +- src/lang/en.json | 16 +- src/lang/fr.json | 16 +- src/lang/ru.json | 16 +- src/pages/IssuesPage.tsx | 20 +- src/pages/TimersPage.tsx | 55 +++ src/utils/issue.ts | 21 +- 33 files changed, 1142 insertions(+), 631 deletions(-) create mode 100644 src/components/issues/IssueContextMenu.tsx delete mode 100644 src/components/issues/IssueTimer.tsx create mode 100644 src/components/issues/IssueTitle.tsx create mode 100644 src/components/timers/Timer.tsx create mode 100644 src/components/timers/TimerContextMenu.tsx create mode 100644 src/components/timers/TimersBadge.tsx create mode 100644 src/hooks/useIssues.ts create mode 100644 src/hooks/useLocalIssues.ts create mode 100644 src/pages/TimersPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 105e6e3..b2c7a68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { faGear, faList, faStopwatch, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { faCalendarDays, faGear, faList, faStopwatch, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { Suspense, lazy } from "react"; @@ -10,6 +10,7 @@ import { clsxm } from "./utils/clsxm"; import { getPlatform } from "./utils/platform"; import { createPopOut, getWindowLocationType } from "./utils/popout"; +const TimersPage = lazy(() => import("./pages/TimersPage")); const IssuesPage = lazy(() => import("./pages/IssuesPage")); const SettingsPage = lazy(() => import("./pages/SettingsPage")); const TimePage = lazy(() => import("./pages/TimePage")); @@ -32,6 +33,11 @@ function App() {
, + name: formatMessage({ id: "nav.tabs.timers" }), + }, { href: "/issues", icon: , @@ -39,7 +45,7 @@ function App() { }, { href: "/time", - icon: , + icon: , name: formatMessage({ id: "nav.tabs.time" }), }, { @@ -49,7 +55,9 @@ function App() { }, ]} /> - {locationType === "popup" && } + {locationType === "popup" && ( + + )}
} /> + + + + } + /> { {...props} onContextMenu={(e) => { e.preventDefault(); + e.stopPropagation(); setPosition({ x: e.pageX, y: e.pageY }); }} > @@ -62,7 +63,7 @@ const ContextMenu = ({ menu, children, ...props }: PropTypes) => {
setPosition(undefined)} > diff --git a/src/components/general/Navbar.tsx b/src/components/general/Navbar.tsx index 459cd59..c0d36df 100644 --- a/src/components/general/Navbar.tsx +++ b/src/components/general/Navbar.tsx @@ -16,14 +16,14 @@ const Navbar = ({ navigation }: PropTypes) => { const location = useLocation(); return ( -

); }; diff --git a/src/components/issue/IssuesList.tsx b/src/components/issue/IssuesList.tsx index 2831ca6..b4a1c4f 100644 --- a/src/components/issue/IssuesList.tsx +++ b/src/components/issue/IssuesList.tsx @@ -1,6 +1,5 @@ -import { faMagnifyingGlass, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; +import { PlusIcon, SearchIcon } from "lucide-react"; import { Fragment, useState } from "react"; import { FormattedMessage } from "react-intl"; import useActiveRedmineTab from "../../hooks/useActiveRedmineTab"; @@ -14,6 +13,7 @@ import useTimers from "../../hooks/useTimers"; import { useSettings } from "../../provider/SettingsProvider"; import { TIssue, TReference } from "../../types/redmine"; import { getGroupedIssues, getSortedIssues } from "../../utils/issue"; +import { Badge } from "../ui/badge"; import CreateIssueModal from "./CreateIssueModal"; import Issue from "./Issue"; import VersionTooltip from "./VersionTooltip"; @@ -58,7 +58,7 @@ const IssuesList = ({ issues: rawIssues, localIssues, issuePriorities, projectVe {project && (
@@ -68,12 +68,12 @@ const IssuesList = ({ issues: rawIssues, localIssues, issuePriorities, projectVe
{projectRoles?.hasProjectPermission(project.id, "add_issues") && ( )} {onSearchInProject && ( )}
@@ -82,23 +82,22 @@ const IssuesList = ({ issues: rawIssues, localIssues, issuePriorities, projectVe {groups.map(({ type, version, issues }) => ( {settings.style.groupIssuesByVersion && versions.length > 0 && ["version", "no-version"].includes(type) && ( - <> - {version && } -
)} {issues.map((issue) => ( diff --git a/src/components/issue/IssuesListSkeleton.tsx b/src/components/issue/IssuesListSkeleton.tsx index bf30d30..6134179 100644 --- a/src/components/issue/IssuesListSkeleton.tsx +++ b/src/components/issue/IssuesListSkeleton.tsx @@ -1,26 +1,33 @@ import clsx from "clsx"; import { Fragment } from "react"; +import { Skeleton } from "../ui/skeleton"; const IssuesListSkeleton = () => { return ( <> {[...Array(Math.floor(Math.random() * 2 + 2)).keys()].map((i) => ( -
+
+ +
+ + +
+
{[...Array(Math.floor(Math.random() * 5 + 1)).keys()].map((_, i) => { return ( -
-
-
+
+ +
-
+
-
-
-
-
-
-
+
+
+ + + +
diff --git a/src/components/issue/Search.tsx b/src/components/issue/Search.tsx index b100732..b820b38 100644 --- a/src/components/issue/Search.tsx +++ b/src/components/issue/Search.tsx @@ -1,12 +1,12 @@ -import { faChevronRight, faSearch, faX } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; import { ReactNode, Ref, useImperativeHandle, useRef, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import useDebounce from "../../hooks/useDebounce"; import useHotKey from "../../hooks/useHotkey"; import { useSettings } from "../../provider/SettingsProvider"; import { TReference } from "../../types/redmine"; -import TextInput from "../general/TextInput"; +import { Badge } from "../ui/badge"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; export type SearchQuery = { searching: boolean; @@ -76,29 +76,29 @@ const Search = ({ children, ref }: PropTypes) => { return ( <> {isSearching && ( -
- } - type="search" - name="query" - placeholder={formatMessage({ id: "issues.search" })} - value={query} - onChange={(e) => setQuery(e.target.value)} - autoFocus - /> +
+ + setQuery(e.target.value)} autoFocus /> + + + + {inProject && ( -
- +
+ {children}, + badge: (children) => ( + + {children} + + ), }} /> -
- setInProject(undefined)} /> +
+ setInProject(undefined)} />
)} diff --git a/src/components/issue/VersionTooltip.tsx b/src/components/issue/VersionTooltip.tsx index 45d2b6d..2accaab 100644 --- a/src/components/issue/VersionTooltip.tsx +++ b/src/components/issue/VersionTooltip.tsx @@ -1,23 +1,30 @@ import { differenceInDays, parseISO, startOfDay } from "date-fns"; +import { ReactNode } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { Tooltip } from "react-tooltip"; import { TVersion } from "../../types/redmine"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; type PropTypes = { - version: TVersion; + version?: TVersion; + children?: ReactNode; }; -const VersionTooltip = ({ version }: PropTypes) => { +const VersionTooltip = ({ version, children }: PropTypes) => { const { formatDate, formatRelativeTime } = useIntl(); + if (!version) return children; + return ( - -
-

- {version.name} {version.due_date && <>({formatRelativeTime(differenceInDays(parseISO(version.due_date), startOfDay(new Date())), "days")})} - {version.description &&

{version.description}

} -

- + + {children} + +
+

+ {version.name} {version.due_date && <>({formatRelativeTime(differenceInDays(parseISO(version.due_date), startOfDay(new Date())), "days")})} +

+ {version.description &&

{version.description}

} +
+
@@ -35,10 +42,10 @@ const VersionTooltip = ({ version }: PropTypes) => { )}
-
-

- -

+

+ +

+
); }; diff --git a/src/components/issue/form/IssueForm.tsx b/src/components/issue/form/IssueForm.tsx index 2cf4e8f..4db7253 100644 --- a/src/components/issue/form/IssueForm.tsx +++ b/src/components/issue/form/IssueForm.tsx @@ -1,4 +1,6 @@ /* eslint-disable react/no-children-prop */ +import { DialogFooter } from "@/components/ui/dialog"; +import { Form, FormGrid } from "@/components/ui/form"; import { parseISO } from "date-fns"; import { FormattedMessage, useIntl } from "react-intl"; import { z } from "zod/v4"; @@ -10,7 +12,6 @@ import useMyUser from "../../../hooks/useMyUser"; import useProject from "../../../hooks/useProject"; import { TIssue } from "../../../types/redmine"; import DismissibleWarning from "../../general/DismissableWarning"; -import IssueTitle from "../IssueTitle"; import AssigneeField from "./fields/AssigneeField"; import CategoryField from "./fields/CategoryField"; import DoneRatioField from "./fields/DoneRatioField"; @@ -43,10 +44,7 @@ const createOrEditIssueFormSchema = ({ formatMessage }: { formatMessage: ReturnT fixed_version_id: z.int().nullish(), start_date: z.date().nullish(), due_date: z.date().nullish(), - estimated_hours: z - .number() - .min(0.01, formatMessage({ id: "issues.issue.field.estimated-hours.validation.greater-than-zero" })) - .nullish(), + estimated_hours: z.number().nullish(), done_ratio: z.int().min(0).max(100).nullish(), }) .check((ctx) => { @@ -118,13 +116,7 @@ export const IssueForm = (props: PropTypes) => { }); return ( -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > + { const selectedTracker = issueTrackers.data?.find((tracker) => tracker.id === state.values.tracker_id); @@ -135,24 +127,23 @@ export const IssueForm = (props: PropTypes) => { }; }} children={({ selectedTracker, hasTrackerNoEnabledFields }) => ( -
- {props.action === "edit" && } - -
+ <> + ( - formatMessage({ id: "general.no-options" })} required - size="sm" - options={issueTrackers.data?.map((tracker) => ({ - label: tracker.name, - value: tracker.id, - }))} + options={ + issueTrackers.data?.map((tracker) => ({ + label: tracker.name, + value: tracker.id, + })) ?? [] + } isLoading={issueTrackers.isLoading} + className="col-span-1" /> )} listeners={{ @@ -168,13 +159,11 @@ export const IssueForm = (props: PropTypes) => { ( - formatMessage({ id: "general.no-options" })} required - isDisabled={props.action === "edit" ? props.issue.allowed_statuses?.length === 0 : true} - size="sm" + disabled={props.action === "edit" ? props.issue.allowed_statuses?.length === 0 : true} options={ props.action === "create" ? selectedTracker?.default_status @@ -185,49 +174,49 @@ export const IssueForm = (props: PropTypes) => { }, ] : [] - : issueStatuses.data?.map((status) => ({ + : (issueStatuses.data?.map((status) => ({ label: status.name, value: status.id, - })) + })) ?? []) } + className="col-span-1" /> )} /> -
- - ( - - )} - /> - {(hasTrackerNoEnabledFields || selectedTracker?.enabled_standard_fields?.includes("description")) && ( ( - + )} /> - )} -
-
- } /> + {(hasTrackerNoEnabledFields || selectedTracker?.enabled_standard_fields?.includes("description")) && ( + ( + + )} + /> + )} + + + } /> {(hasTrackerNoEnabledFields || selectedTracker?.enabled_standard_fields?.includes("assigned_to_id")) && ( - } /> + } /> )} {(hasTrackerNoEnabledFields || selectedTracker?.enabled_standard_fields?.includes("category_id")) && ( - } /> + } /> )} {(hasTrackerNoEnabledFields || selectedTracker?.enabled_standard_fields?.includes("fixed_version_id")) && ( - } /> + } /> )} -
-
+ + + {(hasTrackerNoEnabledFields || selectedTracker?.enabled_standard_fields?.includes("start_date")) && ( ({ @@ -240,10 +229,13 @@ export const IssueForm = (props: PropTypes) => { )} /> @@ -263,10 +255,13 @@ export const IssueForm = (props: PropTypes) => { )} /> @@ -278,15 +273,14 @@ export const IssueForm = (props: PropTypes) => { ( - + )} /> )} - {hasTrackerNoEnabledFields || - (selectedTracker?.enabled_standard_fields?.includes("done_ratio") && } />)} -
-
+ {hasTrackerNoEnabledFields || (selectedTracker?.enabled_standard_fields?.includes("done_ratio") && } />)} + + {props.action === "create" && issueStatuses.hasIssueNoAllowedStatuses && ( @@ -303,13 +297,14 @@ export const IssueForm = (props: PropTypes) => { - - - - -
+ )} /> - + + + + + + ); }; diff --git a/src/components/issue/form/fields/ActivityField.tsx b/src/components/issue/form/fields/ActivityField.tsx index 9edaeca..b63c251 100644 --- a/src/components/issue/form/fields/ActivityField.tsx +++ b/src/components/issue/form/fields/ActivityField.tsx @@ -1,14 +1,14 @@ +import { ComboboxField } from "@/components/form/ComboboxField"; import { ComponentProps, useEffect } from "react"; import { useIntl } from "react-intl"; import useTimeEntryActivities from "../../../../hooks/useTimeEntryActivities"; -import { SelectField } from "../../../form/SelectField"; type Props = { projectId: number; onDefaultActivityChange?: (activityId: number) => void; }; -const ActivityField = ({ projectId, onDefaultActivityChange, ...props }: ComponentProps & Props) => { +const ActivityField = ({ projectId, onDefaultActivityChange, ...props }: Omit, "options"> & Props) => { const { formatMessage } = useIntl(); const timeEntryActivities = useTimeEntryActivities(projectId); @@ -20,15 +20,16 @@ const ActivityField = ({ projectId, onDefaultActivityChange, ...props }: Compone }, [timeEntryActivities.defaultActivity, onDefaultActivityChange]); return ( - formatMessage({ id: "general.no-options" })} - options={timeEntryActivities.data?.map((activity) => ({ - label: activity.name, - value: activity.id, - }))} + options={ + timeEntryActivities.data?.map((activity) => ({ + label: activity.name, + value: activity.id, + })) ?? [] + } isLoading={timeEntryActivities.isLoading} /> ); diff --git a/src/components/issue/form/fields/AssigneeField.tsx b/src/components/issue/form/fields/AssigneeField.tsx index 0a76b78..f41b79a 100644 --- a/src/components/issue/form/fields/AssigneeField.tsx +++ b/src/components/issue/form/fields/AssigneeField.tsx @@ -1,15 +1,15 @@ +import { ComboboxField } from "@/components/form/ComboboxField"; import { ComponentProps, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import useMyUser from "../../../../hooks/useMyUser"; import useProjectUsers from "../../../../hooks/useProjectUsers"; import { getGroupedUsers } from "../../../../utils/user"; -import { SelectField } from "../../../form/SelectField"; type Props = { projectId: number; }; -const AssigneeField = ({ projectId, ...props }: ComponentProps & Props) => { +const AssigneeField = ({ projectId, ...props }: Omit, "options"> & Props) => { const { formatMessage } = useIntl(); const [loadUsers, setLoadUsers] = useState(false); @@ -21,12 +21,11 @@ const AssigneeField = ({ projectId, ...props }: ComponentProps getGroupedUsers(users.data), [users.data]); return ( - formatMessage({ id: "general.no-options" })} - onFocus={() => setLoadUsers(true)} + onOpen={() => setLoadUsers(true)} options={ loadUsers ? groupedUsers.map(({ role, users }) => ({ diff --git a/src/components/issue/form/fields/CategoryField.tsx b/src/components/issue/form/fields/CategoryField.tsx index be3fff9..6a9d218 100644 --- a/src/components/issue/form/fields/CategoryField.tsx +++ b/src/components/issue/form/fields/CategoryField.tsx @@ -1,13 +1,13 @@ +import { ComboboxField } from "@/components/form/ComboboxField"; import { ComponentProps } from "react"; import { useIntl } from "react-intl"; import useProject from "../../../../hooks/useProject"; -import { SelectField } from "../../../form/SelectField"; type Props = { projectId: number; }; -const CategoryField = ({ projectId, ...props }: ComponentProps & Props) => { +const CategoryField = ({ projectId, ...props }: Omit, "options"> & Props) => { const { formatMessage } = useIntl(); const project = useProject(projectId); @@ -15,15 +15,16 @@ const CategoryField = ({ projectId, ...props }: ComponentProps formatMessage({ id: "general.no-options" })} - options={project.data?.issue_categories?.map((category) => ({ - label: category.name, - value: category.id, - }))} + options={ + project.data?.issue_categories?.map((category) => ({ + label: category.name, + value: category.id, + })) ?? [] + } isLoading={project.isLoading} /> ); diff --git a/src/components/issue/form/fields/DoneRatioField.tsx b/src/components/issue/form/fields/DoneRatioField.tsx index b2b4fc3..a9ca9b6 100644 --- a/src/components/issue/form/fields/DoneRatioField.tsx +++ b/src/components/issue/form/fields/DoneRatioField.tsx @@ -1,9 +1,8 @@ import { ComponentProps } from "react"; import { useIntl } from "react-intl"; -import { Props } from "react-select"; import { SelectField } from "../../../form/SelectField"; -const DoneRatioField = ({ ...props }: ComponentProps & Props) => { +const DoneRatioField = ({ ...props }: Omit, "options">) => { const { formatMessage } = useIntl(); return ( @@ -11,7 +10,6 @@ const DoneRatioField = ({ ...props }: ComponentProps & Props {...props} title={formatMessage({ id: "issues.issue.field.done-ratio" })} placeholder={formatMessage({ id: "issues.issue.field.done-ratio" })} - noOptionsMessage={() => formatMessage({ id: "general.no-options" })} options={[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((value) => ({ label: `${value} %`, value, diff --git a/src/components/issue/form/fields/DoneSliderField.tsx b/src/components/issue/form/fields/DoneSliderField.tsx index 8b520ef..9069c1d 100644 --- a/src/components/issue/form/fields/DoneSliderField.tsx +++ b/src/components/issue/form/fields/DoneSliderField.tsx @@ -1,3 +1,4 @@ +import { Field } from "@/components/ui/field"; import clsx from "clsx"; import { ComponentProps } from "react"; import { useFieldContext } from "../../../../hooks/useAppForm"; @@ -6,21 +7,23 @@ const DoneSliderField = ({ className, ...props }: Omit, const { state, handleChange, handleBlur } = useFieldContext(); return ( -
- handleChange(e.target.valueAsNumber)} - onBlur={handleBlur} - min="0" - max="100" - step="10" - type="range" - className={clsx("h-5 w-[80px] cursor-pointer appearance-none border-transparent", "focus:ring-primary-focus focus:ring-2 focus:outline-hidden")} - style={{ background: `linear-gradient(90deg, #bae0ba ${state.value * 0.9 + 10}%, #eeeeee ${state.value * 0.9 + 10}%)` }} - /> -

{state.value}%

-
+ +
+ handleChange(e.target.valueAsNumber)} + onBlur={handleBlur} + min="0" + max="100" + step="10" + type="range" + className={clsx("h-5 w-[80px] cursor-pointer appearance-none border-transparent", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]")} + style={{ background: `linear-gradient(90deg, #bae0ba ${state.value * 0.9 + 10}%, #eeeeee ${state.value * 0.9 + 10}%)` }} + /> +

{state.value}%

+
+
); }; diff --git a/src/components/issue/form/fields/PriorityField.tsx b/src/components/issue/form/fields/PriorityField.tsx index d9a1f26..42515c3 100644 --- a/src/components/issue/form/fields/PriorityField.tsx +++ b/src/components/issue/form/fields/PriorityField.tsx @@ -1,23 +1,24 @@ +import { ComboboxField } from "@/components/form/ComboboxField"; import { ComponentProps } from "react"; import { useIntl } from "react-intl"; import useIssuePriorities from "../../../../hooks/useIssuePriorities"; -import { SelectField } from "../../../form/SelectField"; -const PriorityField = (props: ComponentProps) => { +const PriorityField = (props: Omit, "options">) => { const { formatMessage } = useIntl(); const issuePriorities = useIssuePriorities(); return ( - formatMessage({ id: "general.no-options" })} - options={issuePriorities.data?.map((priority) => ({ - label: priority.name, - value: priority.id, - }))} + options={ + issuePriorities.data?.map((priority) => ({ + label: priority.name, + value: priority.id, + })) ?? [] + } isLoading={issuePriorities.isLoading} /> ); diff --git a/src/components/issue/form/fields/VersionField.tsx b/src/components/issue/form/fields/VersionField.tsx index af3ad60..a6154db 100644 --- a/src/components/issue/form/fields/VersionField.tsx +++ b/src/components/issue/form/fields/VersionField.tsx @@ -1,13 +1,13 @@ +import { ComboboxField } from "@/components/form/ComboboxField"; import { ComponentProps } from "react"; import { useIntl } from "react-intl"; import useProjectVersions from "../../../../hooks/useProjectVersions"; -import { SelectField } from "../../../form/SelectField"; type Props = { projectId: number; }; -const VersionField = ({ projectId, ...props }: ComponentProps & Props) => { +const VersionField = ({ projectId, ...props }: Omit, "options"> & Props) => { const { formatMessage } = useIntl(); const projectVersions = useProjectVersions([projectId]); @@ -15,17 +15,18 @@ const VersionField = ({ projectId, ...props }: ComponentProps formatMessage({ id: "general.no-options" })} - options={projectVersions.data[projectId] - ?.filter((version) => version.status === "open") - .map((version) => ({ - label: version.name, - value: version.id, - }))} + options={ + projectVersions.data[projectId] + ?.filter((version) => version.status === "open") + .map((version) => ({ + label: version.name, + value: version.id, + })) ?? [] + } isLoading={projectVersions.isLoading} /> ); diff --git a/src/components/time-entry/CreateTimeEntryModal.tsx b/src/components/time-entry/CreateTimeEntryModal.tsx index 3e0f559..6a05dd6 100644 --- a/src/components/time-entry/CreateTimeEntryModal.tsx +++ b/src/components/time-entry/CreateTimeEntryModal.tsx @@ -1,7 +1,5 @@ /* eslint-disable react/no-children-prop */ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { AxiosError, isAxiosError } from "axios"; -import clsx from "clsx"; import { startOfDay } from "date-fns"; import { useIntl } from "react-intl"; import z from "zod/v4"; @@ -12,14 +10,13 @@ import useMyUser from "../../hooks/useMyUser"; import useProject from "../../hooks/useProject"; import { useRedmineApi } from "../../provider/RedmineApiProvider"; import { useSettings } from "../../provider/SettingsProvider"; -import { TCreateTimeEntry, TIssue, TRedmineError, TUpdateIssue } from "../../types/redmine"; -import Fieldset from "../general/Fieldset"; -import Modal from "../general/Modal"; -import Toast from "../general/Toast"; +import { TCreateTimeEntry, TIssue, TUpdateIssue } from "../../types/redmine"; import ActivityField from "../issue/form/fields/ActivityField"; import { DoneSliderField } from "../issue/form/fields/DoneSliderField"; import IssueTitle from "../issue/IssueTitle"; import SpentVsEstimatedTime from "../issue/SpentVsEstimatedTime"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; +import { Form, FormFieldset, FormGrid } from "../ui/form"; import UserField from "./form/fields/UserField"; import TimeEntryPreview from "./TimeEntryPreview"; @@ -68,6 +65,9 @@ const CreateTimeEntryModal = ({ issue, initialValues, onClose, onSuccess }: Prop }); } }, + meta: { + successMessage: formatMessage({ id: "issues.modal.add-spent-time.success" }), + }, }); const updateIssueMutation = useMutation({ @@ -138,9 +138,9 @@ const CreateTimeEntryModal = ({ issue, initialValues, onClose, onSuccess }: Prop return ( <> - { + { // if comment exist => save/update comment const comment = form.state.values.comments; if (cachedComments.isEnabled && (comment || cachedComments.isCached)) { @@ -150,140 +150,106 @@ const CreateTimeEntryModal = ({ issue, initialValues, onClose, onSuccess }: Prop onClose(); }} > -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > -
+ + + + {formatMessage({ id: "issues.modal.add-spent-time.title" })} + + + } /> -
- } /> + state.values.hours} children={(hours) => } /> - state.values.hours} children={(hours) => } /> -
+ ({ + hours: state.values.hours, + spent_on: state.values.spent_on, + })} + children={({ hours, spent_on }) => } + /> - ({ - hours: state.values.hours, - spent_on: state.values.spent_on, - })} - children={({ hours, spent_on }) => } - /> + + + ( + + )} + /> -
-
- ( - - )} - /> + ( + + )} + /> - ( - + {projectRoles.hasProjectPermission(issue.project.id, "log_time_for_other_users") && ( + } /> )} - /> -
- - {projectRoles.hasProjectPermission(issue.project.id, "log_time_for_other_users") && ( - } /> - )} - ( - 0} + ( + 0} + /> + )} /> - )} - /> - field.setValue(activityId)} required size="sm" />} - /> -
+ field.setValue(activityId)} required />} + /> +
+
- {settings.features.addNotes && projectRoles.hasProjectPermission(issue.project.id, "add_issue_notes") && ( - state.values.add_notes} - children={(add_notes) => - !add_notes ? ( - } /> - ) : ( -
+ {settings.features.addNotes && projectRoles.hasProjectPermission(issue.project.id, "add_issue_notes") && ( + state.values.add_notes} + children={(add_notes) => + !add_notes ? ( } /> - } /> -
- ) - } - /> - )} - - - - -
-
-
- {createTimeEntryMutation.isError && ( - ).response?.data?.errors?.join(", ") ?? (createTimeEntryMutation.error as AxiosError).message) - : (createTimeEntryMutation.error as Error).message - } - /> - )} - {updateIssueMutation.isError && ( - ).response?.data?.errors?.join(", ") ?? (updateIssueMutation.error as AxiosError).message) - : (updateIssueMutation.error as Error).message - } - /> - )} + ) : ( + + + } /> + } /> + + + ) + } + /> + )} + + + + + + + + + ); }; diff --git a/src/components/time-entry/EditTimeEntryModal.tsx b/src/components/time-entry/EditTimeEntryModal.tsx index b6fbf9c..9ea61e0 100644 --- a/src/components/time-entry/EditTimeEntryModal.tsx +++ b/src/components/time-entry/EditTimeEntryModal.tsx @@ -1,7 +1,5 @@ /* eslint-disable react/no-children-prop */ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { AxiosError, isAxiosError } from "axios"; -import clsx from "clsx"; import { parseISO } from "date-fns"; import { useIntl } from "react-intl"; import { z } from "zod/v4"; @@ -9,12 +7,12 @@ import { useAppForm } from "../../hooks/useAppForm"; import useIssue from "../../hooks/useIssue"; import { useRedmineApi } from "../../provider/RedmineApiProvider"; import { useSettings } from "../../provider/SettingsProvider"; -import { TRedmineError, TTimeEntry, TUpdateTimeEntry } from "../../types/redmine"; -import { clsxm } from "../../utils/clsxm"; -import Modal from "../general/Modal"; -import TextInput from "../general/TextInput"; -import Toast from "../general/Toast"; +import { TTimeEntry, TUpdateTimeEntry } from "../../types/redmine"; import ActivityField from "../issue/form/fields/ActivityField"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; +import { Field, FieldLabel } from "../ui/field"; +import { Form, FormGrid } from "../ui/form"; +import { Input } from "../ui/input"; type PropTypes = { entry: TTimeEntry; @@ -29,7 +27,7 @@ const editTimeEntryFormSchema = ({ formatMessage }: { formatMessage: ReturnType< .min(0.01, formatMessage({ id: "time.time-entry.field.hours.validation.greater-than-zero" })) .max(24, formatMessage({ id: "time.time-entry.field.hours.validation.less-than-24" })), spent_on: z.date(formatMessage({ id: "time.time-entry.field.spent-on.validation.required" })).max(new Date(), formatMessage({ id: "time.time-entry.field.spent-on.validation.in-future" })), - comments: z.string().optional(), + comments: z.string().nullish(), activity_id: z.int(formatMessage({ id: "time.time-entry.field.activity.validation.required" })), }); @@ -52,6 +50,9 @@ const EditTimeEntryModal = ({ entry, onClose, onSuccess }: PropTypes) => { queryKey: ["timeEntries"], }); }, + meta: { + successMessage: formatMessage({ id: "time.modal.edit-time-entry.success" }), + }, }); const form = useAppForm({ @@ -73,108 +74,71 @@ const EditTimeEntryModal = ({ entry, onClose, onSuccess }: PropTypes) => { }); return ( - <> - -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > -
- + + + + + {formatMessage({ id: "time.modal.edit-time-entry.title" })} + + + + {formatMessage({ id: "time.time-entry.field.project" })} + + {issue.data && ( - + + {formatMessage({ id: "time.time-entry.field.issue" })} + + )} -
- ( - - )} - /> - - ( - - )} - /> -
+ ( + + )} + /> ( - + )} /> - } /> + } + /> + } /> +
+ -
-
-
- {updateTimeEntryMutation.isError && ( - ).response?.data?.errors?.join(", ") ?? (updateTimeEntryMutation.error as AxiosError).message) - : (updateTimeEntryMutation.error as Error).message - } - /> - )} - + + + + ); }; diff --git a/src/components/time-entry/TimeEntry.tsx b/src/components/time-entry/TimeEntry.tsx index e83ba92..56631c1 100644 --- a/src/components/time-entry/TimeEntry.tsx +++ b/src/components/time-entry/TimeEntry.tsx @@ -1,8 +1,8 @@ import { Fragment } from "react"; -import { Tooltip } from "react-tooltip"; import useFormatHours from "../../hooks/useFormatHours"; import useMyProjectRoles from "../../hooks/useMyProjectRoles"; import { TTimeEntry } from "../../types/redmine"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import TimeEntryContextMenu from "./TimeEntryContextMenu"; import TimeEntryTooltip from "./TimeEntryTooltip"; @@ -24,47 +24,47 @@ const TimeEntry = ({ entries, previewHours, maxHours = 24, withContextMenu = fal
{entries.map((entry) => ( - - {withContextMenu ? ( - - ) : ( + + {withContextMenu ? ( + + ) : ( +
+ )} + + + ))} + {(previewHours && ( + +
- )} - - ))} - {(previewHours && ( - <> - + +

{formatHours(previewHours)}

-
-
- + + )) || undefined}
; -} & Omit, "menu">; +} & ComponentProps; function TimeEntryContextMenu({ entry, projectRoles, children, ...props }: PropTypes) { const { formatMessage } = useIntl(); @@ -22,30 +21,25 @@ function TimeEntryContextMenu({ entry, projectRoles, children, ...props }: PropT return ( <> - , - onClick: () => { - window.open(`${settings.redmineURL}/time_entries/${entry.id}/edit`, "_blank"); - }, - }, - ], - [ - { - name: formatMessage({ id: "time.time-entry.context-menu.edit" }), - icon: , - onClick: () => setEdit(true), - disabled: !projectRoles.hasProjectPermission(entry.project.id, "edit_own_time_entries"), - }, - ], - ]} - > - {children} + + {children} + + { + window.open(`${settings.redmineURL}/time_entries/${entry.id}/edit`, "_blank"); + }} + > + + {formatMessage({ id: "time.time-entry.context-menu.open-in-redmine" })} + + + setEdit(true)} disabled={!projectRoles.hasProjectPermission(entry.project.id, "edit_own_time_entries")}> + + {formatMessage({ id: "time.time-entry.context-menu.edit" })} + + + {edit && setEdit(false)} onSuccess={() => setEdit(false)} />} ); diff --git a/src/components/time-entry/TimeEntryList.tsx b/src/components/time-entry/TimeEntryList.tsx index b89ab45..701aa56 100644 --- a/src/components/time-entry/TimeEntryList.tsx +++ b/src/components/time-entry/TimeEntryList.tsx @@ -1,8 +1,10 @@ import { addDays, format, isFuture, isMonday, isWeekend, parseISO, previousMonday, startOfDay, subWeeks } from "date-fns"; +import { ClockIcon } from "lucide-react"; import { useIntl } from "react-intl"; import useFormatHours from "../../hooks/useFormatHours"; import { TTimeEntry } from "../../types/redmine"; import { roundHours } from "../../utils/date"; +import { Badge } from "../ui/badge"; import TimeEntry from "./TimeEntry"; type PropTypes = { @@ -43,7 +45,7 @@ const TimeEntryList = ({ entries }: PropTypes) => { const monday = isMonday(today) ? today : previousMonday(today); return ( - <> +
{Array(2) .fill(monday) .map((d, i) => subWeeks(d, i)) @@ -53,17 +55,15 @@ const TimeEntryList = ({ entries }: PropTypes) => { .map((d, i) => addDays(d, 6 - i)); const summedHours = groupedEntries.filter((entries) => days.find((d) => d.getTime() === entries.date.getTime())).reduce((sum, entry) => sum + entry.hours, 0); return ( -
+

{formatDate(monday)} - {formatDate(addDays(monday, 6))}

- - + + {formatHours(roundHours(summedHours))} - +
{days.map((d, i) => { if (isFuture(d)) return; @@ -80,7 +80,7 @@ const TimeEntryList = ({ entries }: PropTypes) => { return (

{format(date, "EEE")}

-

{formatHours(roundHours(hours))}

+

{formatHours(roundHours(hours))}

0 ? maxHours : undefined} withContextMenu />
@@ -90,7 +90,7 @@ const TimeEntryList = ({ entries }: PropTypes) => {
); })} - +
); }; diff --git a/src/components/time-entry/TimeEntryListSkeleton.tsx b/src/components/time-entry/TimeEntryListSkeleton.tsx index 287f318..3c9f3b0 100644 --- a/src/components/time-entry/TimeEntryListSkeleton.tsx +++ b/src/components/time-entry/TimeEntryListSkeleton.tsx @@ -1,29 +1,41 @@ +import { Skeleton } from "../ui/skeleton"; + const TimeEntryListSkeleton = () => { return ( - <> +
{[...Array(2).keys()].map((i) => { return ( -
+
-

- + +
{[...Array(5).keys()].map((i) => { + const entries = [...Array(Math.floor(Math.random() * 4 + 1)).keys()].map(() => Math.floor(Math.random() * 2 + 1) / 8); + const sumHours = entries.reduce((sum, entry) => sum + entry, 0); return ( -
-

-

+
+ +
-
- {[...Array(Math.floor(Math.random() * 4 + 1)).keys()].map((i) => ( -
+ {entries.map((hours, i) => ( + ))} +
@@ -32,7 +44,7 @@ const TimeEntryListSkeleton = () => {
); })} - +
); }; diff --git a/src/components/time-entry/TimeEntryPreview.tsx b/src/components/time-entry/TimeEntryPreview.tsx index 0316df0..0e6cc16 100644 --- a/src/components/time-entry/TimeEntryPreview.tsx +++ b/src/components/time-entry/TimeEntryPreview.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import useFormatHours from "../../hooks/useFormatHours"; import useMyTimeEntries from "../../hooks/useMyTimeEntries"; import { roundHours } from "../../utils/date"; @@ -6,9 +7,10 @@ import TimeEntry from "./TimeEntry"; type PropTypes = { date: Date; previewHours: number; + className?: string; }; -const TimeEntryPreview = ({ date, previewHours }: PropTypes) => { +const TimeEntryPreview = ({ date, previewHours, className }: PropTypes) => { const formatHours = useFormatHours(); const myTimeEntriesQuery = useMyTimeEntries(date, date); @@ -16,7 +18,7 @@ const TimeEntryPreview = ({ date, previewHours }: PropTypes) => { const sumHours = myTimeEntriesQuery.data.reduce((sum, entry) => sum + entry.hours, 0) + previewHours; return ( -
+

{formatHours(roundHours(sumHours))}

12 ? sumHours : 12} /> diff --git a/src/components/time-entry/TimeEntryTooltip.tsx b/src/components/time-entry/TimeEntryTooltip.tsx index 3e59ced..5d5fd91 100644 --- a/src/components/time-entry/TimeEntryTooltip.tsx +++ b/src/components/time-entry/TimeEntryTooltip.tsx @@ -1,23 +1,24 @@ +import { ReactNode } from "react"; import { FormattedMessage } from "react-intl"; -import { Tooltip } from "react-tooltip"; import useFormatHours from "../../hooks/useFormatHours"; import { TTimeEntry } from "../../types/redmine"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; type PropTypes = { entry: TTimeEntry; + children?: ReactNode; }; -const TimeEntryTooltip = ({ entry }: PropTypes) => { +const TimeEntryTooltip = ({ entry, children }: PropTypes) => { const formatHours = useFormatHours(); return ( - -
-

- {formatHours(entry.hours)} - {entry.comments &&

{entry.comments}

} -

- + + {children} + +

{formatHours(entry.hours)}

+ {entry.comments &&

{entry.comments}

} +
@@ -47,7 +48,7 @@ const TimeEntryTooltip = ({ entry }: PropTypes) => {
-
+
); }; diff --git a/src/components/time-entry/form/fields/UserField.tsx b/src/components/time-entry/form/fields/UserField.tsx index f3acc1c..7b691c7 100644 --- a/src/components/time-entry/form/fields/UserField.tsx +++ b/src/components/time-entry/form/fields/UserField.tsx @@ -1,15 +1,15 @@ +import { ComboboxField } from "@/components/form/ComboboxField"; import { ComponentProps, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import useMyUser from "../../../../hooks/useMyUser"; import useProjectUsers from "../../../../hooks/useProjectUsers"; import { getGroupedUsers } from "../../../../utils/user"; -import { SelectField } from "../../../form/SelectField"; type Props = { projectId: number; }; -const UserField = ({ projectId, ...props }: ComponentProps & Props) => { +const UserField = ({ projectId, ...props }: Omit, "options"> & Props) => { const { formatMessage } = useIntl(); const [loadUsers, setLoadUsers] = useState(false); @@ -21,12 +21,12 @@ const UserField = ({ projectId, ...props }: ComponentProps & const groupedUsers = useMemo(() => getGroupedUsers(users.data), [users.data]); return ( - formatMessage({ id: "time.time-entry.field.user.no-options" })} - onFocus={() => setLoadUsers(true)} + noOptionsMessage={formatMessage({ id: "time.time-entry.field.user.no-options" })} + onOpen={() => setLoadUsers(true)} options={ loadUsers ? groupedUsers.map(({ role, users }) => ({ diff --git a/src/components/timer/EditTimer.tsx b/src/components/timer/EditTimer.tsx index 1c5b177..df78b5c 100644 --- a/src/components/timer/EditTimer.tsx +++ b/src/components/timer/EditTimer.tsx @@ -1,9 +1,9 @@ import clsx from "clsx"; import { FocusEvent, useState } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; import useHotKey from "../../hooks/useHotkey"; -import Button from "../general/Button"; -import Modal from "../general/Modal"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../ui/alert-dialog"; +import { Input } from "../ui/input"; type PropTypes = { initTime: number; @@ -29,17 +29,15 @@ const EditTimer = ({ initTime, onOverrideTime, onCancel: onConfirmCancel }: Prop return ( <>
- 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500" - )} + className={clsx("h-8 appearance-none p-0 text-center", initTime > 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500", { + "w-4": h.length === 1, + "w-6": h.length === 2, + "w-8": h.length >= 3, + })} /** * auto focus & select input on focus */ @@ -67,17 +65,12 @@ const EditTimer = ({ initTime, onOverrideTime, onCancel: onConfirmCancel }: Prop }} /> : - 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500" - )} + className={clsx("h-8 w-6 appearance-none p-0 text-center", initTime > 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500")} onChange={(e) => { const { value, min, max } = e.target; setM(to2Digit(Math.max(Number(min), Math.min(Number(max), Number(value))))); @@ -100,17 +93,12 @@ const EditTimer = ({ initTime, onOverrideTime, onCancel: onConfirmCancel }: Prop }} /> : - 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500" - )} + className={clsx("h-8 w-6 appearance-none p-0 text-center", initTime > 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500")} onChange={(e) => { const { value, min, max } = e.target; setS(to2Digit(Math.max(Number(min), Math.min(Number(max), Number(value))))); @@ -134,19 +122,18 @@ const EditTimer = ({ initTime, onOverrideTime, onCancel: onConfirmCancel }: Prop />
{confirmCancelModal && ( - -

- -

-
- - -
-
+ + + + {formatMessage({ id: "issues.modal.save-changes.title" })} + {formatMessage({ id: "issues.modal.save-changes.message" })} + + + {formatMessage({ id: "issues.modal.save-changes.cancel" })} + onOverrideTime(updatedTime)}>{formatMessage({ id: "issues.modal.save-changes.save" })} + + + )} ); diff --git a/src/components/timer/Timer.tsx b/src/components/timer/Timer.tsx index d1a8a85..c101843 100644 --- a/src/components/timer/Timer.tsx +++ b/src/components/timer/Timer.tsx @@ -1,20 +1,17 @@ -import { faCircleCheck } from "@fortawesome/free-regular-svg-icons"; -import { faPause, faPlay, faStop } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; +import { BadgeCheckIcon, TimerIcon, TimerOffIcon, TimerResetIcon } from "lucide-react"; import { ComponentProps, Ref, useEffect, useImperativeHandle, useRef, useState } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; -import { Tooltip } from "react-tooltip"; +import { useIntl } from "react-intl"; import { PriorityType } from "../../hooks/useIssuePriorities"; import { TimerController } from "../../hooks/useTimers"; import { useSettings } from "../../provider/SettingsProvider"; import { TIssue } from "../../types/redmine"; import { clsxm } from "../../utils/clsxm"; import { formatTimer, roundTimeNearestInterval } from "../../utils/date"; -import Button from "../general/Button"; -import Modal from "../general/Modal"; +import HelpTooltip from "../general/HelpTooltip"; import IssueTitle from "../issue/IssueTitle"; import CreateTimeEntryModal from "../time-entry/CreateTimeEntryModal"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../ui/alert-dialog"; import EditTimer from "./EditTimer"; import TimerContextMenu from "./TimerContextMenu"; @@ -64,13 +61,13 @@ const TimerWrapper = ({ variant = "inner", timer, issue, issuePriorityType }: Ti ref={timerRef} displayNameField={variant === "expanded"} className={clsx( - "bg-background hover:bg-background-hover rounded-lg border border-gray-200 p-0.5 px-1.5 dark:border-gray-700", - "focus:ring-primary-focus focus:ring-4 focus:outline-hidden" + "bg-card rounded-lg border border-gray-200 p-0.5 px-1.5 dark:border-gray-700", + "focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]" )} tabIndex={1} // On "Enter" or "Space" => toggle timer onKeyDown={(e) => { - if (e.key === "Enter" || e.code === "Space") { + if ((e.key === "Enter" || e.code === "Space") && e.currentTarget === document.activeElement) { timer.toggleTimer(); e.preventDefault(); e.stopPropagation(); @@ -90,9 +87,9 @@ const TimerWrapper = ({ variant = "inner", timer, issue, issuePriorityType }: Ti role="listitem" data-type="timer" className={clsxm( - "relative block w-full rounded-lg p-1", - "focus:ring-primary-focus focus:ring-4 focus:outline-hidden", - "bg-background hover:bg-background-hover border border-gray-200 dark:border-gray-700" + "bg-card relative flex w-full flex-col gap-1 rounded-lg p-1", + "border border-gray-200 dark:border-gray-700", + "focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]" )} tabIndex={1} // On "Enter" or "Space" => toggle timer @@ -104,7 +101,7 @@ const TimerWrapper = ({ variant = "inner", timer, issue, issuePriorityType }: Ti } }} > - {issue ? :

#{timer.issueId}

} + {issue ? :

#{timer.issueId}

} setCreateTimeEntryHours(hours)} />
@@ -162,7 +159,7 @@ const Timer = ({ timer, onTimerDone, ref, displayNameField, ...props }: TimerPro {displayNameField && ( setEditMode(false)} /> )) || ( - <> - {settings.style.showTooltips && ( - - )} - 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500", timer.isActive && "font-bold")} - onDoubleClick={() => setEditMode(true)} - data-tooltip-id={`tooltip-edit-timer-${timer.id}`} - > + + 0 ? "text-yellow-500" : "text-gray-700 dark:text-gray-500", timer.isActive && "font-bold")} onDoubleClick={() => setEditMode(true)}> {formatTimer(currenTime)} - + )} {!timer.isActive ? ( - <> - {settings.style.showTooltips && ( - - - - )} - - + + + ) : ( - <> - {settings.style.showTooltips && ( - - - - )} - - + + + )} - <> - {settings.style.showTooltips && ( - - )} - setConfirmResetModal(true)} - data-tooltip-id={`tooltip-reset-timer-${timer.id}`} - tabIndex={-1} - /> - + + setConfirmResetModal(true)} tabIndex={-1} /> + - <> - {settings.style.showTooltips && ( - - )} - + { const time = settings.features.roundToNearestInterval ? roundTimeNearestInterval(currenTime, settings.features.roundingInterval) : currenTime; const hours = Number((time / 1000 / 60 / 60).toFixed(2)); onTimerDone(hours); }} - data-tooltip-id={`tooltip-done-timer-${timer.id}`} tabIndex={-1} /> - +
{confirmResetModal && ( - setConfirmResetModal(false)}> -

- -

-
- - -
-
+ setConfirmResetModal(false)}> + + + {formatMessage({ id: "issues.modal.reset-timer.title" })} + {formatMessage({ id: "issues.modal.reset-timer.message" })} + + + {formatMessage({ id: "issues.modal.reset-timer.cancel" })} + timer.resetTimer()}>{formatMessage({ id: "issues.modal.reset-timer.reset" })} + + + )} ); diff --git a/src/components/timer/TimerContextMenu.tsx b/src/components/timer/TimerContextMenu.tsx index 092b8d8..c2204be 100644 --- a/src/components/timer/TimerContextMenu.tsx +++ b/src/components/timer/TimerContextMenu.tsx @@ -1,9 +1,8 @@ -import { faPause, faPen, faPlay, faStop, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { PencilIcon, TimerIcon, TimerOffIcon, TimerResetIcon, TrashIcon } from "lucide-react"; import { ReactNode } from "react"; import { useIntl } from "react-intl"; import { TimerController } from "../../hooks/useTimers"; -import ContextMenu from "../general/ContextMenu"; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "../ui/context-menu"; type PropTypes = { timer: TimerController; @@ -15,32 +14,26 @@ const TimerContextMenu = ({ timer, children, onEdit }: PropTypes) => { const { formatMessage } = useIntl(); return ( - , - onClick: timer.isActive ? timer.pauseTimer : timer.startTimer, - }, - { - name: formatMessage({ id: "timer.context-menu.edit" }), - icon: , - onClick: onEdit, - }, - { - name: formatMessage({ id: "timer.context-menu.reset" }), - icon: , - onClick: timer.resetTimer, - disabled: timer.getElapsedTime() === 0, - }, - { - name: formatMessage({ id: "timer.context-menu.delete" }), - icon: , - onClick: timer.deleteTimer, - }, - ]} - > - {children} + + {children} + + + {timer.isActive ? : } + {formatMessage({ id: timer.isActive ? "timer.context-menu.pause" : "timer.context-menu.start" })} + + + + {formatMessage({ id: "timer.context-menu.edit" })} + + + + {formatMessage({ id: "timer.context-menu.reset" })} + + + + {formatMessage({ id: "timer.context-menu.delete" })} + + ); }; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..8616857 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,84 @@ +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; +import * as React from "react"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ className, ...props }: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogDescription({ className, ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogAction({ className, ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogCancel({ className, ...props }: React.ComponentProps) { + return ; +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..3ee4429 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,33 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { + return
; +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Alert, AlertDescription, AlertTitle }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f61b9f0 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,30 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot as SlotPrimitive } from "radix-ui"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? SlotPrimitive.Slot : "span"; + + return ; +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..3a35198 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,50 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot as SlotPrimitive } from "radix-ui"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? SlotPrimitive.Slot : "button"; + + return ; +} + +export { Button, buttonVariants }; diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..ef835d7 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,131 @@ +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import * as React from "react"; +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"]; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn("flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav), + button_previous: cn(buttonVariants({ variant: buttonVariant }), "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", defaultClassNames.button_previous), + button_next: cn(buttonVariants({ variant: buttonVariant }), "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", defaultClassNames.button_next), + month_caption: cn("flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", defaultClassNames.month_caption), + dropdowns: cn("w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", defaultClassNames.dropdowns), + dropdown_root: cn("relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", defaultClassNames.dropdown_root), + dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" ? "text-sm" : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn("text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", defaultClassNames.weekday), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header), + week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number), + day: cn( + "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + defaultClassNames.day + ), + range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn("bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", defaultClassNames.today), + outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside), + disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + // eslint-disable-next-line react/prop-types + Root: ({ className, rootRef, ...props }) => { + return
; + }, + // eslint-disable-next-line react/prop-types + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ; + } + + if (orientation === "right") { + return ; + } + + return ; + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
{children}
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( +