From 046c42eb483f9b7f0fe9471925bf9a3f03abe7ac Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Mon, 5 May 2025 22:34:04 -0700 Subject: [PATCH] Add tasks page Signed-off-by: Bryan Frimin --- apps/console/src/components/NavMain.tsx | 2 +- .../src/pages/organizations/Routes.tsx | 2 + .../organizations/measures/MeasureView.tsx | 1 + .../MeasureViewCreateTaskMutation.graphql.ts | 5 +- .../organizations/tasks/ListTaskPage.tsx | 43 + .../organizations/tasks/ListTaskView.tsx | 888 +++++++++++ .../ListTaskViewCreateTaskMutation.graphql.ts | 238 +++ ...skViewOrganizationMeasuresQuery.graphql.ts | 271 ++++ .../ListTaskViewOrganizationQuery.graphql.ts | 267 ++++ .../ListTaskViewQuery.graphql.ts | 328 ++++ ...TaskViewUpdateTaskStateMutation.graphql.ts | 118 ++ pkg/coredata/{mesure.go => measure.go} | 6 - pkg/coredata/migrations/20250505T171400Z.sql | 8 + pkg/coredata/risk.go | 3 +- pkg/coredata/task.go | 222 ++- pkg/probo/evidence_service.go | 12 +- .../{mesure_service.go => measure_service.go} | 17 +- pkg/probo/task_service.go | 84 +- pkg/server/api/console/v1/schema.graphql | 27 +- pkg/server/api/console/v1/schema/schema.go | 1360 ++++++++++++++++- pkg/server/api/console/v1/types/types.go | 33 +- pkg/server/api/console/v1/v1_resolver.go | 191 ++- 22 files changed, 3929 insertions(+), 197 deletions(-) create mode 100644 apps/console/src/pages/organizations/tasks/ListTaskPage.tsx create mode 100644 apps/console/src/pages/organizations/tasks/ListTaskView.tsx create mode 100644 apps/console/src/pages/organizations/tasks/__generated__/ListTaskViewCreateTaskMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/tasks/__generated__/ListTaskViewOrganizationMeasuresQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/tasks/__generated__/ListTaskViewOrganizationQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/tasks/__generated__/ListTaskViewQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/tasks/__generated__/ListTaskViewUpdateTaskStateMutation.graphql.ts rename pkg/coredata/{mesure.go => measure.go} (98%) create mode 100644 pkg/coredata/migrations/20250505T171400Z.sql rename pkg/probo/{mesure_service.go => measure_service.go} (96%) diff --git a/apps/console/src/components/NavMain.tsx b/apps/console/src/components/NavMain.tsx index 6fe06af88..c83817e18 100644 --- a/apps/console/src/components/NavMain.tsx +++ b/apps/console/src/components/NavMain.tsx @@ -139,7 +139,7 @@ function getNavItems(organizationId?: string): NavItem[] { title: "Tasks", icon: Inbox, url: organizationId - ? `/organizations/${organizationId}/dashboard` + ? `/organizations/${organizationId}/tasks` : undefined, }, { diff --git a/apps/console/src/pages/organizations/Routes.tsx b/apps/console/src/pages/organizations/Routes.tsx index 4773596ef..7e626f32d 100644 --- a/apps/console/src/pages/organizations/Routes.tsx +++ b/apps/console/src/pages/organizations/Routes.tsx @@ -27,12 +27,14 @@ import { ListRiskPage } from "./risks/ListRiskPage"; import ShowRiskView from "./risks/ShowRiskView"; import { ListVendorPage } from "./vendors/ListVendorPage"; import { VendorPage } from "./vendors/VendorPage"; +import { ListTaskPage } from "./tasks/ListTaskPage"; export function OrganizationsRoutes() { return ( }> } /> + } /> } /> } /> } /> diff --git a/apps/console/src/pages/organizations/measures/MeasureView.tsx b/apps/console/src/pages/organizations/measures/MeasureView.tsx index bbd79d100..4971bec23 100644 --- a/apps/console/src/pages/organizations/measures/MeasureView.tsx +++ b/apps/console/src/pages/organizations/measures/MeasureView.tsx @@ -982,6 +982,7 @@ function MeasureViewContent({ variables: { connections: [`${data.measure.tasks?.__id}`], input: { + organizationId: organizationId!, measureId: data.measure.id, name: newTaskName, description: newTaskDescription, diff --git a/apps/console/src/pages/organizations/measures/__generated__/MeasureViewCreateTaskMutation.graphql.ts b/apps/console/src/pages/organizations/measures/__generated__/MeasureViewCreateTaskMutation.graphql.ts index 277ac85ac..2dd0e3a6b 100644 --- a/apps/console/src/pages/organizations/measures/__generated__/MeasureViewCreateTaskMutation.graphql.ts +++ b/apps/console/src/pages/organizations/measures/__generated__/MeasureViewCreateTaskMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -13,8 +13,9 @@ export type TaskState = "DONE" | "TODO"; export type CreateTaskInput = { assignedToId?: string | null | undefined; description: string; - measureId: string; + measureId?: string | null | undefined; name: string; + organizationId: string; timeEstimate?: any | null | undefined; }; export type MeasureViewCreateTaskMutation$variables = { diff --git a/apps/console/src/pages/organizations/tasks/ListTaskPage.tsx b/apps/console/src/pages/organizations/tasks/ListTaskPage.tsx new file mode 100644 index 000000000..5610f0cc4 --- /dev/null +++ b/apps/console/src/pages/organizations/tasks/ListTaskPage.tsx @@ -0,0 +1,43 @@ +import { PageTemplateSkeleton } from "@/components/PageTemplate"; +import { Suspense } from "react"; +import { lazy } from "@probo/react-lazy"; +import { useLocation } from "react-router"; +import { ErrorBoundaryWithLocation } from "../ErrorBoundary"; + +const ListTaskView = lazy(() => import("./ListTaskView")); + +export function ListTaskViewSkeleton() { + return ( + +
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ + ); +} + +export function ListTaskPage() { + const location = useLocation(); + + return ( + }> + + + + + ); +} \ No newline at end of file diff --git a/apps/console/src/pages/organizations/tasks/ListTaskView.tsx b/apps/console/src/pages/organizations/tasks/ListTaskView.tsx new file mode 100644 index 000000000..af3a5add8 --- /dev/null +++ b/apps/console/src/pages/organizations/tasks/ListTaskView.tsx @@ -0,0 +1,888 @@ +import { Suspense, useEffect, useState } from "react"; +import { + useQueryLoader, + graphql, + PreloadedQuery, + usePreloadedQuery, + useMutation, + useRelayEnvironment, + fetchQuery, +} from "react-relay"; +import { useSearchParams, useParams } from "react-router"; +import { PageTemplate } from "@/components/PageTemplate"; +import { ListTaskViewSkeleton } from "./ListTaskPage"; +import { ListTaskViewQuery, TaskState } from "./__generated__/ListTaskViewQuery.graphql"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Avatar } from "@/components/ui/avatar"; +import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogClose, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + CircleDashed, + Circle, + Plus, + Filter, + Search, + CornerDownRight, + Flame, + Building, + X, + ChevronDown, + User, + CheckCircle2, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ListTaskViewOrganizationQuery } from "./__generated__/ListTaskViewOrganizationQuery.graphql"; + +// Function to format ISO8601 duration to human-readable format +const formatDuration = (isoDuration: string | null | undefined): string => { + if (!isoDuration || !isoDuration.startsWith("P")) { + return isoDuration || ""; + } + + try { + const durationRegex = + /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; + const matches = isoDuration.match(durationRegex); + + if (!matches) return isoDuration; + + const years = matches[1] ? parseInt(matches[1]) : 0; + const months = matches[2] ? parseInt(matches[2]) : 0; + const days = matches[3] ? parseInt(matches[3]) : 0; + const hours = matches[4] ? parseInt(matches[4]) : 0; + const minutes = matches[5] ? parseInt(matches[5]) : 0; + const seconds = matches[6] ? parseInt(matches[6]) : 0; + + const parts = []; + if (years) parts.push(`${years} ${years === 1 ? "year" : "years"}`); + if (months) parts.push(`${months} ${months === 1 ? "month" : "months"}`); + if (days) parts.push(`${days} ${days === 1 ? "day" : "days"}`); + if (hours) parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`); + if (minutes) + parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`); + if (seconds) + parts.push(`${seconds} ${seconds === 1 ? "second" : "seconds"}`); + + return parts.length > 0 ? parts.join(", ") : "No duration"; + } catch (error) { + console.error("Error parsing duration:", error); + return isoDuration; + } +}; + +// Define organization measures query +const organizationMeasuresQuery = graphql` + query ListTaskViewOrganizationMeasuresQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ... on Organization { + measures(first: 100) @connection(key: "Organization__measures") { + edges { + node { + id + name + description + category + state + } + } + } + } + } + } +`; + +// Define the create task mutation +const createTaskMutation = graphql` + mutation ListTaskViewCreateTaskMutation( + $input: CreateTaskInput! + $connections: [ID!]! + ) { + createTask(input: $input) { + taskEdge @prependEdge(connections: $connections) { + node { + id + name + description + state + timeEstimate + measure { + id + name + } + assignedTo { + id + fullName + } + } + } + } + } +`; + +// Define the update task mutation +const updateTaskStateMutation = graphql` + mutation ListTaskViewUpdateTaskStateMutation( + $input: UpdateTaskInput! + ) { + updateTask(input: $input) { + task { + id + state + } + } + } +`; + +const listTaskViewQuery = graphql` + query ListTaskViewQuery($organizationId: ID!) { + node(id: $organizationId) { + id + ... on Organization { + name + tasks(first: 100) @connection(key: "ListTaskView_tasks") { + __id + edges { + node { + id + name + description + state + timeEstimate + measure { + id + name + } + assignedTo { + id + fullName + } + } + } + } + } + } + } +`; + +// Define organization members query +const organizationQuery = graphql` + query ListTaskViewOrganizationQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ... on Organization { + peoples(first: 100, orderBy: { direction: ASC, field: FULL_NAME }) + @connection(key: "ListTaskView_peoples") { + edges { + node { + id + fullName + primaryEmailAddress + } + } + } + } + } + } +`; + +interface Badge { + id: string; + name: string; + type: 'FIRE' | 'BANK' | string; +} + +interface Task { + id: string; + name: string; + description?: string; + state: string; + timeEstimate?: number | null; + assignedTo?: { + id: string; + fullName: string; + } | null; + parent?: { + id: string; + name: string; + } | null; + badges?: Badge[]; +} + +function TaskCard({ task, onClick, onToggleState }: { + task: Task; + onClick?: (task: Task) => void; + onToggleState?: (taskId: string, newState: TaskState) => void; +}) { + return ( +
onClick && onClick(task)} + > +
+
{ + e.stopPropagation(); + if (onToggleState) { + const newState = task.state === "TODO" ? "DONE" : "TODO"; + onToggleState(task.id, newState as TaskState); + } + }} + > + {task.state === "TODO" ? ( + + ) : task.state === "DONE" ? ( + + ) : ( + + )} +
+
+
+ + {task.name} + + + {task.state === "TODO" ? "To Do" : "Done"} + + {task.timeEstimate && ( + + {formatDuration(task.timeEstimate.toString())} + + )} +
+ {task.parent && ( +
+ + {task.parent.name} +
+ )} +
+
+ +
+
+ {task.badges && task.badges.map((badge) => ( + + {badge.type === "FIRE" ? ( + + ) : badge.type === "BANK" ? ( + + ) : null} + {badge.name} + + ))} +
+ {task.assignedTo && ( + +
+ {task.assignedTo.fullName.split(' ').map((n: string) => n[0]).join('')} +
+
+ )} +
+
+ ); +} + +function ListTaskContent({ + queryRef, + organizationQueryRef, +}: { + queryRef: PreloadedQuery; + organizationQueryRef: PreloadedQuery; +}) { + const data = usePreloadedQuery(listTaskViewQuery, queryRef); + const organizationData = usePreloadedQuery( + organizationQuery, + organizationQueryRef + ); + + const organization = data.node; + const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); + + // Get organization members + const members = organizationData.organization?.peoples?.edges.map(edge => edge.node) || []; + + // Fetch measures + const environment = useRelayEnvironment(); + const [measures, setMeasures] = useState>([]); + const [measuresLoading, setMeasuresLoading] = useState(false); + const [measureSearchQuery, setMeasureSearchQuery] = useState(""); + + // Task form state + const [taskTitle, setTaskTitle] = useState(""); + const [taskDescription, setTaskDescription] = useState(""); + const [taskStatus, setTaskStatus] = useState<"TODO" | "DONE">("TODO"); + const [assignee, setAssignee] = useState<{id: string, fullName: string} | null>(null); + const [measureId, setMeasureId] = useState(null); + const [selectedMeasure, setSelectedMeasure] = useState<{id: string, name: string} | null>(null); + const [timeEstimate, setTimeEstimate] = useState(null); + const [timeUnit, setTimeUnit] = useState<"minutes" | "hours" | "days" | "weeks">("hours"); + + // Create task mutation + const [createTask, isCreatingTask] = useMutation(createTaskMutation); + + // Update task state mutation + const [updateTaskState] = useMutation(updateTaskStateMutation); + + // Fetch measures from the organization + useEffect(() => { + if (organization?.id) { + setMeasuresLoading(true); + fetchQuery( + environment, + organizationMeasuresQuery, + { organizationId: organization.id } + ).subscribe({ + next: (data: any) => { + if (data.organization?.measures?.edges) { + const measuresList = data.organization.measures.edges.map((edge: any) => ({ + id: edge.node.id, + name: edge.node.name, + category: edge.node.category, + description: edge.node.description, + state: edge.node.state + })); + setMeasures(measuresList); + } + setMeasuresLoading(false); + }, + error: () => { + setMeasuresLoading(false); + } + }); + } + }, [environment, organization?.id]); + + // Filter measures based on search query + const filteredMeasures = measures.filter(measure => + measure.name.toLowerCase().includes(measureSearchQuery.toLowerCase()) || + (measure.description && measure.description.toLowerCase().includes(measureSearchQuery.toLowerCase())) + ); + + const allTasks = data.node?.tasks?.edges.map((edge) => ({ + ...edge.node, + badges: [ + { id: "1", name: "Policy violation", type: "FIRE" }, + { id: "2", name: "SOC2", type: "BANK" }, + ], + parent: edge.node.measure ? { id: edge.node.measure.id, name: edge.node.measure.name } : null, + })) || []; + + const todoTasks = allTasks.filter(task => task.state === "TODO"); + const doneTasks = allTasks.filter(task => task.state === "DONE"); + + const handleTaskClick = (task: Task) => { + console.log("Task clicked:", task); + // Navigate to task detail page + // window.location.href = `/organizations/${organizationId}/tasks/${task.id}`; + }; + + // Function to convert timeEstimate to ISO8601 duration + const convertToISODuration = (value: number | null, unit: string): string | null => { + if (value === null || value <= 0) return null; + + switch (unit) { + case "minutes": + return `PT${value}M`; + case "hours": + return `PT${value}H`; + case "days": + return `P${value}D`; + case "weeks": + return `P${value * 7}D`; + default: + return null; + } + }; + + const handleCreateTask = () => { + if (!taskTitle.trim()) return; + + // Convert timeEstimate to ISO8601 duration + const formattedTimeEstimate = convertToISODuration(timeEstimate, timeUnit); + + createTask({ + variables: { + input: { + name: taskTitle, + description: taskDescription, + organizationId: organization?.id || "", + measureId: selectedMeasure?.id || null, + assignedToId: assignee?.id, + timeEstimate: formattedTimeEstimate, + }, + connections: [data.node?.tasks?.__id || ""], + }, + onCompleted: () => { + setIsNewTaskOpen(false); + resetTaskForm(); + }, + onError: error => { + console.error("Error creating task:", error); + } + }); + }; + + const resetTaskForm = () => { + setTaskTitle(""); + setTaskDescription(""); + setTaskStatus("TODO"); + setAssignee(null); + setSelectedMeasure(null); + setMeasureId(null); + setTimeEstimate(null); + setTimeUnit("hours"); + setMeasureSearchQuery(""); + }; + + // Get initials from name + const getInitials = (name: string) => { + return name.split(' ').map(n => n[0]).join(''); + }; + + // Function to handle toggling task state + const handleToggleTaskState = (taskId: string, newState: TaskState) => { + updateTaskState({ + variables: { + input: { + taskId: taskId, + state: newState + } + }, + optimisticResponse: { + updateTask: { + task: { + id: taskId, + state: newState, + } + } + } + }); + }; + + return ( + + + + } + > +
+ +
+ +
+ + +
+ + To Do +
+ {todoTasks.length} +
+
+
+ +
+ + Done +
+ {doneTasks.length} +
+
+
+ +
+ All +
+ {allTasks.length} +
+
+
+
+
+ + + {todoTasks.length > 0 ? ( + todoTasks.map((task) => ( + + )) + ) : ( +
+ No todo tasks found. Create a new task to get started. +
+ )} +
+ + + {doneTasks.length > 0 ? ( + doneTasks.map((task) => ( + + )) + ) : ( +
+ No completed tasks found. +
+ )} +
+ + + {allTasks.length > 0 ? ( + allTasks.map((task) => ( + + )) + ) : ( +
+ No tasks found. Create a new task to get started. +
+ )} +
+
+
+
+
+ + {/* New Task Modal */} + + + {/* Modal Header */} +
+
+
List of your assets
+
+ +
+
New task
+
+ +
+ + {/* Task Form - Two Column Layout */} +
+ {/* Left Column - Task Details */} +
+ setTaskTitle(e.target.value)} + /> +