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:
Johannes
2023-05-28 19:11:23 +02:00
committed by GitHub
parent 7238b28fb5
commit fe4d6a8ce8
7 changed files with 291 additions and 80 deletions

View File

@@ -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

View File

@@ -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>
);
}

View 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;
}

View File

@@ -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>

View File

@@ -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}

View 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>
);
}

View 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}/`);
}
}
};