Add Team switch (#259)

* add team switch when user has multiple teams

* fix hydration error by changing the way env variables load

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Johannes
2023-05-01 15:11:53 +02:00
committed by GitHub
parent c20ca5e789
commit 13508f42be
14 changed files with 369 additions and 151 deletions

View File

@@ -1,5 +1,6 @@
"use client";
import FaveIcon from "@/app/favicon.ico";
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,6 +18,8 @@ import {
} from "@/components/shared/DropdownMenu";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useMemberships } from "@/lib/memberships";
import { useTeam } from "@/lib/teams/teams";
import { capitalizeFirstLetter } from "@/lib/utils";
import {
CustomersIcon,
@@ -47,7 +50,6 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
import FaveIcon from "@/app/favicon.ico";
interface EnvironmentsNavbarProps {
environmentId: string;
@@ -56,12 +58,16 @@ interface EnvironmentsNavbarProps {
export default function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const pathname = usePathname();
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const { memberships, isErrorMemberships, isLoadingMemberships } = useMemberships();
const { team } = useTeam(environmentId);
const [currentTeamName, setCurrentTeamName] = useState("");
const [currentTeamId, setCurrentTeamId] = useState("");
const [loading, setLoading] = useState(false);
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [showAddProductModal, setShowAddProductModal] = useState(false);
useEffect(() => {
@@ -72,6 +78,13 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
}
}, [environment]);
useEffect(() => {
if (team && team.name !== "") {
setCurrentTeamName(team.name);
setCurrentTeamId(team.id);
}
}, [team]);
const navigation = useMemo(
() => [
{
@@ -139,11 +152,6 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
href: `/environments/${environmentId}/settings/billing`,
hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1",
},
/* {
icon: RocketLaunchIcon,
label: "Upgrade account",
href: `/environments/${environmentId}/settings/billing`,
}, */
],
},
{
@@ -182,11 +190,24 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
router.push(`/environments/${newEnvironmentId}/`);
};
if (isLoadingEnvironment || loading) {
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}/`);
}
}
};
if (isLoadingEnvironment || loading || isLoadingMemberships) {
return <LoadingSpinner />;
}
if (isErrorEnvironment) {
if (isErrorEnvironment || isErrorMemberships) {
return <ErrorComponent />;
}
@@ -255,6 +276,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
<DropdownMenuSeparator />
{/* Product Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
@@ -286,6 +309,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Environment Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
@@ -309,6 +334,34 @@ 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 />

View File

@@ -1,155 +1,155 @@
"use client";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
AdjustmentsVerticalIcon,
ChatBubbleLeftEllipsisIcon,
CreditCardIcon,
DocumentCheckIcon,
DocumentMagnifyingGlassIcon,
KeyIcon,
LinkIcon,
PaintBrushIcon,
UserCircleIcon,
UsersIcon,
KeyIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
export default function SettingsNavbar({ environmentId }: { environmentId: string }) {
const pathname = usePathname();
const navigation = [
{
title: "Account",
links: [
{
name: "Profile",
href: `/environments/${environmentId}/settings/profile`,
icon: UserCircleIcon,
current: pathname?.includes("/profile"),
hidden: false,
},
/* {
name: "Notifications",
href: `/environments/${environmentId}/settings/notifications`,
icon: MegaphoneIcon,
current: pathname?.includes("/notifications"),
}, */
],
},
{
title: "Product",
links: [
{
name: "Settings",
href: `/environments/${environmentId}/settings/product`,
icon: AdjustmentsVerticalIcon,
current: pathname?.includes("/product"),
hidden: false,
},
{
name: "Look & Feel",
href: `/environments/${environmentId}/settings/lookandfeel`,
icon: PaintBrushIcon,
current: pathname?.includes("/lookandfeel"),
hidden: false,
},
{
name: "API Keys",
href: `/environments/${environmentId}/settings/api-keys`,
icon: KeyIcon,
current: pathname?.includes("/api-keys"),
hidden: false,
},
],
},
{
title: "Team",
links: [
{
name: "Members",
href: `/environments/${environmentId}/settings/members`,
icon: UsersIcon,
current: pathname?.includes("/members"),
hidden: false,
},
/*
const navigation = useMemo(
() => [
{
title: "Account",
links: [
{
name: "Profile",
href: `/environments/${environmentId}/settings/profile`,
icon: UserCircleIcon,
current: pathname?.includes("/profile"),
hidden: false,
},
],
},
{
title: "Product",
links: [
{
name: "Settings",
href: `/environments/${environmentId}/settings/product`,
icon: AdjustmentsVerticalIcon,
current: pathname?.includes("/product"),
hidden: false,
},
{
name: "Look & Feel",
href: `/environments/${environmentId}/settings/lookandfeel`,
icon: PaintBrushIcon,
current: pathname?.includes("/lookandfeel"),
hidden: false,
},
{
name: "API Keys",
href: `/environments/${environmentId}/settings/api-keys`,
icon: KeyIcon,
current: pathname?.includes("/api-keys"),
hidden: false,
},
],
},
{
title: "Team",
links: [
{
name: "Members",
href: `/environments/${environmentId}/settings/members`,
icon: UsersIcon,
current: pathname?.includes("/members"),
hidden: false,
},
/*
{
name: "Tags",
href: `/environments/${environmentId}/settings/tags`,
icon: PlusCircleIcon,
current: pathname?.includes("/tags"),
}, */
{
name: "Billing & Plan",
href: `/environments/${environmentId}/settings/billing`,
icon: CreditCardIcon,
hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1",
current: pathname?.includes("/billing"),
},
],
},
{
title: "Setup",
links: [
{
name: "Setup Checklist",
href: `/environments/${environmentId}/settings/setup`,
icon: DocumentCheckIcon,
current: pathname?.includes("/setup"),
hidden: false,
},
{
name: "Documentation",
href: "https://formbricks.com/docs",
icon: DocumentMagnifyingGlassIcon,
target: "_blank",
hidden: false,
},
{
name: "Join Discord",
href: "https://formbricks.com/discord",
icon: ChatBubbleLeftEllipsisIcon,
target: "_blank",
hidden: false,
},
],
},
{
title: "Compliance",
links: [
{
name: "GDPR & CCPA",
href: "https://formbricks.com/gdpr",
icon: LinkIcon,
target: "_blank",
hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1",
},
{
name: "Privacy",
href: "https://formbricks.com/privacy",
icon: LinkIcon,
target: "_blank",
hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1",
},
{
name: "Terms",
href: "https://formbricks.com/terms",
icon: LinkIcon,
target: "_blank",
hidden: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1",
},
{
name: "License",
href: "https://github.com/formbricks/formbricks/blob/main/LICENSE",
icon: LinkIcon,
target: "_blank",
hidden: false,
},
],
},
];
{
name: "Billing & Plan",
href: `/environments/${environmentId}/settings/billing`,
icon: CreditCardIcon,
hidden: !IS_FORMBRICKS_CLOUD,
current: pathname?.includes("/billing"),
},
],
},
{
title: "Setup",
links: [
{
name: "Setup Checklist",
href: `/environments/${environmentId}/settings/setup`,
icon: DocumentCheckIcon,
current: pathname?.includes("/setup"),
hidden: false,
},
{
name: "Documentation",
href: "https://formbricks.com/docs",
icon: DocumentMagnifyingGlassIcon,
target: "_blank",
hidden: false,
},
{
name: "Join Discord",
href: "https://formbricks.com/discord",
icon: ChatBubbleLeftEllipsisIcon,
target: "_blank",
hidden: false,
},
],
},
{
title: "Compliance",
links: [
{
name: "GDPR & CCPA",
href: "https://formbricks.com/gdpr",
icon: LinkIcon,
target: "_blank",
hidden: !IS_FORMBRICKS_CLOUD,
},
{
name: "Privacy",
href: "https://formbricks.com/privacy",
icon: LinkIcon,
target: "_blank",
hidden: !IS_FORMBRICKS_CLOUD,
},
{
name: "Terms",
href: "https://formbricks.com/terms",
icon: LinkIcon,
target: "_blank",
hidden: !IS_FORMBRICKS_CLOUD,
},
{
name: "License",
href: "https://github.com/formbricks/formbricks/blob/main/LICENSE",
icon: LinkIcon,
target: "_blank",
hidden: false,
},
],
},
],
[environmentId, pathname]
);
if (!navigation) return null;
return (
<div className="fixed h-full bg-white py-2 pl-4 pr-10">

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useTeam } from "@/lib/teams";
import { useTeam } from "@/lib/teams/teams";
import { Badge, Button, ErrorComponent } from "@formbricks/ui";
import type { Session } from "next-auth";
import { useRouter } from "next/navigation";

View File

@@ -1,18 +1,51 @@
"use client";
import { Button } from "@formbricks/ui";
import { Input } from "@formbricks/ui";
import { Label } from "@formbricks/ui";
import { useEffect, useState } from "react";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useTeamMutation } from "@/lib/teams/mutateTeams";
import { useTeam } from "@/lib/teams/teams";
import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
export default function EditTeamName({ environmentId }) {
const { team, isLoadingTeam, isErrorTeam } = useTeam(environmentId);
const { register, handleSubmit } = useForm();
const [teamId, setTeamId] = useState("");
useEffect(() => {
if (team && team.id !== "") {
setTeamId(team.id);
}
}, [team]);
const { isMutatingTeam, triggerTeamMutate } = useTeamMutation(teamId);
if (isLoadingTeam) {
return <LoadingSpinner />;
}
if (isErrorTeam) {
return <ErrorComponent />;
}
export function EditTeamName() {
return (
<div className="w-full max-w-sm items-center">
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit((data) => {
triggerTeamMutate({ ...data })
.catch((error) => {
toast.error(`Error: ${error.message}`);
})
.then(() => {
toast.success("Team name updated successfully.");
});
})}>
<Label htmlFor="teamname">Team Name</Label>
<Input type="text" id="teamname" />
<Input type="text" id="teamname" defaultValue={team.name} {...register("name")} />
<Button type="submit" className="mt-4" onClick={() => new Error("Not implemented yet")}>
<Button type="submit" className="mt-4" loading={isMutatingTeam}>
Update
</Button>
</div>
</form>
);
}

View File

@@ -1,14 +1,18 @@
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { EditMemberships } from "./EditMemberships";
import EditTeamName from "./EditTeamName";
export default function MembersSettingsPage({ params }) {
return (
<div>
<SettingsTitle title="Team Members" />
<SettingsTitle title="Team" />
<SettingsCard title="Manage members" description="Add or remove members in your team.">
<EditMemberships environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard title="Team Name" description="Give your team a descriptive name.">
<EditTeamName environmentId={params.environmentId} />
</SettingsCard>
</div>
);
}

View File

@@ -19,8 +19,6 @@ export default function SummaryList({ environmentId, surveyId }) {
const responses = responsesData?.responses;
console.log(responses);
const summaryData: QuestionSummary[] = useMemo(() => {
if (survey && responses) {
return survey.questions.map((question) => {

View File

@@ -0,0 +1,11 @@
import useSWRMutation from "swr/mutation";
import { updateRessource } from "@formbricks/lib/fetcher";
export function useTeamMutation(teamId: string) {
const { trigger, isMutating } = useSWRMutation(`/api/v1/teams/${teamId}`, updateRessource);
return {
triggerTeamMutate: trigger,
isMutatingTeam: isMutating,
};
}

View File

@@ -1,5 +1,10 @@
/** @type {import('next').NextConfig} */
const path = require("path");
const Dotenv = require("dotenv-webpack");
const rootPath = path.join(__dirname, "..", "..");
const { createId } = require("@paralleldrive/cuid2");
const nextConfig = {
@@ -48,6 +53,14 @@ const nextConfig = {
},
];
},
webpack: (config) => {
config.plugins.push(
new Dotenv({
path: path.resolve(rootPath, ".env"),
})
);
return config;
},
env: {
INSTANCE_ID: createId(),
},

View File

@@ -24,6 +24,7 @@
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.5.2",
"date-fns": "^2.29.3",
"dotenv-webpack": "^8.0.1",
"eslint": "8.38.0",
"eslint-config-next": "^13.3.0",
"jsonwebtoken": "^9.0.0",

View File

@@ -16,6 +16,26 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
where: {
userId: user.id,
},
include: {
team: {
select: {
id: true,
name: true,
products: {
select: {
id: true,
name: true,
environments: {
select: {
id: true,
type: true,
},
},
},
},
},
},
},
});
return res.json(memberships);
}

View File

@@ -0,0 +1,60 @@
import { getSessionUser, hasTeamAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const currentUser: any = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
const teamId = req.query.teamId?.toString();
if (teamId === undefined) {
return res.status(400).json({ message: "Missing teamId" });
}
const hasAccess = await hasTeamAccess(currentUser, teamId);
if (!hasAccess) {
return res.status(403).json({ message: "Not authorized" });
}
// PUT /api/v1/teams/[teamId]
// Update a team
if (req.method === "PUT") {
const { name } = req.body;
if (name === undefined) {
return res.status(400).json({ message: "Missing name" });
}
// check if currentUser is owner of the team
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: currentUser.id,
teamId,
},
},
});
if (membership?.role !== "owner") {
return res.status(403).json({ message: "You are not allowed to update this team" });
}
// update team
const team = await prisma.team.update({
where: {
id: teamId,
},
data: {
name,
},
});
return res.json(team);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -1 +1,2 @@
export const RESPONSES_LIMIT_FREE = 100;
export const IS_FORMBRICKS_CLOUD = process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD !== "1";

26
pnpm-lock.yaml generated
View File

@@ -219,6 +219,9 @@ importers:
date-fns:
specifier: ^2.29.3
version: 2.29.3
dotenv-webpack:
specifier: ^8.0.1
version: 8.0.1(webpack@5.75.0)
eslint:
specifier: 8.38.0
version: 8.38.0
@@ -4645,7 +4648,7 @@ packages:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1'
dependencies:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.3.1(postcss@8.4.21)
tailwindcss: 3.3.1(postcss@8.4.22)
dev: true
/@tailwindcss/typography@0.5.9(tailwindcss@3.3.1):
@@ -8445,6 +8448,22 @@ packages:
dependencies:
is-obj: 2.0.0
/dotenv-defaults@2.0.2:
resolution: {integrity: sha512-iOIzovWfsUHU91L5i8bJce3NYK5JXeAwH50Jh6+ARUdLiiGlYWfGw6UkzsYqaXZH/hjE/eCd/PlfM/qqyK0AMg==}
dependencies:
dotenv: 8.6.0
dev: false
/dotenv-webpack@8.0.1(webpack@5.75.0):
resolution: {integrity: sha512-CdrgfhZOnx4uB18SgaoP9XHRN2v48BbjuXQsZY5ixs5A8579NxQkmMxRtI7aTwSiSQcM2ao12Fdu+L3ZS3bG4w==}
engines: {node: '>=10'}
peerDependencies:
webpack: ^4 || ^5
dependencies:
dotenv-defaults: 2.0.2
webpack: 5.75.0
dev: false
/dotenv@16.0.3:
resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==}
engines: {node: '>=12'}
@@ -8455,6 +8474,11 @@ packages:
engines: {node: '>=4.6.0'}
dev: false
/dotenv@8.6.0:
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
engines: {node: '>=10'}
dev: false
/duplexer3@0.1.5:
resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}