Merge branch 'formbricks:main' into feature/docs-in-page-section-nav

This commit is contained in:
Pranoy Roy
2024-06-03 21:09:50 +05:30
committed by GitHub
17 changed files with 567 additions and 393 deletions

View File

@@ -0,0 +1,65 @@
const LoadingCard = ({ title, description, skeletonLines }) => {
return (
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
};
const Loading = () => {
const cards = [
{
title: "Email alerts (Surveys)",
description: "Set up an alert to get an email on new responses.",
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
{
title: "Weekly summary (Products)",
description: "Stay up-to-date with a Weekly every Monday.",
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
];
const pages = ["Profile", "Notifications"];
return (
<div className="p-6">
<div>
<div className="flex items-center justify-between space-x-4 pb-4">
<h1 className="text-3xl font-bold text-slate-800">Account Settings</h1>
</div>
</div>
<div className="mb-6 border-b border-slate-200">
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
{pages.map((navElem) => (
<div
key={navElem}
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
{navElem}
</div>
))}
</nav>
<div className="justify-self-end"></div>
</div>
</div>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
};
export default Loading;

View File

@@ -1,111 +0,0 @@
"use client";
import {
removeAvatarAction,
updateAvatarAction,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { ProfileAvatar } from "@formbricks/ui/Avatars";
import { Button } from "@formbricks/ui/Button";
export const EditAvatar = ({ session, environmentId }: { session: Session; environmentId: string }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleUpload = async (file: File, environmentId: string) => {
setIsLoading(true);
try {
if (session?.user.imageUrl) {
// If avatar image already exist, then remove it before update action
await removeAvatarAction(environmentId);
}
const { url, error } = await handleFileUpload(file, environmentId);
if (error) {
toast.error(error);
setIsLoading(false);
return;
}
await updateAvatarAction(url);
router.refresh();
} catch (err) {
toast.error("Avatar update failed. Please try again.");
setIsLoading(false);
}
setIsLoading(false);
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatarAction(environmentId);
} catch (err) {
toast.error("Avatar update failed. Please try again.");
} finally {
setIsLoading(false);
if (inputRef.current) {
inputRef.current.value = "";
}
}
};
return (
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
</div>
<div className="mt-4">
<Button
size="sm"
className="mr-2"
variant="secondary"
onClick={() => {
inputRef.current?.click();
}}>
{session?.user.imageUrl ? "Change Image" : "Upload Image"}
<input
type="file"
id="hiddenFileInput"
ref={inputRef}
className="hidden"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handleUpload(file, environmentId);
}
}}
/>
</Button>
{session?.user?.imageUrl && (
<Button className="mr-2" variant="warn" size="sm" onClick={handleRemove}>
Remove Image
</Button>
)}
</div>
</div>
);
};

View File

@@ -1,73 +0,0 @@
"use client";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TUser } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { updateUserAction } from "../actions";
type FormData = {
name: string;
};
export const EditName = ({ user }: { user: TUser }) => {
const {
register,
handleSubmit,
formState: { isSubmitting },
watch,
} = useForm<FormData>();
const nameValue = watch("name", user.name || "");
const isNotEmptySpaces = (value: string) => value.trim() !== "";
const onSubmit: SubmitHandler<FormData> = async (data) => {
try {
data.name = data.name.trim();
if (!isNotEmptySpaces(data.name)) {
toast.error("Please enter at least one character");
return;
}
if (data.name === user.name) {
toast.success("This is already your name");
return;
}
await updateUserAction({ name: data.name });
toast.success("Your name was updated successfully");
} catch (error) {
toast.error(`Error: ${error.message}`);
}
};
return (
<>
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(onSubmit)}>
<Label htmlFor="fullname">Full Name</Label>
<Input
type="text"
id="fullname"
defaultValue={user.name || ""}
{...register("name", { required: true })}
/>
<div className="mt-4">
<Label htmlFor="email">Email</Label>
<Input type="email" id="fullname" defaultValue={user.email} disabled />
</div>
<Button
type="submit"
variant="darkCTA"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={nameValue === "" || isSubmitting}>
Update
</Button>
</form>
</>
);
};

View File

@@ -0,0 +1,171 @@
"use client";
import {
removeAvatarAction,
updateAvatarAction,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { zodResolver } from "@hookform/resolvers/zod";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { ProfileAvatar } from "@formbricks/ui/Avatars";
import { Button } from "@formbricks/ui/Button";
import { FormError, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
interface EditProfileAvatarFormProps {
session: Session;
environmentId: string;
}
export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAvatarFormProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const fileSchema =
typeof window !== "undefined"
? z
.instanceof(FileList)
.refine((files) => files.length === 1, "You must select a file.")
.refine((files) => {
const file = files[0];
const allowedTypes = ["image/jpeg", "image/png"];
return allowedTypes.includes(file.type);
}, "Invalid file type. Only JPEG and PNG are allowed.")
.refine((files) => {
const file = files[0];
const maxSize = 10 * 1024 * 1024;
return file.size <= maxSize;
}, "File size must be less than 10MB.")
: z.any();
const formSchema = z.object({
file: fileSchema,
});
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
const handleUpload = async (file: File, environmentId: string) => {
setIsLoading(true);
try {
if (session?.user.imageUrl) {
// If avatar image already exists, then remove it before update action
await removeAvatarAction(environmentId);
}
const { url, error } = await handleFileUpload(file, environmentId);
if (error) {
toast.error(error);
setIsLoading(false);
return;
}
await updateAvatarAction(url);
router.refresh();
} catch (err) {
toast.error("Avatar update failed. Please try again.");
setIsLoading(false);
}
setIsLoading(false);
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatarAction(environmentId);
} catch (err) {
toast.error("Avatar update failed. Please try again.");
} finally {
setIsLoading(false);
form.reset();
}
};
const onSubmit = async (data: FormValues) => {
const file = data.file[0];
if (file) {
await handleUpload(file, environmentId);
}
};
return (
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
</div>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
<FormField
name="file"
control={form.control}
render={({ field, fieldState }) => (
<FormItem>
<div className="flex">
<Button
type="button"
size="sm"
className="mr-2"
variant={!!fieldState.error?.message ? "warn" : "secondary"}
onClick={() => {
inputRef.current?.click();
}}>
{session?.user.imageUrl ? "Change Image" : "Upload Image"}
<input
type="file"
id="hiddenFileInput"
ref={(e) => {
field.ref(e);
// @ts-expect-error
inputRef.current = e;
}}
className="hidden"
accept="image/*"
onChange={(e) => {
field.onChange(e.target.files);
form.handleSubmit(onSubmit)();
}}
/>
</Button>
{session?.user?.imageUrl && (
<Button type="button" className="mr-2" variant="warn" size="sm" onClick={handleRemove}>
Remove Image
</Button>
)}
</div>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
</div>
);
};

View File

@@ -0,0 +1,82 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TUser, ZUser } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { updateUserAction } from "../actions";
const ZEditProfileNameFormSchema = ZUser.pick({ name: true });
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
const form = useForm<TEditProfileNameForm>({
defaultValues: { name: user.name },
mode: "onChange",
resolver: zodResolver(ZEditProfileNameFormSchema),
});
const { isSubmitting, isDirty } = form.formState;
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
try {
const name = data.name.trim();
await updateUserAction({ name });
toast.success("Your name was updated successfully");
form.reset({ name });
} catch (error) {
toast.error(`Error: ${error.message}`);
}
};
return (
<FormProvider {...form}>
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input
{...field}
type="text"
placeholder="Full Name"
required
isInvalid={!!form.formState.errors.name}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
{/* disabled */}
<div className="mt-4 space-y-2">
<Label htmlFor="email">Email</Label>
<Input type="email" id="fullname" defaultValue={user.email} disabled />
</div>
<Button
type="submit"
variant="darkCTA"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
Update
</Button>
</form>
</FormProvider>
);
};

View File

@@ -1,12 +1,12 @@
const LoadingCard = ({ title, description, skeletonLines }) => {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
<div className="rounded-lg px-6 py-5">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
@@ -28,7 +28,6 @@ const Loading = () => {
{ classes: "h-6 w-64" },
{ classes: "h-4 w-28" },
{ classes: "h-6 w-64" },
{ classes: "h-8 w-24" },
],
},
{
@@ -48,9 +47,29 @@ const Loading = () => {
},
];
const pages = ["Profile", "Notifications"];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
<div className="p-6">
<div>
<div className="flex items-center justify-between space-x-4 pb-4">
<h1 className="text-3xl font-bold text-slate-800">Account Settings</h1>
</div>
</div>
<div className="mb-6 border-b border-slate-200">
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
{pages.map((navElem) => (
<div
key={navElem}
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
{navElem}
</div>
))}
</nav>
<div className="justify-self-end"></div>
</div>
</div>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}

View File

@@ -11,8 +11,8 @@ import { SettingsId } from "@formbricks/ui/SettingsId";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditAvatar } from "./components/EditAvatar";
import { EditName } from "./components/EditName";
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async ({ params }: { params: { environmentId: string } }) => {
const { environmentId } = params;
@@ -30,12 +30,12 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
{user && (
<div>
<SettingsCard title="Personal information" description="Update your personal information.">
<EditName user={user} />
<EditProfileDetailsForm user={user} />
</SettingsCard>
<SettingsCard
title="Avatar"
description="Assist your organization in identifying you on Formbricks.">
<EditAvatar session={session} environmentId={environmentId} />
<EditProfileAvatarForm session={session} environmentId={environmentId} />
</SettingsCard>
{user.identityProvider === "email" && (
<SettingsCard title="Security" description="Manage your password and other security settings.">

View File

@@ -1,12 +1,29 @@
const pages = ["Members", "Billing & Plan"];
const Loading = () => {
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Billing & Plan</h2>
<div className="grid grid-cols-2 gap-4 rounded-lg p-8">
<div className=" h-[75vh] animate-pulse rounded-md bg-slate-200 "></div>
<div className=" h-96 animate-pulse rounded-md bg-slate-200"></div>
<div className="col-span-2 h-96 bg-slate-200 p-8"></div>
<div className="p-6">
<div>
<div className="flex items-center justify-between space-x-4 pb-4">
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
</div>
</div>
<div className="mb-6 border-b border-slate-200">
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
{pages.map((navElem) => (
<div
key={navElem}
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
{navElem}
</div>
))}
</nav>
<div className="justify-self-end"></div>
</div>
</div>
<div className=" my-8 h-64 animate-pulse rounded-xl bg-slate-200 "></div>
<div className=" my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
</div>
);
};

View File

@@ -1,12 +1,29 @@
const pages = ["Members", "Enterprise License"];
const Loading = () => {
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Enterprise License</h2>
<div className="grid grid-cols-2 gap-4 rounded-lg p-8">
<div className=" h-[75vh] animate-pulse rounded-md bg-slate-200 "></div>
<div className=" h-96 animate-pulse rounded-md bg-slate-200"></div>
<div className="col-span-2 h-96 bg-slate-200 p-8"></div>
<div className="p-6">
<div>
<div className="flex items-center justify-between space-x-4 pb-4">
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
</div>
</div>
<div className="mb-6 border-b border-slate-200">
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
{pages.map((navElem) => (
<div
key={navElem}
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
{navElem}
</div>
))}
</nav>
<div className="justify-self-end"></div>
</div>
</div>
<div className=" my-8 h-64 animate-pulse rounded-xl bg-slate-200 "></div>
<div className=" my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
</div>
);
};

View File

@@ -1,96 +0,0 @@
"use client";
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { SubmitHandler, useForm, useWatch } from "react-hook-form";
import toast from "react-hot-toast";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
interface EditOrganizationNameForm {
name: string;
}
interface EditOrganizationNameProps {
environmentId: string;
organization: TOrganization;
membershipRole?: TMembershipRole;
}
export const EditOrganizationName = ({ organization, membershipRole }: EditOrganizationNameProps) => {
const router = useRouter();
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<EditOrganizationNameForm>({
defaultValues: {
name: organization.name,
},
});
const [isUpdatingOrganization, setIsUpdatingOrganization] = useState(false);
const { isViewer } = getAccessFlags(membershipRole);
const organizationName = useWatch({
control,
name: "name",
});
const isOrganizationNameInputEmpty = !organizationName?.trim();
const currentOrganizationName = organizationName?.trim().toLowerCase() ?? "";
const previousOrganizationName = organization?.name?.trim().toLowerCase() ?? "";
const handleUpdateOrganizationName: SubmitHandler<EditOrganizationNameForm> = async (data) => {
try {
data.name = data.name.trim();
setIsUpdatingOrganization(true);
await updateOrganizationNameAction(organization.id, data.name);
setIsUpdatingOrganization(false);
toast.success("Organization name updated successfully.");
router.refresh();
} catch (err) {
setIsUpdatingOrganization(false);
toast.error(`Error: ${err.message}`);
}
};
return isViewer ? (
<p className="text-sm text-red-700">You are not authorized to perform this action.</p>
) : (
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(handleUpdateOrganizationName)}>
<Label htmlFor="organizationname">Organization Name</Label>
<Input
type="text"
id="organizationname"
defaultValue={organization?.name ?? ""}
{...register("name", {
required: {
message: "Organization name is required.",
value: true,
},
})}
/>
{errors?.name?.message && <p className="text-xs text-red-500">{errors.name.message}</p>}
<Button
type="submit"
className="mt-4"
variant="darkCTA"
size="sm"
loading={isUpdatingOrganization}
disabled={isOrganizationNameInputEmpty || currentOrganizationName === previousOrganizationName}>
Update
</Button>
</form>
);
};

View File

@@ -0,0 +1,90 @@
"use client";
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/members/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormError, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
interface EditOrganizationNameProps {
environmentId: string;
organization: TOrganization;
membershipRole?: TMembershipRole;
}
const ZEditOrganizationNameFormSchema = ZOrganization.pick({ name: true });
type EditOrganizationNameForm = z.infer<typeof ZEditOrganizationNameFormSchema>;
export const EditOrganizationNameForm = ({ organization, membershipRole }: EditOrganizationNameProps) => {
const form = useForm<EditOrganizationNameForm>({
defaultValues: {
name: organization.name,
},
mode: "onChange",
resolver: zodResolver(ZEditOrganizationNameFormSchema),
});
const { isViewer } = getAccessFlags(membershipRole);
const { isSubmitting, isDirty } = form.formState;
const handleUpdateOrganizationName: SubmitHandler<EditOrganizationNameForm> = async (data) => {
try {
const name = data.name.trim();
const updatedOrg = await updateOrganizationNameAction(organization.id, name);
toast.success("Organization name updated successfully.");
form.reset({ name: updatedOrg.name });
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return isViewer ? (
<p className="text-sm text-red-700">You are not authorized to perform this action.</p>
) : (
<FormProvider {...form}>
<form
className="w-full max-w-sm items-center"
onSubmit={form.handleSubmit(handleUpdateOrganizationName)}>
<FormField
control={form.control}
name="name"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input
{...field}
type="text"
isInvalid={!!fieldState.error?.message}
placeholder="Organization Name"
required
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
className="mt-4"
variant="darkCTA"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
Update
</Button>
</form>
</FormProvider>
);
};

View File

@@ -1,77 +1,68 @@
import { Skeleton } from "@formbricks/ui/Skeleton";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
const LoadingCard = ({
title,
description,
skeleton,
}: {
title: string;
description: string;
skeleton: React.ReactNode;
}) => {
const LoadingCard = ({ title, description, skeletonLines }) => {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<div className="my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">{skeleton}</div>
<div className="w-full">
<div className="rounded-lg px-6">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse rounded-full bg-slate-200 ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
};
const cards = [
{
title: "Manage members",
description: "Add or remove members in your organization.",
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }, { classes: "h-8 w-80" }],
},
{
title: "Organization Name",
description: "Give your organization a descriptive name.",
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
},
{
title: "Delete Organization",
description:
"Delete organization with all its products including all surveys, responses, people, actions and attributes",
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
},
];
const pages = ["Members", IS_FORMBRICKS_CLOUD ? "Billing & Plan" : "Enterprise License"];
const Loading = () => {
const cards = [
{
title: "Manage members",
description: "Add or remove members in your organization",
skeleton: (
<div className="flex flex-col space-y-4 p-4">
<div className="flex items-center justify-end gap-4">
<Skeleton className="h-12 w-40 rounded-lg" />
<Skeleton className="h-12 w-40 rounded-lg" />
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2"></div>
<div className="col-span-5">Fullname</div>
<div className="col-span-5">Email</div>
<div className="col-span-3">Role</div>
<div className="col-span-5"></div>
</div>
<div className="h-10"></div>
</div>
</div>
),
},
{
title: "Organization Name",
description: "Give your organization a descriptive name",
skeleton: (
<div className="flex flex-col p-4">
<Skeleton className="mb-2 h-5 w-32" />
<Skeleton className="mb-4 h-12 w-96 rounded-lg" />
<Skeleton className="h-12 w-36 rounded-lg" />
</div>
),
},
{
title: "Delete account",
description: "Delete your account with all of your personal information and data.",
skeleton: (
<div className="flex flex-col p-4">
<Skeleton className="mb-2 h-5 w-full" />
<Skeleton className="h-12 w-36 rounded-lg" />
</div>
),
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
<div className="p-6">
<div>
<div className="flex items-center justify-between space-x-4 pb-4">
<h1 className="text-3xl font-bold text-slate-800">Organization Settings</h1>
</div>
</div>
<div className="mb-6 border-b border-slate-200">
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
{pages.map((navElem) => (
<div
key={navElem}
className="flex h-full items-center border-b-2 border-transparent px-3 text-sm font-medium text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700">
{navElem}
</div>
))}
</nav>
<div className="justify-self-end"></div>
</div>
</div>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}

View File

@@ -15,34 +15,19 @@ import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/ser
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
import { SettingsId } from "@formbricks/ui/SettingsId";
import { Skeleton } from "@formbricks/ui/Skeleton";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditMemberships } from "./components/EditMemberships";
import { EditOrganizationName } from "./components/EditOrganizationName";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
const MembersLoading = () => (
<div className="rounded-lg border border-slate-200">
<div className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2"></div>
<div className="col-span-5">Fullname</div>
<div className="col-span-5">Email</div>
<div className="col-span-3">Role</div>
</div>
<div className="p-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="grid-cols-20 grid h-12 content-center rounded-t-lg bg-white p-4 text-left text-sm font-semibold text-slate-900">
<Skeleton className="col-span-2 h-10 w-10 rounded-full" />
<Skeleton className="col-span-5 h-8 w-24" />
<Skeleton className="col-span-5 h-8 w-24" />
<Skeleton className="col-span-3 h-8 w-24" />
</div>
))}
</div>
<div className="px-2">
{Array.from(Array(2)).map((_, index) => (
<div key={index} className="mt-4">
<div className={`h-8 w-80 animate-pulse rounded-full bg-slate-200`} />
</div>
))}
</div>
);
@@ -104,7 +89,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
)}
</SettingsCard>
<SettingsCard title="Organization Name" description="Give your organization a descriptive name.">
<EditOrganizationName
<EditOrganizationNameForm
organization={organization}
environmentId={params.environmentId}
membershipRole={currentUserMembership?.role}

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "name" SET NOT NULL;

View File

@@ -569,7 +569,7 @@ model User {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String?
name String
email String @unique
emailVerified DateTime? @map(name: "email_verified")
imageUrl String?

View File

@@ -27,7 +27,10 @@ export const ZOrganization = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
name: z
.string({ message: "Name is required" })
.trim()
.min(1, { message: "Name must be at least 1 character long" }),
billing: ZOrganizationBilling,
});

View File

@@ -23,7 +23,10 @@ export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings
export const ZUser = z.object({
id: z.string(),
name: z.string().nullable(),
name: z
.string({ message: "Name is required" })
.trim()
.min(1, { message: "Name should be at least 1 character long" }),
email: z.string().email(),
emailVerified: z.date().nullable(),
imageUrl: z.string().url().nullable(),
@@ -40,7 +43,7 @@ export const ZUser = z.object({
export type TUser = z.infer<typeof ZUser>;
export const ZUserUpdateInput = z.object({
name: z.string().nullish(),
name: z.string().optional(),
email: z.string().email().optional(),
emailVerified: z.date().nullish(),
onboardingCompleted: z.boolean().optional(),
@@ -53,7 +56,10 @@ export const ZUserUpdateInput = z.object({
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
export const ZUserCreateInput = z.object({
name: z.string().optional(),
name: z
.string({ message: "Name is required" })
.trim()
.min(1, { message: "Name should be at least 1 character long" }),
email: z.string().email(),
emailVerified: z.date().optional(),
onboardingCompleted: z.boolean().optional(),