Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@react-email/render": "^0.0.10",
"@t3-oss/env-nextjs": "^0.7.0",
"@tanstack/react-query": "^5.17.19",
"@types/lodash.debounce": "^4.0.9",
"ai": "^2.2.27",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
Expand All @@ -47,6 +48,7 @@
"drizzle-zod": "^0.5.1",
"framer-motion": "^10.18.0",
"geist": "^1.1.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.292.0",
"next": "^14.0.0",
"next-themes": "^0.2.1",
Expand Down
34 changes: 29 additions & 5 deletions src/actions/task-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use server";

import { auth } from "@clerk/nextjs";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "~/server/db";
Expand Down Expand Up @@ -62,11 +63,34 @@ export async function updateTask(id: number, data: NewTask) {

export async function getTask(id: number) {
try {
const task: Task[] = await db
.select()
.from(tasks)
.where(eq(tasks.id, id));
return task[0];
const { userId }: { userId: string | null } = auth();
if (!userId) {
return { success: false, message: "UserId not found" };
}

const taskQuery = await db.query.tasks.findFirst({
where: (tasks) => eq(tasks.id, id),
with: {
project: {
with: {
usersToProjects: {
with: {
user: true,
},
where: (user) => eq(user.userId, userId),
},
},
},
},
});
if (!taskQuery) {
return { success: false, message: "Task not found" };
}
if (!taskQuery.project.usersToProjects.length) {
return { success: false, message: "User not authorized" };
}

return { success: true, task: taskQuery };
} catch (error) {
if (error instanceof Error) throwServerError(error.message);
}
Expand Down
9 changes: 3 additions & 6 deletions src/app/(application)/project/[projectId]/backlog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import {
import Tasks from "../../../../../components/backlog/tasks";
import { getTasksFromProject } from "~/actions/task-actions";
import BreadCrumbs from "~/components/layout/breadcrumbs/breadcrumbs";
import { Bot } from "lucide-react";
import { Button } from "~/components/ui/button";
import CreateTask from "~/components/backlog/create-task";
import { getAsigneesForProject } from "~/actions/project-actions";
import AiDialog from "~/app/(application)/tasks/ai-dialog";

type Params = {
params: {
Expand All @@ -32,13 +31,11 @@ export default async function BacklogPage({ params: { projectId } }: Params) {
<header className="container flex items-center justify-between gap-2 border-b pb-2">
<BreadCrumbs />
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Bot className="h-4 w-4" />
</Button>
<AiDialog projectId={projectId} />
<CreateTask projectId={projectId} assignees={assignees} />
</div>
</header>
<section className="container flex flex-col pt-4">
<section className="flex flex-col pt-4">
<HydrationBoundary state={dehydrate(queryClient)}>
<Tasks projectId={projectId} assignees={assignees} />
</HydrationBoundary>
Expand Down
36 changes: 36 additions & 0 deletions src/app/(application)/project/[projectId]/task/[taskId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
HydrationBoundary,
QueryClient,
dehydrate,
} from "@tanstack/react-query";
import { getAsigneesForProject } from "~/actions/project-actions";
import { getTask } from "~/actions/task-actions";
import Task from "~/components/task/Task";

type Params = {
params: {
taskId: string;
projectId: string;
};
};

export default async function TaskPage({
params: { taskId, projectId },
}: Params) {
const asignees = await getAsigneesForProject(parseInt(projectId));

// Prefetch task using react-query
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["task", taskId],
queryFn: () => getTask(parseInt(taskId)),
});

return (
<div className="max-h-screen overflow-y-scroll">
<HydrationBoundary state={dehydrate(queryClient)}>
<Task taskId={taskId} assignees={asignees} />
</HydrationBoundary>
</div>
);
}
47 changes: 21 additions & 26 deletions src/app/(application)/tasks/ai-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,23 @@ import { useChat } from "ai/react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { Label } from "~/components/ui/label";
import { Bot, ChevronRight, Loader2 } from "lucide-react";
import { Bot, ChevronRight, Loader2, SparkleIcon } from "lucide-react";
import { DialogClose } from "@radix-ui/react-dialog";
import { type Task, selectTaskSchema } from "~/server/db/schema";
import { createTask } from "~/actions/task-actions";
import { throwClientError } from "~/utils/errors";

type AiTask = { [K in keyof Omit<Task, "id">]?: Task[K] };
type AiTask = Task;

type Props = {
dispatch: (action: { type: "ADD" | "DELETE"; payload: Task }) => void;
projectId: string;
};

function extractValidJson(data: string): unknown {
Expand Down Expand Up @@ -64,7 +62,7 @@ function extractValidJson(data: string): unknown {
}
}

const AiDialog = ({ dispatch }: Props) => {
const AiDialog = ({ projectId }: Props) => {
const [open, setOpen] = useState(false);
const {
messages,
Expand All @@ -89,6 +87,9 @@ const AiDialog = ({ dispatch }: Props) => {

const taskObject = extractValidJson(filtered[0].content) as AiTask;
if (taskObject) {
taskObject.projectId = parseInt(projectId);
taskObject.assignee = null;
taskObject.id = Math.random() * 1000;
setTaskObject(taskObject);
}
}, [messages]);
Expand All @@ -105,17 +106,11 @@ const AiDialog = ({ dispatch }: Props) => {
async function handleAccept() {
try {
const validatedTask = selectTaskSchema.parse(taskObject);
dispatch({
type: "ADD",
payload: { ...validatedTask, id: Math.random() },
});
await createTask(validatedTask);
setOpen(false);

// reset state
setTaskObject(null);
setReviewResponse(false);
// clear chat
} catch (error) {
if (error instanceof Error) throwClientError(error.message);
}
Expand All @@ -135,21 +130,18 @@ const AiDialog = ({ dispatch }: Props) => {
}}
>
<DialogTrigger asChild>
<Button
variant="default"
size="icon"
className="rounded-full "
>
<Button variant="outline" size="sm">
<Bot className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>AI Task Creation</DialogTitle>
<DialogDescription>
Describe the task you would like to create, and our
AI model will create it for you.
</DialogDescription>
<DialogTitle>
<Button size="iconSm" className="mr-2">
<SparkleIcon className="h-4 w-4" />
</Button>
AI Task Creation
</DialogTitle>
</DialogHeader>
<div>
{taskObject ? (
Expand Down Expand Up @@ -190,6 +182,12 @@ const AiDialog = ({ dispatch }: Props) => {
<strong>Type:</strong> {taskObject.type}
</li>
)}
{taskObject?.assignee && (
<li>
<strong>assignee:</strong>{" "}
{taskObject.assignee}
</li>
)}
</ul>
) : null}
{reviewResponse ? (
Expand All @@ -200,13 +198,10 @@ const AiDialog = ({ dispatch }: Props) => {
onSubmit={handleSubmit}
id="chat"
>
<Label htmlFor="description">
Task Description
</Label>
<Textarea
name="description"
id="description"
placeholder="Type your task description here..."
placeholder="Describe the task you would like to create, and our AI model will create it for you..."
value={input}
onChange={handleInputChange}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export async function POST(req: Request) {
const systemMessage: OpenAI.ChatCompletionMessageParam = {
role: "system",
content: `
RESPOND IN JSON FORMAT!

Create a new task for project management. Provide the following details:
- title: [Specify the title of the task]
- description: [Include a brief description of the task]
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function RootLayout({
enableSystem
>
<Providers>
<main>{children}</main>/
<main>{children}</main>
</Providers>
</ThemeProvider>
<Toaster richColors />
Expand Down
11 changes: 7 additions & 4 deletions src/components/backlog/task/property/property-static.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ const PropertyStatic = ({ form, property }: Props) => {
""
)}
<p
className={cn("w-min flex-grow-0 whitespace-nowrap px-1", {
"opacity-80": property === "description",
"font-medium": property === "title",
})}
className={cn(
"flex-shrink overflow-hidden text-ellipsis whitespace-nowrap px-1 ",
{
"min-w-[2ch] opacity-80": property === "description",
"min-w-fit font-medium": property === "title",
},
)}
>
{form.watch(property)}
</p>
Expand Down
2 changes: 1 addition & 1 deletion src/components/backlog/task/task-dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Props = {
const TaskDropDownMenu = ({ task, children, deleteTaskMutation }: Props) => {
return (
<ContextMenu>
<ContextMenuTrigger className="flex items-center justify-between border-b py-2">
<ContextMenuTrigger className="relative flex max-w-full items-center justify-between overflow-hidden border-b py-2 hover:bg-accent/25">
{children}
</ContextMenuTrigger>
<ContextMenuContent className="bg-accent/50 backdrop-blur-sm">
Expand Down
19 changes: 15 additions & 4 deletions src/components/backlog/task/task.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@ import { zodResolver } from "@hookform/resolvers/zod";
import type { UseMutationResult } from "@tanstack/react-query";
import type { UpdateTask } from "~/components/backlog/tasks";
import TaskDropDownMenu from "./task-dropdown-menu";
import Link from "next/link";

type Props = {
task: TaskType;
assignees: User[];
addTaskMutation: UseMutationResult<void, Error, UpdateTask, unknown>;
deleteTaskMutation: UseMutationResult<void, Error, number, unknown>;
projectId: string;
};

const Task = ({
task,
assignees,
addTaskMutation,
deleteTaskMutation,
projectId,
}: Props) => {
const form = useForm<NewTask>({
resolver: zodResolver(taskSchema),
Expand Down Expand Up @@ -69,7 +72,10 @@ const Task = ({

function renderProperties() {
return order.map((group, groupIdx) => (
<div key={groupIdx} className="flex items-center gap-2">
<div
key={groupIdx}
className="flex flex-shrink items-center gap-2 first:min-w-0 first:flex-grow first:pl-8 last:pr-8"
>
{group.map((item, idx) => {
if (item.key === "id" || item.key === "projectId")
return null;
Expand All @@ -89,9 +95,14 @@ const Task = ({
}

return (
<TaskDropDownMenu deleteTaskMutation={deleteTaskMutation} task={task}>
{renderProperties()}
</TaskDropDownMenu>
<Link href={`/project/${projectId}/task/${task.id}`}>
<TaskDropDownMenu
deleteTaskMutation={deleteTaskMutation}
task={task}
>
{renderProperties()}
</TaskDropDownMenu>
</Link>
);
};

Expand Down
Loading