chore: Rewrite environment navbar to server components (#690)

* moved environment navbar to RSC

* added Error component

* format

* update errors path

* use standard services in environment navbar, update product service according to standards, add redirect shortcuts for teams and products

* update analysis github workflow with environment variables

* fix WEBAPP_URL is required

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-09-12 10:45:03 +05:30
committed by GitHub
parent cbc649111c
commit 1be6deec64
19 changed files with 708 additions and 539 deletions

View File

@@ -39,6 +39,9 @@ jobs:
version: 7
run_install: true
- name: create .env
run: cp .env.example .env
- name: Restore next build
uses: actions/cache@v3
id: restore-build-cache

View File

@@ -1,502 +1,46 @@
"use client";
export const revalidate = REVALIDATION_INTERVAL;
import FaveIcon from "@/app/favicon.ico";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
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 { formbricksLogout } from "@/lib/formbricks";
import { useMemberships } from "@/lib/memberships";
import { useTeam } from "@/lib/teams/teams";
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
import formbricks from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
CustomersIcon,
DashboardIcon,
ErrorComponent,
FilterIcon,
FormIcon,
Popover,
PopoverContent,
PopoverTrigger,
ProfileAvatar,
SettingsIcon,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import {
AdjustmentsVerticalIcon,
ArrowRightOnRectangleIcon,
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
CodeBracketIcon,
CreditCardIcon,
DocumentCheckIcon,
HeartIcon,
PaintBrushIcon,
PlusIcon,
UserCircleIcon,
UsersIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import { MenuIcon } from "lucide-react";
import Navigation from "@/app/(app)/environments/[environmentId]/Navigation";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
import { getProducts } from "@formbricks/lib/services/product";
import { getTeamByEnvironmentId, getTeamsByUserId } from "@formbricks/lib/services/team";
import { ErrorComponent } from "@formbricks/ui";
import type { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
interface EnvironmentsNavbarProps {
environmentId: string;
session: Session;
}
export default function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
const router = useRouter();
const pathname = usePathname();
export default async function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
const [environment, teams, team] = await Promise.all([
getEnvironment(environmentId),
getTeamsByUserId(session.user.id),
getTeamByEnvironmentId(environmentId),
]);
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);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
} else {
setWidgetSetupCompleted(false);
}
}, [environment]);
useEffect(() => {
if (team && team.name !== "") {
setCurrentTeamName(team.name);
setCurrentTeamId(team.id);
}
}, [team]);
const navigation = useMemo(
() => [
{
name: "Surveys",
href: `/environments/${environmentId}/surveys`,
icon: FormIcon,
current: pathname?.includes("/surveys"),
},
{
name: "People",
href: `/environments/${environmentId}/people`,
icon: CustomersIcon,
current: pathname?.includes("/people"),
},
{
name: "Actions & Attributes",
href: `/environments/${environmentId}/actions`,
icon: FilterIcon,
current: pathname?.includes("/actions") || pathname?.includes("/attributes"),
},
{
name: "Integrations",
href: `/environments/${environmentId}/integrations`,
icon: DashboardIcon,
current: pathname?.includes("/integrations"),
},
{
name: "Settings",
href: `/environments/${environmentId}/settings/profile`,
icon: SettingsIcon,
current: pathname?.includes("/settings"),
},
],
[environmentId, pathname]
);
const dropdownnavigation = [
{
title: "Survey",
links: [
{
icon: AdjustmentsVerticalIcon,
label: "Product Settings",
href: `/environments/${environmentId}/settings/product`,
},
{
icon: PaintBrushIcon,
label: "Look & Feel",
href: `/environments/${environmentId}/settings/lookandfeel`,
},
],
},
{
title: "Account",
links: [
{
icon: UserCircleIcon,
label: "Profile",
href: `/environments/${environmentId}/settings/profile`,
},
{ icon: UsersIcon, label: "Team", href: `/environments/${environmentId}/settings/members` },
{
icon: CreditCardIcon,
label: "Billing & Plan",
href: `/environments/${environmentId}/settings/billing`,
hidden: !IS_FORMBRICKS_CLOUD,
},
],
},
{
title: "Setup",
links: [
{
icon: DocumentCheckIcon,
label: "Setup checklist",
href: `/environments/${environmentId}/settings/setup`,
hidden: widgetSetupCompleted,
},
{
icon: CodeBracketIcon,
label: "Developer Docs",
href: "https://formbricks.com/docs",
target: "_blank",
},
{
icon: HeartIcon,
label: "Contribute to Formbricks",
href: "https://github.com/formbricks/formbricks",
target: "_blank",
},
],
},
];
const handleEnvironmentChange = (environmentType: "production" | "development") => {
changeEnvironment(environmentType, environment, router);
};
const handleEnvironmentChangeByProduct = (productId: string) => {
changeEnvironmentByProduct(productId, environment, router);
};
const handleEnvironmentChangeByTeam = (teamId: string) => {
changeEnvironmentByTeam(teamId, memberships, router);
};
if (isLoadingEnvironment || loading || isLoadingMemberships) {
return <LoadingSpinner />;
}
if (isErrorEnvironment || isErrorMemberships || !environment || !memberships) {
if (!team || !environment) {
return <ErrorComponent />;
}
if (pathname?.includes("/edit")) return null;
const [products, environments] = await Promise.all([
getProducts(team.id),
getEnvironments(environment.productId),
]);
if (!products || !environments || !teams) {
return <ErrorComponent />;
}
return (
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
{environment?.type === "development" && (
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
You&apos;re in development mode. Use it to test surveys, actions and attributes.
</div>
)}
<div className="w-full px-4 sm:px-6">
<div className="flex h-14 justify-between">
<div className="flex space-x-4 py-2">
<Link
href={`/environments/${environmentId}/surveys/`}
className="flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
</Link>
{navigation.map((item) => {
const IconComponent: React.ElementType = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={clsx(
item.current
? "bg-slate-100 text-slate-900"
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
"hidden items-center rounded-md px-2 py-1 text-sm font-medium lg:inline-flex"
)}
aria-current={item.current ? "page" : undefined}>
<IconComponent className="mr-3 h-5 w-5" />
{item.name}
</Link>
);
})}
</div>
{/* Mobile Menu */}
<div className="flex items-center lg:hidden">
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
<span>
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
</span>
</PopoverTrigger>
<PopoverContent className="mr-4 bg-slate-100 shadow">
<div className="flex flex-col">
{navigation.map((navItem) => (
<Link key={navItem.name} href={navItem.href}>
<div
onClick={() => setMobileNavMenuOpen(false)}
className={cn(
"flex items-center space-x-2 rounded-md p-2",
navItem.current && "bg-slate-200"
)}>
<navItem.icon className="h-5 w-5" />
<span className="font-medium text-slate-600">{navItem.name}</span>
</div>
</Link>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* User Dropdown */}
<div className="hidden lg:ml-6 lg:flex lg:items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex cursor-pointer flex-row items-center space-x-5">
{session.user.image ? (
<Image
src={session.user.image}
width="100"
height="100"
className="ph-no-capture h-9 w-9 rounded-full"
alt="Profile picture"
/>
) : (
<ProfileAvatar userId={session.user.id} />
)}
<div>
<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(team?.name)}</p>
</div>
<ChevronDownIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="cursor-default break-all">
<span className="ph-no-capture font-normal">Signed in as </span>
{session?.user?.name.length > 30 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>{truncate(session?.user?.name, 30)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
{session?.user?.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
session?.user?.name
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Product Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<div className="flex items-center space-x-1">
<p className="">{truncate(environment?.product?.name, 20)}</p>
{!widgetSetupCompleted && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<div className="mt-0.5 h-2 w-2 rounded-full bg-amber-500 hover:bg-amber-600"></div>
</TooltipTrigger>
<TooltipContent>
<p>Your app is not connected to Formbricks.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className=" block text-xs text-slate-500">Product</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="max-w-[45rem]">
<DropdownMenuRadioGroup
value={environment?.product.id}
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
{environment?.availableProducts?.map((product) => (
<DropdownMenuRadioItem
value={product.id}
className="cursor-pointer break-all"
key={product.id}>
{product?.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowAddProductModal(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<span>Add product</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</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>
<DropdownMenuSubTrigger>
<div>
<p>{capitalizeFirstLetter(environment?.type)}</p>
<p className=" block text-xs text-slate-500">Environment</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={environment?.type}
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
<DropdownMenuRadioItem value="production" className="cursor-pointer">
Production
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="development" className="cursor-pointer">
Development
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{dropdownnavigation.map((item) => (
<DropdownMenuGroup key={item.title}>
<DropdownMenuSeparator />
{item.links.map(
(link) =>
!link.hidden && (
<Link href={link.href} target={link.target} key={link.label}>
<DropdownMenuItem key={link.label}>
<div className="flex items-center">
<link.icon className="mr-2 h-4 w-4" />
<span>{link.label}</span>
</div>
</DropdownMenuItem>
</Link>
)
)}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuGroup>
{IS_FORMBRICKS_CLOUD && (
<DropdownMenuItem>
<button
onClick={() => {
formbricks.track("Top Menu: Product Feedback");
}}>
<div className="flex items-center">
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
<span>Product Feedback</span>
</div>
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={async () => {
setLoading(true);
await signOut();
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Logout
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<AddProductModal
open={showAddProductModal}
setOpen={(val) => setShowAddProductModal(val)}
environmentId={environmentId}
/>
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
</nav>
<Navigation
environment={environment}
team={team}
teams={teams}
products={products}
environments={environments}
session={session}
/>
);
}

View File

@@ -0,0 +1,20 @@
export default function NavbarLoading() {
return (
<div>
<div className="flex justify-between space-x-4 px-4 py-2">
<div className="flex">
<div className=" mx-2 h-8 w-8 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
</div>
<div className="flex">
<div className=" mx-2 h-8 w-8 animate-pulse rounded-full bg-gray-200"></div>
<div className=" mx-2 h-8 w-20 animate-pulse rounded-md bg-gray-200"></div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,495 @@
"use client";
import FaveIcon from "@/app/favicon.ico";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import { formbricksLogout } from "@/lib/formbricks";
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
import formbricks from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { TTeam } from "@formbricks/types/v1/teams";
import {
CustomersIcon,
DashboardIcon,
FilterIcon,
FormIcon,
Popover,
PopoverContent,
PopoverTrigger,
ProfileAvatar,
SettingsIcon,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import {
AdjustmentsVerticalIcon,
ArrowRightOnRectangleIcon,
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
CodeBracketIcon,
CreditCardIcon,
DocumentCheckIcon,
HeartIcon,
PaintBrushIcon,
PlusIcon,
UserCircleIcon,
UsersIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import { MenuIcon } from "lucide-react";
import type { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
interface NavigationProps {
environment: TEnvironment;
teams: TTeam[];
session: Session;
team: TTeam;
products: TProduct[];
environments: TEnvironment[];
}
export default function Navigation({
environment,
teams,
team,
session,
products,
environments,
}: NavigationProps) {
const router = useRouter();
const pathname = usePathname();
const [currentTeamName, setCurrentTeamName] = useState("");
const [currentTeamId, setCurrentTeamId] = useState("");
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [showAddProductModal, setShowAddProductModal] = useState(false);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const product = products.find((product) => product.id === environment.productId);
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
} else {
setWidgetSetupCompleted(false);
}
}, [environment]);
useEffect(() => {
if (team && team.name !== "") {
setCurrentTeamName(team.name);
setCurrentTeamId(team.id);
}
}, [team]);
const navigation = useMemo(
() => [
{
name: "Surveys",
href: `/environments/${environment.id}/surveys`,
icon: FormIcon,
current: pathname?.includes("/surveys"),
},
{
name: "People",
href: `/environments/${environment.id}/people`,
icon: CustomersIcon,
current: pathname?.includes("/people"),
},
{
name: "Actions & Attributes",
href: `/environments/${environment.id}/actions`,
icon: FilterIcon,
current: pathname?.includes("/actions") || pathname?.includes("/attributes"),
},
{
name: "Integrations",
href: `/environments/${environment.id}/integrations`,
icon: DashboardIcon,
current: pathname?.includes("/integrations"),
},
{
name: "Settings",
href: `/environments/${environment.id}/settings/profile`,
icon: SettingsIcon,
current: pathname?.includes("/settings"),
},
],
[environment.id, pathname]
);
const dropdownnavigation = [
{
title: "Survey",
links: [
{
icon: AdjustmentsVerticalIcon,
label: "Product Settings",
href: `/environments/${environment.id}/settings/product`,
},
{
icon: PaintBrushIcon,
label: "Look & Feel",
href: `/environments/${environment.id}/settings/lookandfeel`,
},
],
},
{
title: "Account",
links: [
{
icon: UserCircleIcon,
label: "Profile",
href: `/environments/${environment.id}/settings/profile`,
},
{ icon: UsersIcon, label: "Team", href: `/environments/${environment.id}/settings/members` },
{
icon: CreditCardIcon,
label: "Billing & Plan",
href: `/environments/${environment.id}/settings/billing`,
hidden: !IS_FORMBRICKS_CLOUD,
},
],
},
{
title: "Setup",
links: [
{
icon: DocumentCheckIcon,
label: "Setup checklist",
href: `/environments/${environment.id}/settings/setup`,
hidden: widgetSetupCompleted,
},
{
icon: CodeBracketIcon,
label: "Developer Docs",
href: "https://formbricks.com/docs",
target: "_blank",
},
{
icon: HeartIcon,
label: "Contribute to Formbricks",
href: "https://github.com/formbricks/formbricks",
target: "_blank",
},
],
},
];
const handleEnvironmentChange = (environmentType: "production" | "development") => {
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
if (newEnvironmentId) {
router.push(`/environments/${newEnvironmentId}/`);
}
};
const handleEnvironmentChangeByProduct = (productId: string) => {
router.push(`/products/${productId}/`);
};
const handleEnvironmentChangeByTeam = (teamId: string) => {
router.push(`/teams/${teamId}/`);
};
if (pathname?.includes("/edit")) return null;
return (
<>
{product && (
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
{environment?.type === "development" && (
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
You&apos;re in development mode. Use it to test surveys, actions and attributes.
</div>
)}
<div className="w-full px-4 sm:px-6">
<div className="flex h-14 justify-between">
<div className="flex space-x-4 py-2">
<Link
href={`/environments/${environment.id}/surveys/`}
className="flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
</Link>
{navigation.map((item) => {
const IconComponent: React.ElementType = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={clsx(
item.current
? "bg-slate-100 text-slate-900"
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
"hidden items-center rounded-md px-2 py-1 text-sm font-medium lg:inline-flex"
)}
aria-current={item.current ? "page" : undefined}>
<IconComponent className="mr-3 h-5 w-5" />
{item.name}
</Link>
);
})}
</div>
{/* Mobile Menu */}
<div className="flex items-center lg:hidden">
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
<span>
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
</span>
</PopoverTrigger>
<PopoverContent className="mr-4 bg-slate-100 shadow">
<div className="flex flex-col">
{navigation.map((navItem) => (
<Link key={navItem.name} href={navItem.href}>
<div
onClick={() => setMobileNavMenuOpen(false)}
className={cn(
"flex items-center space-x-2 rounded-md p-2",
navItem.current && "bg-slate-200"
)}>
<navItem.icon className="h-5 w-5" />
<span className="font-medium text-slate-600">{navItem.name}</span>
</div>
</Link>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* User Dropdown */}
<div className="hidden lg:ml-6 lg:flex lg:items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex cursor-pointer flex-row items-center space-x-5">
{session.user.image ? (
<Image
src={session.user.image}
width="100"
height="100"
className="ph-no-capture h-9 w-9 rounded-full"
alt="Profile picture"
/>
) : (
<ProfileAvatar userId={session.user.id} />
)}
<div>
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
{truncate(product!.name, 30)}
</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>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="cursor-default break-all">
<span className="ph-no-capture font-normal">Signed in as </span>
{session?.user?.name.length > 30 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>{truncate(session?.user?.name, 30)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-[45rem] break-all" side="left" sideOffset={5}>
{session?.user?.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
session?.user?.name
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Product Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<div className="flex items-center space-x-1">
<p className="">{truncate(product!.name, 20)}</p>
{!widgetSetupCompleted && (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<div className="mt-0.5 h-2 w-2 rounded-full bg-amber-500 hover:bg-amber-600"></div>
</TooltipTrigger>
<TooltipContent>
<p>Your app is not connected to Formbricks.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className=" block text-xs text-slate-500">Product</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="max-w-[45rem]">
<DropdownMenuRadioGroup
value={product!.id}
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
{products.map((product) => (
<DropdownMenuRadioItem
value={product.id}
className="cursor-pointer break-all"
key={product.id}>
{product?.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowAddProductModal(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<span>Add product</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</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)}>
{teams?.map((team) => (
<DropdownMenuRadioItem value={team.id} className="cursor-pointer" key={team.id}>
{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>
<DropdownMenuSubTrigger>
<div>
<p>{capitalizeFirstLetter(environment?.type)}</p>
<p className=" block text-xs text-slate-500">Environment</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={environment?.type}
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
<DropdownMenuRadioItem value="production" className="cursor-pointer">
Production
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="development" className="cursor-pointer">
Development
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{dropdownnavigation.map((item) => (
<DropdownMenuGroup key={item.title}>
<DropdownMenuSeparator />
{item.links.map(
(link) =>
!link.hidden && (
<Link href={link.href} target={link.target} key={link.label}>
<DropdownMenuItem key={link.label}>
<div className="flex items-center">
<link.icon className="mr-2 h-4 w-4" />
<span>{link.label}</span>
</div>
</DropdownMenuItem>
</Link>
)
)}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuGroup>
{IS_FORMBRICKS_CLOUD && (
<DropdownMenuItem>
<button
onClick={() => {
formbricks.track("Top Menu: Product Feedback");
}}>
<div className="flex items-center">
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
<span>Product Feedback</span>
</div>
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={async () => {
await signOut();
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Logout
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<AddProductModal
open={showAddProductModal}
setOpen={(val) => setShowAddProductModal(val)}
environmentId={environment.id}
/>
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
</nav>
)}
</>
);
}

View File

@@ -20,6 +20,10 @@ export default async function ApiKeyList({
};
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
const environments = await getEnvironments(product.id);
const environmentTypeId = findEnvironmentByType(environments, environmentType);
const apiKeys = await getApiKeys(environmentTypeId);

View File

@@ -11,6 +11,9 @@ import { EditHighlightBorder } from "./EditHighlightBorder";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const product = await getProductByEnvironmentId(params.environmentId);
if (!product) {
throw new Error("Product not found");
}
return (
<div>
<SettingsTitle title="Look & Feel" />

View File

@@ -14,6 +14,10 @@ import { SURVEY_BASE_URL } from "@formbricks/lib/constants";
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
const environment = await getEnvironment(environmentId);
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
const environments: TEnvironment[] = await getEnvironments(product.id);

View File

@@ -7,6 +7,10 @@ export default async function SurveyTemplatesPage({ params }) {
const environment = await getEnvironment(environmentId);
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
return (
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
);

View File

@@ -0,0 +1,24 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { hasTeamAccess } from "@/lib/api/apiHelper";
import { getEnvironments } from "@formbricks/lib/services/environment";
import { getProduct } from "@formbricks/lib/services/product";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/v1/errors";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
export async function GET(_: Request, context: { params: { productId: string } }) {
const productId = context?.params?.productId;
if (!productId) return notFound();
// check auth
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const product = await getProduct(productId);
if (!product) return notFound();
const hasAccess = await hasTeamAccess(session.user, product.teamId);
if (!hasAccess) throw new AuthorizationError("Unauthorized");
// redirect to product's production environment
const environments = await getEnvironments(product.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (!prodEnvironment) return notFound();
redirect(`/environments/${prodEnvironment.id}/`);
}

View File

@@ -0,0 +1,26 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { hasTeamAccess } from "@/lib/api/apiHelper";
import { getEnvironments } from "@formbricks/lib/services/environment";
import { getProducts } from "@formbricks/lib/services/product";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/v1/errors";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
export async function GET(_: Request, context: { params: { teamId: string } }) {
const teamId = context?.params?.teamId;
if (!teamId) return notFound();
// check auth
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const hasAccess = await hasTeamAccess(session.user, teamId);
if (!hasAccess) throw new AuthorizationError("Unauthorized");
// redirect to first product's production environment
const products = await getProducts(teamId);
if (products.length === 0) return notFound();
const firstProduct = products[0];
const environments = await getEnvironments(firstProduct.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (!prodEnvironment) return notFound();
redirect(`/environments/${prodEnvironment.id}/`);
}

View File

@@ -109,6 +109,10 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
getProductByEnvironmentId(environmentId),
]);
if (!product) {
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
}
// return state
const state: TJsState = {
person,

View File

@@ -101,6 +101,10 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
getProductByEnvironmentId(environmentId),
]);
if (!product) {
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
}
// return state
const state: TJsState = {
person,

View File

@@ -60,6 +60,10 @@ export async function POST(req: Request): Promise<NextResponse> {
captureNewSessionTelemetry(inputValidation.data.jsVersion);
if (!product) {
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
}
// return state
const state: TJsState = {
person,
@@ -87,6 +91,10 @@ export async function POST(req: Request): Promise<NextResponse> {
getProductByEnvironmentId(environmentId),
]);
if (!product) {
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
}
captureNewSessionTelemetry(inputValidation.data.jsVersion);
// return state
@@ -144,6 +152,10 @@ export async function POST(req: Request): Promise<NextResponse> {
getProductByEnvironmentId(environmentId),
]);
if (!product) {
return responses.notFoundResponse("ProductByEnvironmentId", environmentId, true);
}
// return state
const state: TJsState = {
person,

View File

@@ -36,6 +36,10 @@ export default async function LinkSurveyPage({ params, searchParams }) {
// get product and person
const product = await getProductByEnvironmentId(survey.environmentId);
if (!product) {
throw new Error("Product not found");
}
const userId = searchParams.userId;
let person;
if (userId) {

View File

@@ -1,6 +1,5 @@
import { createTeam } from "@/app/(app)/environments/[environmentId]/actions";
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";
@@ -8,7 +7,6 @@ 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/(app)/environments/[environmentId]/actions";
interface CreateTeamModalProps {
open: boolean;
@@ -18,7 +16,6 @@ interface CreateTeamModalProps {
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();
@@ -26,9 +23,8 @@ export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps)
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!");
router.push(`/teams/${newTeam.id}`);
setOpen(false);
setLoading(false);
};

View File

@@ -7,7 +7,7 @@ export const env = createEnv({
* Will throw if you access these variables on the client.
*/
server: {
WEBAPP_URL: z.string().url(),
WEBAPP_URL: z.string().url().optional(),
DATABASE_URL: z.string().url(),
PRISMA_GENERATE_DATAPROXY: z.enum(["true", ""]).optional(),
NEXTAUTH_SECRET: z.string().min(1),

View File

@@ -1,27 +0,0 @@
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}/`);
}
}
};

View File

@@ -1,11 +1,11 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/v1/errors";
import { ZProduct } from "@formbricks/types/v1/product";
import { DatabaseError, ValidationError } from "@formbricks/types/v1/errors";
import type { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { ZProduct } from "@formbricks/types/v1/product";
import { Prisma } from "@prisma/client";
import { cache } from "react";
import "server-only";
import { z } from "zod";
const selectProduct = {
id: true,
@@ -22,7 +22,26 @@ const selectProduct = {
darkOverlay: true,
};
export const getProductByEnvironmentId = cache(async (environmentId: string): Promise<TProduct> => {
export const getProducts = cache(async (teamId: string): Promise<TProduct[]> => {
try {
const products = await prisma.product.findMany({
where: {
teamId,
},
select: selectProduct,
});
return products;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
});
export const getProductByEnvironmentId = cache(async (environmentId: string): Promise<TProduct | null> => {
if (!environmentId) {
throw new ValidationError("EnvironmentId is required");
}
@@ -39,25 +58,13 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
select: selectProduct,
});
if (!productPrisma) {
throw new ResourceNotFoundError("Product for Environment", environmentId);
}
return productPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
try {
const product = ZProduct.parse(productPrisma);
return product;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of product failed");
}
});
export const updateProduct = async (
@@ -91,3 +98,22 @@ export const updateProduct = async (
throw new ValidationError("Data validation of product failed");
}
};
export const getProduct = cache(async (productId: string): Promise<TProduct | null> => {
let productPrisma;
try {
productPrisma = await prisma.product.findUnique({
where: {
id: productId,
},
select: selectProduct,
});
return productPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
});

View File

@@ -32,6 +32,29 @@ export const select = {
stripeCustomerId: true,
};
export const getTeamsByUserId = cache(async (userId: string): Promise<TTeam[]> => {
try {
const teams = await prisma.team.findMany({
where: {
memberships: {
some: {
userId,
},
},
},
select,
});
return teams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
});
export const getTeamByEnvironmentId = cache(async (environmentId: string): Promise<TTeam | null> => {
try {
const team = await prisma.team.findFirst({