Working on project.

This commit is contained in:
charles-gauthereau
2024-11-30 21:05:47 +01:00
parent 3e62359bd9
commit afa46c171b
19 changed files with 701 additions and 180 deletions
+10 -12
View File
@@ -6,6 +6,7 @@ import {env} from "@/env.mjs";
import GoogleProvider from "next-auth/providers/google";
export const {handlers, auth: baseAuth, signIn, signOut} = NextAuth({
adapter: PrismaAdapter(prisma),
theme: {
@@ -78,22 +79,20 @@ export const {handlers, auth: baseAuth, signIn, signOut} = NextAuth({
// // session.user.authMethod = user.authMethod;
// return session
// },
async jwt({token, trigger, session, user}) {
async jwt({ token, trigger, session, user }) {
if (trigger === "update" && session) {
return {...session, user: {...token, ...session?.user}};
// return {...token, ...session?.user};
return { ...token, ...session?.user };
}
return {...token, ...user};
return { ...token, ...user };
},
async session({session, token, user}) {
session.user = token.user;
// session.user = token;
async session({ session, token, user }) {
session.user = token;
return session;
},
async signIn({account, user, profile}) {
async signIn({ account, user, profile }) {
const existingUser = await prisma.user.findFirst({
where: {email: user.email},
where: { email: user.email },
});
if (!existingUser) {
@@ -126,7 +125,7 @@ export const {handlers, auth: baseAuth, signIn, signOut} = NextAuth({
// Update auth method if user exists
await prisma.user.update({
where: {id: existingUser.id},
where: { id: existingUser.id },
data: {
authMethod: account.provider,
},
@@ -136,5 +135,4 @@ export const {handlers, auth: baseAuth, signIn, signOut} = NextAuth({
},
}
});
});
-1
View File
@@ -3,7 +3,6 @@ import {User} from "@prisma/client";
export const currentUser = async () => {
const session = await baseAuth();
console.log("mysession", session)
if (!session?.user) {
return null;
}
@@ -3,12 +3,12 @@ import {
SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenu, SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import {LoggedInButton} from "@/components/wrappers/Dashboard/LoggedInButton/LoggedInButton";
import {SidebarMenuCustom} from "@/components/wrappers/Dashboard/SideBar/SideBarMenu/SideBarMenu";
import {OrganizationComboBox} from "@/components/wrappers/organization/OrganizationCombobox";
import {OrganizationComboBox} from "@/components/wrappers/Organization/OrganizationCombobox";
import {prisma} from "@/prisma";
export async function AppSidebar() {
@@ -0,0 +1,382 @@
//https://github.com/sersavan/shadcn-multi-select-component
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
CheckIcon,
XCircle,
ChevronDown,
XIcon,
WandSparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
/**
* Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
*/
const multiSelectVariants = cva(
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
{
variants: {
variant: {
default:
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
secondary:
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
inverted: "inverted",
},
},
defaultVariants: {
variant: "default",
},
}
);
/**
* Props for MultiSelect component
*/
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
* Each option object has a label, value, and an optional icon.
*/
options: {
/** The text to display for the option. */
label: string;
/** The unique value associated with the option. */
value: string;
/** Optional icon component to display alongside the option. */
icon?: React.ComponentType<{ className?: string }>;
}[];
/**
* Callback function triggered when the selected values change.
* Receives an array of the new selected values.
*/
onValueChange: (value: string[]) => void;
/** The default selected values when the component mounts. */
defaultValue?: string[];
/**
* Placeholder text to be displayed when no values are selected.
* Optional, defaults to "Select options".
*/
placeholder?: string;
/**
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
* Optional, defaults to 0 (no animation).
*/
animation?: number;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
/**
* The modality of the popover. When set to true, interaction with outside elements
* will be disabled and only popover content will be visible to screen readers.
* Optional, defaults to false.
*/
modalPopover?: boolean;
/**
* If true, renders the multi-select component as a child of another component.
* Optional, defaults to false.
*/
asChild?: boolean;
/**
* Additional class names to apply custom styles to the multi-select component.
* Optional, can be used to add custom styles.
*/
className?: string;
}
export const MultiSelect = React.forwardRef<
HTMLButtonElement,
MultiSelectProps
>(
(
{
options,
onValueChange,
variant,
defaultValue = [],
placeholder = "Select options",
animation = 0,
maxCount = 3,
modalPopover = false,
asChild = false,
className,
...props
},
ref
) => {
const [selectedValues, setSelectedValues] =
React.useState<string[]>(defaultValue);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(false);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const toggleOption = (option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const handleClear = () => {
setSelectedValues([]);
onValueChange([]);
};
const handleTogglePopover = () => {
setIsPopoverOpen((prev) => !prev);
};
const clearExtraOptions = () => {
const newSelectedValues = selectedValues.slice(0, maxCount);
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const toggleAll = () => {
if (selectedValues.length === options.length) {
handleClear();
} else {
const allValues = options.map((option) => option.value);
setSelectedValues(allValues);
onValueChange(allValues);
}
};
return (
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
modal={modalPopover}
>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto",
className
)}
>
{selectedValues.length > 0 ? (
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
const IconComponent = option?.icon;
return (
<Badge
key={value}
className={cn(
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{IconComponent && (
<IconComponent className="h-4 w-4 mr-2" />
)}
{option?.label}
<XCircle
className="ml-2 h-4 w-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleOption(value);
}}
/>
</Badge>
);
})}
{selectedValues.length > maxCount && (
<Badge
className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
)}
style={{ animationDuration: `${animation}s` }}
>
{`+ ${selectedValues.length - maxCount} more`}
<XCircle
className="ml-2 h-4 w-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
clearExtraOptions();
}}
/>
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<XIcon
className="h-4 mx-2 cursor-pointer text-muted-foreground"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span className="text-sm text-muted-foreground mx-3">
{placeholder}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
<CommandItem
key="all"
onSelect={toggleAll}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
selectedValues.length === options.length
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
<span>(Select All)</span>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption(option.value)}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem
onSelect={handleClear}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
</>
)}
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="flex-1 justify-center cursor-pointer max-w-full"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
{animation > 0 && selectedValues.length > 0 && (
<WandSparkles
className={cn(
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
isAnimating ? "" : "text-muted-foreground"
)}
onClick={() => setIsAnimating(!isAnimating)}
/>
)}
</Popover>
);
}
);
MultiSelect.displayName = "MultiSelect";
@@ -8,6 +8,7 @@ import {Organization} from "@prisma/client";
export type organizationComboBoxProps = {
organizations: Organization[]
defaultOrganization: Organization
}
@@ -31,6 +32,10 @@ export function OrganizationComboBox(props: organizationComboBoxProps) {
}
return (
<ComboBox values={values} defaultValue={defaultOrganization.id} onValueChange={updateSession}/>
<ComboBox
sideBar={true}
values={values}
defaultValue={defaultOrganization.id}
onValueChange={updateSession}/>
)
}
@@ -11,12 +11,14 @@ export const ProjectCard = (props: projectCardProps) => {
const {data: project} = props;
return (
<Link href={`/dashboard/projects/${project.id}`}>
<Card className="flex flex-row justify-between">
<div className="">
<CardHeader>{project.name}</CardHeader>
<CardContent>
{project.databases.length} databases
</CardContent>
</div>
</Card>
@@ -0,0 +1,62 @@
"use server"
import {userAction} from "@/safe-actions";
import {prisma} from "@/prisma";
import {ProjectSchema} from "@/components/wrappers/Project/ProjectForm.schema";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {Projects} from "@prisma/client";
export const createProjectAction = userAction
.schema(
z.object({
data: ProjectSchema,
organizationId: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<Projects>> => {
try {
const project = await prisma.project.create({
data: {
name: parsedInput.data.name,
slug: parsedInput.data.slug,
organizationId: parsedInput.organizationId,
}
})
for (const db of parsedInput.data.databases) {
await prisma.database.update({
where: {
id: db,
},
data:{
projectId: project.id,
}
})
}
return {
success: true,
value: project,
actionSuccess: {
message: "Project has been successfully created.",
messageParams: {projectName: parsedInput.data.name},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to create Project.",
status: 500, // Optional: Use a meaningful status code
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {projectName: parsedInput.data.name},
},
};
}
});
@@ -1,9 +1,10 @@
import {z} from "zod";
import Database from "@prisma/client"
export const ProjectSchema = z.object({
name: z.string(),
slug: z.string(),
databases: z.array(z.string()),
});
export type ProjectSchema = z.infer<typeof ProjectSchema>;
@@ -0,0 +1,156 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {ProjectSchema} from "@/components/wrappers/Project/ProjectForm.schema";
import {createProjectAction} from "@/components/wrappers/Project/ProjectForm.action";
import {useRouter} from "next/navigation";
import {Database, Organization} from "@prisma/client"
import {MultiSelect} from "@/components/wrappers/MultiSelect/MultiSelect";
import {ZodString} from "zod";
export type projectFormProps = {
defaultValues?: ProjectSchema;
databases: Database[],
organization: Organization,
projectId?: string;
}
export const ProjectForm = (props: projectFormProps) => {
const router = useRouter();
const isCreate = !Boolean(props.defaultValues)
const formatDatabasesList = (databases: Database[]) => {
return databases.map(database => ({
value: database.id,
label: `${database.name} | ${database.generatedId}`,
}));
};
const formatDefaultDatabases = (databases: ProjectSchema["databases"]): string[] => {
return databases.map(database => database.id);
};
const formattedDefaultValues = {
...props.defaultValues,
databases: !isCreate ? formatDefaultDatabases(props.defaultValues?.databases) : []
}
const form = useZodForm({
schema: ProjectSchema,
defaultValues: formattedDefaultValues,
});
const mutation = useMutation({
mutationFn: async (values: ProjectSchema) => {
console.log(values)
const projectCreated = await createProjectAction({data: values, organizationId: props.organization.id});
console.log(projectCreated)
//
// if (data) {
// toast.success(`Success`);
// router.push(`/dashboard/projects/${data.id}`);
// router.refresh()
// }
//
// if (validationErrors) {
// toast.success(`Error`);
//
// }
}
})
return (
<Card>
<CardHeader>
</CardHeader>
<CardContent>
<Form form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Project 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="databases"
render={({field}) => (
<FormItem>
<FormLabel>Databases</FormLabel>
<FormControl>
<MultiSelect
options={formatDatabasesList(props.databases)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select databases"
variant="inverted"
animation={2}
// maxCount={100}
/>
</FormControl>
<FormDescription>Select databases you want to add to this project</FormDescription>
<FormMessage/>
</FormItem>
)
}
/>
<Button>
Create Project
</Button>
</Form>
</CardContent>
</Card>
)
}
+28 -12
View File
@@ -20,12 +20,14 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import {FormControl} from "@/components/ui/form";
import {SidebarMenuButton} from "@/components/ui/sidebar";
export type comboBoxProps = {
values: Array<{ value: string, label: string }>
defaultValue?: string
onValueChange?: any
searchField?: boolean
sideBar?: boolean
}
@@ -41,19 +43,33 @@ export function ComboBox(props: comboBoxProps) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{value
? choices.find((choice) => choice.value === value)?.label
: "Select choice..."}
<ChevronDown className="opacity-50"/>
</Button>
{props.sideBar ?
<SidebarMenuButton>
{value
? choices.find((choice) => choice.value === value)?.label
: "Select choice..."}
<ChevronDown className="ml-auto"/>
</SidebarMenuButton>
:
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{value
? choices.find((choice) => choice.value === value)?.label
: "Select choice..."}
<ChevronDown className="opacity-50"/>
</Button>
}
</PopoverTrigger>
<PopoverContent className="p-0 popover-content-width-full">
<PopoverContent
className="p-0 popover-content-width-full"
>
<Command>
{searchField ? <CommandInput placeholder="Search choice..." className="h-9"/> : null}
<CommandList>
@@ -1,21 +0,0 @@
"use server"
import {userAction} from "@/safe-actions";
import {prisma} from "@/prisma";
import {ProjectSchema} from "@/components/wrappers/project/ProjectForm.schema";
export const createProjectAction = userAction
.schema(ProjectSchema)
.action(async ({parsedInput, ctx}) => {
// Verify if slug already exist
// await verifySlugUniqueness(parsedInput.slug);
console.log("ctx", ctx)
return prisma.project.create({
data: {
...parsedInput,
}
});
});
@@ -1,99 +0,0 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {ProjectSchema} from "@/components/wrappers/project/ProjectForm.schema";
import {createProjectAction} from "@/components/wrappers/project/ProjectForm.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {useSession} from "next-auth/react";
export type projectFormProps = {
defaultValues?: ProjectSchema;
}
export const ProjectForm = (props: projectFormProps) => {
const router = useRouter();
const {data: session} = useSession()
console.log("organization", session)
const form = useZodForm({
schema: ProjectSchema,
});
const mutation = useMutation({
mutationFn: async (values: ProjectSchema) => {
const {data, validationErrors} = await createProjectAction({...values, organizationId: organization?.id});
if (data) {
toast.success(`Success`);
router.push(`/dashboard/projects/${data.id}`);
router.refresh()
}
if (validationErrors) {
toast.success(`Error`);
}
}
})
return (
<Card>
<CardHeader>
</CardHeader>
<CardContent>
<Form form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Project 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button>
Create Project
</Button>
</Form>
</CardContent>
</Card>
)
}
+1 -1
View File
@@ -74,7 +74,7 @@ async function createDefaultOrganization() {
}
})
if (!defaultOrganization) {
console.log("==== Creating default organization... ====\n")
console.log("==== Creating default Organization... ====\n")
await prisma.organization.create({
data: {
...defaultOrganizationConf