mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 22:39:54 -06:00
Enable users to create a new team (#299)
* Add option to create a new team --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
16
.github/PULL_REQUEST_TEMPLATE.md
vendored
16
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -30,11 +30,11 @@ Fixes # (issue)
|
||||
|
||||
<!-- We're starting to get more and more contributions. Please help us making this efficient for all of us and go through this checklist. Please tick off what you did -->
|
||||
|
||||
- [ ] I have read the [contributing guide](https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I have performed a self-review of my own code and corrected any misspellings
|
||||
- [ ] I have added comments to my code, particularly in hard-to-understand bits
|
||||
- [ ] I ran `pnpm build` and `pnpm lint` and checked for build and linting errors
|
||||
- [ ] My contribution does not cause any warnings
|
||||
- [ ] I have removed all `console.logs`
|
||||
- [ ] I have merged the latest changes from main onto my branch with `git pull origin main`
|
||||
- [ ] I have updated the Formbricks Docs if necessary
|
||||
- [ ] Read the [contributing guide](https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md)
|
||||
- [ ] Self-reviewed my own code
|
||||
- [ ] Commented on my code in hard-to-understand bits
|
||||
- [ ] Ran `pnpm build`
|
||||
- [ ] Checked for warnings, there are none
|
||||
- [ ] Removed all `console.logs`
|
||||
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
|
||||
- [ ] Updated the Formbricks Docs if changes were necessary
|
||||
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/shared/DropdownMenu";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import {
|
||||
changeEnvironment,
|
||||
changeEnvironmentByProduct,
|
||||
changeEnvironmentByTeam,
|
||||
} from "@/lib/environments/changeEnvironments";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { useMemberships } from "@/lib/memberships";
|
||||
import { useTeam } from "@/lib/teams/teams";
|
||||
@@ -73,6 +79,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && environment.widgetSetupCompleted) {
|
||||
@@ -183,35 +190,23 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
},
|
||||
];
|
||||
|
||||
const changeEnvironment = (environmentType: string) => {
|
||||
const newEnvironmentId = environment.product.environments.find((e) => e.type === environmentType)?.id;
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
const handleEnvironmentChange = (environmentType: "production" | "development") => {
|
||||
changeEnvironment(environmentType, environment, router);
|
||||
};
|
||||
|
||||
const changeEnvironmentByProduct = (productId: string) => {
|
||||
const product = environment.availableProducts.find((p) => p.id === productId);
|
||||
const newEnvironmentId = product?.environments[0]?.id;
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
const handleEnvironmentChangeByProduct = (productId: string) => {
|
||||
changeEnvironmentByProduct(productId, environment, router);
|
||||
};
|
||||
|
||||
const changeEnvironmentByTeam = (teamId: string) => {
|
||||
const newTeamMembership = memberships.find((m) => m.teamId === teamId);
|
||||
const newTeamProduct = newTeamMembership?.team?.products?.[0];
|
||||
|
||||
if (newTeamProduct) {
|
||||
const newEnvironmentId = newTeamProduct.environments.find((e) => e.type === "production")?.id;
|
||||
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
}
|
||||
const handleEnvironmentChangeByTeam = (teamId: string) => {
|
||||
changeEnvironmentByTeam(teamId, memberships, router);
|
||||
};
|
||||
|
||||
if (isLoadingEnvironment || loading || isLoadingMemberships) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorEnvironment || isErrorMemberships) {
|
||||
if (isErrorEnvironment || isErrorMemberships || !environment || !memberships) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
@@ -272,7 +267,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
|
||||
{truncate(environment?.product?.name, 30)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{capitalizeFirstLetter(environment?.type)}</p>
|
||||
<p className="text-sm text-slate-500">{capitalizeFirstLetter(team?.name)}</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||
</div>
|
||||
@@ -325,7 +320,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
<DropdownMenuSubContent className="max-w-[45rem]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={environment?.product.id}
|
||||
onValueChange={changeEnvironmentByProduct}>
|
||||
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
|
||||
{environment?.availableProducts?.map((product) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={product.id}
|
||||
@@ -345,6 +340,38 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Team Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<p>{currentTeamName}</p>
|
||||
<p className="block text-xs text-slate-500">Team</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentTeamId}
|
||||
onValueChange={(teamId) => handleEnvironmentChangeByTeam(teamId)}>
|
||||
{memberships?.map((membership) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={membership.teamId}
|
||||
className="cursor-pointer"
|
||||
key={membership.teamId}>
|
||||
{membership?.team?.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowCreateTeamModal(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<span>Create team</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Environment Switch */}
|
||||
|
||||
<DropdownMenuSub>
|
||||
@@ -358,7 +385,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={environment?.type}
|
||||
onValueChange={(v) => changeEnvironment(v)}>
|
||||
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
|
||||
<DropdownMenuRadioItem value="production" className="cursor-pointer">
|
||||
Production
|
||||
</DropdownMenuRadioItem>
|
||||
@@ -370,34 +397,6 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Team Switch */}
|
||||
{memberships.length > 1 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div>
|
||||
<p>{currentTeamName}</p>
|
||||
<p className="block text-xs text-slate-500">Team</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentTeamId}
|
||||
onValueChange={(teamId) => changeEnvironmentByTeam(teamId)}>
|
||||
{memberships?.map((membership) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={membership.teamId}
|
||||
className="cursor-pointer"
|
||||
key={membership.teamId}>
|
||||
{membership?.team?.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
{dropdownnavigation.map((item) => (
|
||||
<DropdownMenuGroup key={item.title}>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -439,6 +438,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
|
||||
setOpen={(val) => setShowAddProductModal(val)}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
87
apps/web/app/environments/[environmentId]/actions.ts
Normal file
87
apps/web/app/environments/[environmentId]/actions.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Team } from "@prisma/client";
|
||||
|
||||
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
|
||||
const newTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
memberships: {
|
||||
create: {
|
||||
user: { connect: { id: ownerUserId } },
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
products: {
|
||||
create: [
|
||||
{
|
||||
name: "My Product",
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: "production",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "development",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
});
|
||||
|
||||
return newTeam;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { QuestionMarkCircleIcon, TagIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
import AttributeDetailModal from "./AttributeDetailModal";
|
||||
import UploadAttributesModal from "./UploadAttributesModal";
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
|
||||
export default function AttributeClassesList({ environmentId }: { environmentId: string }) {
|
||||
const { attributeClasses, isLoadingAttributeClasses, isErrorAttributeClasses } =
|
||||
@@ -41,10 +42,8 @@ export default function AttributeClassesList({ environmentId }: { environmentId:
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="grid h-12 grid-cols-5 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6 ">Name</div>
|
||||
<div className="text-center">Activity</div>
|
||||
<div className="text-center">Live Studies</div>
|
||||
<div className="text-center">Created</div>
|
||||
<div className="text-center">Last Updated</div>
|
||||
</div>
|
||||
@@ -56,7 +55,7 @@ export default function AttributeClassesList({ environmentId }: { environmentId:
|
||||
}}
|
||||
className="w-full"
|
||||
key={attributeClass.id}>
|
||||
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="m-2 grid h-16 grid-cols-5 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 flex-shrink-0">
|
||||
@@ -68,14 +67,16 @@ export default function AttributeClassesList({ environmentId }: { environmentId:
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="text-slate-900">{attributeClass.userId}</div>
|
||||
<div className="text-slate-900">
|
||||
{timeSinceConditionally(attributeClass.createdAt.toString())}
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="text-slate-900">{attributeClass.email}</div>
|
||||
</div>
|
||||
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="text-slate-900">{attributeClass._count?.sessions}</div>
|
||||
<div className="text-slate-900">
|
||||
{timeSinceConditionally(attributeClass.updatedAt.toString())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -16,12 +16,18 @@ import { PaperAirplaneIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import AddMemberModal from "./AddMemberModal";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
|
||||
export function EditMemberships({ environmentId }) {
|
||||
type EditMembershipsProps = {
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
const { team, isErrorTeam, isLoadingTeam, mutateTeam } = useMembers(environmentId);
|
||||
|
||||
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
|
||||
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
|
||||
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
|
||||
const [activeMember, setActiveMember] = useState({} as any);
|
||||
|
||||
@@ -30,6 +36,16 @@ export function EditMemberships({ environmentId }) {
|
||||
setActiveMember(member);
|
||||
setDeleteMemberModalOpen(true);
|
||||
};
|
||||
|
||||
if (isLoadingTeam) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorTeam || !team) {
|
||||
console.error(isErrorTeam);
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
const handleDeleteMember = async () => {
|
||||
if (activeMember.accepted) {
|
||||
await removeMember(team.teamId, activeMember.userId);
|
||||
@@ -50,20 +66,19 @@ export function EditMemberships({ environmentId }) {
|
||||
mutateTeam();
|
||||
};
|
||||
|
||||
if (isLoadingTeam) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorTeam) {
|
||||
console.error(isErrorTeam);
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5 text-right">
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
variant="secondary"
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
setCreateTeamModalOpen(true);
|
||||
}}>
|
||||
Create New Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setAddMemberModalOpen(true);
|
||||
}}>
|
||||
@@ -121,7 +136,7 @@ export function EditMemberships({ environmentId }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateTeamModal open={isCreateTeamModalOpen} setOpen={(val) => setCreateTeamModalOpen(val)} />
|
||||
<AddMemberModal
|
||||
open={isAddMemberModalOpen}
|
||||
setOpen={setAddMemberModalOpen}
|
||||
|
||||
81
apps/web/components/team/CreateTeamModal.tsx
Normal file
81
apps/web/components/team/CreateTeamModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { changeEnvironmentByTeam } from "@/lib/environments/changeEnvironments";
|
||||
import { useMemberships } from "@/lib/memberships";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { createTeam } from "../../app/environments/[environmentId]/actions";
|
||||
|
||||
interface CreateTeamModalProps {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps) {
|
||||
const router = useRouter();
|
||||
const { profile } = useProfile();
|
||||
const { mutateMemberships } = useMemberships();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const submitTeam = async (data) => {
|
||||
setLoading(true);
|
||||
const newTeam = await createTeam(data.name, (profile as any).id);
|
||||
const newMemberships = await mutateMemberships();
|
||||
changeEnvironmentByTeam(newTeam.id, newMemberships, router);
|
||||
toast.success("Team created successfully!");
|
||||
setOpen(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-10 w-10 text-slate-500">
|
||||
<PlusCircleIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">Create team</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Create a new team to handle a different set of products.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitTeam)}>
|
||||
<div className="flex w-full justify-between space-y-4 rounded-lg p-6">
|
||||
<div className="grid w-full gap-x-2">
|
||||
<div>
|
||||
<Label>Team Name</Label>
|
||||
<Input placeholder="e.g. Power Puff Girls" {...register("name", { required: true })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={loading}>
|
||||
Create team
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
27
apps/web/lib/environments/changeEnvironments.ts
Normal file
27
apps/web/lib/environments/changeEnvironments.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const changeEnvironment = (environmentType: string, environment: any, router: any) => {
|
||||
const newEnvironmentId = environment.product.environments.find((e) => e.type === environmentType)?.id;
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
};
|
||||
|
||||
export const changeEnvironmentByProduct = (productId: string, environment: any, router: any) => {
|
||||
const product = environment.availableProducts.find((p) => p.id === productId);
|
||||
const newEnvironmentId = product?.environments[0]?.id;
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
};
|
||||
|
||||
export const changeEnvironmentByTeam = (teamId: string, memberships: any, router: any) => {
|
||||
const newTeamMembership = memberships.find((m) => m.teamId === teamId);
|
||||
const newTeamProduct = newTeamMembership?.team?.products?.[0];
|
||||
|
||||
if (newTeamProduct) {
|
||||
const newEnvironmentId = newTeamProduct.environments.find((e) => e.type === "production")?.id;
|
||||
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user