diff --git a/app/bun.lockb b/app/bun.lockb index 9faef3bd..d5b56ce8 100755 Binary files a/app/bun.lockb and b/app/bun.lockb differ diff --git a/app/package.json b/app/package.json index 258f4148..16130e2e 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ "@hookform/resolvers": "^3.9.1", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.1", diff --git a/app/src/components/ui/checkbox.tsx b/app/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..ddbdd01d --- /dev/null +++ b/app/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/app/src/lib/api/mutations/repositories.ts b/app/src/lib/api/mutations/repositories.ts index 89740eba..dd4c4963 100644 --- a/app/src/lib/api/mutations/repositories.ts +++ b/app/src/lib/api/mutations/repositories.ts @@ -9,6 +9,7 @@ export const repositoriesMutations = { useAddRepository, useFollowRepository, useDeleteRepository, + useUpdateMilltimeProject, }; function useAddRepository(options?: DefaultMutationOptions) { @@ -107,3 +108,26 @@ export type DeleteRepositoryBody = { project: string; repoName: string; }; + +type UpdateMilltimeProjectsBody = RepoKey & { + milltimeProjectIds: string[]; +}; + +function useUpdateMilltimeProjects( + options?: DefaultMutationOptions, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ["updateMilltimeProjects"], + mutationFn: (body: UpdateMilltimeProjectsBody) => + api.post("repositories/milltime-projects", { + json: body, + }), + ...options, + onSuccess: (data, vars, ctx) => { + queryClient.invalidateQueries(queries.differs()); + options?.onSuccess?.(data, vars, ctx); + }, + }); +} diff --git a/app/src/lib/api/queries/differs.ts b/app/src/lib/api/queries/differs.ts index a7bb9866..0a630ccf 100644 --- a/app/src/lib/api/queries/differs.ts +++ b/app/src/lib/api/queries/differs.ts @@ -22,4 +22,5 @@ export type Differ = { nanos: number; } | null; isInvalid: boolean; + milltimeProjectId?: string; }; diff --git a/app/src/routes/_layout/prs/$prId/route.tsx b/app/src/routes/_layout/prs/$prId/route.tsx index a22a9897..b0c9a5aa 100644 --- a/app/src/routes/_layout/prs/$prId/route.tsx +++ b/app/src/routes/_layout/prs/$prId/route.tsx @@ -1,40 +1,41 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { queries } from "@/lib/api/queries/queries"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { AzureAvatar } from "@/components/azure-avatar"; +import { AccordionContent, AccordionTrigger } from "@/components/ui/accordion"; +import { Accordion, AccordionItem } from "@/components/ui/accordion"; +import { Dialog, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { DialogContent } from "@/components/ui/dialog"; import BranchLink from "@/components/branch-link"; import { PRLink } from "@/components/pr-link"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { DialogFooter, DialogHeader } from "@/components/ui/dialog"; +import { mutations } from "@/lib/api/mutations/mutations"; import { ListPullRequest, - Thread as PullRequestThread, User, + Thread as PullRequestThread, } from "@/lib/api/queries/pullRequests"; -import { queries } from "@/lib/api/queries/queries"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; import dayjs from "dayjs"; import { ClipboardCopy, - CodeXmlIcon, MessageCircleCodeIcon, + CodeXmlIcon, } from "lucide-react"; -import { toast } from "sonner"; -import Markdown from "react-markdown"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; import React from "react"; +import { toast } from "sonner"; import { PRNotificationSettings } from "../-components/pr-notification-settings"; +import { Popover } from "@/components/ui/popover"; +import { + Select, + SelectItem, + SelectContent, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import Markdown from "react-markdown"; export const Route = createFileRoute("/_layout/prs/$prId")({ loader: ({ context }) => @@ -52,7 +53,38 @@ function PRDetailsDialog() { select: (data) => data.find((pr) => pr.id === +prId), }); - const copyTimeReportTextToClipboard = (mode: "review" | "develop") => { + const { data: differs } = useSuspenseQuery(queries.differs()); + const { data: allProjects } = useSuspenseQuery(queries.listProjects()); + const [selectedProject, setSelectedProject] = React.useState( + null, + ); + const [selectedActivity, setSelectedActivity] = React.useState( + null, + ); + const [timeReportMode, setTimeReportMode] = React.useState< + "review" | "develop" | null + >(null); + + const differ = differs.find((d) => d.repoName === pr?.repoName); + const connectedProjects = allProjects.filter((p) => + differ?.milltimeProjectIds.includes(p.projectId), + ); + + const { data: activities } = useSuspenseQuery({ + ...queries.listActivities(selectedProject ?? ""), + enabled: !!selectedProject, + }); + + const { mutate: startTimer } = mutations.useStartTimer({ + onSuccess: () => { + toast.success("Timer started successfully"); + setSelectedProject(null); + setSelectedActivity(null); + setTimeReportMode(null); + }, + }); + + const getTimeReportText = (mode: "review" | "develop") => { let text = ""; const workItem = pr?.workItems.at(0); if (!workItem) { @@ -61,7 +93,10 @@ function PRDetailsDialog() { const parentWorkItem = workItem.parentId; text = `${parentWorkItem ? `#${parentWorkItem} ` : ""}#${workItem.id} - ${mode === "review" ? "[CR] " : ""}${workItem.title}`; } + return text; + }; + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); toast.info(
@@ -73,6 +108,35 @@ function PRDetailsDialog() { ); }; + const startTimeReport = () => { + if (!timeReportMode || !selectedProject || !selectedActivity) return; + + const text = getTimeReportText(timeReportMode); + copyToClipboard(text); + + const project = projects.find((p) => p.projectId === selectedProject); + if (!project) { + toast.error("Project not found"); + return; + } + + const activity = activities?.find((a) => a.activity === selectedActivity); + if (!activity) { + toast.error("Activity not found"); + return; + } + + startTimer({ + projectId: project.projectId, + projectName: project.projectName, + activity: activity.activity, + activityName: activity.activityName, + userNote: text, + regDay: dayjs().format("YYYY-MM-DD"), + weekNumber: dayjs().week(), + }); + }; + if (!pr) { return null; } @@ -91,26 +155,168 @@ function PRDetailsDialog() { - - + + +
+
+

Select Project

+ +
+ {selectedProject && ( +
+

Select Activity

+ +
+ )} + +
+
+ + { + if (open) { + setTimeReportMode("develop"); + } else { + setTimeReportMode(null); + setSelectedProject(null); + setSelectedActivity(null); + } + }} > - - Develop - + + + + +
+
+

Select Project

+ +
+ {selectedProject && ( +
+

Select Activity

+ +
+ )} + +
+
+
diff --git a/app/src/routes/_layout/prs/-components/pr-details-dialog.tsx b/app/src/routes/_layout/prs/-components/pr-details-dialog.tsx new file mode 100644 index 00000000..3667df79 --- /dev/null +++ b/app/src/routes/_layout/prs/-components/pr-details-dialog.tsx @@ -0,0 +1,210 @@ +import { AzureAvatar } from "@/components/azure-avatar"; +import BranchLink from "@/components/branch-link"; +import { PRLink } from "@/components/pr-link"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ListPullRequest } from "@/lib/api/queries/pullRequests"; +import { queries } from "@/lib/api/queries/queries"; +import { mutations } from "@/lib/api/mutations/mutations"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import { + ClipboardCopy, + CodeXmlIcon, + MessageCircleCodeIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import React from "react"; +import { PRNotificationSettings } from "./pr-notification-settings"; +import { TimerSelectionForm } from "./timer-selection-form"; + +interface PRDetailsDialogProps { + pr: ListPullRequest; + prId: string; + parentSearch: Record; +} + +export function PRDetailsDialog({ pr, prId, parentSearch }: PRDetailsDialogProps) { + const navigate = useNavigate(); + + const { data: differs } = useSuspenseQuery(queries.differs()); + const { data: allProjects } = useSuspenseQuery(queries.listProjects()); + const [selectedProject, setSelectedProject] = React.useState(null); + const [selectedActivity, setSelectedActivity] = React.useState(null); + const [timeReportMode, setTimeReportMode] = React.useState<"review" | "develop" | null>(null); + + const differ = differs.find(d => d.repoName === pr?.repoName); + const connectedProjects = allProjects.filter(p => differ?.milltimeProjectIds.includes(p.projectId)); + + const { data: activities } = useSuspenseQuery({ + ...queries.listActivities(selectedProject ?? ""), + enabled: !!selectedProject, + }); + + const { mutate: startTimer } = mutations.useStartTimer({ + onSuccess: () => { + toast.success("Timer started successfully"); + setSelectedProject(null); + setSelectedActivity(null); + setTimeReportMode(null); + }, + }); + + const getTimeReportText = (mode: "review" | "develop") => { + let text = ""; + const workItem = pr?.workItems.at(0); + if (!workItem) { + text = `!${prId} - ${mode === "review" ? "[CR] " : ""}${pr?.title}`; + } else { + const parentWorkItem = workItem.parentId; + text = `${parentWorkItem ? `#${parentWorkItem} ` : ""}#${workItem.id} - ${mode === "review" ? "[CR] " : ""}${workItem.title}`; + } + return text; + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.info( +
+ +

+ Copied {text} to clipboard +

+
, + ); + }; + + const startTimeReport = () => { + if (!timeReportMode || !selectedProject || !selectedActivity) return; + + const text = getTimeReportText(timeReportMode); + copyToClipboard(text); + + const project = allProjects.find(p => p.projectId === selectedProject); + if (!project) { + toast.error("Project not found"); + return; + } + + const activity = activities?.find(a => a.activity === selectedActivity); + if (!activity) { + toast.error("Activity not found"); + return; + } + + startTimer({ + projectId: project.projectId, + projectName: project.projectName, + activity: activity.activity, + activityName: activity.activityName, + userNote: text, + regDay: dayjs().format("YYYY-MM-DD"), + weekNumber: dayjs().week(), + }); + }; + + return ( + { + if (!open) { + navigate({ to: "..", search: parentSearch }); + } + }} + > + + + + + +

{pr.title}

+
+
+ + + +
+ + + { + if (open) { + setTimeReportMode("review"); + } else { + setTimeReportMode(null); + setSelectedProject(null); + setSelectedActivity(null); + } + }}> + + + + + + + + { + if (open) { + setTimeReportMode("develop"); + } else { + setTimeReportMode(null); + setSelectedProject(null); + setSelectedActivity(null); + } + }}> + + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/app/src/routes/_layout/prs/-components/timer-selection-form.tsx b/app/src/routes/_layout/prs/-components/timer-selection-form.tsx new file mode 100644 index 00000000..e3bc91a3 --- /dev/null +++ b/app/src/routes/_layout/prs/-components/timer-selection-form.tsx @@ -0,0 +1,91 @@ +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Activity, ProjectSearchItem } from "@/lib/api/queries/milltime"; + +interface TimerSelectionFormProps { + projects: ProjectSearchItem[]; + selectedProject: string | null; + setSelectedProject: (value: string) => void; + selectedActivity: string | null; + setSelectedActivity: (value: string) => void; + activities: Activity[] | undefined; + onStartTimer: () => void; +} + +export function TimerSelectionForm({ + projects, + selectedProject, + setSelectedProject, + selectedActivity, + setSelectedActivity, + activities, + onStartTimer, +}: TimerSelectionFormProps) { + if (!projects.length) { + return ( +
+

+ No Milltime projects connected to this repository. Connect projects in the Repositories view. +

+
+ ); + } + + return ( +
+
+

Select Project

+ +
+ {selectedProject && ( +
+

Select Activity

+ +
+ )} + +
+ ) +} \ No newline at end of file diff --git a/app/src/routes/_layout/repositories/-components/repo-card.tsx b/app/src/routes/_layout/repositories/-components/repo-card.tsx index ef875594..4035ca03 100644 --- a/app/src/routes/_layout/repositories/-components/repo-card.tsx +++ b/app/src/routes/_layout/repositories/-components/repo-card.tsx @@ -24,8 +24,12 @@ import { PlayCircle, Trash, Unplug, + Timer, } from "lucide-react"; import { toast } from "sonner"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { queries } from "@/lib/api/queries/queries"; +import { Checkbox } from "@/components/ui/checkbox"; export const RepoCard = (props: { differ: Differ; @@ -47,6 +51,13 @@ export const RepoCard = (props: { const { mutate: deleteRepository, isPending: isDeleting } = mutations.useDeleteRepository(); + const { data: projects } = useSuspenseQuery(queries.listProjects()); + const { mutate: updateMilltimeProject } = mutations.useUpdateMilltimeProject({ + onSuccess: () => { + toast.success("Milltime project updated successfully"); + }, + }); + return ( +
+ +
+

Connected Milltime Projects

+ {projects.map((project) => ( +
+ { + const newProjectIds = checked + ? [...props.differ.milltimeProjectIds, project.projectId] + : props.differ.milltimeProjectIds.filter(id => id !== project.projectId); + updateMilltimeProjects({ + ...props.differ, + milltimeProjectIds: newProjectIds, + }); + }} + /> + +
+ ))} +
+
)} diff --git a/toki-api/migrations/20240124000000_add_milltime_project_id.sql b/toki-api/migrations/20240124000000_add_milltime_project_id.sql new file mode 100644 index 00000000..0cfe34e6 --- /dev/null +++ b/toki-api/migrations/20240124000000_add_milltime_project_id.sql @@ -0,0 +1 @@ +ALTER TABLE repositories ADD COLUMN milltime_project_id TEXT; \ No newline at end of file diff --git a/toki-api/migrations/20240124000000_add_repository_milltime_projects.sql b/toki-api/migrations/20240124000000_add_repository_milltime_projects.sql new file mode 100644 index 00000000..21ad0495 --- /dev/null +++ b/toki-api/migrations/20240124000000_add_repository_milltime_projects.sql @@ -0,0 +1,5 @@ +CREATE TABLE repository_milltime_projects ( + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + milltime_project_id TEXT NOT NULL, + PRIMARY KEY (repository_id, milltime_project_id) +); \ No newline at end of file diff --git a/toki-api/src/domain/repository.rs b/toki-api/src/domain/repository.rs index bf5426d6..d7262471 100644 --- a/toki-api/src/domain/repository.rs +++ b/toki-api/src/domain/repository.rs @@ -9,6 +9,7 @@ pub struct Repository { pub organization: String, pub project: String, pub repo_name: String, + pub milltime_project_ids: Vec, } impl From<&Repository> for RepoKey { diff --git a/toki-api/src/repositories/repository_repo.rs b/toki-api/src/repositories/repository_repo.rs index 6c594d48..a5b46c5f 100644 --- a/toki-api/src/repositories/repository_repo.rs +++ b/toki-api/src/repositories/repository_repo.rs @@ -7,7 +7,8 @@ use super::repo_error::RepositoryError; pub trait RepoRepository { async fn get_repositories(&self) -> Result, RepositoryError>; async fn upsert_repository(&self, repository: &NewRepository) -> Result; - async fn delete_repository(&self, repo_key: &RepoKey) -> Result<(), RepositoryError>; // Added method + async fn delete_repository(&self, repo_key: &RepoKey) -> Result<(), RepositoryError>; + async fn update_milltime_projects(&self, repo_key: &RepoKey, milltime_project_ids: Vec) -> Result<(), RepositoryError>; } pub struct RepoRepositoryImpl { @@ -22,17 +23,28 @@ impl RepoRepositoryImpl { impl RepoRepository for RepoRepositoryImpl { async fn get_repositories(&self) -> Result, RepositoryError> { - let repos = sqlx::query_as!( - Repository, + let repos = sqlx::query!( r#" - SELECT id, organization, project, repo_name - FROM repositories + SELECT r.id, r.organization, r.project, r.repo_name, + ARRAY_AGG(rmp.milltime_project_id) FILTER (WHERE rmp.milltime_project_id IS NOT NULL) as milltime_project_ids + FROM repositories r + LEFT JOIN repository_milltime_projects rmp ON r.id = rmp.repository_id + GROUP BY r.id, r.organization, r.project, r.repo_name "# ) .fetch_all(&self.pool) .await?; - Ok(repos) + Ok(repos + .into_iter() + .map(|r| Repository { + id: r.id, + organization: r.organization, + project: r.project, + repo_name: r.repo_name, + milltime_project_ids: r.milltime_project_ids.unwrap_or_default(), + }) + .collect()) } async fn upsert_repository(&self, repository: &NewRepository) -> Result { @@ -72,6 +84,51 @@ impl RepoRepository for RepoRepositoryImpl { Ok(()) } + + async fn update_milltime_projects(&self, repo_key: &RepoKey, milltime_project_ids: Vec) -> Result<(), RepositoryError> { + let mut tx = self.pool.begin().await?; + + // Get repository ID + let repo = sqlx::query!( + r#" + SELECT id FROM repositories + WHERE organization = $1 AND project = $2 AND repo_name = $3 + "#, + repo_key.organization, + repo_key.project, + repo_key.repo_name, + ) + .fetch_one(&mut *tx) + .await?; + + // Delete existing connections + sqlx::query!( + r#" + DELETE FROM repository_milltime_projects + WHERE repository_id = $1 + "#, + repo.id, + ) + .execute(&mut *tx) + .await?; + + // Insert new connections + for project_id in milltime_project_ids { + sqlx::query!( + r#" + INSERT INTO repository_milltime_projects (repository_id, milltime_project_id) + VALUES ($1, $2) + "#, + repo.id, + project_id, + ) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } } pub struct NewRepository { diff --git a/toki-api/src/routes/differs.rs b/toki-api/src/routes/differs.rs index 8b078eef..85b43f15 100644 --- a/toki-api/src/routes/differs.rs +++ b/toki-api/src/routes/differs.rs @@ -40,6 +40,7 @@ struct Differ { refresh_interval: Option, followed: bool, is_invalid: bool, + milltime_project_id: Option, } #[instrument(name = "get_differs", skip(user, app_state))] @@ -82,6 +83,7 @@ async fn get_differs( let last_updated = *differ.last_updated.read().await; let refresh_interval = *differ.interval.read().await; + let repo = all_repos.iter().find(|r| RepoKey::from(*r) == key).unwrap(); differ_dtos.push(Differ { key: key.clone(), status, @@ -90,6 +92,7 @@ async fn get_differs( followed: followed_repos.contains(&key), is_invalid: false, repo_id: differ_to_repo_id[&key], + milltime_project_id: repo.milltime_project_id.clone(), }); } @@ -105,6 +108,7 @@ async fn get_differs( followed: followed_repos.contains(&key), is_invalid: true, repo_id: repo.id, + milltime_project_id: repo.milltime_project_id.clone(), }); } } diff --git a/toki-api/src/routes/repositories.rs b/toki-api/src/routes/repositories.rs index 0638e1fb..68e87bfe 100644 --- a/toki-api/src/routes/repositories.rs +++ b/toki-api/src/routes/repositories.rs @@ -25,6 +25,7 @@ pub fn router() -> Router { .route("/", get(get_repositories)) .route("/", post(add_repository)) .route("/follow", post(follow_repository)) + .route("/milltime-projects", post(update_milltime_projects)) } #[instrument(name = "GET /repositories", skip(app_state))] @@ -181,3 +182,33 @@ async fn delete_repository( Ok(StatusCode::OK) } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdateMilltimeProjectsBody { + organization: String, + project: String, + repo_name: String, + milltime_project_ids: Vec, +} + +#[instrument(name = "POST /repositories/milltime-projects", skip(app_state))] +async fn update_milltime_projects( + State(app_state): State, + Json(body): Json, +) -> Result { + let repo_key = RepoKey::new(&body.organization, &body.project, &body.repo_name); + let repository_repo = app_state.repository_repo.clone(); + + repository_repo + .update_milltime_projects(&repo_key, body.milltime_project_ids) + .await + .map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update Milltime projects: {}", err), + ) + })?; + + Ok(StatusCode::OK) +}