feat: nav cleanup pt. 2 (#6515)

Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Dhruwang Jariwala
2025-09-11 09:37:17 +05:30
committed by GitHub
parent d46644fe0d
commit 0188aad97b
122 changed files with 955 additions and 1335 deletions
@@ -1,14 +0,0 @@
import { getTranslate } from "@/tolgee/server";
export const ActionTableHeading = async () => {
const t = await getTranslate();
return (
<>
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">{t("common.edit")}</span>
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
<div className="col-span-2 text-center">{t("common.created")}</div>
</div>
</>
);
};
@@ -1,58 +0,0 @@
"use client";
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { TActionClass } from "@formbricks/types/action-classes";
interface AddActionModalProps {
environmentId: string;
actionClasses: TActionClass[];
isReadOnly: boolean;
}
export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: AddActionModalProps) => {
const { t } = useTranslate();
const [open, setOpen] = useState(false);
const [newActionClasses, setNewActionClasses] = useState<TActionClass[]>(actionClasses);
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
{t("common.add_action")}
<PlusIcon />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick>
<DialogHeader>
<MousePointerClickIcon />
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
<DialogDescription>
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,44 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock child components
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
// Check if mocked components are rendered
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
// Check for translated table headers
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
expect(screen.getByText("common.created")).toBeInTheDocument();
expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
// Check for skeleton elements (presence of animate-pulse class)
const skeletonElements = document.querySelectorAll(".animate-pulse");
expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
// Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
const pulseDivs = screen.getAllByText((_, element) => {
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
});
expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
});
});
@@ -1,46 +0,0 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslate } from "@tolgee/react";
const Loading = () => {
const { t } = useTranslate();
return (
<>
<PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} />
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">{t("common.edit")}</span>
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
<div className="col-span-2 text-center">{t("common.created")}</div>
</div>
{[...Array(3)].map((_, index) => (
<div
key={index}
className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="ml-4 text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="mt-1 text-xs text-slate-400">
<div className="h-2 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
))}
</div>
</PageContentWrapper>
</>
);
};
export default Loading;
@@ -1,161 +0,0 @@
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
// Import the component after mocks
import Page from "./page";
// Mock dependencies
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironments: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
ActionClassesTable: ({ children }) => <div>ActionClassesTable Mock{children}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
AddActionModal: () => <div>AddActionModal Mock</div>,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, cta }) => (
<div>
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
</div>
),
}));
// Mock data
const mockEnvironmentId = "test-env-id";
const mockProjectId = "test-project-id";
const mockEnvironment = {
id: mockEnvironmentId,
name: "Test Environment",
type: "development",
} as unknown as TEnvironment;
const mockOtherEnvironment = {
id: "other-env-id",
name: "Other Environment",
type: "production",
} as unknown as TEnvironment;
const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
const mockActionClasses = [
{ id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
{ id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
];
const mockOtherEnvActionClasses = [
{ id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
];
const mockLocale = "en-US";
const mockParams = { environmentId: mockEnvironmentId };
const mockProps = { params: mockParams };
describe("Actions Page", () => {
beforeEach(() => {
vi.mocked(getActionClasses)
.mockResolvedValueOnce(mockActionClasses) // First call for current env
.mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("renders the page correctly with actions", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("redirects if isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: true,
environment: mockEnvironment,
} as TEnvironmentAuth);
await Page(mockProps);
expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
});
test("does not render AddActionModal CTA if isReadOnly is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: true,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
});
test("renders AddActionModal CTA if isReadOnly is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
});
});
@@ -1,66 +0,0 @@
import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable";
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Actions",
};
const Page = async (props) => {
const params = await props.params;
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]);
const locale = await findMatchingLocale();
const environments = await getEnvironments(project.id);
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const renderAddActionButton = () => (
<AddActionModal
environmentId={params.environmentId}
actionClasses={actionClasses}
isReadOnly={isReadOnly}
/>
);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
<ActionClassesTable
environment={environment}
otherEnvironment={otherEnvironment}
otherEnvActionClasses={otherEnvActionClasses}
environmentId={params.environmentId}
actionClasses={actionClasses}
isReadOnly={isReadOnly}>
<ActionTableHeading />
{actionClasses.map((actionClass) => (
<ActionClassDataRow key={actionClass.id} actionClass={actionClass} locale={locale} />
))}
</ActionClassesTable>
</PageContentWrapper>
);
};
export default Page;
@@ -1,6 +0,0 @@
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
export const ACTION_TYPE_ICON_LOOKUP = {
code: <Code2Icon className="h-4 w-4" />,
noCode: <MousePointerClickIcon className="h-4 w-4" />,
};
@@ -142,7 +142,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isOwnerOrManager={isOwnerOrManager}
isAccessControlAllowed={isAccessControlAllowed}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>
@@ -1,4 +1,5 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
@@ -7,7 +8,6 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
@@ -32,7 +32,7 @@ vi.mock("next-auth/react", () => ({
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
vi.mock("@/modules/projects/settings/(setup)/app-connection/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(),
}));
vi.mock("@/app/lib/formbricks", () => ({
@@ -197,14 +197,6 @@ describe("MainNavigation", () => {
});
});
test("renders correct active navigation link", () => {
vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
render(<MainNavigation {...defaultProps} />);
const actionsLink = screen.getByRole("link", { name: /common.actions/ });
// Check if the parent li has the active class styling
expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
});
test("renders user dropdown and handles logout", async () => {
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
@@ -1,12 +1,12 @@
"use client";
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
@@ -18,12 +18,10 @@ import {
import { useTranslate } from "@tolgee/react";
import {
ArrowUpRightIcon,
BlocksIcon,
ChevronRightIcon,
Cog,
LogOutIcon,
MessageCircle,
MousePointerClick,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
@@ -112,18 +110,6 @@ export const MainNavigation = ({
icon: UserIcon,
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
},
{
name: t("common.actions"),
href: `/environments/${environment.id}/actions`,
icon: MousePointerClick,
isActive: pathname?.includes("/actions"),
},
{
name: t("common.integrations"),
href: `/environments/${environment.id}/integrations`,
icon: BlocksIcon,
isActive: pathname?.includes("/integrations"),
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/project/general`,
@@ -3,13 +3,6 @@
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -26,7 +19,6 @@ interface TopControlBarProps {
isOwnerOrManager: boolean;
isAccessControlAllowed: boolean;
membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null;
}
export const TopControlBar = ({
@@ -42,70 +34,29 @@ export const TopControlBar = ({
isOwnerOrManager,
isAccessControlAllowed,
membershipRole,
projectPermission,
}: TopControlBarProps) => {
const { t } = useTranslate();
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const { isMember } = getAccessFlags(membershipRole);
const { environment } = useEnvironment();
return (
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<div className="flex items-center">
<ProjectAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
organizations={organizations}
currentProjectId={currentProjectId}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
<div className="z-50 flex items-center space-x-2">
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link
href="https://github.com/formbricks/formbricks/issues"
target="_blank"
aria-label={t("common.share_feedback")}
rel="noopener noreferrer">
<BugIcon />
</Link>
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.account")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link href={`/environments/${environment.id}/settings/profile`} aria-label={t("common.account")}>
<CircleUserIcon />
</Link>
</Button>
</TooltipRenderer>
{isBilling || isReadOnly ? (
<></>
) : (
<TooltipRenderer tooltipContent={t("common.new_survey")}>
<Button variant="secondary" size="icon" className="h-fit w-fit p-1" asChild>
<Link
href={`/environments/${environment.id}/surveys/templates`}
aria-label={t("common.new_survey")}>
<PlusIcon />
</Link>
</Button>
</TooltipRenderer>
)}
</div>
<ProjectAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
organizations={organizations}
currentProjectId={currentProjectId}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
);
};
@@ -143,10 +143,7 @@ describe("EnvironmentBreadcrumb", () => {
test("renders environment breadcrumb with production environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
@@ -159,7 +156,7 @@ describe("EnvironmentBreadcrumb", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockDevelopmentEnvironment.id}
currentEnvironment={mockDevelopmentEnvironment}
/>
);
@@ -172,7 +169,7 @@ describe("EnvironmentBreadcrumb", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockDevelopmentEnvironment.id}
currentEnvironment={mockDevelopmentEnvironment}
/>
);
@@ -182,10 +179,7 @@ describe("EnvironmentBreadcrumb", () => {
test("does not highlight breadcrumb item for production environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
@@ -195,10 +189,7 @@ describe("EnvironmentBreadcrumb", () => {
test("shows chevron down icon when dropdown is open", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
@@ -212,10 +203,7 @@ describe("EnvironmentBreadcrumb", () => {
test("renders dropdown content with environment options", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
@@ -229,10 +217,7 @@ describe("EnvironmentBreadcrumb", () => {
test("renders all environment options in dropdown", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
@@ -255,10 +240,7 @@ describe("EnvironmentBreadcrumb", () => {
test("handles environment change when clicking dropdown option", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
@@ -275,10 +257,7 @@ describe("EnvironmentBreadcrumb", () => {
test("capitalizes environment type in display", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
const environmentSpans = screen.getAllByText("production");
@@ -290,7 +269,7 @@ describe("EnvironmentBreadcrumb", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockDevelopmentEnvironment.id}
currentEnvironment={mockDevelopmentEnvironment}
/>
);
@@ -301,10 +280,7 @@ describe("EnvironmentBreadcrumb", () => {
test("renders without tooltip for production environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
@@ -314,10 +290,7 @@ describe("EnvironmentBreadcrumb", () => {
test("sets breadcrumb item as active when dropdown is open", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
);
// Initially not active
@@ -339,11 +312,18 @@ describe("EnvironmentBreadcrumb", () => {
render(
<EnvironmentBreadcrumb
environments={singleEnvironment}
currentEnvironmentId={mockProductionEnvironment.id}
currentEnvironment={mockProductionEnvironment}
/>
);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
});
test("handles empty environments array gracefully", () => {
render(<EnvironmentBreadcrumb environments={[]} currentEnvironment={mockProductionEnvironment} />);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getByText("production")).toBeInTheDocument();
});
});
@@ -16,22 +16,18 @@ import { useState } from "react";
export const EnvironmentBreadcrumb = ({
environments,
currentEnvironmentId,
currentEnvironment,
}: {
environments: { id: string; type: string }[];
currentEnvironmentId: string;
currentEnvironment: { id: string; type: string };
}) => {
const { t } = useTranslate();
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
if (!currentEnvironment) {
return null;
}
const handleEnvironmentChange = (environmentId: string) => {
if (environmentId === currentEnvironment.id) return;
setIsLoading(true);
router.push(`/environments/${environmentId}/`);
};
@@ -77,7 +73,7 @@ export const EnvironmentBreadcrumb = ({
{environments.map((env) => (
<DropdownMenuCheckboxItem
key={env.id}
checked={env.id === currentEnvironment.id}
checked={env.type === currentEnvironment.type}
onClick={() => handleEnvironmentChange(env.id)}
className="cursor-pointer">
<div className="flex items-center gap-2 capitalize">
@@ -10,6 +10,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import * as Sentry from "@sentry/nextjs";
import { useTranslate } from "@tolgee/react";
import {
BuildingIcon,
@@ -21,6 +22,7 @@ import {
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { logger } from "@formbricks/logger";
interface OrganizationBreadcrumbProps {
currentOrganizationId: string;
@@ -50,10 +52,14 @@ export const OrganizationBreadcrumb = ({
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
if (!currentOrganization) {
return null;
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
setIsLoading(true);
router.push(`/organizations/${organizationId}/`);
};
@@ -105,7 +105,7 @@ describe("ProjectAndOrgSwitch", () => {
id: "env-1",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
type: "production",
type: "development",
projectId: "proj-1",
appSetupCompleted: true,
};
@@ -160,7 +160,6 @@ describe("ProjectAndOrgSwitch", () => {
expect(screen.getByTestId("organization-breadcrumb")).toBeInTheDocument();
expect(screen.getByTestId("project-breadcrumb")).toBeInTheDocument();
expect(screen.getByTestId("environment-breadcrumb")).toBeInTheDocument();
});
});
@@ -234,17 +233,7 @@ describe("ProjectAndOrgSwitch", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
expect(envBreadcrumb).toHaveTextContent("Environment: production");
expect(envBreadcrumb).toHaveTextContent("Environments Count: 2");
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-1");
});
test("handles development environment", () => {
render(<ProjectAndOrgSwitch {...defaultProps} currentEnvironmentId="env-2" />);
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
expect(envBreadcrumb).toHaveTextContent("Environment: development");
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-2");
});
test("handles single environment", () => {
@@ -42,6 +42,8 @@ export const ProjectAndOrgSwitch = ({
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
[organizations]
);
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
return (
<Breadcrumb>
@@ -66,10 +68,11 @@ export const ProjectAndOrgSwitch = ({
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
/>
)}
{currentEnvironmentId && (
<EnvironmentBreadcrumb environments={environments} currentEnvironmentId={currentEnvironmentId} />
{showEnvironmentBreadcrumb && (
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
)}
</BreadcrumbList>
</Breadcrumb>
@@ -10,6 +10,7 @@ import { ProjectBreadcrumb } from "./project-breadcrumb";
// Mock the dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
usePathname: vi.fn(() => "/environments/env-123/project/general"),
}));
vi.mock("@tolgee/react", () => ({
@@ -87,6 +88,7 @@ vi.mock("@/modules/ui/components/dropdown-menu", () => ({
</button>
),
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
}));
// Mock Lucide React icons
@@ -122,6 +124,16 @@ vi.mock("lucide-react", () => ({
<title>Loader2 Icon</title>
</svg>
),
CogIcon: ({ className }: any) => (
<svg data-testid="cog-icon" className={className}>
<title>Cog Icon</title>
</svg>
),
SettingsIcon: ({ className }: any) => (
<svg data-testid="settings-icon" className={className}>
<title>Settings Icon</title>
</svg>
),
}));
describe("ProjectBreadcrumb", () => {
@@ -177,6 +189,7 @@ describe("ProjectBreadcrumb", () => {
isLicenseActive: false,
currentEnvironmentId: "env-123",
isAccessControlAllowed: true,
isEnvironmentBreadcrumbVisible: true,
};
beforeEach(() => {
@@ -229,7 +242,7 @@ describe("ProjectBreadcrumb", () => {
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
expect(screen.getByText("common.choose_project")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
expect(screen.getAllByTestId("dropdown-group")).toHaveLength(2); // Projects group and settings group
});
test("renders all project options in dropdown", async () => {
@@ -336,6 +349,7 @@ describe("ProjectBreadcrumb", () => {
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
organizationProjectsLimit: 3,
isFormbricksCloud: true,
isEnvironmentBreadcrumbVisible: true,
currentOrganization: {
...mockOrganization,
billing: { ...mockOrganization.billing, plan: "startup" } as unknown as TOrganizationBilling,
@@ -361,6 +375,7 @@ describe("ProjectBreadcrumb", () => {
organizationProjectsLimit: 3,
isFormbricksCloud: false,
isLicenseActive: true,
isEnvironmentBreadcrumbVisible: true,
};
render(<ProjectBreadcrumb {...props} />);
@@ -8,13 +8,16 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import * as Sentry from "@sentry/nextjs";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { logger } from "@formbricks/logger";
interface ProjectBreadcrumbProps {
currentProjectId: string;
@@ -26,6 +29,7 @@ interface ProjectBreadcrumbProps {
currentOrganizationId: string;
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
}
export const ProjectBreadcrumb = ({
@@ -38,6 +42,7 @@ export const ProjectBreadcrumb = ({
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
}: ProjectBreadcrumbProps) => {
const { t } = useTranslate();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
@@ -45,13 +50,57 @@ export const ProjectBreadcrumb = ({
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const pathname = usePathname();
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/project/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${currentEnvironmentId}/project/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${currentEnvironmentId}/project/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${currentEnvironmentId}/project/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${currentEnvironmentId}/project/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${currentEnvironmentId}/project/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/project/tags`,
},
];
const currentProject = projects.find((project) => project.id === currentProjectId);
if (!currentProject) {
return null;
const errorMessage = `Project not found for project id: ${currentProjectId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
setIsLoading(true);
router.push(`/projects/${projectId}/`);
};
@@ -105,14 +154,14 @@ export const ProjectBreadcrumb = ({
{isProjectDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
isEnvironmentBreadcrumbVisible && <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FolderOpenIcon className="mr-2 inline h-4 w-4" />
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_project")}
</div>
<DropdownMenuGroup>
@@ -133,9 +182,25 @@ export const ProjectBreadcrumb = ({
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_project")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.project_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={pathname.includes(setting.id)}
onClick={() => router.push(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
@@ -1,5 +1,5 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
@@ -16,11 +16,11 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { AddIntegrationModal } from "./AddIntegrationModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown",
() => ({
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
<div>
@@ -44,7 +44,7 @@ vi.mock(
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable", () => ({
fetchTables: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
@@ -1,8 +1,8 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
@@ -41,6 +41,7 @@ import {
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { IntegrationModalInputs } from "../lib/types";
type EditModeProps =
| { isEditMode: false; defaultData?: never }
@@ -55,17 +56,6 @@ type AddIntegrationModalProps = {
airtableIntegration: TIntegrationAirtable;
} & EditModeProps;
export type IntegrationModalInputs = {
base: string;
table: string;
survey: string;
questions: string[];
includeVariables: boolean;
includeHiddenFields: boolean;
includeMetadata: boolean;
includeCreatedAt: boolean;
};
const NoBaseFoundError = () => {
const { t } = useTranslate();
return (
@@ -239,7 +229,7 @@ export const AddIntegrationModal = ({
if (isEditMode) {
// update action
airtableIntegrationData.config!.data[defaultData.index] = integrationData;
airtableIntegrationData.config.data[defaultData.index] = integrationData;
} else {
// create action
airtableIntegrationData.config?.data.push(integrationData);
@@ -1,4 +1,4 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -8,7 +8,7 @@ import { AirtableWrapper } from "./AirtableWrapper";
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration",
() => ({
ManageIntegration: ({ setIsConnected }) => (
<div data-testid="manage-integration">
@@ -28,7 +28,7 @@ vi.mock("@/modules/ui/components/connect-integration", () => ({
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable", () => ({
authorize: vi.fn(),
}));
@@ -1,7 +1,7 @@
"use client";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import airtableLogo from "@/images/airtableLogo.svg";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { useState } from "react";
@@ -11,7 +11,7 @@ import {
import { useTranslate } from "@tolgee/react";
import { Control, Controller, UseFormSetValue } from "react-hook-form";
import { TIntegrationItem } from "@formbricks/types/integration";
import { IntegrationModalInputs } from "./AddIntegrationModal";
import { IntegrationModalInputs } from "../lib/types";
interface BaseSelectProps {
control: Control<IntegrationModalInputs, any>;
@@ -1,4 +1,4 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -6,11 +6,11 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal",
() => ({
AddIntegrationModal: ({ open, setOpenWithStates }) =>
open ? (
@@ -1,10 +1,7 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import {
AddIntegrationModal,
IntegrationModalInputs,
} from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -19,6 +16,7 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
@@ -0,0 +1,10 @@
export type IntegrationModalInputs = {
base: string;
table: string;
survey: string;
questions: string[];
includeVariables: boolean;
includeHiddenFields: boolean;
includeMetadata: boolean;
includeCreatedAt: boolean;
};
@@ -1,4 +1,4 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
@@ -15,10 +15,13 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import Page from "./page";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
vi.mock(
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper",
() => ({
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys");
vi.mock("@/lib/airtable/service");
let mockAirtableClientId: string | undefined = "test-client-id";
@@ -139,7 +142,7 @@ describe("Airtable Integration Page", () => {
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
@@ -175,7 +178,7 @@ describe("Airtable Integration Page", () => {
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
@@ -206,7 +209,7 @@ describe("Airtable Integration Page", () => {
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
@@ -1,5 +1,5 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
@@ -42,7 +42,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
<div className="h-[75vh] w-full">
<AirtableWrapper
@@ -1,8 +1,7 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
@@ -10,13 +9,13 @@ import {
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions", () => ({
getSpreadsheetNameByIdAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util", () => ({
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
@@ -164,11 +163,11 @@ vi.mock("@tolgee/react", async () => {
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
(await import("@/app/(app)/environments/[environmentId]/project/integrations/actions"))
.createOrUpdateIntegrationAction
);
const getSpreadsheetNameByIdAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
(await import("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions"))
.getSpreadsheetNameByIdAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
@@ -1,12 +1,12 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
import {
constructGoogleSheetsUrl,
extractSpreadsheetIdFromUrl,
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -154,10 +154,10 @@ export const AddIntegrationModal = ({
integrationData.includeCreatedAt = includeCreatedAt;
if (selectedIntegration) {
// update action
googleSheetIntegrationData.config!.data[selectedIntegration.index] = integrationData;
googleSheetIntegrationData.config.data[selectedIntegration.index] = integrationData;
} else {
// create action
googleSheetIntegrationData.config!.data.push(integrationData);
googleSheetIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
if (selectedIntegration) {
@@ -196,7 +196,7 @@ export const AddIntegrationModal = ({
};
const deleteLink = async () => {
googleSheetIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
@@ -1,6 +1,6 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
import { cleanup, render, screen } from "@testing-library/react";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -12,7 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
// Mock child components and functions
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration",
() => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
<div data-testid="manage-integration">
@@ -31,7 +31,7 @@ vi.mock("@/modules/ui/components/connect-integration", () => ({
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/AddIntegrationModal",
() => ({
AddIntegrationModal: vi.fn(({ open }) =>
open ? <div data-testid="add-integration-modal">Modal</div> : null
@@ -39,7 +39,7 @@ vi.mock(
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google", () => ({
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
}));
@@ -129,7 +129,7 @@ describe("GoogleSheetWrapper", () => {
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
// Need to wait for the promise returned by authorize to resolve
await vi.waitFor(() => {
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
});
@@ -1,7 +1,7 @@
"use client";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { useState } from "react";
@@ -34,7 +34,7 @@ export const GoogleSheetWrapper = ({
const [isConnected, setIsConnected] = useState(
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setModalOpen] = useState<boolean>(false);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
@@ -55,14 +55,14 @@ export const GoogleSheetWrapper = ({
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
setOpen={setIsModalOpen}
googleSheetIntegration={googleSheetIntegration}
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
googleSheetIntegration={googleSheetIntegration}
setOpenAddIntegrationModal={setModalOpen}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={locale}
@@ -1,4 +1,4 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -6,7 +6,7 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
@@ -1,6 +1,6 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -108,24 +108,21 @@ export const ManageIntegration = ({
<div className="col-span-2 hidden text-center sm:block">{t("common.questions")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
);
})}
{integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
);
})}
</div>
</div>
)}
@@ -1,7 +1,7 @@
export const extractSpreadsheetIdFromUrl = (url: string): string => {
const regex = /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/;
const match = url.match(regex);
if (match && match[1]) {
const match = regex.exec(url);
if (match?.[1]) {
return match[1];
} else {
throw new Error("Invalid Google Sheets URL");
@@ -27,7 +27,7 @@ const Loading = () => {
<div className="grid-cols-7">
{[...Array(3)].map((_, index) => (
<div
key={index}
key={`${index.toString()}-google-sheet-loading`}
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="text-left">
@@ -1,5 +1,5 @@
import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import Page from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/page";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -16,7 +16,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper",
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper",
() => ({
GoogleSheetWrapper: vi.fn(
({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => (
@@ -33,7 +33,7 @@ vi.mock(
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
@@ -178,7 +178,7 @@ describe("GoogleSheetsIntegrationPage", () => {
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
`test-webapp-url/environments/${mockProps.params.environmentId}/project/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
@@ -199,7 +199,7 @@ describe("GoogleSheetsIntegrationPage", () => {
mockGoogleSheetClientId = undefined;
const { default: PageWithMissingConstants } = (await import(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/page"
)) as { default: typeof Page };
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
@@ -1,5 +1,5 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
@@ -40,7 +40,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper
@@ -1,4 +1,4 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -11,7 +11,7 @@ import {
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
@@ -184,7 +184,7 @@ vi.mock("@tolgee/react", async () => {
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
(await import("@/app/(app)/environments/[environmentId]/project/integrations/actions"))
.createOrUpdateIntegrationAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
@@ -1,11 +1,11 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -221,10 +221,10 @@ export const AddIntegrationModal = ({
if (selectedIntegration) {
// update action
notionIntegrationData.config!.data[selectedIntegration.index] = integrationData;
notionIntegrationData.config.data[selectedIntegration.index] = integrationData;
} else {
// create action
notionIntegrationData.config!.data.push(integrationData);
notionIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
@@ -243,7 +243,7 @@ export const AddIntegrationModal = ({
};
const deleteLink = async () => {
notionIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
@@ -9,9 +9,11 @@ import type {
} from "@formbricks/types/integration/notion";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
vi.mock("react-hot-toast", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
@@ -1,6 +1,6 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -118,23 +118,20 @@ export const ManageIntegration = ({
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.databaseName}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
);
})}
{integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.databaseName}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
);
})}
</div>
</div>
)}
@@ -1,4 +1,4 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/lib/notion";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -61,7 +61,7 @@ vi.mock("@/modules/ui/components/connect-integration", () => ({
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/notion/lib/notion", () => ({
authorize: vi.fn(),
}));
@@ -140,7 +140,7 @@ describe("NotionWrapper", () => {
test("calls authorize and redirects when Connect button is clicked", async () => {
const mockAuthorize = vi.mocked(authorize);
const redirectUrl = "https://notion.com/auth";
mockAuthorize.mockResolvedValue(redirectUrl);
mockAuthorize.mockImplementation(() => Promise.resolve(redirectUrl));
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
@@ -1,7 +1,7 @@
"use client";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
import notionLogo from "@/images/notion.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { useState } from "react";
@@ -34,7 +34,7 @@ export const NotionWrapper = ({
databasesArray,
locale,
}: NotionWrapperProps) => {
const [isModalOpen, setModalOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConnected, setIsConnected] = useState(
notionIntegration ? notionIntegration.config.key?.bot_id : false
);
@@ -58,7 +58,7 @@ export const NotionWrapper = ({
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
setOpen={setIsModalOpen}
notionIntegration={notionIntegration}
databases={databasesArray}
selectedIntegration={selectedIntegration}
@@ -66,7 +66,7 @@ export const NotionWrapper = ({
<ManageIntegration
environment={environment}
notionIntegration={notionIntegration}
setOpenAddIntegrationModal={setModalOpen}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={locale}
@@ -24,7 +24,7 @@ const Loading = () => {
<div className="grid-cols-7">
{[...Array(3)].map((_, index) => (
<div
key={index}
key={`${index.toString()}-notion-loading`}
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="text-left">
@@ -1,5 +1,5 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import Page from "@/app/(app)/environments/[environmentId]/project/integrations/notion/page";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -13,23 +13,26 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({
NotionWrapper: vi.fn(
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked NotionWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{notionIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
vi.mock(
"@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper",
() => ({
NotionWrapper: vi.fn(
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked NotionWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{notionIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
@@ -1,5 +1,5 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
@@ -1,5 +1,5 @@
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
import Page from "@/app/(app)/environments/[environmentId]/integrations/page";
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook";
import Page from "@/app/(app)/environments/[environmentId]/project/integrations/page";
import { getIntegrations } from "@/lib/integration/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
@@ -10,7 +10,7 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TIntegration } from "@formbricks/types/integration";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/webhook", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook", () => ({
getWebhookCountBySource: vi.fn(),
}));
@@ -106,8 +106,6 @@ describe("Integrations Page", () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("common.integrations")).toBeInTheDocument(); // Page Header
expect(screen.getByTestId("card-Javascript SDK")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.website_or_app_integration_description")
@@ -1,4 +1,4 @@
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook";
import ActivePiecesLogo from "@/images/activepieces.webp";
import AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
@@ -11,14 +11,22 @@ import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import { getIntegrations } from "@/lib/integration/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { TFnType } from "@tolgee/react";
import Image from "next/image";
import { redirect } from "next/navigation";
import { TIntegrationType } from "@formbricks/types/integration";
const getStatusText = (count: number, t: TFnType, type: string) => {
if (count === 1) return `1 ${type}`;
if (count === 0) return t("common.not_connected");
return `${count} ${type}s`;
};
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
@@ -67,16 +75,11 @@ const Page = async (props) => {
description: t("environments.integrations.zapier_integration_description"),
icon: <Image src={ZapierLogo} alt="Zapier Logo" />,
connected: zapierWebhookCount > 0,
statusText:
zapierWebhookCount === 1
? "1 zap"
: zapierWebhookCount === 0
? t("common.not_connected")
: `${zapierWebhookCount} zaps`,
statusText: getStatusText(zapierWebhookCount, t, "zap"),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/integrations/webhooks`,
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
connectText: t("environments.integrations.manage_webhooks"),
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
@@ -86,16 +89,11 @@ const Page = async (props) => {
description: t("environments.integrations.webhook_integration_description"),
icon: <Image src={WebhookLogo} alt="Webhook Logo" />,
connected: userWebhookCount > 0,
statusText:
userWebhookCount === 1
? "1 webhook"
: userWebhookCount === 0
? t("common.not_connected")
: `${userWebhookCount} webhooks`,
statusText: getStatusText(userWebhookCount, t, "webhook"),
disabled: false,
},
{
connectHref: `/environments/${params.environmentId}/integrations/google-sheets`,
connectHref: `/environments/${params.environmentId}/project/integrations/google-sheets`,
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
@@ -109,7 +107,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/integrations/airtable`,
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
@@ -123,7 +121,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/integrations/slack`,
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
@@ -147,12 +145,7 @@ const Page = async (props) => {
description: t("environments.integrations.n8n_integration_description"),
icon: <Image src={n8nLogo} alt="n8n Logo" />,
connected: n8nwebhookCount > 0,
statusText:
n8nwebhookCount === 1
? `1 ${t("common.integration")}`
: n8nwebhookCount === 0
? t("common.not_connected")
: `${n8nwebhookCount} ${t("common.integrations")}`,
statusText: getStatusText(n8nwebhookCount, t, t("common.integration")),
disabled: isReadOnly,
},
{
@@ -166,16 +159,11 @@ const Page = async (props) => {
description: t("environments.integrations.make_integration_description"),
icon: <Image src={MakeLogo} alt="Make Logo" />,
connected: makeWebhookCount > 0,
statusText:
makeWebhookCount === 1
? `1 ${t("common.integration")}`
: makeWebhookCount === 0
? t("common.not_connected")
: `${makeWebhookCount} ${t("common.integrations")}`,
statusText: getStatusText(makeWebhookCount, t, t("common.integration")),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/integrations/notion`,
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
@@ -199,12 +187,7 @@ const Page = async (props) => {
description: t("environments.integrations.activepieces_integration_description"),
icon: <Image src={ActivePiecesLogo} alt="ActivePieces Logo" />,
connected: activePiecesWebhookCount > 0,
statusText:
activePiecesWebhookCount === 1
? `1 ${t("common.integration")}`
: activePiecesWebhookCount === 0
? t("common.not_connected")
: `${activePiecesWebhookCount} ${t("common.integrations")}`,
statusText: getStatusText(activePiecesWebhookCount, t, t("common.integration")),
disabled: isReadOnly,
},
];
@@ -226,7 +209,9 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.integrations")} />
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
</PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
{integrationCards.map((card) => (
<Card
@@ -1,4 +1,4 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -12,7 +12,7 @@ import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/t
import { AddChannelMappingModal } from "./AddChannelMappingModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
@@ -1,6 +1,6 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
@@ -133,10 +133,10 @@ export const AddChannelMappingModal = ({
};
if (selectedIntegration) {
// update action
slackIntegrationData.config!.data[selectedIntegration.index] = integrationData;
slackIntegrationData.config.data[selectedIntegration.index] = integrationData;
} else {
// create action
slackIntegrationData.config!.data.push(integrationData);
slackIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
if (selectedIntegration) {
@@ -172,7 +172,7 @@ export const AddChannelMappingModal = ({
};
const deleteLink = async () => {
slackIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
@@ -1,4 +1,4 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -6,7 +6,7 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
@@ -1,6 +1,6 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -124,24 +124,21 @@ export const ManageIntegration = ({
<div className="col-span-2 hidden text-center sm:block">{t("common.questions")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.surveyName}-${data.channelName}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.channelName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
);
})}
{integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.surveyName}-${data.channelName}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.channelName}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
);
})}
</div>
</div>
)}
@@ -1,3 +1,6 @@
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/actions";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -6,33 +9,33 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSlackChannelsAction } from "../actions";
import { authorize } from "../lib/slack";
import { SlackWrapper } from "./SlackWrapper";
// Mock child components and actions
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/actions", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/slack/actions", () => ({
getSlackChannelsAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal",
"@/app/(app)/environments/[environmentId]/project/integrations/slack/components/AddChannelMappingModal",
() => ({
AddChannelMappingModal: vi.fn(({ open }) => (open ? <div data-testid="add-modal">Add Modal</div> : null)),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration", () => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal, setIsConnected, handleSlackAuthorization }) => (
<div data-testid="manage-integration">
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
<button onClick={() => setIsConnected(false)}>Disconnect</button>
<button onClick={handleSlackAuthorization}>Reconnect</button>
</div>
)),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/project/integrations/slack/components/ManageIntegration",
() => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal, setIsConnected, handleSlackAuthorization }) => (
<div data-testid="manage-integration">
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
<button onClick={() => setIsConnected(false)}>Disconnect</button>
<button onClick={handleSlackAuthorization}>Reconnect</button>
</div>
)),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/lib/slack", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack", () => ({
authorize: vi.fn(),
}));
@@ -1,9 +1,9 @@
"use client";
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/slack/lib/slack";
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack";
import slackLogo from "@/images/slacklogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { useCallback, useEffect, useState } from "react";
@@ -32,7 +32,7 @@ export const SlackWrapper = ({
}: SlackWrapperProps) => {
const [isConnected, setIsConnected] = useState(slackIntegration ? slackIntegration.config?.key : false);
const [slackChannels, setSlackChannels] = useState<TIntegrationItem[]>([]);
const [isModalOpen, setModalOpen] = useState<boolean>(false);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationSlackConfigData & { index: number }) | null
@@ -72,7 +72,7 @@ export const SlackWrapper = ({
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
setOpen={setIsModalOpen}
channels={slackChannels}
slackIntegration={slackIntegration}
selectedIntegration={selectedIntegration}
@@ -80,7 +80,7 @@ export const SlackWrapper = ({
<ManageIntegration
environment={environment}
slackIntegration={slackIntegration}
setOpenAddIntegrationModal={setModalOpen}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
refreshChannels={getSlackChannels}
@@ -1,6 +1,6 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import Page from "@/app/(app)/environments/[environmentId]/integrations/slack/page";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
import Page from "@/app/(app)/environments/[environmentId]/project/integrations/slack/page";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -13,18 +13,21 @@ import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/type
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper", () => ({
SlackWrapper: vi.fn(({ isEnabled, environment, surveys, slackIntegration, webAppUrl, locale }) => (
<div data-testid="slack-wrapper">
Mock SlackWrapper: isEnabled={isEnabled.toString()}, envId={environment.id}, surveys=
{surveys.length}, integrationId={slackIntegration?.id}, webAppUrl={webAppUrl}, locale={locale}
</div>
)),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper",
() => ({
SlackWrapper: vi.fn(({ isEnabled, environment, surveys, slackIntegration, webAppUrl, locale }) => (
<div data-testid="slack-wrapper">
Mock SlackWrapper: isEnabled={isEnabled.toString()}, envId={environment.id}, surveys=
{surveys.length}, integrationId={slackIntegration?.id}, webAppUrl={webAppUrl}, locale={locale}
</div>
)),
})
);
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: true,
@@ -155,7 +158,7 @@ describe("SlackIntegrationPage", () => {
"environments.integrations.slack.slack_integration"
);
expect(screen.getByTestId("go-back-button")).toHaveTextContent(
`Go Back: http://test.formbricks.com/environments/${environmentId}/integrations`
`Go Back: http://test.formbricks.com/environments/${environmentId}/project/integrations`
);
expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument();
@@ -1,5 +1,5 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -32,7 +32,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
<div className="h-[75vh] w-full">
<SlackWrapper
@@ -2,9 +2,16 @@
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { H3, Small } from "@/modules/ui/components/typography";
import { Button } from "@/modules/ui/components/button";
import { H4, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
interface ButtonInfo {
text: string;
onClick: () => void;
variant: "secondary" | "default" | "outline" | "ghost" | "link";
}
export const SettingsCard = ({
title,
description,
@@ -13,6 +20,7 @@ export const SettingsCard = ({
noPadding = false,
beta,
className,
buttonInfo,
}: {
title: string;
description: string;
@@ -21,6 +29,7 @@ export const SettingsCard = ({
noPadding?: boolean;
beta?: boolean;
className?: string;
buttonInfo?: ButtonInfo;
}) => {
const { t } = useTranslate();
return (
@@ -30,19 +39,24 @@ export const SettingsCard = ({
className
)}
id={title}>
<div className="border-b border-slate-200 px-4 pb-4">
<div className="flex">
<H3 className="capitalize">{title}</H3>
<div className="flex justify-between border-b border-slate-200 px-4 pb-4">
<div>
<H4 className="font-medium capitalize tracking-normal">{title}</H4>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (
<Badge size="normal" type="success" text={t("environments.settings.enterprise.coming_soon")} />
)}
</div>
<Small color="muted" margin="headerDescription">
{description}
</Small>
</div>
<Small color="muted" margin="headerDescription">
{description}
</Small>
{buttonInfo && (
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
{buttonInfo?.text}
</Button>
)}
</div>
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
</div>
+1 -8
View File
@@ -10,14 +10,7 @@ export const canUserAccessOrganization = async (userId: string, organizationId:
try {
const userOrganizations = await getOrganizationsByUserId(userId);
const givenOrganizationExists = userOrganizations.filter(
(organization) => organization.id === organizationId
);
if (!givenOrganizationExists) {
return false;
}
return true;
return userOrganizations.some((organization) => organization.id === organizationId);
} catch (error) {
throw error;
}
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "Kontoeinstellungen",
"action": "Aktion",
"actions": "Aktionen",
"actions_description": "Code- und No-Code-Aktionen werden verwendet, um Abfangumfragen innerhalb von Apps und auf Websites auszulösen.",
"active_surveys": "Aktive Umfragen",
"activity": "Aktivität",
"add": "Hinzufügen",
@@ -790,6 +791,8 @@
"receiving_data": "Daten werden empfangen \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Erneut prüfen",
"scroll_to_the_top": "Scroll nach oben!",
"setup_alert_description": "Befolge dieses Schritt-für-Schritt-Tutorial, um deine App oder Website in weniger als 5 Minuten zu verbinden.",
"setup_alert_title": "Wie man verbindet",
"step_1": "Schritt 1: Installiere mit pnpm, npm oder yarn",
"step_2": "Schritt 2: Widget initialisieren",
"step_2_description": "Importiere Formbricks und initialisiere das Widget in deiner Komponente (z.B. App.tsx):",
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "Account settings",
"action": "Action",
"actions": "Actions",
"actions_description": "Code and No-Code Actions are used to trigger intercept surveys within apps & on websites.",
"active_surveys": "Active surveys",
"activity": "Activity",
"add": "Add",
@@ -790,6 +791,8 @@
"receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Re-check",
"scroll_to_the_top": "Scroll to the top!",
"setup_alert_description": "Follow this step-by-step tutorial to connect your app or website in under 5 minutes.",
"setup_alert_title": "How to connect",
"step_1": "Step 1: Install with pnpm, npm or yarn",
"step_2": "Step 2: Initialize widget",
"step_2_description": "Import Formbricks and initialize the widget in your Component (e.g. App.tsx):",
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "Paramètres du compte",
"action": "Action",
"actions": "Actions",
"actions_description": "Les actions avec ou sans code sont utilisées pour déclencher des enquêtes d'interception dans les applications et sur les sites Web.",
"active_surveys": "Sondages actifs",
"activity": "Activité",
"add": "Ajouter",
@@ -790,6 +791,8 @@
"receiving_data": "Réception des données \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Re-vérifier",
"scroll_to_the_top": "Faites défiler vers le haut !",
"setup_alert_description": "Suivez ce tutoriel étape par étape pour connecter votre application ou site web en moins de 5 minutes.",
"setup_alert_title": "Comment connecter",
"step_1": "Étape 1 : Installer avec pnpm, npm ou yarn",
"step_2": "Étape 2 : Initialiser le widget",
"step_2_description": "Importez Formbricks et initialisez le widget dans votre composant (par exemple, App.tsx) :",
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "アカウント設定",
"action": "アクション",
"actions": "アクション",
"actions_description": "コードとノーコードアクションは、アプリ内やウェブサイト上で調査を発動するために使用されます。",
"active_surveys": "アクティブなフォーム",
"activity": "アクティビティ",
"add": "追加",
@@ -790,6 +791,8 @@
"receiving_data": "データ受信中 \uD83D\uDC83\uD83D\uDD7A",
"recheck": "再チェック",
"scroll_to_the_top": "ページ上部に戻る",
"setup_alert_description": "5 分以内でアプリまたはウェブサイト を 接続する手順をステップバイステップ の チュートリアルに従ってください。",
"setup_alert_title": "接続方法",
"step_1": "ステップ1: pnpm / npm / yarnでインストール",
"step_2": "ステップ2: ウィジェットの初期化",
"step_2_description": "Formbricksをインポートし、コンポーネント(例: App.tsx)でウィジェットを初期化します。",
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "Configurações da conta",
"action": "Ação",
"actions": "Ações",
"actions_description": "Ações de Código e Sem Código são usadas para acionar interceptar pesquisas dentro de apps & em sites.",
"active_surveys": "Pesquisas ativas",
"activity": "Atividade",
"add": "Adicionar",
@@ -790,6 +791,8 @@
"receiving_data": "Recebendo dados \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Verificar novamente",
"scroll_to_the_top": "Rola pra cima!",
"setup_alert_description": "Siga este tutorial passo a passo para conectar seu app ou site em menos de 5 minutos.",
"setup_alert_title": "Como conectar",
"step_1": "Passo 1: Instale com pnpm, npm ou yarn",
"step_2": "Passo 2: Iniciar widget",
"step_2_description": "Importe o Formbricks e inicialize o widget no seu Componente (por exemplo, App.tsx):",
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "Configurações da conta",
"action": "Ação",
"actions": "Ações",
"actions_description": "Ações com Código e Sem Código são usadas para acionar inquéritos de intercepção dentro de apps e em websites.",
"active_surveys": "Inquéritos ativos",
"activity": "Atividade",
"add": "Adicionar",
@@ -790,6 +791,8 @@
"receiving_data": "A receber dados \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Verificar novamente",
"scroll_to_the_top": "Rolar para o topo!",
"setup_alert_description": "Siga este tutorial passo-a-passo para ligar a sua aplicação ou website em menos de 5 minutos",
"setup_alert_title": "Como conectar",
"step_1": "Passo 1: Instalar com pnpm, npm ou yarn",
"step_2": "Passo 2: Inicializar widget",
"step_2_description": "Importar Formbricks e inicializar o widget no seu Componente (por exemplo, App.tsx):",
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "Setări cont",
"action": "Acțiune",
"actions": "Acțiuni",
"actions_description": "Acțiunile Cod și No-Code sunt utilizate pentru a declanșa chestionare de interceptare în aplicații și pe site-uri web.",
"active_surveys": "Sondaje active",
"activity": "Activitate",
"add": "Adaugă",
@@ -790,6 +791,8 @@
"receiving_data": "Recepționare date \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Re-verifică",
"scroll_to_the_top": "Derulați în partea de sus!",
"setup_alert_description": "Urmează acest tutorial pas cu pas pentru a-ți conecta aplicația sau site-ul în mai puțin de 5 minute.",
"setup_alert_title": "Cum să conectezi",
"step_1": "Pasul 1: Instalează cu pnpm, npm sau yarn",
"step_2": "Pasul 2: Inițializează widget-ul",
"step_2_description": "Importați Formbricks și inițializați widgetul în componenta dumneavoastră (de exemplu, App.tsx):",
+3
View File
@@ -118,6 +118,7 @@
"account_settings": "帳戶設定",
"action": "操作",
"actions": "操作",
"actions_description": "代碼 和 無代碼 動作 用於 觸發 截取 調查 於 應用程式 和 網站上 。",
"active_surveys": "啟用中的問卷",
"activity": "活動",
"add": "新增",
@@ -790,6 +791,8 @@
"receiving_data": "正在接收資料 \uD83D\uDC83\uD83D\uDD7A",
"recheck": "重新檢查",
"scroll_to_the_top": "捲動至頂端!",
"setup_alert_description": "遵循 此 分步 教程 ,在 5 分鐘 內 將您的應用程式 或 網站 連線 。",
"setup_alert_title": "如何 連線",
"step_1": "步驟 1:使用 pnpm、npm 或 yarn 安裝",
"step_2": "步驟 2:初始化小工具",
"step_2_description": "匯入 Formbricks 並在您的元件中初始化小工具(例如,App.tsx):",
@@ -7,51 +7,49 @@ import { UsersIcon } from "lucide-react";
const Loading = async () => {
const t = await getTranslate();
return (
<>
<PageContentWrapper>
<PageHeader pageTitle="Contacts">
<ContactsSecondaryNavigation activeId="segments" loading />
</PageHeader>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
</div>
<div className="w-full">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<UsersIcon className="h-5 w-5 flex-shrink-0 animate-pulse text-slate-500" />
<div className="flex flex-col">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="mt-1 text-xs text-slate-900">
<div className="h-2 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
<PageContentWrapper>
<PageHeader pageTitle="Contacts">
<ContactsSecondaryNavigation activeId="segments" loading />
</PageHeader>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
</div>
<div className="w-full">
{[...Array(3)].map((_, index) => (
<div
key={`${index.toString()}-segment-loading`}
className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<UsersIcon className="h-5 w-5 flex-shrink-0 animate-pulse text-slate-500" />
<div className="flex flex-col">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="mt-1 text-xs text-slate-900">
<div className="h-2 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
</div>
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="whitespace-wrap col-span-1 my-auto text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="col-span-1 my-auto whitespace-normal text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
))}
</div>
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="whitespace-wrap col-span-1 my-auto text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="col-span-1 my-auto whitespace-normal text-center text-sm text-slate-500">
<div className="m-4 h-4 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
))}
</div>
</PageContentWrapper>
</>
</div>
</PageContentWrapper>
);
};
@@ -31,7 +31,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
<TableBody className="[&_tr:last-child]:border-b">
{teams.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="text-center">
<TableCell colSpan={4} className="text-center">
{t("environments.project.teams.no_teams_found")}
</TableCell>
</TableRow>
@@ -51,7 +51,6 @@ export const CreateOrganizationModal = ({ open, setOpen }: CreateOrganizationMod
} else {
const errorMessage = getFormattedErrorMessage(createOrganizationResponse);
toast.error(errorMessage);
console.error(errorMessage);
}
setLoading(false);
@@ -136,8 +136,8 @@ const getLatestStableFbRelease = async (): Promise<string | null> => {
}
return null;
} catch (err) {
return null;
} catch (error) {
throw new Error("Failed to get latest stable Formbricks release", { cause: error });
}
};
@@ -46,9 +46,28 @@ vi.mock("@/modules/projects/settings/(setup)/components/setup-instructions", ()
</div>
),
}));
vi.mock("@/modules/projects/settings/(setup)/components/environment-id-field", () => ({
EnvironmentIdField: ({ environmentId }: any) => (
<div data-testid="environment-id-field">{environmentId}</div>
vi.mock("../components/action-settings-card", () => ({
ActionSettingsCard: () => <div data-testid="action-settings-card">action-settings-card</div>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: any) => <div data-testid="id-badge">{id}</div>,
}));
vi.mock("next/link", () => ({
__esModule: true,
default: ({ children, href, target }: any) => (
<a href={href} target={target} data-testid="link">
{children}
</a>
),
}));
@@ -57,15 +76,56 @@ vi.mock("@/tolgee/server", () => ({
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(async (environmentId: string) => ({ environment: { id: environmentId } })),
getEnvironmentAuth: vi.fn(async (environmentId: string) => ({
environment: { id: environmentId, projectId: "project-123" },
isReadOnly: false,
})),
}));
let mockWebappUrl = "https://example.com";
vi.mock("@/lib/environment/service", () => ({
getEnvironments: vi.fn(async (projectId: string) => [
{ id: "env-123", projectId },
{ id: "env-456", projectId },
]),
}));
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(async () => []),
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(() => "https://example.com"),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(async () => "en"),
}));
vi.mock("@/lib/constants", () => ({
get WEBAPP_URL() {
return mockWebappUrl;
},
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SENTRY_RELEASE: "mock-sentry-release",
SENTRY_ENVIRONMENT: "mock-sentry-environment",
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/env", () => ({
@@ -87,16 +147,18 @@ describe("AppConnectionPage", () => {
expect(await findByTestId("page-header")).toHaveTextContent("common.project_configuration");
expect(await findByTestId("project-config-navigation")).toHaveTextContent("env-123 app-connection");
expect(await findByTestId("environment-notice")).toHaveTextContent("env-123 /project/app-connection");
// Check that ActionSettingsCard is rendered
expect(await findByTestId("action-settings-card")).toBeInTheDocument();
expect(await findByTestId("action-settings-card")).toHaveTextContent("action-settings-card");
const cards = await findAllByTestId("settings-card");
expect(cards.length).toBe(3);
expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection");
expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection_description");
expect(cards[0]).toHaveTextContent("env-123"); // WidgetStatusIndicator
expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup");
expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup_description");
expect(cards[1]).toHaveTextContent("env-123"); // SetupInstructions
expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id");
expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id_description");
expect(cards[2]).toHaveTextContent("env-123"); // EnvironmentIdField
expect(cards.length).toBe(2);
expect(cards[0]).toHaveTextContent("environments.project.app-connection.environment_id");
expect(cards[0]).toHaveTextContent("environments.project.app-connection.environment_id_description");
expect(cards[0]).toHaveTextContent("env-123"); // IdBadge
expect(cards[1]).toHaveTextContent("environments.project.app-connection.app_connection");
expect(cards[1]).toHaveTextContent("environments.project.app-connection.app_connection_description");
expect(cards[1]).toHaveTextContent("env-123"); // WidgetStatusIndicator
});
});
@@ -1,56 +1,90 @@
"use server";
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import Link from "next/link";
import { ActionSettingsCard } from "../components/action-settings-card";
export const AppConnectionPage = async (props) => {
const params = await props.params;
export const AppConnectionPage = async ({ params }: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const { environmentId } = await params;
const { environment } = await getEnvironmentAuth(params.environmentId);
const { environment, isReadOnly } = await getEnvironmentAuth(environmentId);
const [environments, actionClasses] = await Promise.all([
getEnvironments(environment.projectId),
getActionClasses(environmentId),
]);
const otherEnvironment = environments.filter((env) => env.id !== environmentId)[0];
const [otherEnvActionClasses, locale] = await Promise.all([
otherEnvironment ? getActionClasses(otherEnvironment.id) : Promise.resolve([]),
findMatchingLocale(),
]);
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="app-connection" />
<ProjectConfigNavigation environmentId={environmentId} activeId="app-connection" />
</PageHeader>
<div className="space-y-4">
<EnvironmentNotice environmentId={params.environmentId} subPageUrl="/project/app-connection" />
<EnvironmentNotice environmentId={environmentId} subPageUrl="/project/app-connection" />
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<IdBadge id={environmentId} />
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.app_connection")}
description={t("environments.project.app-connection.app_connection_description")}>
{environment && (
<div className="space-y-4">
<WidgetStatusIndicator environment={environment} />
<Alert variant="info">
<AlertTitle>{t("environments.project.app-connection.cache_update_delay_title")}</AlertTitle>
<AlertDescription>
{t("environments.project.app-connection.cache_update_delay_description")}
</AlertDescription>
</Alert>
{!environment.appSetupCompleted ? (
<Alert variant="outbound">
<AlertTitle>{t("environments.project.app-connection.setup_alert_title")}</AlertTitle>
<AlertDescription>
{t("environments.project.app-connection.setup_alert_description")}
</AlertDescription>
<AlertButton asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
) : (
<Alert variant="warning">
<AlertTitle>{t("environments.project.app-connection.cache_update_delay_title")}</AlertTitle>
<AlertDescription>
{t("environments.project.app-connection.cache_update_delay_description")}
</AlertDescription>
</Alert>
)}
</div>
)}
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.how_to_setup")}
description={t("environments.project.app-connection.how_to_setup_description")}
noPadding>
<SetupInstructions environmentId={params.environmentId} publicDomain={publicDomain} />
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<IdBadge id={params.environmentId} />
</SettingsCard>
<ActionSettingsCard
environment={environment}
otherEnvironment={otherEnvironment}
otherEnvActionClasses={otherEnvActionClasses}
environmentId={environmentId}
actionClasses={actionClasses}
isReadOnly={isReadOnly}
locale={locale}
/>
</div>
</PageContentWrapper>
);
@@ -0,0 +1,6 @@
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
export const ACTION_TYPE_ICON_LOOKUP = {
code: <Code2Icon className="h-4 w-4" data-testid="code-icon" />,
noCode: <MousePointerClickIcon className="h-4 w-4" data-testid="nocode-icon" />,
};
@@ -1,3 +1,4 @@
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -5,18 +6,43 @@ import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "../actions";
import { ActionActivityTab } from "./ActionActivityTab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
vi.mock("@/modules/projects/settings/(setup)/app-connection/utils", () => ({
ACTION_TYPE_ICON_LOOKUP: {
noCode: <div>NoCodeIcon</div>,
automatic: <div>AutomaticIcon</div>,
code: <div>CodeIcon</div>,
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SENTRY_RELEASE: "mock-sentry-release",
SENTRY_ENVIRONMENT: "mock-sentry-environment",
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/time", () => ({
convertDateTimeStringShort: (dateString: string) => `formatted-${dateString}`,
}));
@@ -53,10 +79,14 @@ vi.mock("@/modules/ui/components/loading-spinner", () => ({
LoadingSpinner: () => <div>LoadingSpinner</div>,
}));
vi.mock("../actions", () => ({
vi.mock("@/modules/projects/settings/(setup)/app-connection/actions", () => ({
getActiveInactiveSurveysAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
const mockActionClass = {
id: "action1",
createdAt: new Date("2023-01-01T10:00:00Z"),
@@ -1,9 +1,10 @@
"use client";
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ACTION_TYPE_ICON_LOOKUP } from "@/modules/projects/settings/(setup)/app-connection/utils";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
@@ -14,7 +15,6 @@ import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "../actions";
interface ActivityTabProps {
actionClass: TActionClass;

Some files were not shown because too many files have changed in this diff Show More