chore: add manage group actions

This commit is contained in:
Pablo Lara
2024-12-18 17:38:09 +01:00
parent 0459a4d6f6
commit 2d190eb020
11 changed files with 391 additions and 84 deletions

View File

@@ -68,7 +68,7 @@ export const sendInvite = async (formData: FormData) => {
? [
{
id: role,
type: "role",
type: "roles",
},
]
: [],
@@ -131,7 +131,7 @@ export const updateInvite = async (formData: FormData) => {
data: [
{
id: roleId,
type: "role",
type: "roles",
},
],
};

View File

@@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth.config";
import { getErrorMessage, parseStringify } from "@/lib";
export const createProviderGroup = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const name = formData.get("name") as string;
const providersJson = formData.get("providers") as string;
const rolesJson = formData.get("roles") as string;
// Parse JSON strings and handle empty cases
const providers = providersJson ? JSON.parse(providersJson) : [];
const roles = rolesJson ? JSON.parse(rolesJson) : [];
// Prepare base payload
const payload: any = {
data: {
type: "provider-groups",
attributes: {
name,
},
relationships: {},
},
};
// Add relationships only if there are items
if (providers.length > 0) {
payload.data.relationships.providers = {
data: providers,
};
}
if (roles.length > 0) {
payload.data.relationships.roles = {
data: roles,
};
}
const body = JSON.stringify(payload);
try {
const url = new URL(`${keyServer}/provider-groups`);
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();
revalidatePath("/providers/manage-groups");
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};

View File

@@ -82,7 +82,7 @@ export const addRole = async (formData: FormData) => {
const url = new URL(`${keyServer}/roles`);
const body = JSON.stringify({
data: {
type: "role",
type: "roles",
attributes: {
name: formData.get("name"),
manage_users: formData.get("manage_users") === "true",
@@ -128,7 +128,7 @@ export const updateRole = async (formData: FormData, roleId: string) => {
const url = new URL(`${keyServer}/roles/${roleId}`);
const body = JSON.stringify({
data: {
type: "role",
type: "roles",
id: roleId,
attributes: {
name: formData.get("name"),

View File

@@ -119,7 +119,7 @@ export const updateUserRole = async (formData: FormData) => {
const requestBody = {
data: [
{
type: "role",
type: "roles",
id: roleId,
},
],

View File

@@ -18,7 +18,7 @@ export default function ProviderLayout({ children }: ProviderLayoutProps) {
href="/providers"
/>
<Spacer y={16} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">{children}</div>
{children}
</>
);
}

View File

@@ -1,11 +1,57 @@
import React from "react";
import { Divider } from "@nextui-org/react";
import React, { Suspense } from "react";
import { Header } from "@/components/ui";
import { getProviders } from "@/actions/providers";
import { getRoles } from "@/actions/roles";
import { AddGroupForm } from "@/components/manage-groups/forms";
import { SkeletonManageGroups } from "@/components/manage-groups/skeleton-manage-groups";
import { ProviderProps, SearchParamsProps } from "@/types";
export default function ManageGroupsPage({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams);
export default function ManageGroupsPage() {
return (
<>
<Header title="Manage Groups" icon="fluent:people-team-24-regular" />
</>
<div className="grid min-h-[70vh] grid-cols-1 items-center justify-center gap-4 md:grid-cols-12">
<div className="col-span-1 flex justify-end md:col-span-4">
<Suspense key={searchParamsKey} fallback={<SkeletonManageGroups />}>
<SSRAddGroupForm searchParams={searchParams} />
</Suspense>
</div>
<Divider orientation="vertical" className="mx-auto h-full" />
<div className="col-span-1 flex justify-start md:col-span-6">
{/* Space to add the table */}
</div>
</div>
);
}
const SSRAddGroupForm = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const providersResponse = await getProviders({});
const rolesResponse = await getRoles({});
const providersData = providersResponse?.data.map(
(provider: ProviderProps) => ({
id: provider.id,
name: provider.attributes.alias,
}),
);
const rolesData = rolesResponse?.data.map((role: any) => ({
id: role.id,
name: role.attributes.name,
}));
return (
<AddGroupForm providers={providersData || []} roles={rolesData || []} />
);
};

View File

@@ -58,7 +58,7 @@ const SSRDataTable = async ({
// Create a dictionary for roles by user ID
const roleDict = (usersData?.included || []).reduce(
(acc: Record<string, any>, item: Role) => {
if (item.type === "role") {
if (item.type === "roles") {
acc[item.id] = item.attributes;
}
return acc;

View File

@@ -1,67 +1,84 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Select, SelectItem } from "@nextui-org/react";
import { Divider } from "@nextui-org/react";
import { SaveIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { sendInvite } from "@/actions/invitations/invitation";
import { createProviderGroup } from "@/actions/manage-groups";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import {
CustomButton,
CustomDropdownSelection,
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"),
roleId: z.string().nonempty("Role is required"),
const addGroupSchema = z.object({
name: z.string().nonempty("Provider group name is required"),
providers: z.array(z.string()).optional(),
roles: z.array(z.string()).optional(),
});
export type FormValues = z.infer<typeof sendInvitationFormSchema>;
export type FormValues = z.infer<typeof addGroupSchema>;
export const AddGroupForm = ({
roles = [],
defaultRole = "admin",
isSelectorDisabled = false,
providers = [],
}: {
roles: Array<{ id: string; name: string }>;
defaultRole?: string;
isSelectorDisabled: boolean;
providers: Array<{ id: string; name: string }>;
}) => {
const { toast } = useToast();
const router = useRouter();
const form = useForm<FormValues>({
resolver: zodResolver(sendInvitationFormSchema),
resolver: zodResolver(addGroupSchema),
defaultValues: {
email: "",
roleId: isSelectorDisabled ? defaultRole : "",
name: "",
providers: [],
roles: [],
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormValues) => {
const formData = new FormData();
formData.append("email", values.email);
formData.append("role", values.roleId);
console.log(values);
try {
const data = await sendInvite(formData);
const formData = new FormData();
formData.append("name", values.name);
if (values.providers?.length) {
const providersData = values.providers.map((id) => ({
id,
type: "providers",
}));
formData.append("providers", JSON.stringify(providersData));
}
if (values.roles?.length) {
const rolesData = values.roles.map((id) => ({
id,
type: "roles",
}));
formData.append("roles", JSON.stringify(rolesData));
}
const data = await createProviderGroup(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", {
case "/data/attributes/name":
form.setError("name", {
type: "server",
message: errorMessage,
});
break;
case "/data/relationships/roles":
form.setError("roleId", {
form.setError("roles", {
type: "server",
message: errorMessage,
});
@@ -75,8 +92,11 @@ export const AddGroupForm = ({
}
});
} else {
const invitationId = data?.data?.id || "";
router.push(`/invitations/check-details/?id=${invitationId}`);
form.reset();
toast({
title: "Success!",
description: "The group was created successfully.",
});
}
} catch (error) {
toast({
@@ -93,65 +113,84 @@ export const AddGroupForm = ({
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col space-y-4"
>
{/* Email Field */}
<CustomInput
control={form.control}
name="email"
type="email"
label="Email"
labelPlacement="inside"
placeholder="Enter the email address"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.email}
/>
{/* Name Field */}
<p className="text-small font-medium text-default-700">
Please provide a name for the group. You can also select providers and
roles to associate with the group, this step is optional and can be
done later if needed.
</p>
<Divider orientation="horizontal" className="mb-2" />
<div className="flex flex-col gap-2">
<CustomInput
control={form.control}
name="name"
type="text"
label="Provider group name"
labelPlacement="outside"
placeholder="Enter the provider group name"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.name}
/>
</div>
{/* Providers Field */}
<Controller
name="roleId"
name="providers"
control={form.control}
render={({ field }) => (
<>
<Select
{...field}
label="Role"
placeholder="Select a role"
variant="bordered"
isDisabled={isSelectorDisabled}
selectedKeys={[field.value]}
onSelectionChange={(selected) =>
field.onChange(selected?.currentKey || "")
}
>
{isSelectorDisabled ? (
<SelectItem key={defaultRole}>{defaultRole}</SelectItem>
) : (
roles.map((role) => (
<SelectItem key={role.id}>{role.name}</SelectItem>
))
)}
</Select>
{form.formState.errors.roleId && (
<p className="mt-2 text-sm text-red-600">
{form.formState.errors.roleId.message}
</p>
)}
</>
<CustomDropdownSelection
label="Select Providers"
name="providers"
values={providers}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
)}
/>
{form.formState.errors.providers && (
<p className="mt-2 text-sm text-red-600">
{form.formState.errors.providers.message}
</p>
)}
{/* Roles Field */}
<Controller
name="roles"
control={form.control}
render={({ field }) => (
<CustomDropdownSelection
label="Select Roles"
name="roles"
values={roles}
selectedKeys={field.value || []}
onChange={(name, selectedValues) =>
field.onChange(selectedValues)
}
/>
)}
/>
{form.formState.errors.roles && (
<p className="mt-2 text-sm text-red-600">
{form.formState.errors.roles.message}
</p>
)}
{/* Submit Button */}
<div className="flex w-full justify-end sm:space-x-6">
<CustomButton
type="submit"
ariaLabel="Send Invitation"
ariaLabel="Create Group"
className="w-1/2"
variant="solid"
color="action"
size="lg"
size="md"
isLoading={isLoading}
startContent={!isLoading && <SaveIcon size={24} />}
>
{isLoading ? <>Loading</> : <span>Send Invitation</span>}
{isLoading ? <>Loading</> : <span>Create Group</span>}
</CustomButton>
</div>
</form>

View File

@@ -0,0 +1,156 @@
"use client";
import {
Button,
Checkbox,
CheckboxGroup,
Divider,
Popover,
PopoverContent,
PopoverTrigger,
ScrollShadow,
} from "@nextui-org/react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { PlusCircleIcon } from "@/components/icons";
interface CustomDropdownSelectionProps {
label: string;
name: string;
values: { id: string; name: string }[];
onChange: (name: string, selectedValues: string[]) => void;
selectedKeys?: string[];
}
const selectedTagClass =
"inline-flex items-center border py-1 text-xs transition-colors border-transparent bg-default-500 text-secondary-foreground hover:bg-default-500/80 rounded-md px-2 font-normal";
export const CustomDropdownSelection: React.FC<
CustomDropdownSelectionProps
> = ({ label, name, values, onChange, selectedKeys = [] }) => {
const [selectedValues, setSelectedValues] = useState<Set<string>>(
new Set(selectedKeys),
);
const allValues = values.map((item) => item.id);
const memoizedValues = useMemo(() => values, [values]);
// Update the internal state when selectedKeys changes from props
useEffect(() => {
const newSelection = new Set(selectedKeys);
if (
JSON.stringify(Array.from(selectedValues)) !==
JSON.stringify(Array.from(newSelection))
) {
if (selectedKeys.length === allValues.length) {
newSelection.add("all");
}
setSelectedValues(newSelection);
}
}, [selectedKeys]);
const onSelectionChange = useCallback(
(keys: string[]) => {
setSelectedValues((prevSelected) => {
const newSelection = new Set(keys);
// If all values are selected and "all" is not included,
// add "all" automatically
if (
newSelection.size === allValues.length &&
!newSelection.has("all")
) {
return new Set(["all", ...allValues]);
} else if (prevSelected.has("all")) {
// If "all" was previously selected, remove it
newSelection.delete("all");
return new Set(allValues.filter((key) => newSelection.has(key)));
}
return newSelection;
});
// Notify the change without including "all"
const selectedValues = keys.filter((key) => key !== "all");
onChange(name, selectedValues);
},
[allValues, name, onChange],
);
const handleSelectAllClick = useCallback(() => {
setSelectedValues((prevSelected: Set<string>) => {
const newSelection: Set<string> = prevSelected.has("all")
? new Set()
: new Set(["all", ...allValues]);
// Notify the change without including "all"
const selectedValues = Array.from(newSelection).filter(
(key) => key !== "all",
);
onChange(name, selectedValues);
return newSelection;
});
}, [allValues, name, onChange]);
return (
<div className="relative flex w-full flex-col gap-2">
<Popover backdrop="transparent" placement="bottom-start">
<PopoverTrigger>
<Button
className="border-input hover:bg-accent hover:text-accent-foreground inline-flex h-10 items-center justify-center whitespace-nowrap rounded-md border border-dashed bg-background px-3 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none disabled:opacity-50 dark:bg-prowler-blue-800"
startContent={<PlusCircleIcon size={16} />}
size="md"
>
<h3 className="text-small">{label}</h3>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 dark:bg-prowler-blue-800">
<div className="flex w-full flex-col gap-6 p-2">
<CheckboxGroup
color="default"
label={label}
value={Array.from(selectedValues)}
onValueChange={onSelectionChange}
className="font-bold"
>
<Checkbox
className="font-normal"
value="all"
onClick={handleSelectAllClick}
>
Select All
</Checkbox>
<Divider orientation="horizontal" className="mt-2" />
<ScrollShadow
hideScrollBar
className="flex max-h-96 max-w-56 flex-col gap-y-2 py-2"
>
{memoizedValues.map(({ id, name }) => (
<Checkbox className="font-normal" key={id} value={id}>
{name}
</Checkbox>
))}
</ScrollShadow>
</CheckboxGroup>
</div>
</PopoverContent>
</Popover>
{/* Selected Values Display */}
{selectedValues.size > 0 && selectedValues.size !== 1 && (
<div className="mt-2 flex flex-wrap gap-2">
{Array.from(selectedValues)
.filter((value) => value !== "all")
.map((value) => {
const selectedItem = values.find((item) => item.id === value);
return (
<span key={value} className={selectedTagClass}>
{selectedItem?.name || value}
</span>
);
})}
</div>
)}
</div>
);
};

View File

@@ -2,6 +2,7 @@ export * from "./custom-alert-modal";
export * from "./custom-box";
export * from "./custom-button";
export * from "./custom-dropdown-filter";
export * from "./custom-dropdown-selection";
export * from "./custom-input";
export * from "./custom-loader";
export * from "./custom-radio";

View File

@@ -249,7 +249,7 @@ export interface InvitationProps {
}
export interface Role {
type: "role";
type: "roles";
id: string;
attributes: {
name: string;
@@ -373,7 +373,7 @@ export interface UserProps {
count: number;
};
data: Array<{
type: "role";
type: "roles";
id: string;
}>;
};