Add tasks page

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-05-05 22:34:04 -07:00
parent c93e795da5
commit 046c42eb48
22 changed files with 3929 additions and 197 deletions

View File

@@ -139,7 +139,7 @@ function getNavItems(organizationId?: string): NavItem[] {
title: "Tasks",
icon: Inbox,
url: organizationId
? `/organizations/${organizationId}/dashboard`
? `/organizations/${organizationId}/tasks`
: undefined,
},
{

View File

@@ -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 (
<Routes>
<Route path=":organizationId/*" element={<OrganizationLayout />}>
<Route index element={<HomePage />} />
<Route path="tasks" element={<ListTaskPage />} />
<Route path="people" element={<PeopleListPage />} />
<Route path="people/new" element={<NewPeoplePage />} />
<Route path="people/:peopleId" element={<PeoplePage />} />

View File

@@ -982,6 +982,7 @@ function MeasureViewContent({
variables: {
connections: [`${data.measure.tasks?.__id}`],
input: {
organizationId: organizationId!,
measureId: data.measure.id,
name: newTaskName,
description: newTaskDescription,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<f037ead461a7a59d39f2e98140e4c72f>>
* @generated SignedSource<<b53893b96884bbc49ad8f2dbdb22e2ea>>
* @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 = {

View File

@@ -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 (
<PageTemplateSkeleton title="Tasks">
<div className="space-y-6">
<div className="rounded-xl border bg-level-1 p-4 space-y-4">
<div className="h-5 w-32 subtle-bg animate-pulse rounded" />
<div className="flex gap-2">
<div className="h-10 flex-1 subtle-bg animate-pulse rounded" />
<div className="h-10 w-32 subtle-bg animate-pulse rounded" />
</div>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-[72px] subtle-bg animate-pulse rounded-xl"
/>
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
export function ListTaskPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<ListTaskViewSkeleton />}>
<ErrorBoundaryWithLocation>
<ListTaskView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -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 (
<div
className="flex justify-between items-center px-6 py-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer"
onClick={() => onClick && onClick(task)}
>
<div className="flex gap-4 items-center">
<div
className="flex items-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onToggleState) {
const newState = task.state === "TODO" ? "DONE" : "TODO";
onToggleState(task.id, newState as TaskState);
}
}}
>
{task.state === "TODO" ? (
<CircleDashed className="w-5 h-5 text-green-500" />
) : task.state === "DONE" ? (
<CheckCircle2 className="w-5 h-5 text-gray-500" />
) : (
<Circle className="w-5 h-5 text-gray-300" />
)}
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${task.state === "DONE" ? "text-gray-500 line-through" : "text-gray-900"}`}>
{task.name}
</span>
<Badge
variant="outline"
className={`text-xs px-1.5 py-0.5 rounded-lg flex items-center gap-1
${task.state === "TODO"
? "bg-green-50 text-green-700 border-green-200"
: "bg-gray-50 text-gray-700 border-gray-200"}
`}
>
{task.state === "TODO" ? "To Do" : "Done"}
</Badge>
{task.timeEstimate && (
<span className="text-xs text-gray-500">
{formatDuration(task.timeEstimate.toString())}
</span>
)}
</div>
{task.parent && (
<div className="flex items-center gap-1 text-sm text-gray-500">
<CornerDownRight className="w-3 h-3 text-gray-500" />
<span>{task.parent.name}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-2">
{task.badges && task.badges.map((badge) => (
<Badge
key={badge.id}
variant="outline"
className="text-xs px-1.5 py-0.5 rounded-lg flex items-center gap-1 text-gray-600 border-gray-200/50"
>
{badge.type === "FIRE" ? (
<Flame className="w-3 h-3 text-gray-600" />
) : badge.type === "BANK" ? (
<Building className="w-3 h-3 text-gray-600" />
) : null}
{badge.name}
</Badge>
))}
</div>
{task.assignedTo && (
<Avatar className="h-6 w-6 border border-white shadow-sm">
<div className="bg-green-100 h-full w-full rounded-full flex items-center justify-center text-[10px] font-medium text-green-800">
{task.assignedTo.fullName.split(' ').map((n: string) => n[0]).join('')}
</div>
</Avatar>
)}
</div>
</div>
);
}
function ListTaskContent({
queryRef,
organizationQueryRef,
}: {
queryRef: PreloadedQuery<ListTaskViewQuery>;
organizationQueryRef: PreloadedQuery<ListTaskViewOrganizationQuery>;
}) {
const data = usePreloadedQuery<ListTaskViewQuery>(listTaskViewQuery, queryRef);
const organizationData = usePreloadedQuery<ListTaskViewOrganizationQuery>(
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<Array<{id: string, name: string, category: string, description?: string, state?: string}>>([]);
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<string | null>(null);
const [selectedMeasure, setSelectedMeasure] = useState<{id: string, name: string} | null>(null);
const [timeEstimate, setTimeEstimate] = useState<number | null>(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 (
<PageTemplate
title="Tasks"
description="Track your assigned compliance tasks and keep progress on track."
actions={
<>
<Button
className="bg-gray-900 text-white hover:bg-gray-800 gap-1.5"
onClick={() => setIsNewTaskOpen(true)}
>
<Plus className="w-4 h-4" />
New task
</Button>
</>
}
>
<div className="space-y-6">
<Card className="border border-gray-100 rounded-xl p-0 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<Tabs defaultValue="todo" className="w-full">
<div className="flex justify-between items-center w-full mb-6">
<TabsList className="w-full">
<TabsTrigger value="todo" className="border-b-2 border-transparent data-[state=active]:border-gray-900">
<div className="flex items-center gap-2">
<CircleDashed className="w-4 h-4 text-green-500" />
<span>To Do</span>
<div className="flex items-center justify-center bg-green-100 rounded-full h-6 w-6 text-xs text-green-800">
{todoTasks.length}
</div>
</div>
</TabsTrigger>
<TabsTrigger value="done" className="border-b-2 border-transparent data-[state=active]:border-gray-900">
<div className="flex items-center gap-2">
<Circle className="w-4 h-4 text-gray-500" />
<span>Done</span>
<div className="flex items-center justify-center bg-gray-100 rounded-full h-6 w-6 text-xs text-gray-800">
{doneTasks.length}
</div>
</div>
</TabsTrigger>
<TabsTrigger value="all" className="border-b-2 border-transparent data-[state=active]:border-gray-900">
<div className="flex items-center gap-2">
<span>All</span>
<div className="flex items-center justify-center bg-gray-100 rounded-full h-6 w-6 text-xs">
{allTasks.length}
</div>
</div>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="todo">
{todoTasks.length > 0 ? (
todoTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onClick={handleTaskClick}
onToggleState={handleToggleTaskState}
/>
))
) : (
<div className="p-4 text-center text-gray-500">
No todo tasks found. Create a new task to get started.
</div>
)}
</TabsContent>
<TabsContent value="done">
{doneTasks.length > 0 ? (
doneTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onClick={handleTaskClick}
onToggleState={handleToggleTaskState}
/>
))
) : (
<div className="p-4 text-center text-gray-500">
No completed tasks found.
</div>
)}
</TabsContent>
<TabsContent value="all">
{allTasks.length > 0 ? (
allTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onClick={handleTaskClick}
onToggleState={handleToggleTaskState}
/>
))
) : (
<div className="p-4 text-center text-gray-500">
No tasks found. Create a new task to get started.
</div>
)}
</TabsContent>
</Tabs>
</div>
</Card>
</div>
{/* New Task Modal */}
<Dialog open={isNewTaskOpen} onOpenChange={setIsNewTaskOpen}>
<DialogContent className="sm:max-w-[1080px] p-0 rounded-lg overflow-hidden border border-gray-200">
{/* Modal Header */}
<div className="flex justify-between items-center px-6 py-4 border-b border-[rgba(2,42,2,0.08)]">
<div className="flex items-center gap-2">
<div className="text-sm text-gray-500">List of your assets</div>
<div className="text-gray-500">
<CornerDownRight className="w-3 h-3" />
</div>
<div className="text-sm font-medium">New task</div>
</div>
<DialogClose className="rounded-full w-8 h-8 flex items-center justify-center hover:bg-gray-100"/>
</div>
{/* Task Form - Two Column Layout */}
<div className="flex">
{/* Left Column - Task Details */}
<div className="flex-1 border-r border-[rgba(2,42,2,0.08)] p-6">
<Input
placeholder="Task title"
className="text-2xl font-semibold border-none px-0 mb-4 w-full"
value={taskTitle}
onChange={(e) => setTaskTitle(e.target.value)}
/>
<Textarea
placeholder="Add description..."
className="resize-none border-none px-0 text-base w-full min-h-[400px]"
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
/>
</div>
{/* Right Column - Properties */}
<div className="w-[400px] p-6">
<h3 className="text-base font-medium text-gray-900 mb-4">Properties</h3>
<div className="space-y-4">
{/* Status */}
<div className="flex justify-between items-center border-b border-[rgba(2,42,2,0.08)] py-3">
<Label className="text-sm font-medium text-gray-500">Status</Label>
<div className="bg-[rgba(0,39,0,0.05)] px-[10px] py-[6px] rounded-lg flex items-center gap-1.5">
<img
src="/images/radio-unchecked.svg"
alt="Radio"
className="w-4 h-4"
/>
<span className="text-sm font-medium">To do</span>
</div>
</div>
{/* Assignee */}
<div className="flex justify-between items-center border-b border-[rgba(2,42,2,0.08)] py-3">
<Label className="text-sm font-medium text-gray-500">Assignee</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="bg-[rgba(0,39,0,0.05)] px-[10px] py-[6px] rounded-lg flex items-center gap-1.5 cursor-pointer">
{assignee ? (
<>
<Avatar className="h-4 w-4">
<div className="bg-blue-100 h-full w-full rounded-full flex items-center justify-center text-[7px] font-medium text-blue-800">
{getInitials(assignee.fullName)}
</div>
</Avatar>
<span className="text-sm font-medium">{assignee.fullName}</span>
</>
) : (
<>
<User className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">Unassigned</span>
</>
)}
<ChevronDown className="h-3 w-3 text-gray-500 ml-1" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[220px] max-h-[300px] overflow-y-auto">
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={() => setAssignee(null)}
>
<User className="h-4 w-4 text-gray-500" />
<span>Unassigned</span>
</DropdownMenuItem>
{members.map(member => (
<DropdownMenuItem
key={member.id}
className="flex items-center gap-2 cursor-pointer"
onClick={() => setAssignee({id: member.id, fullName: member.fullName})}
>
<Avatar className="h-4 w-4">
<div className="bg-blue-100 h-full w-full rounded-full flex items-center justify-center text-[7px] font-medium text-blue-800">
{getInitials(member.fullName)}
</div>
</Avatar>
<span>{member.fullName}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Measure */}
<div className="flex justify-between items-center border-b border-[rgba(2,42,2,0.08)] py-3">
<Label className="text-sm font-medium text-gray-500">Measure</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="bg-[rgba(0,39,0,0.05)] px-[10px] py-[6px] rounded-lg flex items-center gap-1.5 cursor-pointer">
{selectedMeasure ? (
<span className="text-sm font-medium">{selectedMeasure.name}</span>
) : (
<span className="text-sm font-medium">No measure</span>
)}
<ChevronDown className="h-3 w-3 text-gray-500 ml-1" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[320px] max-h-[300px] overflow-y-auto">
<div className="p-2">
<div className="relative mb-2">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="Search measures..."
value={measureSearchQuery}
onChange={(e) => setMeasureSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
onClick={() => {
setSelectedMeasure(null);
setMeasureId(null);
}}
>
<span>No measure</span>
</DropdownMenuItem>
{measuresLoading ? (
<div className="p-3 text-center text-sm text-gray-500">
Loading measures...
</div>
) : filteredMeasures.length > 0 ? (
filteredMeasures.map(measure => (
<DropdownMenuItem
key={measure.id}
className="flex items-start gap-2 cursor-pointer py-2"
onClick={() => {
setSelectedMeasure({id: measure.id, name: measure.name});
setMeasureId(measure.id);
}}
>
<div className="flex flex-col">
<span className="font-medium">{measure.name}</span>
{measure.category && (
<span className="text-xs text-gray-500">{measure.category}</span>
)}
</div>
</DropdownMenuItem>
))
) : (
<div className="p-3 text-center text-sm text-gray-500">
No measures found
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Time estimate */}
<div className="flex justify-between items-center border-b border-[rgba(2,42,2,0.08)] py-3">
<Label className="text-sm font-medium text-gray-500">Time estimate</Label>
<div className="flex items-center gap-2">
{timeEstimate === null ? (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => setTimeEstimate(0)}
>
<Plus className="w-4 h-4" />
</Button>
) : (
<>
<Input
type="number"
min="0"
value={timeEstimate}
onChange={(e) => setTimeEstimate(parseInt(e.target.value) || 0)}
className="w-16 h-8 text-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 flex items-center gap-1"
>
<span>{timeUnit}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTimeUnit("minutes")}>
minutes
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTimeUnit("hours")}>
hours
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTimeUnit("days")}>
days
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTimeUnit("weeks")}>
weeks
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-8 p-0"
onClick={() => setTimeEstimate(null)}
>
<X className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
</div>
</div>
</div>
{/* Modal Footer */}
<div className="flex justify-between items-center px-6 py-4 border-t border-[rgba(2,42,2,0.08)]">
<Button
variant="outline"
className="h-8 flex items-center gap-1.5 border-[rgba(2,42,2,0.08)]"
>
<img src="/images/attachment.svg" alt="Attachment" className="w-4 h-4" />
Upload evidence
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="h-8 border-[rgba(2,42,2,0.08)]"
onClick={() => setIsNewTaskOpen(false)}
>
Cancel
</Button>
<Button
className="h-8 bg-gray-900 text-white"
onClick={handleCreateTask}
disabled={!taskTitle.trim()}
>
Create task
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</PageTemplate>
);
}
export default function ListTaskView() {
const [searchParams] = useSearchParams();
const [queryRef, loadQuery] =
useQueryLoader<ListTaskViewQuery>(listTaskViewQuery);
const [organizationQueryRef, loadOrganizationQuery] =
useQueryLoader<ListTaskViewOrganizationQuery>(organizationQuery);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({ organizationId: organizationId! });
loadOrganizationQuery({ organizationId: organizationId! });
}, [loadQuery, loadOrganizationQuery, organizationId]);
if (!queryRef || !organizationQueryRef) {
return <ListTaskViewSkeleton />;
}
return (
<Suspense fallback={<ListTaskViewSkeleton />}>
<ListTaskContent queryRef={queryRef} organizationQueryRef={organizationQueryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,238 @@
/**
* @generated SignedSource<<84a35602a155b0f1fa8504cf32c10932>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type TaskState = "DONE" | "TODO";
export type CreateTaskInput = {
assignedToId?: string | null | undefined;
description: string;
measureId?: string | null | undefined;
name: string;
organizationId: string;
timeEstimate?: any | null | undefined;
};
export type ListTaskViewCreateTaskMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateTaskInput;
};
export type ListTaskViewCreateTaskMutation$data = {
readonly createTask: {
readonly taskEdge: {
readonly node: {
readonly assignedTo: {
readonly fullName: string;
readonly id: string;
} | null | undefined;
readonly description: string;
readonly id: string;
readonly measure: {
readonly id: string;
readonly name: string;
} | null | undefined;
readonly name: string;
readonly state: TaskState;
readonly timeEstimate: any | null | undefined;
};
};
};
};
export type ListTaskViewCreateTaskMutation = {
response: ListTaskViewCreateTaskMutation$data;
variables: ListTaskViewCreateTaskMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "taskEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "timeEstimate",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "measure",
"plural": false,
"selections": [
(v3/*: any*/),
(v4/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "assignedTo",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "ListTaskViewCreateTaskMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateTaskPayload",
"kind": "LinkedField",
"name": "createTask",
"plural": false,
"selections": [
(v5/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "ListTaskViewCreateTaskMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateTaskPayload",
"kind": "LinkedField",
"name": "createTask",
"plural": false,
"selections": [
(v5/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "taskEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "7b9680f637411402ff51239a43b44a14",
"id": null,
"metadata": {},
"name": "ListTaskViewCreateTaskMutation",
"operationKind": "mutation",
"text": "mutation ListTaskViewCreateTaskMutation(\n $input: CreateTaskInput!\n) {\n createTask(input: $input) {\n taskEdge {\n node {\n id\n name\n description\n state\n timeEstimate\n measure {\n id\n name\n }\n assignedTo {\n id\n fullName\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "43ccaf49d31ec84dcf3b6b3f2e3a851c";
export default node;

View File

@@ -0,0 +1,271 @@
/**
* @generated SignedSource<<5d866d20a606b1fb34d64ad47aa76f78>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type ListTaskViewOrganizationMeasuresQuery$variables = {
organizationId: string;
};
export type ListTaskViewOrganizationMeasuresQuery$data = {
readonly organization: {
readonly id: string;
readonly measures?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly state: MeasureState;
};
}>;
};
};
};
export type ListTaskViewOrganizationMeasuresQuery = {
response: ListTaskViewOrganizationMeasuresQuery$data;
variables: ListTaskViewOrganizationMeasuresQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"alias": null,
"args": null,
"concreteType": "MeasureEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
v5 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ListTaskViewOrganizationMeasuresQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": "measures",
"args": null,
"concreteType": "MeasureConnection",
"kind": "LinkedField",
"name": "__Organization__measures_connection",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ListTaskViewOrganizationMeasuresQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "MeasureConnection",
"kind": "LinkedField",
"name": "measures",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": "measures(first:100)"
},
{
"alias": null,
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "Organization__measures",
"kind": "LinkedHandle",
"name": "measures"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "1986791d30c729854184fc15725ff5f0",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"organization",
"measures"
]
}
]
},
"name": "ListTaskViewOrganizationMeasuresQuery",
"operationKind": "query",
"text": "query ListTaskViewOrganizationMeasuresQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n measures(first: 100) {\n edges {\n node {\n id\n name\n description\n category\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "3a4008ec2a42d13418f83e9ddd460b34";
export default node;

View File

@@ -0,0 +1,267 @@
/**
* @generated SignedSource<<c922e0dcd99fb4bc9d1d26954781b945>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ListTaskViewOrganizationQuery$variables = {
organizationId: string;
};
export type ListTaskViewOrganizationQuery$data = {
readonly organization: {
readonly id: string;
readonly peoples?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly fullName: string;
readonly id: string;
readonly primaryEmailAddress: string;
};
}>;
};
};
};
export type ListTaskViewOrganizationQuery = {
response: ListTaskViewOrganizationQuery$data;
variables: ListTaskViewOrganizationQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "FULL_NAME"
}
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v5 = [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
v6 = [
{
"kind": "Literal",
"name": "first",
"value": 100
},
(v3/*: any*/)
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ListTaskViewOrganizationQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": "peoples",
"args": [
(v3/*: any*/)
],
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "__ListTaskView_peoples_connection",
"plural": false,
"selections": (v5/*: any*/),
"storageKey": "__ListTaskView_peoples_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ListTaskViewOrganizationQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v4/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "peoples",
"plural": false,
"selections": (v5/*: any*/),
"storageKey": "peoples(first:100,orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
},
{
"alias": null,
"args": (v6/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "ListTaskView_peoples",
"kind": "LinkedHandle",
"name": "peoples"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "a2ecca8e88fa80fc9828ddc2a04d18ac",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"organization",
"peoples"
]
}
]
},
"name": "ListTaskViewOrganizationQuery",
"operationKind": "query",
"text": "query ListTaskViewOrganizationQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n peoples(first: 100, orderBy: {direction: ASC, field: FULL_NAME}) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "736b443f4010e86687954adcaed6f3ab";
export default node;

View File

@@ -0,0 +1,328 @@
/**
* @generated SignedSource<<7732676a00f7ed0f6817555b68540aca>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type TaskState = "DONE" | "TODO";
export type ListTaskViewQuery$variables = {
organizationId: string;
};
export type ListTaskViewQuery$data = {
readonly node: {
readonly id: string;
readonly name?: string;
readonly tasks?: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly assignedTo: {
readonly fullName: string;
readonly id: string;
} | null | undefined;
readonly description: string;
readonly id: string;
readonly measure: {
readonly id: string;
readonly name: string;
} | null | undefined;
readonly name: string;
readonly state: TaskState;
readonly timeEstimate: any | null | undefined;
};
}>;
};
};
};
export type ListTaskViewQuery = {
response: ListTaskViewQuery$data;
variables: ListTaskViewQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v5 = [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "timeEstimate",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "measure",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "assignedTo",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
v6 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ListTaskViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
{
"alias": "tasks",
"args": null,
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "__ListTaskView_tasks_connection",
"plural": false,
"selections": (v5/*: any*/),
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ListTaskViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v4/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "tasks",
"plural": false,
"selections": (v5/*: any*/),
"storageKey": "tasks(first:100)"
},
{
"alias": null,
"args": (v6/*: any*/),
"filters": null,
"handle": "connection",
"key": "ListTaskView_tasks",
"kind": "LinkedHandle",
"name": "tasks"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "52b577b08c305249855293949d6e2339",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"node",
"tasks"
]
}
]
},
"name": "ListTaskViewQuery",
"operationKind": "query",
"text": "query ListTaskViewQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n name\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n timeEstimate\n measure {\n id\n name\n }\n assignedTo {\n id\n fullName\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "5ca091444c547744068f2b2a83cb31df";
export default node;

View File

@@ -0,0 +1,118 @@
/**
* @generated SignedSource<<7098e326d01cc6831f293726096a7275>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type TaskState = "DONE" | "TODO";
export type UpdateTaskInput = {
description?: string | null | undefined;
name?: string | null | undefined;
state?: TaskState | null | undefined;
taskId: string;
timeEstimate?: any | null | undefined;
};
export type ListTaskViewUpdateTaskStateMutation$variables = {
input: UpdateTaskInput;
};
export type ListTaskViewUpdateTaskStateMutation$data = {
readonly updateTask: {
readonly task: {
readonly id: string;
readonly state: TaskState;
};
};
};
export type ListTaskViewUpdateTaskStateMutation = {
response: ListTaskViewUpdateTaskStateMutation$data;
variables: ListTaskViewUpdateTaskStateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateTaskPayload",
"kind": "LinkedField",
"name": "updateTask",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "task",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ListTaskViewUpdateTaskStateMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ListTaskViewUpdateTaskStateMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "ddeeb9b6aa991efa6e305491ef360537",
"id": null,
"metadata": {},
"name": "ListTaskViewUpdateTaskStateMutation",
"operationKind": "mutation",
"text": "mutation ListTaskViewUpdateTaskStateMutation(\n $input: UpdateTaskInput!\n) {\n updateTask(input: $input) {\n task {\n id\n state\n }\n }\n}\n"
}
};
})();
(node as any).hash = "46d32aeba7c467fd48291f43baaf0b6a";
export default node;

View File

@@ -30,7 +30,6 @@ import (
type (
Measure struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
OrganizationID gid.GID `db:"organization_id"`
Category string `db:"category"`
Name string `db:"name"`
@@ -82,7 +81,6 @@ WITH msrs AS (
)
SELECT
id,
tenant_id,
organization_id,
category,
name,
@@ -146,7 +144,6 @@ WITH mtgtns AS (
)
SELECT
id,
tenant_id,
organization_id,
category,
name,
@@ -191,7 +188,6 @@ func (m *Measures) LoadByOrganizationID(
q := `
SELECT
id,
tenant_id,
organization_id,
category,
name,
@@ -237,7 +233,6 @@ func (m *Measure) LoadByID(
q := `
SELECT
id,
tenant_id,
organization_id,
category,
name,
@@ -311,7 +306,6 @@ ON CONFLICT (organization_id, reference_id) DO UPDATE SET
category = @category,
updated_at = @updated_at
RETURNING
tenant_id,
id,
organization_id,
category,

View File

@@ -0,0 +1,8 @@
ALTER TABLE tasks ADD COLUMN organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE;
UPDATE tasks
SET organization_id = m.organization_id::text
FROM measures m
WHERE tasks.measure_id = m.id;
ALTER TABLE tasks ALTER COLUMN organization_id SET NOT NULL;

View File

@@ -166,8 +166,9 @@ WHERE %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"organization_id": organizationID}
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {

View File

@@ -29,16 +29,17 @@ import (
type (
Task struct {
ID gid.GID `db:"id"`
MeasureID gid.GID `db:"measure_id"`
Name string `db:"name"`
Description string `db:"description"`
State TaskState `db:"state"`
ReferenceID string `db:"reference_id"`
TimeEstimate *time.Duration `db:"time_estimate"`
AssignedToID *gid.GID `db:"assigned_to"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
MeasureID *gid.GID `db:"measure_id"`
Name string `db:"name"`
Description string `db:"description"`
State TaskState `db:"state"`
ReferenceID string `db:"reference_id"`
TimeEstimate *time.Duration `db:"time_estimate"`
AssignedToID *gid.GID `db:"assigned_to"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Tasks []*Task
@@ -62,6 +63,7 @@ func (c *Task) LoadByID(
q := `
SELECT
id,
organization_id,
measure_id,
name,
description,
@@ -109,6 +111,7 @@ INSERT INTO
tasks (
tenant_id,
id,
organization_id,
measure_id,
name,
description,
@@ -122,6 +125,7 @@ INSERT INTO
VALUES (
@tenant_id,
@task_id,
@organization_id,
@measure_id,
@name,
@description,
@@ -135,17 +139,18 @@ VALUES (
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"task_id": c.ID,
"measure_id": c.MeasureID,
"name": c.Name,
"description": c.Description,
"reference_id": c.ReferenceID,
"state": c.State,
"time_estimate": c.TimeEstimate,
"assigned_to": c.AssignedToID,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
"tenant_id": scope.GetTenantID(),
"task_id": c.ID,
"organization_id": c.OrganizationID,
"measure_id": c.MeasureID,
"name": c.Name,
"description": c.Description,
"reference_id": c.ReferenceID,
"state": c.State,
"time_estimate": c.TimeEstimate,
"assigned_to": c.AssignedToID,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
@@ -161,6 +166,7 @@ INSERT INTO
tasks (
tenant_id,
id,
organization_id,
measure_id,
name,
description,
@@ -174,6 +180,7 @@ INSERT INTO
VALUES (
@tenant_id,
@task_id,
@organization_id,
@measure_id,
@name,
@description,
@@ -190,6 +197,7 @@ ON CONFLICT (measure_id, reference_id) DO UPDATE SET
updated_at = @updated_at
RETURNING
id,
organization_id,
measure_id,
name,
description,
@@ -202,17 +210,18 @@ RETURNING
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"task_id": c.ID,
"measure_id": c.MeasureID,
"name": c.Name,
"description": c.Description,
"reference_id": c.ReferenceID,
"state": c.State,
"time_estimate": c.TimeEstimate,
"assigned_to": c.AssignedToID,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
"tenant_id": scope.GetTenantID(),
"task_id": c.ID,
"organization_id": c.OrganizationID,
"measure_id": c.MeasureID,
"name": c.Name,
"description": c.Description,
"reference_id": c.ReferenceID,
"state": c.State,
"time_estimate": c.TimeEstimate,
"assigned_to": c.AssignedToID,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
@@ -229,6 +238,54 @@ RETURNING
return nil
}
func (c *Tasks) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[TaskOrderField],
) error {
q := `
SELECT
id,
measure_id,
organization_id,
name,
description,
state,
reference_id,
time_estimate,
assigned_to,
created_at,
updated_at
FROM
tasks
WHERE
%s
AND organization_id = @organization_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query tasks: %w", err)
}
tasks, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Task])
if err != nil {
return fmt.Errorf("cannot collect tasks: %w", err)
}
*c = tasks
return nil
}
func (c *Tasks) LoadByMeasureID(
ctx context.Context,
conn pg.Conn,
@@ -240,6 +297,7 @@ func (c *Tasks) LoadByMeasureID(
SELECT
id,
measure_id,
organization_id,
name,
description,
state,
@@ -288,7 +346,8 @@ SET
description = @description,
state = @state,
time_estimate = @time_estimate,
updated_at = @updated_at
updated_at = @updated_at,
assigned_to = @assigned_to
WHERE %s
AND id = @task_id
`
@@ -301,6 +360,7 @@ WHERE %s
"state": c.State,
"time_estimate": c.TimeEstimate,
"updated_at": c.UpdatedAt,
"assigned_to": c.AssignedToID,
}
maps.Copy(args, scope.SQLArguments())
@@ -309,102 +369,6 @@ WHERE %s
return err
}
func (c *Task) AssignTo(
ctx context.Context,
conn pg.Conn,
scope Scoper,
assignTo gid.GID,
) error {
q := `
UPDATE tasks SET
assigned_to = @assigned_to,
updated_at = @updated_at
WHERE %s
AND id = @task_id
RETURNING
id,
measure_id,
name,
description,
reference_id,
state,
time_estimate,
assigned_to,
created_at,
updated_at
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"task_id": c.ID,
"assigned_to": assignTo,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query tasks: %w", err)
}
task, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Task])
if err != nil {
return fmt.Errorf("cannot collect tasks: %w", err)
}
*c = task
return nil
}
func (c *Task) Unassign(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE tasks SET
assigned_to = NULL,
updated_at = @updated_at
WHERE %s
AND id = @task_id
RETURNING
id,
measure_id,
name,
description,
reference_id,
state,
time_estimate,
assigned_to,
created_at,
updated_at
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"task_id": c.ID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query tasks: %w", err)
}
task, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Task])
if err != nil {
return fmt.Errorf("cannot collect tasks: %w", err)
}
*c = task
return nil
}
func (c *Task) Delete(
ctx context.Context,
conn pg.Conn,

View File

@@ -119,8 +119,12 @@ func (s EvidenceService) Request(
return fmt.Errorf("cannot load task: %w", err)
}
if task.MeasureID == nil {
return fmt.Errorf("task %q has no measure", req.TaskID)
}
evidence.TaskID = req.TaskID
evidence.MeasureID = task.MeasureID
evidence.MeasureID = *task.MeasureID
} else if req.MeasureID != nil {
evidence.MeasureID = *req.MeasureID
} else {
@@ -298,7 +302,11 @@ func (s EvidenceService) UploadTaskEvidence(
return fmt.Errorf("cannot load task %q: %w", req.TaskID, err)
}
evidence.MeasureID = task.MeasureID
if task.MeasureID == nil {
return fmt.Errorf("task %q has no measure", req.TaskID)
}
evidence.MeasureID = *task.MeasureID
if err := evidence.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert evidence: %w", err)

View File

@@ -168,14 +168,15 @@ func (s MeasureService) Import(
taskID := gid.New(organizationID.TenantID(), coredata.TaskEntityType)
task := &coredata.Task{
ID: taskID,
MeasureID: measure.ID,
Name: req.Measures[i].Tasks[j].Name,
Description: req.Measures[i].Tasks[j].Description,
ReferenceID: req.Measures[i].Tasks[j].ReferenceID,
State: coredata.TaskStateTodo,
CreatedAt: now,
UpdatedAt: now,
ID: taskID,
OrganizationID: organizationID,
MeasureID: &measure.ID,
Name: req.Measures[i].Tasks[j].Name,
Description: req.Measures[i].Tasks[j].Description,
ReferenceID: req.Measures[i].Tasks[j].ReferenceID,
State: coredata.TaskStateTodo,
CreatedAt: now,
UpdatedAt: now,
}
if err := task.Upsert(ctx, tx, s.svc.scope); err != nil {

View File

@@ -32,11 +32,12 @@ type (
}
CreateTaskRequest struct {
MeasureID gid.GID
Name string
Description string
TimeEstimate *time.Duration
AssignedToID *gid.GID
OrganizationID gid.GID
MeasureID *gid.GID
Name string
Description string
TimeEstimate *time.Duration
AssignedToID *gid.GID
}
UpdateTaskRequest struct {
@@ -61,21 +62,23 @@ func (s TaskService) Create(
}
task := &coredata.Task{
ID: taskID,
MeasureID: req.MeasureID,
Name: req.Name,
Description: req.Description,
TimeEstimate: req.TimeEstimate,
AssignedToID: req.AssignedToID,
State: coredata.TaskStateTodo,
ReferenceID: "custom-task-" + referenceID.String(),
CreatedAt: now,
UpdatedAt: now,
ID: taskID,
OrganizationID: req.OrganizationID,
MeasureID: req.MeasureID,
Name: req.Name,
Description: req.Description,
TimeEstimate: req.TimeEstimate,
AssignedToID: req.AssignedToID,
State: coredata.TaskStateTodo,
ReferenceID: "custom-task-" + referenceID.String(),
CreatedAt: now,
UpdatedAt: now,
}
err = s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := task.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert task: %w", err)
}
@@ -119,7 +122,18 @@ func (s TaskService) Assign(
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
return task.AssignTo(ctx, conn, s.svc.scope, assignedToID)
if err := task.LoadByID(ctx, conn, s.svc.scope, taskID); err != nil {
return fmt.Errorf("cannot load task %q: %w", taskID, err)
}
task.AssignedToID = &assignedToID
task.UpdatedAt = time.Now()
if err := task.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot assign task %q to %q: %w", taskID, assignedToID, err)
}
return nil
},
)
if err != nil {
@@ -133,12 +147,23 @@ func (s TaskService) Unassign(
ctx context.Context,
taskID gid.GID,
) (*coredata.Task, error) {
task := &coredata.Task{ID: taskID}
task := &coredata.Task{}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
return task.Unassign(ctx, conn, s.svc.scope)
if err := task.LoadByID(ctx, conn, s.svc.scope, taskID); err != nil {
return fmt.Errorf("cannot load task %q: %w", taskID, err)
}
task.AssignedToID = nil
task.UpdatedAt = time.Now()
if err := task.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot unassign task %q: %w", taskID, err)
}
return nil
},
)
if err != nil {
@@ -152,7 +177,8 @@ func (s TaskService) Update(
ctx context.Context,
req UpdateTaskRequest,
) (*coredata.Task, error) {
task := &coredata.Task{ID: req.TaskID}
task := &coredata.Task{}
err := s.svc.pg.WithTx(
ctx,
@@ -212,6 +238,26 @@ func (s TaskService) Delete(
return nil
}
func (s TaskService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.TaskOrderField],
) (*page.Page[*coredata.Task, coredata.TaskOrderField], error) {
var tasks coredata.Tasks
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return tasks.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(tasks, cursor), nil
}
func (s TaskService) ListForMeasureID(
ctx context.Context,
measureID gid.GID,

View File

@@ -461,6 +461,14 @@ type Organization implements Node {
orderBy: RiskOrder
): RiskConnection! @goField(forceResolver: true)
tasks(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: TaskOrder
): TaskConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -498,6 +506,8 @@ type Vendor implements Node {
name: String!
description: String
organization: Organization! @goField(forceResolver: true)
complianceReports(
first: Int
after: CursorKey
@@ -538,10 +548,8 @@ type VendorComplianceReport implements Node {
reportDate: Datetime!
validUntil: Datetime
reportName: String!
fileUrl: String! @goField(forceResolver: true)
fileSize: Int!
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -551,6 +559,8 @@ type Framework implements Node {
name: String!
description: String!
organization: Organization! @goField(forceResolver: true)
controls(
first: Int
after: CursorKey
@@ -569,6 +579,8 @@ type Control implements Node {
name: String!
description: String!
framework: Framework! @goField(forceResolver: true)
measures(
first: Int
after: CursorKey
@@ -640,6 +652,9 @@ type Task implements Node {
timeEstimate: Duration
assignedTo: People @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
measure: Measure @goField(forceResolver: true)
evidences(
first: Int
after: CursorKey
@@ -663,6 +678,9 @@ type Evidence implements Node {
url: String
description: String!
task: Task @goField(forceResolver: true)
measure: Measure! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -673,6 +691,7 @@ type Policy implements Node {
description: String!
currentPublishedVersion: Int
owner: People! @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
versions(
first: Int
@@ -710,6 +729,7 @@ type Risk implements Node {
note: String!
owner: People @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
measures(
first: Int
@@ -1140,7 +1160,8 @@ input ImportMeasureInput {
}
input CreateTaskInput {
measureId: ID!
organizationId: ID!
measureId: ID
name: String!
description: String!
timeEstimate: Duration

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,7 @@ type Control struct {
ReferenceID string `json:"referenceId"`
Name string `json:"name"`
Description string `json:"description"`
Framework *Framework `json:"framework"`
Measures *MeasureConnection `json:"measures"`
Policies *PolicyConnection `json:"policies"`
CreatedAt time.Time `json:"createdAt"`
@@ -215,11 +216,12 @@ type CreateRiskPolicyMappingPayload struct {
}
type CreateTaskInput struct {
MeasureID gid.GID `json:"measureId"`
Name string `json:"name"`
Description string `json:"description"`
TimeEstimate *time.Duration `json:"timeEstimate,omitempty"`
AssignedToID *gid.GID `json:"assignedToId,omitempty"`
OrganizationID gid.GID `json:"organizationId"`
MeasureID *gid.GID `json:"measureId,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
TimeEstimate *time.Duration `json:"timeEstimate,omitempty"`
AssignedToID *gid.GID `json:"assignedToId,omitempty"`
}
type CreateTaskPayload struct {
@@ -389,6 +391,8 @@ type Evidence struct {
Filename string `json:"filename"`
URL *string `json:"url,omitempty"`
Description string `json:"description"`
Task *Task `json:"task,omitempty"`
Measure *Measure `json:"measure"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -407,12 +411,13 @@ type EvidenceEdge struct {
}
type Framework struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Controls *ControlConnection `json:"controls"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID gid.GID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Organization *Organization `json:"organization"`
Controls *ControlConnection `json:"controls"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Framework) IsNode() {}
@@ -509,6 +514,7 @@ type Organization struct {
Policies *PolicyConnection `json:"policies"`
Measures *MeasureConnection `json:"measures"`
Risks *RiskConnection `json:"risks"`
Tasks *TaskConnection `json:"tasks"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -567,6 +573,7 @@ type Policy struct {
Description string `json:"description"`
CurrentPublishedVersion *int `json:"currentPublishedVersion,omitempty"`
Owner *People `json:"owner"`
Organization *Organization `json:"organization"`
Versions *PolicyVersionConnection `json:"versions"`
Controls *ControlConnection `json:"controls"`
CreatedAt time.Time `json:"createdAt"`
@@ -702,6 +709,7 @@ type Risk struct {
ResidualSeverity int `json:"residualSeverity"`
Note string `json:"note"`
Owner *People `json:"owner,omitempty"`
Organization *Organization `json:"organization"`
Measures *MeasureConnection `json:"measures"`
Policies *PolicyConnection `json:"policies"`
Controls *ControlConnection `json:"controls"`
@@ -742,6 +750,8 @@ type Task struct {
State coredata.TaskState `json:"state"`
TimeEstimate *time.Duration `json:"timeEstimate,omitempty"`
AssignedTo *People `json:"assignedTo,omitempty"`
Organization *Organization `json:"organization"`
Measure *Measure `json:"measure,omitempty"`
Evidences *EvidenceConnection `json:"evidences"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -943,6 +953,7 @@ type Vendor struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Organization *Organization `json:"organization"`
ComplianceReports *VendorComplianceReportConnection `json:"complianceReports"`
RiskAssessments *VendorRiskAssessmentConnection `json:"riskAssessments"`
BusinessOwner *People `json:"businessOwner,omitempty"`

View File

@@ -20,6 +20,23 @@ import (
"github.com/vektah/gqlparser/v2/gqlerror"
)
// Framework is the resolver for the framework field.
func (r *controlResolver) Framework(ctx context.Context, obj *types.Control) (*types.Framework, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
control, err := svc.Controls.Get(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get control: %w", err))
}
framework, err := svc.Frameworks.Get(ctx, control.FrameworkID)
if err != nil {
panic(fmt.Errorf("cannot get framework: %w", err))
}
return types.NewFramework(framework), nil
}
// Measures is the resolver for the measures field.
func (r *controlResolver) Measures(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeasureOrderBy) (*types.MeasureConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
@@ -87,6 +104,61 @@ func (r *evidenceResolver) FileURL(ctx context.Context, obj *types.Evidence) (*s
return &result, nil
}
// Task is the resolver for the task field.
func (r *evidenceResolver) Task(ctx context.Context, obj *types.Evidence) (*types.Task, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
evidence, err := svc.Evidences.Get(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load evidence: %w", err)
}
if evidence.TaskID == nil {
return nil, fmt.Errorf("evidence is not associated with a task")
}
task, err := svc.Tasks.Get(ctx, *evidence.TaskID)
if err != nil {
return nil, fmt.Errorf("cannot load task: %w", err)
}
return types.NewTask(task), nil
}
// Measure is the resolver for the measure field.
func (r *evidenceResolver) Measure(ctx context.Context, obj *types.Evidence) (*types.Measure, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
evidence, err := svc.Evidences.Get(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load evidence: %w", err)
}
measure, err := svc.Measures.Get(ctx, evidence.MeasureID)
if err != nil {
return nil, fmt.Errorf("cannot load measure: %w", err)
}
return types.NewMeasure(measure), nil
}
// Organization is the resolver for the organization field.
func (r *frameworkResolver) Organization(ctx context.Context, obj *types.Framework) (*types.Organization, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
framework, err := svc.Frameworks.Get(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot load framework: %w", err)
}
organization, err := svc.Organizations.Get(ctx, framework.OrganizationID)
if err != nil {
return nil, fmt.Errorf("cannot load organization: %w", err)
}
return types.NewOrganization(organization), nil
}
// Controls is the resolver for the controls field.
func (r *frameworkResolver) Controls(ctx context.Context, obj *types.Framework, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ControlOrderBy) (*types.ControlConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
@@ -689,10 +761,11 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input types.CreateTas
svc := GetTenantService(ctx, r.proboSvc, input.MeasureID.TenantID())
task, err := svc.Tasks.Create(ctx, probo.CreateTaskRequest{
MeasureID: input.MeasureID,
Name: input.Name,
Description: input.Description,
TimeEstimate: input.TimeEstimate,
MeasureID: input.MeasureID,
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
TimeEstimate: input.TimeEstimate,
})
if err != nil {
panic(fmt.Errorf("cannot create task: %w", err))
@@ -1416,6 +1489,31 @@ func (r *organizationResolver) Risks(ctx context.Context, obj *types.Organizatio
return types.NewRiskConnection(page), nil
}
// Tasks is the resolver for the tasks field.
func (r *organizationResolver) Tasks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TaskOrderBy) (*types.TaskConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.TaskOrderField]{
Field: coredata.TaskOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.TaskOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := svc.Tasks.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization tasks: %w", err))
}
return types.NewTaskConnection(page), nil
}
// Owner is the resolver for the owner field.
func (r *policyResolver) Owner(ctx context.Context, obj *types.Policy) (*types.People, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
@@ -1434,6 +1532,23 @@ func (r *policyResolver) Owner(ctx context.Context, obj *types.Policy) (*types.P
return types.NewPeople(owner), nil
}
// Organization is the resolver for the organization field.
func (r *policyResolver) Organization(ctx context.Context, obj *types.Policy) (*types.Organization, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
policy, err := svc.Policies.Get(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get policy: %w", err))
}
organization, err := svc.Organizations.Get(ctx, policy.OrganizationID)
if err != nil {
panic(fmt.Errorf("cannot get organization: %w", err))
}
return types.NewOrganization(organization), nil
}
// Versions is the resolver for the versions field.
func (r *policyResolver) Versions(ctx context.Context, obj *types.Policy, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.PolicyVersionOrderBy, filter *types.PolicyVersionFilter) (*types.PolicyVersionConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
@@ -1727,6 +1842,23 @@ func (r *riskResolver) Owner(ctx context.Context, obj *types.Risk) (*types.Peopl
return types.NewPeople(owner), nil
}
// Organization is the resolver for the organization field.
func (r *riskResolver) Organization(ctx context.Context, obj *types.Risk) (*types.Organization, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
risk, err := svc.Risks.Get(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get risk: %w", err))
}
organization, err := svc.Organizations.Get(ctx, risk.OrganizationID)
if err != nil {
panic(fmt.Errorf("cannot get organization: %w", err))
}
return types.NewOrganization(organization), nil
}
// Measures is the resolver for the measures field.
func (r *riskResolver) Measures(ctx context.Context, obj *types.Risk, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeasureOrderBy) (*types.MeasureConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
@@ -1823,6 +1955,40 @@ func (r *taskResolver) AssignedTo(ctx context.Context, obj *types.Task) (*types.
return types.NewPeople(people), nil
}
// Organization is the resolver for the organization field.
func (r *taskResolver) Organization(ctx context.Context, obj *types.Task) (*types.Organization, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
task, err := svc.Tasks.Get(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get task: %w", err))
}
organization, err := svc.Organizations.Get(ctx, task.OrganizationID)
if err != nil {
panic(fmt.Errorf("cannot get organization: %w", err))
}
return types.NewOrganization(organization), nil
}
// Measure is the resolver for the measure field.
func (r *taskResolver) Measure(ctx context.Context, obj *types.Task) (*types.Measure, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
task, err := svc.Tasks.Get(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get task: %w", err))
}
measure, err := svc.Measures.Get(ctx, *task.MeasureID)
if err != nil {
panic(fmt.Errorf("cannot get measure: %w", err))
}
return types.NewMeasure(measure), nil
}
// Evidences is the resolver for the evidences field.
func (r *taskResolver) Evidences(ctx context.Context, obj *types.Task, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.EvidenceOrderBy) (*types.EvidenceConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
@@ -1859,6 +2025,23 @@ func (r *userResolver) People(ctx context.Context, obj *types.User, organization
return types.NewPeople(people), nil
}
// Organization is the resolver for the organization field.
func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())
vendor, err := svc.Vendors.Get(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot get vendor: %w", err))
}
organization, err := svc.Organizations.Get(ctx, vendor.OrganizationID)
if err != nil {
panic(fmt.Errorf("cannot get organization: %w", err))
}
return types.NewOrganization(organization), nil
}
// ComplianceReports is the resolver for the complianceReports field.
func (r *vendorResolver) ComplianceReports(ctx context.Context, obj *types.Vendor, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorComplianceReportOrderBy) (*types.VendorComplianceReportConnection, error) {
svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID())