From 29dfd303db4f4165e67d09b76a69e0af5a3ba4c6 Mon Sep 17 00:00:00 2001 From: Pablo Lara Date: Wed, 13 Nov 2024 17:18:32 +0100 Subject: [PATCH] feat: adding workflow to send invites to the user --- actions/invitations/invitation.ts | 40 +++ .../(send-invite)/check-details/page.tsx | 3 + .../invitations/(send-invite)/layout.tsx | 32 ++ .../invitations/(send-invite)/new/page.tsx | 7 + app/(prowler)/invitations/page.tsx | 62 ++++ .../connect-account/page.tsx | 2 - .../providers/(set-up-provider)/layout.tsx | 4 +- app/(prowler)/users/page.tsx | 3 +- components/invitations/index.ts | 1 + .../invitations/send-invitation-button.tsx | 21 ++ .../invitations/workflow/forms/index.ts | 1 + .../workflow/forms/send-invitation-form.tsx | 107 +++++++ components/invitations/workflow/index.ts | 2 + .../invitations/workflow/vertical-steps.tsx | 291 ++++++++++++++++++ .../workflow/workflow-send-invite.tsx | 64 ++++ components/providers/workflow/index.ts | 2 +- ...workflow.tsx => workflow-add-provider.tsx} | 2 +- components/ui/sidebar/sidebar-items.tsx | 16 +- components/users/add-user-button.tsx | 21 ++ components/users/index.ts | 1 + 20 files changed, 664 insertions(+), 18 deletions(-) create mode 100644 actions/invitations/invitation.ts create mode 100644 app/(prowler)/invitations/(send-invite)/check-details/page.tsx create mode 100644 app/(prowler)/invitations/(send-invite)/layout.tsx create mode 100644 app/(prowler)/invitations/(send-invite)/new/page.tsx create mode 100644 app/(prowler)/invitations/page.tsx create mode 100644 components/invitations/index.ts create mode 100644 components/invitations/send-invitation-button.tsx create mode 100644 components/invitations/workflow/forms/index.ts create mode 100644 components/invitations/workflow/forms/send-invitation-form.tsx create mode 100644 components/invitations/workflow/index.ts create mode 100644 components/invitations/workflow/vertical-steps.tsx create mode 100644 components/invitations/workflow/workflow-send-invite.tsx rename components/providers/workflow/{workflow.tsx => workflow-add-provider.tsx} (98%) create mode 100644 components/users/add-user-button.tsx create mode 100644 components/users/index.ts diff --git a/actions/invitations/invitation.ts b/actions/invitations/invitation.ts new file mode 100644 index 0000000000..7fe129c92f --- /dev/null +++ b/actions/invitations/invitation.ts @@ -0,0 +1,40 @@ +"use server"; + +import { auth } from "@/auth.config"; +import { getErrorMessage, parseStringify } from "@/lib"; +export const sendInvite = async (formData: FormData) => { + const session = await auth(); + const keyServer = process.env.API_BASE_URL; + + const email = formData.get("email"); + const url = new URL(`${keyServer}/tenants/invitations`); + + const body = JSON.stringify({ + data: { + type: "Invitation", + attributes: { + email, + }, + relationships: {}, + }, + }); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + body, + }); + const data = await response.json(); + + return parseStringify(data); + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; diff --git a/app/(prowler)/invitations/(send-invite)/check-details/page.tsx b/app/(prowler)/invitations/(send-invite)/check-details/page.tsx new file mode 100644 index 0000000000..824d642093 --- /dev/null +++ b/app/(prowler)/invitations/(send-invite)/check-details/page.tsx @@ -0,0 +1,3 @@ +export default function CheckDetailsPage() { + return
CheckDetailsPage
; +} diff --git a/app/(prowler)/invitations/(send-invite)/layout.tsx b/app/(prowler)/invitations/(send-invite)/layout.tsx new file mode 100644 index 0000000000..2af2be2af2 --- /dev/null +++ b/app/(prowler)/invitations/(send-invite)/layout.tsx @@ -0,0 +1,32 @@ +import "@/styles/globals.css"; + +import { Spacer } from "@nextui-org/react"; +import React from "react"; + +import { WorkflowSendInvite } from "@/components/invitations/workflow"; +import { NavigationHeader } from "@/components/ui"; + +interface InvitationLayoutProps { + children: React.ReactNode; +} + +export default function InvitationLayout({ children }: InvitationLayoutProps) { + return ( + <> + + +
+
+ +
+
+ {children} +
+
+ + ); +} diff --git a/app/(prowler)/invitations/(send-invite)/new/page.tsx b/app/(prowler)/invitations/(send-invite)/new/page.tsx new file mode 100644 index 0000000000..87a2e33acd --- /dev/null +++ b/app/(prowler)/invitations/(send-invite)/new/page.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +import { SendInvitationForm } from "@/components/invitations/workflow/forms/send-invitation-form"; + +export default function SendInvitationPage() { + return ; +} diff --git a/app/(prowler)/invitations/page.tsx b/app/(prowler)/invitations/page.tsx new file mode 100644 index 0000000000..6f0df7bc4b --- /dev/null +++ b/app/(prowler)/invitations/page.tsx @@ -0,0 +1,62 @@ +import { Spacer } from "@nextui-org/react"; +import { Suspense } from "react"; + +import { getUsers } from "@/actions/users/users"; +import { FilterControls } from "@/components/filters"; +import { filterUsers } from "@/components/filters/data-filters"; +import { SendInvitationButton } from "@/components/invitations"; +import { Header } from "@/components/ui"; +import { DataTable } from "@/components/ui/table"; +import { ColumnsUser, SkeletonTableUser } from "@/components/users/table"; +import { SearchParamsProps } from "@/types"; + +export default async function Invitations({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) { + const searchParamsKey = JSON.stringify(searchParams || {}); + + return ( + <> +
+ + + + + + + }> + + + + ); +} + +const SSRDataTable = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const sort = searchParams.sort?.toString(); + + // Extract all filter parameters + const filters = Object.fromEntries( + Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")), + ); + + // Extract query from filters + const query = (filters["filter[search]"] as string) || ""; + + const usersData = await getUsers({ query, page, sort, filters }); + + return ( + + ); +}; diff --git a/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx b/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx index 67094101f5..eead6c2561 100644 --- a/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx +++ b/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx @@ -1,5 +1,3 @@ -"use client"; - import React from "react"; import { ConnectAccountForm } from "@/components/providers/workflow/forms"; diff --git a/app/(prowler)/providers/(set-up-provider)/layout.tsx b/app/(prowler)/providers/(set-up-provider)/layout.tsx index 788ce9cd96..85b78ee687 100644 --- a/app/(prowler)/providers/(set-up-provider)/layout.tsx +++ b/app/(prowler)/providers/(set-up-provider)/layout.tsx @@ -3,7 +3,7 @@ import "@/styles/globals.css"; import { Spacer } from "@nextui-org/react"; import React from "react"; -import { Workflow } from "@/components/providers/workflow"; +import { WorkflowAddProvider } from "@/components/providers/workflow"; import { NavigationHeader } from "@/components/ui"; interface ProviderLayoutProps { @@ -21,7 +21,7 @@ export default function ProviderLayout({ children }: ProviderLayoutProps) {
- +
{children} diff --git a/app/(prowler)/users/page.tsx b/app/(prowler)/users/page.tsx index fdb6f0de03..805c8271e6 100644 --- a/app/(prowler)/users/page.tsx +++ b/app/(prowler)/users/page.tsx @@ -6,6 +6,7 @@ import { FilterControls } from "@/components/filters"; import { filterUsers } from "@/components/filters/data-filters"; import { Header } from "@/components/ui"; import { DataTable } from "@/components/ui/table"; +import { AddUserButton } from "@/components/users"; import { ColumnsUser, SkeletonTableUser } from "@/components/users/table"; import { SearchParamsProps } from "@/types"; @@ -22,7 +23,7 @@ export default async function Users({ - {/* */} + }> diff --git a/components/invitations/index.ts b/components/invitations/index.ts new file mode 100644 index 0000000000..7eabbf93fe --- /dev/null +++ b/components/invitations/index.ts @@ -0,0 +1 @@ +export * from "./send-invitation-button"; diff --git a/components/invitations/send-invitation-button.tsx b/components/invitations/send-invitation-button.tsx new file mode 100644 index 0000000000..ae13d33b1d --- /dev/null +++ b/components/invitations/send-invitation-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { AddIcon } from "../icons"; +import { CustomButton } from "../ui/custom"; + +export const SendInvitationButton = () => { + return ( +
+ } + > + Send Invitation + +
+ ); +}; diff --git a/components/invitations/workflow/forms/index.ts b/components/invitations/workflow/forms/index.ts new file mode 100644 index 0000000000..3180c95b40 --- /dev/null +++ b/components/invitations/workflow/forms/index.ts @@ -0,0 +1 @@ +export * from "./send-invitation-form"; diff --git a/components/invitations/workflow/forms/send-invitation-form.tsx b/components/invitations/workflow/forms/send-invitation-form.tsx new file mode 100644 index 0000000000..39dd42803b --- /dev/null +++ b/components/invitations/workflow/forms/send-invitation-form.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { sendInvite } from "@/actions/invitations/invitation"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { ApiError } from "@/types"; + +const sendInvitationFormSchema = z.object({ + email: z.string().email("Please enter a valid email"), +}); + +export type FormValues = z.infer; + +export const SendInvitationForm = () => { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(sendInvitationFormSchema), + defaultValues: { + email: "", + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + const formData = new FormData(); + formData.append("email", values.email); + + try { + const data = await sendInvite(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/email": + form.setError("email", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + const token = data?.data?.attributes.token || ""; + router.push(`invitations/check-details/?invitation_token=${token}`); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "An unexpected error occurred. Please try again.", + }); + } + }; + + return ( +
+ + + +
+ } + > + {isLoading ? <>Loading : Send Invitation} + +
+ + + ); +}; diff --git a/components/invitations/workflow/index.ts b/components/invitations/workflow/index.ts new file mode 100644 index 0000000000..41589b2616 --- /dev/null +++ b/components/invitations/workflow/index.ts @@ -0,0 +1,2 @@ +export * from "./vertical-steps"; +export * from "./workflow-send-invite"; diff --git a/components/invitations/workflow/vertical-steps.tsx b/components/invitations/workflow/vertical-steps.tsx new file mode 100644 index 0000000000..74eaa32747 --- /dev/null +++ b/components/invitations/workflow/vertical-steps.tsx @@ -0,0 +1,291 @@ +"use client"; + +import type { ButtonProps } from "@nextui-org/react"; +import { cn } from "@nextui-org/react"; +import { useControlledState } from "@react-stately/utils"; +import { domAnimation, LazyMotion, m } from "framer-motion"; +import type { ComponentProps } from "react"; +import React from "react"; + +export type VerticalStepProps = { + className?: string; + description?: React.ReactNode; + title?: React.ReactNode; +}; + +export interface VerticalStepsProps + extends React.HTMLAttributes { + /** + * An array of steps. + * + * @default [] + */ + steps?: VerticalStepProps[]; + /** + * The color of the steps. + * + * @default "primary" + */ + color?: ButtonProps["color"]; + /** + * The current step index. + */ + currentStep?: number; + /** + * The default step index. + * + * @default 0 + */ + defaultStep?: number; + /** + * Whether to hide the progress bars. + * + * @default false + */ + hideProgressBars?: boolean; + /** + * The custom class for the steps wrapper. + */ + className?: string; + /** + * The custom class for the step. + */ + stepClassName?: string; + /** + * Callback function when the step index changes. + */ + onStepChange?: (stepIndex: number) => void; +} + +function CheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} + +export const VerticalSteps = React.forwardRef< + HTMLButtonElement, + VerticalStepsProps +>( + ( + { + color = "primary", + steps = [], + defaultStep = 0, + onStepChange, + currentStep: currentStepProp, + hideProgressBars = false, + stepClassName, + className, + ...props + }, + ref, + ) => { + const [currentStep, setCurrentStep] = useControlledState( + currentStepProp, + defaultStep, + onStepChange, + ); + + const colors = React.useMemo(() => { + let userColor; + let fgColor; + + const colorsVars = [ + "[--active-fg-color:var(--step-fg-color)]", + "[--active-border-color:var(--step-color)]", + "[--active-color:var(--step-color)]", + "[--complete-background-color:var(--step-color)]", + "[--complete-border-color:var(--step-color)]", + "[--inactive-border-color:hsl(var(--nextui-default-300))]", + "[--inactive-color:hsl(var(--nextui-default-300))]", + ]; + + switch (color) { + case "primary": + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + case "secondary": + userColor = "[--step-color:hsl(var(--nextui-secondary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-secondary-foreground))]"; + break; + case "success": + userColor = "[--step-color:hsl(var(--nextui-success))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-success-foreground))]"; + break; + case "warning": + userColor = "[--step-color:hsl(var(--nextui-warning))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-warning-foreground))]"; + break; + case "danger": + userColor = "[--step-color:hsl(var(--nextui-error))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-error-foreground))]"; + break; + case "default": + userColor = "[--step-color:hsl(var(--nextui-default))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-default-foreground))]"; + break; + default: + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + } + + if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor); + if (!className?.includes("--step-color")) colorsVars.unshift(userColor); + if (!className?.includes("--inactive-bar-color")) + colorsVars.push( + "[--inactive-bar-color:hsl(var(--nextui-default-300))]", + ); + + return colorsVars; + }, [color, className]); + + return ( + + ); + }, +); + +VerticalSteps.displayName = "VerticalSteps"; diff --git a/components/invitations/workflow/workflow-send-invite.tsx b/components/invitations/workflow/workflow-send-invite.tsx new file mode 100644 index 0000000000..cf87d04f06 --- /dev/null +++ b/components/invitations/workflow/workflow-send-invite.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Progress, Spacer } from "@nextui-org/react"; +import { usePathname } from "next/navigation"; +import React from "react"; + +import { VerticalSteps } from "./vertical-steps"; + +const steps = [ + { + title: "Send Invitation", + description: + "Enter the email address of the person you want to invite and send the invitation.", + href: "/invitations/new", + }, + { + title: "Review Invitation Details", + description: + "Review the invitation details and share the information required for the person to accept the invitation.", + href: "/invitations/check-details", + }, +]; + +export const WorkflowSendInvite = () => { + const pathname = usePathname(); + + // Calculate current step based on pathname + const currentStepIndex = steps.findIndex((step) => + pathname.endsWith(step.href), + ); + const currentStep = currentStepIndex === -1 ? 0 : currentStepIndex; + + return ( +
+

+ Send invitation +

+

+ Follow the steps to send an invitation to the users. +

+ + + +
+ ); +}; diff --git a/components/providers/workflow/index.ts b/components/providers/workflow/index.ts index f245196eab..fca9e92617 100644 --- a/components/providers/workflow/index.ts +++ b/components/providers/workflow/index.ts @@ -1,2 +1,2 @@ export * from "./vertical-steps"; -export * from "./workflow"; +export * from "./workflow-add-provider"; diff --git a/components/providers/workflow/workflow.tsx b/components/providers/workflow/workflow-add-provider.tsx similarity index 98% rename from components/providers/workflow/workflow.tsx rename to components/providers/workflow/workflow-add-provider.tsx index da5fa18f99..69bc885638 100644 --- a/components/providers/workflow/workflow.tsx +++ b/components/providers/workflow/workflow-add-provider.tsx @@ -32,7 +32,7 @@ const steps = [ }, ]; -export const Workflow = () => { +export const WorkflowAddProvider = () => { const pathname = usePathname(); // Calculate current step based on pathname diff --git a/components/ui/sidebar/sidebar-items.tsx b/components/ui/sidebar/sidebar-items.tsx index 32043aadc9..a4fae9eab4 100644 --- a/components/ui/sidebar/sidebar-items.tsx +++ b/components/ui/sidebar/sidebar-items.tsx @@ -29,10 +29,10 @@ export const items: SidebarItem[] = [ ), }, { - key: "tasks", + key: "invitations", href: "#", icon: "solar:checklist-minimalistic-outline", - title: "Tasks", + title: "Invitations", endContent: ( , - // }, ], }, ]; diff --git a/components/users/add-user-button.tsx b/components/users/add-user-button.tsx new file mode 100644 index 0000000000..b4a63fc7a7 --- /dev/null +++ b/components/users/add-user-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { AddIcon } from "../icons"; +import { CustomButton } from "../ui/custom"; + +export const AddUserButton = () => { + return ( +
+ } + > + Invite User + +
+ ); +}; diff --git a/components/users/index.ts b/components/users/index.ts new file mode 100644 index 0000000000..014350e9a6 --- /dev/null +++ b/components/users/index.ts @@ -0,0 +1 @@ +export * from "./add-user-button";