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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";
import NavigationSidebar, { ItemGroup } from "@/src/components/NavigationSidebar";
import { useProject } from "@/src/hooks/useProject";
import { useTeam } from "@/src/hooks/useTeam";
import { House, Settings } from "lucide-react";

export default function ProjectSidebar() {
const { data: team, isLoading: isTeamLoading } = useTeam();
const { data: project, isLoading: isProjectLoading } = useProject();

const SidebarItems: ItemGroup[] = [
{
groupTitle: "Workspace",
items: [
{
title: "Overview",
Icon: House,
url: "",
},
],
},
{
groupTitle: "Manage",
items: [
{
title: "Project Settings",
Icon: Settings,
url: "/settings",
},
],
},
];

const dynamicItems = SidebarItems.map((group) => ({
...group,
items: group.items.map((item) => ({
...item,
url: `/dashboard/${team?.slug}/projects/${project?.slug}${item.url}`,
})),
}));
return <NavigationSidebar items={dynamicItems} isLoading={isTeamLoading || isProjectLoading} />;
}
10 changes: 10 additions & 0 deletions src/app/dashboard/[teamSlug]/projects/[projectSlug]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import ProjectSidebar from "./components/ProjectSidebar";

export default function Layout({ children }: React.PropsWithChildren) {
return (
<>
<ProjectSidebar />
{children}
</>
);
}
4 changes: 2 additions & 2 deletions src/app/dashboard/[teamSlug]/projects/[projectSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export default function Page() {

return (
<div>
<p>{project?.name}</p>
<p>{project?.slug}</p>
<p>Name: {project?.name}</p>
<p>Slug: {project?.slug}</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";
import CallbackDialog from "@/src/components/CallbackDialog";
import { Button } from "@/src/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/src/components/ui/card";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/src/components/ui/item";
import { Separator } from "@/src/components/ui/separator";
import { Skeleton } from "@/src/components/ui/skeleton";
import { useProject, useProjectMutations } from "@/src/hooks/useProject";
import { useTeam } from "@/src/hooks/useTeam";
import { hasPermission } from "@/src/lib/utils/team-utils";
import { Trash } from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
import { toast } from "sonner";

const CardComponent = ({ children }: React.PropsWithChildren) => {
return (
<Card className="w-full">
<CardHeader>
<CardTitle>DANGER ZONE</CardTitle>
</CardHeader>
<Separator />
<CardContent>
<Item variant={"outline"} className="border-destructive bg-destructive/5">
<ItemMedia variant={"icon"} className="border-none bg-destructive">
<Trash className="stroke-destructive-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>Delete Project</ItemTitle>
<ItemDescription>
Your project will be permanently deleted including all of its data. This action is
irreversible.
</ItemDescription>
</ItemContent>
<ItemActions>{children}</ItemActions>
</Item>
</CardContent>
</Card>
);
};

export default function ProjectDangerZone() {
const router = useRouter();
const { data: team, isLoading: isTeamLoading } = useTeam();
const { data: project, isLoading: isProjectLoading } = useProject();

const { deleteProject } = useProjectMutations();

const handleProjectDeletion = async () => {
const id = toast.loading("Deleting project...");
deleteProject
.mutateAsync({ teamId: team!.id, projectId: project!.id })
.then(() => {
toast.success("Successfully deleted project", { id });
router.replace(`/dashboard/${team?.slug}`);
})
.catch((error) => {
if (error instanceof Error) {
toast.error(error.message, { id });
} else {
toast.error("An unexpected error happened while deleting project", {
id,
});
}
});
};

if (isTeamLoading || isProjectLoading) {
return (
<CardComponent>
<Skeleton className="h-9 w-30" />
</CardComponent>
);
}

return (
<CardComponent>
<CallbackDialog
title="Delete Project"
description="Are you sure you want to delete this project? This action is irreversible"
cancelButtonText="Cancel"
submitButtonText="Delete"
submitButtonVariant={"destructive"}
cancelButtonVariant={"outline"}
callback={handleProjectDeletion}
confirmationText={project?.name}
trigger={
<Button variant={"destructive"} disabled={!hasPermission(team?.role, "DeleteProject")}>
Delete Project
</Button>
}
/>
</CardComponent>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";
import CallbackDialog from "@/src/components/CallbackDialog";
import { Button } from "@/src/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/src/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/src/components/ui/form";
import { Input } from "@/src/components/ui/input";
import { Separator } from "@/src/components/ui/separator";
import { Skeleton } from "@/src/components/ui/skeleton";
import { useProject, useProjectMutations } from "@/src/hooks/useProject";
import { useTeam } from "@/src/hooks/useTeam";
import { RenameProjectSchema } from "@/src/lib/types/project-types";
import { hasPermission } from "@/src/lib/utils/team-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

const CardComponent = ({ children }: React.PropsWithChildren) => (
<Card className="w-full">
<CardHeader>
<CardTitle>Project Name</CardTitle>
<CardDescription>
This is the name of your project displayed across the dashboard.
</CardDescription>
</CardHeader>
<Separator />
<CardContent>{children}</CardContent>
</Card>
);

export default function ProjectName() {
const router = useRouter();
const { data: team, isLoading: isTeamLoading } = useTeam();
const { data: project, isLoading: isProjectLoading } = useProject();
const [open, setIsOpen] = useState(false);
const { renameProject } = useProjectMutations();

const formSchema = RenameProjectSchema.refine((values) => values.name !== project?.name, {
error: "New project name must be different than your current project name",
});

const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
},
});

const handleChangeName = (name: string) => {
const id = toast.loading("Updating project name...");
renameProject
.mutateAsync({ teamId: team!.id, projectId: project!.id, newName: name })
.then(async (newProject) => {
toast.success("Successfully updated project name!", {
id,
});
router.replace(`/dashboard/${team?.slug}/projects/${newProject?.slug}/settings`);
})
.catch((error) => {
if (error instanceof Error) {
toast.error(error.message, { id });
} else {
toast.error("An unexpected error happened while updating project name", {
id,
});
}
});
};

if (isTeamLoading || isProjectLoading) {
return (
<CardComponent>
<div className="flex justify-between gap-2">
<Skeleton className="h-9 w-full max-w-lg" />
<Skeleton className="h-9 w-16" />
</div>
</CardComponent>
);
}

const disabled = !hasPermission(team?.role, "RenameProject");

return (
<CardComponent>
<Form {...form}>
<form
className="flex justify-between gap-2"
onSubmit={form.handleSubmit(() => setIsOpen(true))}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="w-full max-w-lg">
<FormControl>
<Input placeholder={project?.name} disabled={disabled} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<CallbackDialog
title="Rename Project"
description="Changing your project name will invalidate your current project URL. Are you sure you want to proceed?"
open={open}
onOpenChange={setIsOpen}
callback={() => {
form.handleSubmit(({ name }) => {
handleChangeName(name);
})();
}}
/>
<Button disabled={disabled}>Save</Button>
</form>
</Form>
</CardComponent>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import ProjectDangerZone from "./ProjectDangerZone";
import ProjectName from "./ProjectName";

export default function Page() {
return (
<>
<span className="w-full text-left text-lg font-semibold">Project Settings</span>
<ProjectName />
<ProjectDangerZone />
</>
);
}
18 changes: 16 additions & 2 deletions src/hooks/useProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export function useProjectMutations() {
return [...prevData, project];
},
);

queryClient.setQueryData<Project>(
["project", variables.teamId, project.id],
() => project,
);
},
});

Expand All @@ -77,14 +82,18 @@ export function useProjectMutations() {
teamId: string;
projectId: string;
}) => ProjectController.delete(teamId, projectId),
onSuccess: (project, variables) => {
onSuccess: (oldProject, variables) => {
queryClient.setQueryData<Project[]>(
["projects", variables.teamId],
(prevData) => {
if (!prevData) return prevData;
return prevData.filter((p) => p.id !== project.id);
return prevData.filter((p) => p.id !== oldProject.id);
},
);

queryClient.removeQueries({
queryKey: ["project", variables.teamId, oldProject.slug],
});
},
});

Expand All @@ -106,6 +115,11 @@ export function useProjectMutations() {
return prevData.map((p) => (p.id === project.id ? project : p));
},
);

queryClient.setQueryData<Project>(
["project", variables.teamId, project.id],
() => project,
);
},
});

Expand Down
Loading