Compare commits

..

9 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
97be740272 Initial plan 2025-10-30 05:46:19 +00:00
Dhruwang
42f9c81421 fix: survey ui loading issue 2025-10-30 11:09:27 +05:30
Anshuman Pandey
26292ecf39 fix: welcome card headline in survey title (#6749) 2025-10-29 07:57:27 +00:00
Johannes
056e572a31 fix: move Follow ups to Enterprise plan (#6734)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-28 09:04:22 +00:00
Johannes
d7bbd219a3 refactor: simplify Stripe integration and rename enterprise to custom (#6720) 2025-10-28 07:45:59 +00:00
Hemachandar
fe5ff9a71c feat: Show SingleUse ID data in survey responses table (#6742)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-28 08:38:44 +01:00
Johannes
4e3438683e chore: Response page data handling optimization + UI tweaks (#6716)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-28 06:56:06 +00:00
Matti Nannt
f587446079 feat: Optimize layout data fetching and reduce database queries by 50% (#6729)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-28 06:55:44 +00:00
Dhruwang Jariwala
7a3d05eb9a fix: prevent browser confirmation dialog after successful survey save (#6744) 2025-10-28 06:03:43 +00:00
66 changed files with 2171 additions and 1255 deletions

View File

@@ -4,7 +4,6 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -24,8 +23,6 @@ const Page = async (props) => {
const user = await getUser(session.user.id);
if (!user) return notFound();
const organizations = await getOrganizationsByUserId(session.user.id);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
@@ -37,11 +34,10 @@ const Page = async (props) => {
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
currentOrganizationId={organization.id}
organizations={organizations}
projects={[]}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
@@ -16,6 +17,8 @@ import {
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
const ZCreateProjectAction = z.object({
organizationId: ZId,
@@ -84,3 +87,59 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
}
)
);
const ZGetOrganizationsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
});
/**
* Fetches organizations list for switcher dropdown.
* Called on-demand when user opens the organization switcher.
*/
export const getOrganizationsForSwitcherAction = authenticatedActionClient
.schema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member", "billing"],
},
],
});
return await getOrganizationsByUserId(ctx.user.id);
});
const ZGetProjectsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
});
/**
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the project switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.schema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member", "billing"],
},
],
});
// Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new Error("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
});

View File

@@ -1,104 +1,49 @@
import type { Session } from "next-auth";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
interface EnvironmentLayoutProps {
environmentId: string;
session: Session;
layoutData: TEnvironmentLayoutData;
children?: React.ReactNode;
}
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate();
const [user, environment, organizations, organization] = await Promise.all([
getUser(session.user.id),
getEnvironment(environmentId),
getOrganizationsByUserId(session.user.id),
getOrganizationByEnvironmentId(environmentId),
]);
if (!user) {
throw new Error(t("common.user_not_found"));
}
// Destructure all data from props (NO database queries)
const {
user,
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
} = layoutData;
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
// Calculate derived values (no queries)
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
if (!currentUserMembership) {
throw new Error(t("common.membership_not_found"));
}
const membershipRole = currentUserMembership?.role;
const [projects, environments, isAccessControlAllowed] = await Promise.all([
getProjectsByUserId(user.id, currentUserMembership),
getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
throw new Error(t("environments.projects_environments_organizations_not_found"));
}
const { isMember } = getAccessFlags(membershipRole);
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
const { features, lastChecked, isPendingDowngrade, active } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.project_permission_not_found"));
}
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
let peopleCount = 0;
let responseCount = 0;
if (IS_FORMBRICKS_CLOUD) {
[peopleCount, responseCount] = await Promise.all([
getMonthlyActiveOrganizationPeopleCount(organization.id),
getMonthlyOrganizationResponseCount(organization.id),
]);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
// Find the current project from the projects array
const project = projects.find((p) => p.id === environment.projectId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const { isManager, isOwner } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
{IS_FORMBRICKS_CLOUD && (
@@ -122,26 +67,24 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
<MainNavigation
environment={environment}
organization={organization}
projects={projects}
user={user}
project={{ id: project.id, name: project.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole}
membershipRole={membership.role}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
organizations={organizations}
currentProjectId={project.id}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}
isAccessControlAllowed={isAccessControlAllowed}
membershipRole={membershipRole}
membershipRole={membership.role}
/>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>

View File

@@ -42,7 +42,7 @@ interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
projects: { id: string; name: string }[];
project: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
@@ -52,7 +52,7 @@ export const MainNavigation = ({
environment,
organization,
user,
projects,
project,
membershipRole,
isFormbricksCloud,
isDevelopment,
@@ -65,7 +65,6 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;

View File

@@ -9,9 +9,7 @@ import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
organizations: { id: string; name: string }[];
currentProjectId: string;
projects: { id: string; name: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
@@ -24,9 +22,7 @@ interface TopControlBarProps {
export const TopControlBar = ({
environments,
currentOrganizationId,
organizations,
currentProjectId,
projects,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
@@ -46,9 +42,7 @@ export const TopControlBar = ({
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
organizations={organizations}
currentProjectId={currentProjectId}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}

View File

@@ -10,9 +10,11 @@ import {
SettingsIcon,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
@@ -23,10 +25,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps {
currentOrganizationId: string;
organizations: { id: string; name: string }[];
currentOrganizationName?: string; // Optional: pass directly if context not available
isMultiOrgEnabled: boolean;
currentEnvironmentId?: string;
isFormbricksCloud: boolean;
@@ -47,7 +50,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
export const OrganizationBreadcrumb = ({
currentOrganizationId,
organizations,
currentOrganizationName,
isMultiOrgEnabled,
currentEnvironmentId,
isFormbricksCloud,
@@ -60,7 +63,45 @@ export const OrganizationBreadcrumb = ({
const pathname = usePathname();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
// Get current organization name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { organization: currentOrganization } = useOrganization();
const organizationName = currentOrganization?.name || currentOrganizationName || "";
// Lazy-load organizations when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
setIsLoadingOrganizations(true);
setLoadError(null); // Clear any previous errors
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load organizations");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
}
setIsLoadingOrganizations(false);
});
}
}, [
isOrganizationDropdownOpen,
currentOrganizationId,
organizations.length,
isLoadingOrganizations,
loadError,
t,
]);
if (!currentOrganization) {
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
@@ -126,7 +167,7 @@ export const OrganizationBreadcrumb = ({
asChild>
<div className="flex items-center gap-1">
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{currentOrganization.name}</span>
<span>{organizationName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -142,30 +183,52 @@ export const OrganizationBreadcrumb = ({
<BuildingIcon className="mr-2 inline h-4 w-4" />
{t("common.choose_organization")}
</div>
<DropdownMenuGroup>
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === currentOrganization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="cursor-pointer">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
</DropdownMenuCheckboxItem>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setOrganizations([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingOrganizations && !loadError && (
<>
<DropdownMenuGroup>
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === currentOrganizationId}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="cursor-pointer">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
</DropdownMenuCheckboxItem>
)}
</>
)}
</>
)}
{currentEnvironmentId && (
<div>
<DropdownMenuSeparator />
{showOrganizationDropdown && <DropdownMenuSeparator />}
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" />
{t("common.organization_settings")}

View File

@@ -1,6 +1,5 @@
"use client";
import { useMemo } from "react";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
@@ -8,9 +7,9 @@ import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
organizations: { id: string; name: string }[];
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
projects: { id: string; name: string }[];
currentProjectName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
@@ -18,15 +17,15 @@ interface ProjectAndOrgSwitchProps {
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isAccessControlAllowed: boolean;
isMember: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
organizations,
currentOrganizationName,
currentProjectId,
projects,
currentProjectName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
@@ -37,11 +36,6 @@ export const ProjectAndOrgSwitch = ({
isAccessControlAllowed,
isMember,
}: ProjectAndOrgSwitchProps) => {
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
const sortedOrganizations = useMemo(
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
[organizations]
);
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -50,9 +44,9 @@ export const ProjectAndOrgSwitch = ({
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
organizations={sortedOrganizations}
isMultiOrgEnabled={isMultiOrgEnabled}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
@@ -60,9 +54,9 @@ export const ProjectAndOrgSwitch = ({
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
projects={sortedProjects}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}

View File

@@ -3,9 +3,11 @@
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
@@ -18,10 +20,11 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
projects: { id: string; name: string }[];
currentProjectName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
@@ -44,7 +47,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
export const ProjectBreadcrumb = ({
currentProjectId,
projects,
currentProjectName,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
@@ -59,9 +62,41 @@ export const ProjectBreadcrumb = ({
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
// Lazy-load projects when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_projects"));
}
setIsLoadingProjects(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
const projectSettings = [
{
id: "general",
@@ -100,8 +135,6 @@ export const ProjectBreadcrumb = ({
},
];
const currentProject = projects.find((project) => project.id === currentProjectId);
if (!currentProject) {
const errorMessage = `Project not found for project id: ${currentProjectId}`;
logger.error(errorMessage);
@@ -166,7 +199,7 @@ export const ProjectBreadcrumb = ({
asChild>
<div className="flex items-center gap-1">
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{currentProject.name}</span>
<span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isProjectDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -181,26 +214,48 @@ export const ProjectBreadcrumb = ({
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_project")}
</div>
<DropdownMenuGroup>
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProject.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_project")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup>
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_project")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -2,11 +2,13 @@
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
organizationId: string;
}
@@ -20,25 +22,44 @@ export const useEnvironment = () => {
return context;
};
export const useProject = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
}
return { project: context.project };
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organization,
organizationId: project.organizationId,
}),
[environment, project]
[environment, project, organization]
);
return (

View File

@@ -1,10 +1,9 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -15,46 +14,27 @@ const EnvLayout = async (props: {
const params = await props.params;
const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
// Check session first (required for userId)
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect(`/auth/login`);
}
if (!user) {
throw new Error(t("common.user_not_found"));
}
const [project, environment] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
]);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) {
throw new Error(t("common.membership_not_found"));
}
// Single consolidated data fetch (replaces ~12 individual fetches)
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper environment={environment} project={project}>
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</EnvironmentIdBaseLayout>
);

View File

@@ -1,6 +1,6 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -8,7 +8,14 @@ import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface ResponseCardModalProps {
responses: TResponse[];
@@ -42,25 +49,37 @@ export const ResponseCardModal = ({
locale,
}: ResponseCardModalProps) => {
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
const idToIndexMap = useMemo(() => {
const map = new Map<string, number>();
for (let i = 0; i < responses.length; i++) {
map.set(responses[i].id, i);
}
return map;
}, [responses]);
useEffect(() => {
if (selectedResponseId) {
setOpen(true);
const index = responses.findIndex((response) => response.id === selectedResponseId);
const index = idToIndexMap.get(selectedResponseId) ?? -1;
setCurrentIndex(index);
setIsNavigating(false);
} else {
setOpen(false);
}
}, [selectedResponseId, responses, setOpen]);
}, [selectedResponseId, idToIndexMap, setOpen]);
const handleNext = () => {
if (currentIndex !== null && currentIndex < responses.length - 1) {
setIsNavigating(true);
setSelectedResponseId(responses[currentIndex + 1].id);
}
};
const handleBack = () => {
if (currentIndex !== null && currentIndex > 0) {
setIsNavigating(true);
setSelectedResponseId(responses[currentIndex - 1].id);
}
};
@@ -72,8 +91,8 @@ export const ResponseCardModal = ({
}
};
// If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null;
// If no response is selected or currentIndex is null or invalid, do not render the modal
if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
@@ -81,6 +100,11 @@ export const ResponseCardModal = ({
<VisuallyHidden asChild>
<DialogTitle>Survey Response Details</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>
Response {currentIndex + 1} of {responses.length}
</DialogDescription>
</VisuallyHidden>
<DialogBody>
<SingleResponseCard
survey={survey}
@@ -96,12 +120,16 @@ export const ResponseCardModal = ({
/>
</DialogBody>
<DialogFooter>
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<Button
onClick={handleBack}
disabled={currentIndex === 0 || isNavigating}
variant="outline"
size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
disabled={currentIndex === responses.length - 1 || isNavigating}
variant="outline"
size="icon">
<ChevronRight />

View File

@@ -28,60 +28,63 @@ interface ResponseDataViewProps {
quotas: TSurveyQuota[];
}
// Helper function to format array values to record with specified keys
const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]): Record<string, string> => {
if (!Array.isArray(responseValue)) return {};
const result: Record<string, string> = {};
for (let index = 0; index < responseValue.length; index++) {
const curr = responseValue[index];
result[keys[index]] = curr || "";
}
return result;
};
// Export for testing
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
return Array.isArray(responseValue)
? responseValue.reduce((acc, curr, index) => {
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
return acc;
}, {})
: {};
return formatArrayToRecord(responseValue, addressKeys);
};
// Export for testing
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
return Array.isArray(responseValue)
? responseValue.reduce((acc, curr, index) => {
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
return acc;
}, {})
: {};
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
return formatArrayToRecord(responseValue, contactInfoKeys);
};
// Export for testing
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
let responseData: Record<string, any> = {};
const responseData: Record<string, any> = {};
survey.questions.forEach((question) => {
for (const question of survey.questions) {
const responseValue = response.data[question.id];
switch (question.type) {
case "matrix":
if (typeof responseValue === "object") {
responseData = { ...responseData, ...responseValue };
Object.assign(responseData, responseValue);
}
break;
case "address":
responseData = { ...responseData, ...formatAddressData(responseValue) };
Object.assign(responseData, formatAddressData(responseValue));
break;
case "contactInfo":
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
Object.assign(responseData, formatContactInfoData(responseValue));
break;
default:
responseData[question.id] = responseValue;
}
});
}
survey.hiddenFields.fieldIds?.forEach((fieldId) => {
responseData[fieldId] = response.data[fieldId];
});
if (survey.hiddenFields.fieldIds) {
for (const fieldId of survey.hiddenFields.fieldIds) {
responseData[fieldId] = response.data[fieldId];
}
}
return responseData;
};
// Export for testing
export const mapResponsesToTableData = (
const mapResponsesToTableData = (
responses: TResponseWithQuotas[],
survey: TSurvey,
t: TFunction
@@ -127,6 +130,10 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
quotas,
}) => {
const { t } = useTranslation();
const [selectedResponseId, setSelectedResponseId] = React.useState<string | null>(null);
const setSelectedResponseIdTransition = React.useCallback((id: string | null) => {
React.startTransition(() => setSelectedResponseId(id));
}, []);
const data = mapResponsesToTableData(responses, survey, t);
return (
@@ -147,6 +154,8 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
locale={locale}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
selectedResponseId={selectedResponseId}
setSelectedResponseId={setSelectedResponseIdTransition}
/>
</div>
);

View File

@@ -122,12 +122,11 @@ export const ResponsePage = ({
useEffect(() => {
setPage(1);
setHasMore(true);
setResponses([]);
}, [filters]);
return (
<>
<div className="flex gap-1.5">
<div className="flex h-9 gap-1.5">
<CustomFilter survey={surveyMemoized} />
</div>
<ResponseDataView

View File

@@ -39,6 +39,12 @@ import {
import { Skeleton } from "@/modules/ui/components/skeleton";
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
const SkeletonCell = () => (
<Skeleton className="w-full">
<div className="h-6"></div>
</Skeleton>
);
interface ResponseTableProps {
data: TResponseTableData[];
survey: TSurvey;
@@ -55,6 +61,8 @@ interface ResponseTableProps {
locale: TUserLocale;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
selectedResponseId: string | null;
setSelectedResponseId: (id: string | null) => void;
}
export const ResponseTable = ({
@@ -73,12 +81,13 @@ export const ResponseTable = ({
locale,
isQuotasAllowed,
quotas,
selectedResponseId,
setSelectedResponseId,
}: ResponseTableProps) => {
const { t } = useTranslation();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>([]);
@@ -86,7 +95,10 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
);
// Save settings to localStorage when they change
useEffect(() => {
@@ -110,7 +122,13 @@ export const ResponseTable = ({
// Memoize table data and columns
const tableData: TResponseTableData[] = useMemo(
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
() =>
isFetchingFirstPage
? Array.from(
{ length: 10 },
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
)
: data,
[data, isFetchingFirstPage]
);
@@ -119,11 +137,7 @@ export const ResponseTable = ({
isFetchingFirstPage
? columns.map((column) => ({
...column,
cell: () => (
<Skeleton className="w-full">
<div className="h-6"></div>
</Skeleton>
),
cell: SkeletonCell,
}))
: columns,
[columns, isFetchingFirstPage]
@@ -247,8 +261,8 @@ export const ResponseTable = ({
</TableRow>
))}
</TableHeader>
<TableBody ref={parent}>
{/* disable auto animation if there are more than 200 responses for performance optimizations */}
<TableBody ref={responses && responses.length > 200 ? undefined : parent}>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
@@ -261,7 +275,6 @@ export const ResponseTable = ({
row={row}
isExpanded={isExpanded ?? false}
setSelectedResponseId={setSelectedResponseId}
responses={responses}
/>
))}
</TableRow>

View File

@@ -1,6 +1,7 @@
import { Cell, Row, flexRender } from "@tanstack/react-table";
import { Maximize2Icon } from "lucide-react";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import React from "react";
import { TResponseTableData } from "@formbricks/types/responses";
import { cn } from "@/lib/cn";
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
import { TableCell } from "@/modules/ui/components/table";
@@ -10,21 +11,18 @@ interface ResponseTableCellProps {
row: Row<TResponseTableData>;
isExpanded: boolean;
setSelectedResponseId: (responseId: string | null) => void;
responses: TResponse[] | null;
}
export const ResponseTableCell = ({
const ResponseTableCellComponent = ({
cell,
row,
isExpanded,
setSelectedResponseId,
responses,
}: ResponseTableCellProps) => {
// Function to handle cell click
const handleCellClick = () => {
if (cell.column.id !== "select") {
const response = responses?.find((response) => response.id === row.id);
if (response) setSelectedResponseId(response.id);
setSelectedResponseId(row.id);
}
};
@@ -66,3 +64,5 @@ export const ResponseTableCell = ({
</TableCell>
);
};
export const ResponseTableCell = React.memo(ResponseTableCellComponent);

View File

@@ -314,7 +314,7 @@ export const generateResponseTableColumns = (
const singleUseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "singleUseId",
header: () => t("environments.surveys.responses.single_use_id"),
header: () => <div className="gap-x-1.5">{t("environments.surveys.responses.single_use_id")}</div>,
cell: ({ row }) => {
return <p className="truncate text-slate-900">{row.original.singleUseId}</p>;
},

View File

@@ -86,7 +86,7 @@ export const MultipleChoiceSummary = ({
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => {
{results.map((result) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
@@ -107,7 +107,7 @@ export const MultipleChoiceSummary = ({
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
{result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>

View File

@@ -17,7 +17,7 @@ import {
subYears,
} from "date-fns";
import { TFunction } from "i18next";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -37,8 +37,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils";
import { ResponseFilter } from "./ResponseFilter";
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter";
enum DateSelected {
FROM = "common.from",
@@ -137,6 +136,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -270,201 +270,179 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
useClickOutside(datePickerRef, () => handleDatePickerClose());
return (
<>
<div className="relative flex justify-between">
<div className="flex justify-stretch gap-x-1.5">
<ResponseFilter survey={survey} />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsFilterDropDownOpen(value);
}}>
<DropdownMenuTrigger>
<div className="flex min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span className="text-sm text-slate-700">
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: filterRange}
</span>
{isFilterDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
setDateRange({
from: startOfMonth(subMonths(new Date(), 1)),
to: endOfMonth(subMonths(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
setDateRange({
from: startOfQuarter(subQuarters(new Date(), 1)),
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
setDateRange({
from: startOfMonth(subMonths(new Date(), 6)),
to: endOfMonth(getTodayDate()),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
setDateRange({
from: startOfYear(subYears(new Date(), 1)),
to: endOfYear(subYears(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDatePickerOpen(true);
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">
{getFilterDropDownLabels(t).CUSTOM_RANGE}
</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger
asChild
className={cn(
"focus:bg-muted cursor-pointer outline-none",
isDownloading && "cursor-not-allowed opacity-50"
)}
disabled={isDownloading}
data-testid="fb__custom-filter-download-responses-button">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
{isDownloading ? (
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
) : (
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
)}
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<div className="relative flex justify-between">
<div className="flex justify-stretch gap-x-1.5">
<ResponseFilter survey={survey} />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsFilterDropDownOpen(value);
}}>
<DropdownMenuTrigger asChild>
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: filterRange}
</PopoverTriggerButton>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
setDateRange({
from: startOfMonth(subMonths(new Date(), 1)),
to: endOfMonth(subMonths(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
setDateRange({
from: startOfQuarter(subQuarters(new Date(), 1)),
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
setDateRange({
from: startOfMonth(subMonths(new Date(), 6)),
to: endOfMonth(getTodayDate()),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
setDateRange({
from: startOfYear(subYears(new Date(), 1)),
to: endOfYear(subYears(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDatePickerOpen(true);
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
setIsDownloadDropDownOpen(value);
}}>
<DropdownMenuTrigger asChild>
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
<span className="flex items-center gap-2">
{t("common.download")}
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
</span>
</PopoverTriggerButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
<Calendar
autoFocus
mode="range"
defaultMonth={dateRange?.from}
selected={hoveredRange ? hoveredRange : dateRange}
numberOfMonths={2}
onDayClick={(date) => handleDateChange(date)}
onDayMouseEnter={handleDateHoveredChange}
onDayMouseLeave={() => setHoveredRange(null)}
classNames={{
day_today: "hover:bg-slate-200 bg-white",
}}
/>
</div>
)}
<DropdownMenuContent align="start">
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
<Calendar
autoFocus
mode="range"
defaultMonth={dateRange?.from}
selected={hoveredRange || dateRange}
numberOfMonths={2}
onDayClick={(date) => handleDateChange(date)}
onDayMouseEnter={handleDateHoveredChange}
onDayMouseLeave={() => setHoveredRange(null)}
classNames={{
day_today: "hover:bg-slate-200 bg-white",
}}
/>
</div>
)}
</div>
);
};

View File

@@ -2,16 +2,18 @@
import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import * as React from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
@@ -48,117 +50,160 @@ export const QuestionFilterComboBox = ({
disabled = false,
fieldId,
}: QuestionFilterComboBoxProps) => {
const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
const commandRef = React.useRef(null);
const [searchQuery, setSearchQuery] = React.useState<string>("");
const defaultLanguageCode = "default";
useClickOutside(commandRef, () => setOpen(false));
const [open, setOpen] = useState(false);
const commandRef = useRef(null);
const [searchQuery, setSearchQuery] = useState("");
const { t } = useTranslation();
// multiple when question type is multi selection
const isMultiple =
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
type === TSurveyQuestionTypeEnum.PictureSelection ||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
// when question type is multi selection so we remove the option from the options which has been already selected
const options = isMultiple
? filterComboBoxOptions?.filter(
(o) =>
!filterComboBoxValue?.includes(
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
)
)
: filterComboBoxOptions;
useClickOutside(commandRef, () => setOpen(false));
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
const defaultLanguageCode = "default";
// Check if multiple selection is allowed
const isMultiple = useMemo(
() =>
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
type === TSurveyQuestionTypeEnum.PictureSelection ||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either"),
[type, filterValue]
);
// Filter out already selected options for multi-select
const options = useMemo(() => {
if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return !filterComboBoxValue?.includes(optionValue);
});
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
// Disable combo box for NPS/Rating when Submitted/Skipped
const isDisabledComboBox =
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
// Check if this is a URL field with string comparison operations that require text input
// Check if this is a text input field (URL meta field)
const isTextInputField = type === OptionsType.META && fieldId === "url";
const filteredOptions = options?.filter((o) =>
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
.toLowerCase()
.includes(searchQuery.toLowerCase())
// Filter options based on search query
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery, defaultLanguageCode]
);
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
<p className="text-slate-600">{filterComboBoxValue}</p>
) : (
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
{typeof filterComboBoxValue !== "string" &&
filterComboBoxValue?.map((o, index) => (
<button
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
))}
</div>
const handleCommandItemSelect = (o: string) => {
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
onChangeFilterComboBoxValue(newValue);
return;
}
onChangeFilterComboBoxValue(value);
setOpen(false);
};
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
const handleOpenDropdown = () => {
if (isComboBoxDisabled) return;
setOpen(true);
};
const ChevronIcon = open ? ChevronUp : ChevronDown;
// Helper to filter out a specific value from the array
const getFilteredValues = (valueToRemove: string): string[] => {
if (!Array.isArray(filterComboBoxValue)) return [];
return filterComboBoxValue.filter((i) => i !== valueToRemove);
};
// Handle removal of a multi-select tag
const handleRemoveTag = (e: React.MouseEvent, valueToRemove: string) => {
e.stopPropagation();
const filteredValues = getFilteredValues(valueToRemove);
handleRemoveMultiSelect(filteredValues);
};
// Render a single multi-select tag
const renderTag = (value: string, index: number) => (
<button
key={`${value}-${index}`}
type="button"
onClick={(e) => handleRemoveTag(e, value)}
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
{value}
<X className="h-3 w-3" />
</button>
);
const commandItemOnSelect = (o: string) => {
if (!isMultiple) {
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
} else {
onChangeFilterComboBoxValue(
Array.isArray(filterComboBoxValue)
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
// Render multi-select tags
const renderMultiSelectTags = () => {
if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) {
return null;
}
return (
<div className="no-scrollbar flex grow gap-2 overflow-auto">
{filterComboBoxValue.map((value, index) => renderTag(value, index))}
</div>
);
};
// Render the appropriate content based on filterComboBoxValue state
const renderComboBoxContent = () => {
if (!filterComboBoxValue || filterComboBoxValue.length === 0) {
return (
<p className={clsx("text-sm", isComboBoxDisabled ? "text-slate-300" : "text-slate-400")}>
{t("common.select")}...
</p>
);
}
if (!isMultiple) {
setOpen(false);
if (Array.isArray(filterComboBoxValue)) {
return renderMultiSelectTags();
}
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
};
return (
<div className="inline-flex w-full flex-row">
{filterOptions && filterOptions?.length <= 1 ? (
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
{filterOptions && filterOptions.length <= 1 ? (
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
</div>
) : (
<DropdownMenu
onOpenChange={(value) => {
value && setOpen(false);
setOpenFilterValue(value);
if (value) setOpen(false);
}}>
<DropdownMenuTrigger
disabled={disabled}
className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
!disabled ? "cursor-pointer" : "opacity-50"
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
)}>
<div className="flex items-center justify-between">
{!filterValue ? (
<p className="text-slate-400">{t("common.select")}...</p>
) : (
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
)}
{filterOptions && filterOptions.length > 1 && (
<>
{openFilterValue ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</>
)}
</div>
{filterValue ? (
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
{filterOptions && filterOptions.length > 1 && (
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white p-2">
<DropdownMenuContent className="bg-white">
{filterOptions?.map((o, index) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
className="cursor-pointer"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
@@ -166,78 +211,78 @@ export const QuestionFilterComboBox = ({
</DropdownMenuContent>
</DropdownMenu>
)}
{isTextInputField ? (
<Input
type="text"
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
disabled={disabled || !filterValue}
disabled={isComboBoxDisabled}
placeholder={t("common.enter_url")}
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
/>
) : (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<Command ref={commandRef} className="relative h-fit w-full min-w-0 overflow-visible bg-transparent">
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
<div
role="button"
tabIndex={isComboBoxDisabled ? -1 : 0}
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
</button>
"flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2",
isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</button>
onClick={handleOpenDropdown}
onKeyDown={(e) => {
const isActivationKey = e.key === "Enter" || e.key === " ";
if (isActivationKey && !isComboBoxDisabled) {
e.preventDefault();
handleOpenDropdown();
}
}}>
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div>
<Button
onClick={(e) => {
e.stopPropagation();
if (isComboBoxDisabled) return;
setOpen(!open);
}}
disabled={isComboBoxDisabled}
variant="secondary"
size="icon"
className="flex-shrink-0"
aria-expanded={open}
aria-label={t("common.select")}>
<ChevronIcon />
</Button>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
{open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
<CommandList className="max-h-52">
<CommandInput
value={searchQuery}
onValueChange={setSearchQuery}
placeholder={`${t("common.search")}...`}
className="border-none"
/>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
key={optionValue}
onSelect={() => handleCommandItemSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
{optionValue}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
</div>
);
})}
</CommandGroup>
</CommandList>
</div>
)}
</Command>
)}
</div>

View File

@@ -32,6 +32,7 @@ import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
import {
Command,
CommandEmpty,
@@ -111,51 +112,46 @@ const questionIcons = {
const getIcon = (type: string) => {
const IconComponent = questionIcons[type];
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
};
const getIconBackground = (type: OptionsType | string): string => {
const backgroundMap: Record<string, string> = {
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
[OptionsType.QUESTIONS]: "bg-brand-dark",
[OptionsType.TAGS]: "bg-indigo-500",
[OptionsType.QUOTAS]: "bg-slate-500",
};
return backgroundMap[type] ?? "bg-amber-500";
};
const getLabelClassName = (type: OptionsType | string, label?: string): string => {
if (type !== OptionsType.META) return "";
return label === "os" || label === "url" ? "uppercase" : "capitalize";
};
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
if (type) {
if (type === OptionsType.QUESTIONS && questionType) {
return getIcon(questionType);
} else if (type === OptionsType.ATTRIBUTES) {
return getIcon(OptionsType.ATTRIBUTES);
} else if (type === OptionsType.HIDDEN_FIELDS) {
return getIcon(OptionsType.HIDDEN_FIELDS);
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
return getIcon(label);
} else if (type === OptionsType.TAGS) {
return getIcon(OptionsType.TAGS);
} else if (type === OptionsType.QUOTAS) {
return getIcon(OptionsType.QUOTAS);
}
}
};
const getColor = () => {
if (type === OptionsType.ATTRIBUTES) {
return "bg-indigo-500";
} else if (type === OptionsType.QUESTIONS) {
return "bg-brand-dark";
} else if (type === OptionsType.TAGS) {
return "bg-indigo-500";
} else if (type === OptionsType.QUOTAS) {
return "bg-slate-500";
} else {
return "bg-amber-500";
}
};
const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined;
return label === "os" || label === "url" ? "uppercase" : "capitalize";
const getDisplayIcon = () => {
if (!type) return null;
if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType);
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS);
if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS);
return null;
};
return (
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
<div className="flex h-full min-w-0 items-center gap-2">
<span
className={clsx(
"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-white",
getIconBackground(type ?? "")
)}>
{getDisplayIcon()}
</span>
<p className={clsx("truncate text-sm text-slate-600", getLabelClassName(type ?? "", label))}>
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p>
</div>
@@ -169,64 +165,74 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
const [inputValue, setInputValue] = useState("");
useClickOutside(commandRef, () => setOpen(false));
const hasSelection = selected.hasOwnProperty("label");
const ChevronIcon = open ? ChevronUp : ChevronDown;
return (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
<button
onClick={() => setOpen(true)}
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
{!open && selected.hasOwnProperty("label") && (
<SelectedCommandItem
label={selected?.label}
type={selected?.type}
questionType={selected?.questionType}
/>
)}
{(open || !selected.hasOwnProperty("label")) && (
<Command
ref={commandRef}
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400">
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center justify-between"
onClick={() => !open && setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
!open && setOpen(true);
}
}}>
{!open && hasSelection && <SelectedCommandItem {...selected} />}
{(open || !hasSelection) && (
<CommandInput
value={inputValue}
onValueChange={setInputValue}
placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
/>
)}
<div>
{open ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</button>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
{data?.option?.map((o, i) => (
<CommandItem
key={`${o.label}-${i}`}
onSelect={() => {
setInputValue("");
onChangeValue(o);
setOpen(false);
}}
className="cursor-pointer">
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
</CommandItem>
))}
</CommandGroup>
)}
</Fragment>
))}
</CommandList>
</div>
)}
<Button
onClick={(e) => {
e.stopPropagation();
setOpen(!open);
}}
variant="secondary"
size="icon"
className="flex-shrink-0"
aria-expanded={open}
aria-label={t("common.select")}>
<ChevronIcon className="h-4 w-4 opacity-50" />
</Button>
</div>
{open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}
onSelect={() => {
setInputValue("");
onChangeValue(o);
setOpen(false);
}}>
<SelectedCommandItem {...o} />
</CommandItem>
))}
</CommandGroup>
)}
</Fragment>
))}
</CommandList>
</div>
)}
</Command>
);
};

View File

@@ -31,6 +31,32 @@ export type QuestionFilterOptions = {
id: string;
};
interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isOpen: boolean;
children: React.ReactNode;
}
export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverTriggerButtonProps>(
({ isOpen, children, ...props }, ref) => (
<button
ref={ref}
type="button"
{...props}
className="flex min-w-[8rem] cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white p-2 hover:border-slate-400">
<span className="text-sm text-slate-700">{children}</span>
<div className="ml-3">
{isOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</button>
)
);
PopoverTriggerButton.displayName = "PopoverTriggerButton";
interface ResponseFilterProps {
survey: TSurvey;
}
@@ -108,7 +134,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
useEffect(() => {
if (!isOpen) {
clearItem();
handleApplyFilters();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
@@ -127,8 +152,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
const handleClearAllFilters = () => {
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
const clearedFilters = { filter: [], responseStatus: "all" as const };
setFilterValue(clearedFilters);
setSelectedFilter(clearedFilters);
setIsOpen(false);
};
@@ -184,9 +210,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
const handleOpenChange = (open: boolean) => {
if (!open) {
handleApplyFilters();
}
setIsOpen(open);
};
@@ -196,36 +219,26 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</span>
<div className="ml-3">
{isOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
className="w-[300px] rounded-lg border-slate-200 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
<p className="text-slate800 hidden text-lg font-semibold sm:block">
<div className="mb-6 flex flex-wrap items-start justify-between gap-2">
<p className="font-semibold text-slate-800">
{t("environments.surveys.summary.show_all_responses_that_match")}
</p>
<p className="block text-base text-slate-500 sm:hidden">
{t("environments.surveys.summary.show_all_responses_where")}
</p>
<div className="flex items-center space-x-2">
<Select
value={filterValue.responseStatus ?? "all"}
onValueChange={(val) => {
handleResponseStatusChange(val as TResponseStatus);
}}
defaultValue={filterValue.responseStatus}>
<SelectTrigger className="w-full bg-white">
}}>
<SelectTrigger className="w-full bg-white text-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
@@ -285,35 +298,38 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
/>
</div>
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
<p className="block font-light text-slate-500 md:hidden">Delete</p>
<TrashIcon
className="w-4 cursor-pointer text-slate-500 md:text-black"
<Button
variant="secondary"
size="icon"
onClick={() => handleDeleteFilter(i)}
/>
aria-label={t("common.delete")}>
<TrashIcon />
</Button>
</div>
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-6 flex items-center">
<p className="mr-6 text-base text-slate-600">And</p>
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">and</p>
<hr className="w-full text-slate-600" />
</div>
)}
</React.Fragment>
))}
</div>
<div className="mt-8 flex items-center justify-between">
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
{t("common.add_filter")}
<Plus width={18} height={18} className="ml-2" />
</Button>
<div className="mt-6 flex items-center justify-between">
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
{t("common.add_filter")}
<Plus />
</Button>
<Button size="sm" onClick={handleApplyFilters}>
{t("common.apply_filters")}
</Button>
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
{t("common.clear_all")}
</Button>
</div>
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
{t("common.clear_all")}
<TrashIcon />
</Button>
</div>
</PopoverContent>
</Popover>

View File

@@ -1,3 +0,0 @@
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
export default LinkSurveyLoading;

View File

@@ -173,6 +173,7 @@ checksums:
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
@@ -182,6 +183,8 @@ checksums:
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
@@ -328,6 +331,7 @@ checksums:
common/segments: 271db72d5b973fbc5fadab216177eaae
common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -821,7 +825,6 @@ checksums:
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
environments/projects_environments_organizations_not_found: 9d450087c4035083f93bda9aa1889c43
environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905
environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a
environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093
@@ -1573,6 +1576,8 @@ checksums:
environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127
environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5
environments/surveys/responses/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
environments/surveys/responses/an_error_occurred_adding_the_tag: f211ea1ceb8a93b415d88a8deed874ef
environments/surveys/responses/an_error_occurred_creating_the_tag: 89689815f8aff6ff3ba821ab599c540c
environments/surveys/responses/an_error_occurred_deleting_the_tag: c63f28ac2a4cda558423ea7f975d5b8b
environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f
environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70
@@ -1769,7 +1774,6 @@ checksums:
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515
environments/surveys/summary/show_all_responses_that_match: c199f03983d7fcdd5972cc2759558c68
environments/surveys/summary/show_all_responses_where: 370a56de4692a588f7ebdbf7f1e28f6f
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd

View File

@@ -182,21 +182,17 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
export enum PROJECT_FEATURE_KEYS {
FREE = "free",
STARTUP = "startup",
SCALE = "scale",
ENTERPRISE = "enterprise",
CUSTOM = "custom",
}
export enum STRIPE_PROJECT_NAMES {
STARTUP = "Formbricks Startup",
SCALE = "Formbricks Scale",
ENTERPRISE = "Formbricks Enterprise",
CUSTOM = "Formbricks Custom",
}
export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
SCALE_MONTHLY = "formbricks_scale_monthly",
SCALE_YEARLY = "formbricks_scale_yearly",
}
export const BILLING_LIMITS = {
@@ -210,10 +206,10 @@ export const BILLING_LIMITS = {
RESPONSES: 5000,
MIU: 7500,
},
SCALE: {
PROJECTS: 5,
RESPONSES: 10000,
MIU: 30000,
CUSTOM: {
PROJECTS: null,
RESPONSES: null,
MIU: null,
},
} as const;

View File

@@ -53,9 +53,9 @@ export const I18nProvider = ({ children, language, defaultLanguage }: I18nProvid
initializeI18n();
}, [locale, defaultLanguage]);
// Don't render children until i18n is ready to prevent hydration issues
// Don't render children until i18n is ready to prevent race conditions
if (!isReady) {
return <div style={{ visibility: "hidden" }}>{children}</div>;
return null;
}
return (

View File

@@ -200,6 +200,7 @@
"edit": "Bearbeiten",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_projects": "Fehler beim Laden der Projekte",
"finish": "Fertigstellen",
"follow_these": "Folge diesen",
"formbricks_version": "Formbricks Version",
@@ -355,6 +358,7 @@
"segments": "Segmente",
"select": "Auswählen",
"select_all": "Alles auswählen",
"select_filter": "Filter auswählen",
"select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
@@ -886,7 +890,6 @@
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
}
},
"projects_environments_organizations_not_found": "Projekte, Umgebungen oder Organisationen nicht gefunden",
"segments": {
"add_filter_below": "Filter unten hinzufügen",
"add_your_first_filter_to_get_started": "Füge deinen ersten Filter hinzu, um loszulegen",
@@ -983,15 +986,12 @@
"manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"remove_branding": "Branding entfernen",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_miu": "Unbegrenzte MIU",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "Adresszeile 1",
"address_line_2": "Adresszeile 2",
"an_error_occurred_adding_the_tag": "Beim Hinzufügen des Tags ist ein Fehler aufgetreten",
"an_error_occurred_creating_the_tag": "Beim Erstellen des Tags ist ein Fehler aufgetreten",
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
"browser": "Browser",
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "Integrationen einrichten",
"share_survey": "Umfrage teilen",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
"starts": "Startet",
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",

View File

@@ -200,6 +200,7 @@
"edit": "Edit",
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_projects": "Failed to load projects",
"finish": "Finish",
"follow_these": "Follow these",
"formbricks_version": "Formbricks Version",
@@ -355,6 +358,7 @@
"segments": "Segments",
"select": "Select",
"select_all": "Select all",
"select_filter": "Select filter",
"select_survey": "Select Survey",
"select_teams": "Select teams",
"selected": "Selected",
@@ -886,7 +890,6 @@
"team_settings_description": "See which teams can access this project."
}
},
"projects_environments_organizations_not_found": "Projects, environments or organizations not found",
"segments": {
"add_filter_below": "Add filter below",
"add_your_first_filter_to_get_started": "Add your first filter to get started",
@@ -983,15 +986,12 @@
"manage_subscription": "Manage Subscription",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"per_month": "per month",
"per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"remove_branding": "Remove Branding",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles",
"unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_miu": "Unlimited MIU",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "Address Line 1",
"address_line_2": "Address Line 2",
"an_error_occurred_adding_the_tag": "An error occurred adding the tag",
"an_error_occurred_creating_the_tag": "An error occurred creating the tag",
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
"browser": "Browser",
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "Setup integrations",
"share_survey": "Share survey",
"show_all_responses_that_match": "Show all responses that match",
"show_all_responses_where": "Show all responses where...",
"starts": "Starts",
"starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",

View File

@@ -200,6 +200,7 @@
"edit": "Modifier",
"email": "Email",
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_projects": "Échec du chargement des projets",
"finish": "Terminer",
"follow_these": "Suivez ceci",
"formbricks_version": "Version de Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segments",
"select": "Sélectionner",
"select_all": "Sélectionner tout",
"select_filter": "Sélectionner un filtre",
"select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
@@ -886,7 +890,6 @@
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
}
},
"projects_environments_organizations_not_found": "Projets, environnements ou organisations non trouvés",
"segments": {
"add_filter_below": "Ajouter un filtre ci-dessous",
"add_your_first_filter_to_get_started": "Ajoutez votre premier filtre pour commencer.",
@@ -983,15 +986,12 @@
"manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs mensuels identifiés",
"per_month": "par mois",
"per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Assistance premium avec accord de niveau de service",
"remove_branding": "Suppression du logo",
"startup": "Initial",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Gestion des accès",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_miu": "MIU Illimité",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "Ligne d'adresse 1",
"address_line_2": "Ligne d'adresse 2",
"an_error_occurred_adding_the_tag": "Une erreur est survenue lors de l'ajout de l'étiquette",
"an_error_occurred_creating_the_tag": "Une erreur est survenue lors de la création de l'étiquette",
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
"browser": "Navigateur",
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "Configurer les intégrations",
"share_survey": "Partager l'enquête",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
"show_all_responses_where": "Afficher toutes les réponses où...",
"starts": "Commence",
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",

View File

@@ -200,6 +200,7 @@
"edit": "編集",
"email": "メールアドレス",
"ending_card": "終了カード",
"enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス",
"environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
"error_rate_limit_title": "レート制限を超えました",
"expand_rows": "行を展開",
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
"finish": "完了",
"follow_these": "こちらの手順に従って",
"formbricks_version": "Formbricksバージョン",
@@ -355,6 +358,7 @@
"segments": "セグメント",
"select": "選択",
"select_all": "すべて選択",
"select_filter": "フィルターを選択",
"select_survey": "フォームを選択",
"select_teams": "チームを選択",
"selected": "選択済み",
@@ -886,7 +890,6 @@
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
}
},
"projects_environments_organizations_not_found": "プロジェクト、環境、または組織が見つかりません",
"segments": {
"add_filter_below": "下にフィルターを追加",
"add_your_first_filter_to_get_started": "まず最初のフィルターを追加してください",
@@ -983,15 +986,12 @@
"manage_subscription": "サブスクリプションを管理",
"monthly": "月間",
"monthly_identified_users": "月間識別ユーザー数",
"per_month": "月",
"per_year": "年",
"plan_upgraded_successfully": "プランを正常にアップグレードしました",
"premium_support_with_slas": "SLA付きプレミアムサポート",
"remove_branding": "ブランディングを削除",
"startup": "スタートアップ",
"startup_description": "無料プランのすべての機能に追加機能。",
"switch_plan": "プランを切り替え",
"switch_plan_confirmation_text": "本当に {plan} プランに切り替えますか? {price} {period} が請求されます。",
"team_access_roles": "チームアクセスロール",
"unable_to_upgrade_plan": "プランをアップグレードできません",
"unlimited_miu": "無制限のMIU",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "住所1",
"address_line_2": "住所2",
"an_error_occurred_adding_the_tag": "タグの追加中にエラーが発生しました",
"an_error_occurred_creating_the_tag": "タグの作成中にエラーが発生しました",
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
"browser": "ブラウザ",
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "連携を設定",
"share_survey": "フォームを共有",
"show_all_responses_that_match": "一致するすべての回答を表示",
"show_all_responses_where": "以下のすべての回答を表示...",
"starts": "開始",
"starts_tooltip": "フォームが開始された回数。",
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",

View File

@@ -200,6 +200,7 @@
"edit": "Editar",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Terminar",
"follow_these": "Siga esses",
"formbricks_version": "Versão do Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segmentos",
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_survey": "Selecionar Pesquisa",
"select_teams": "Selecionar times",
"selected": "Selecionado",
@@ -886,7 +890,6 @@
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
}
},
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
"segments": {
"add_filter_below": "Adicionar filtro abaixo",
"add_your_first_filter_to_get_started": "Adicione seu primeiro filtro para começar",
@@ -983,15 +986,12 @@
"manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Remover Marca",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "Endereço Linha 1",
"address_line_2": "Complemento",
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a tag",
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a tag",
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
"browser": "navegador",
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "Configurar integrações",
"share_survey": "Compartilhar pesquisa",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
"show_all_responses_where": "Mostre todas as respostas onde...",
"starts": "começa",
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",

View File

@@ -200,6 +200,7 @@
"edit": "Editar",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Concluir",
"follow_these": "Siga estes",
"formbricks_version": "Versão do Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segmentos",
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_survey": "Selecionar Inquérito",
"select_teams": "Selecionar equipas",
"selected": "Selecionado",
@@ -886,7 +890,6 @@
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
}
},
"projects_environments_organizations_not_found": "Projetos, ambientes ou organizações não encontrados",
"segments": {
"add_filter_below": "Adicionar filtro abaixo",
"add_your_first_filter_to_get_started": "Adicione o seu primeiro filtro para começar",
@@ -983,15 +986,12 @@
"manage_subscription": "Gerir Subscrição",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"remove_branding": "Possibilidade de remover o logo",
"startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa",
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_miu": "MIU Ilimitado",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "Endereço Linha 1",
"address_line_2": "Endereço Linha 2",
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a etiqueta",
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a etiqueta",
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
"browser": "Navegador",
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "Configurar integrações",
"share_survey": "Partilhar inquérito",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
"show_all_responses_where": "Mostrar todas as respostas onde...",
"starts": "Começa",
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",

View File

@@ -200,6 +200,7 @@
"edit": "Editare",
"email": "Email",
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere",
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
"error_rate_limit_title": "Limită de cereri depășită",
"expand_rows": "Extinde rândurile",
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_projects": "Nu s-a reușit încărcarea proiectelor",
"finish": "Finalizează",
"follow_these": "Urmați acestea",
"formbricks_version": "Versiunea Formbricks",
@@ -355,6 +358,7 @@
"segments": "Segment",
"select": "Selectați",
"select_all": "Selectați toate",
"select_filter": "Selectați filtrul",
"select_survey": "Selectați chestionar",
"select_teams": "Selectați echipele",
"selected": "Selectat",
@@ -886,7 +890,6 @@
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
}
},
"projects_environments_organizations_not_found": "Proiecte, medii sau organizații nu găsite",
"segments": {
"add_filter_below": "Adăugați un filtru mai jos",
"add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe",
@@ -983,15 +986,12 @@
"manage_subscription": "Gestionați abonamentul",
"monthly": "Lunar",
"monthly_identified_users": "Utilizatori identificați lunar",
"per_month": "pe lună",
"per_year": "pe an",
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
"premium_support_with_slas": "Suport premium cu SLA-uri",
"remove_branding": "Eliminare branding",
"startup": "Pornire",
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
"switch_plan": "Schimbă planul",
"switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.",
"team_access_roles": "Roluri acces echipă",
"unable_to_upgrade_plan": "Nu se poate upgrada planul",
"unlimited_miu": "MIU Nelimitat",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "Adresă Linie 1",
"address_line_2": "Adresă Linie 2",
"an_error_occurred_adding_the_tag": "A apărut o eroare la adăugarea etichetei",
"an_error_occurred_creating_the_tag": "A apărut o eroare la crearea etichetei",
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
"browser": "Browser",
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "Configurare integrare",
"share_survey": "Distribuie chestionarul",
"show_all_responses_that_match": "Afișează toate răspunsurile care corespund",
"show_all_responses_where": "Afișează toate răspunsurile unde...",
"starts": "Începuturi",
"starts_tooltip": "Număr de ori când sondajul a fost început.",
"survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",

View File

@@ -200,6 +200,7 @@
"edit": "编辑",
"email": "邮箱",
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
"enterprise_license": "企业 许可证",
"environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "请求 达到 最大 上限 请 稍后 再试 。",
"error_rate_limit_title": "速率 限制 超过",
"expand_rows": "展开 行",
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_projects": "加载项目失败",
"finish": "完成",
"follow_these": "遵循 这些",
"formbricks_version": "Formbricks 版本",
@@ -355,6 +358,7 @@
"segments": "细分",
"select": "选择",
"select_all": "选择 全部",
"select_filter": "选择过滤器",
"select_survey": "选择 调查",
"select_teams": "选择 团队",
"selected": "已选择",
@@ -886,7 +890,6 @@
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
}
},
"projects_environments_organizations_not_found": "项目 、 环境 或 组织 未 找到",
"segments": {
"add_filter_below": "在下方添加 过滤器",
"add_your_first_filter_to_get_started": "添加 您 的 第一个 过滤器 以 开始",
@@ -983,15 +986,12 @@
"manage_subscription": "管理 订阅",
"monthly": "每月",
"monthly_identified_users": "每月 已识别的 用户",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "计划 升级 成功",
"premium_support_with_slas": "优质支持与 SLAs",
"remove_branding": "移除 品牌",
"startup": "初创企业",
"startup_description": "包含免费版的所有功能以及附加功能.",
"switch_plan": "切换 计划",
"switch_plan_confirmation_text": "你确定要切换到 {plan} 计划吗?你将被收取 {price} {period} 。",
"team_access_roles": "团队访问角色",
"unable_to_upgrade_plan": "无法升级计划",
"unlimited_miu": "无限 MIU",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "地址 第1行",
"address_line_2": "地址 第2行",
"an_error_occurred_adding_the_tag": "添加标签时发生错误",
"an_error_occurred_creating_the_tag": "创建标签时发生错误",
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
"browser": "浏览器",
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "设置 集成",
"share_survey": "分享 问卷调查",
"show_all_responses_that_match": "显示所有匹配的响应",
"show_all_responses_where": "显示所有的响应,条件为...",
"starts": "开始",
"starts_tooltip": "调查 被 开始 的 次数",
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",

View File

@@ -200,6 +200,7 @@
"edit": "編輯",
"email": "電子郵件",
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
"enterprise_license": "企業授權",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -209,6 +210,8 @@
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_projects": "無法載入專案",
"finish": "完成",
"follow_these": "按照這些步驟",
"formbricks_version": "Formbricks 版本",
@@ -355,6 +358,7 @@
"segments": "區隔",
"select": "選擇",
"select_all": "全選",
"select_filter": "選擇篩選器",
"select_survey": "選擇問卷",
"select_teams": "選擇 團隊",
"selected": "已選取",
@@ -886,7 +890,6 @@
"team_settings_description": "查看哪些團隊可以存取此專案。"
}
},
"projects_environments_organizations_not_found": "找不到專案、環境或組織",
"segments": {
"add_filter_below": "在下方新增篩選器",
"add_your_first_filter_to_get_started": "新增您的第一個篩選器以開始使用",
@@ -983,15 +986,12 @@
"manage_subscription": "管理訂閱",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"remove_branding": "移除品牌",
"startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色",
"unable_to_upgrade_plan": "無法升級方案",
"unlimited_miu": "無限 MIU",
@@ -1664,6 +1664,8 @@
"responses": {
"address_line_1": "地址 1",
"address_line_2": "地址 2",
"an_error_occurred_adding_the_tag": "新增標籤時發生錯誤",
"an_error_occurred_creating_the_tag": "建立標籤時發生錯誤",
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
"browser": "瀏覽器",
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
@@ -1880,7 +1882,6 @@
"setup_integrations": "設定整合",
"share_survey": "分享問卷",
"show_all_responses_that_match": "顯示所有相符的回應",
"show_all_responses_where": "顯示所有回應,其中...",
"starts": "開始次數",
"starts_tooltip": "問卷已開始的次數。",
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",

View File

@@ -1,10 +1,11 @@
"use client";
import { AlertCircleIcon, SettingsIcon } from "lucide-react";
import { SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TTag } from "@formbricks/types/tags";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
@@ -39,14 +40,19 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
const [open, setOpen] = React.useState(false);
const [tagsState, setTagsState] = useState(tags);
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
const [isLoadingTagOperation, setIsLoadingTagOperation] = useState(false);
const onDelete = async (tagId: string) => {
try {
await deleteTagOnResponseAction({ responseId, tagId });
setIsLoadingTagOperation(true);
const deleteTagResponse = await deleteTagOnResponseAction({ responseId, tagId });
if (deleteTagResponse?.data) {
updateFetchedResponses();
} catch (e) {
} else {
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
logger.error({ errorMessage }, "Error deleting tag");
toast.error(t("environments.surveys.responses.an_error_occurred_deleting_the_tag"));
}
setIsLoadingTagOperation(false);
};
useEffect(() => {
@@ -60,72 +66,70 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
}, [tagIdToHighlight]);
const handleCreateTag = async (tagName: string) => {
setOpen(false);
setIsLoadingTagOperation(true);
const newTagResponse = await createTagAction({ environmentId, tagName });
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (!newTagResponse?.data) {
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
return;
}
if (createTagResponse?.data?.ok) {
const tag = createTagResponse.data.data;
setTagsState((prevTags) => [
...prevTags,
{
tagId: tag.id,
tagName: tag.name,
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: tag.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
if (!newTagResponse.data.ok) {
const errorMessage = newTagResponse.data.error;
if (errorMessage?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <SettingsIcon className="h-5 w-5 text-orange-500" />,
});
} else {
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
toast.error(errorMessage);
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
}
return;
}
if (
createTagResponse?.data?.ok === false &&
createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS
) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
const newTag = newTagResponse.data.data;
const createTagToResponseResponse = await createTagToResponseAction({ responseId, tagId: newTag.id });
if (createTagToResponseResponse?.data) {
setTagsState((prevTags) => [...prevTags, { tagId: newTag.id, tagName: newTag.name }]);
setTagIdToHighlight(newTag.id);
updateFetchedResponses();
setSearchValue("");
return;
setOpen(false);
} else {
const errorMessage = getFormattedErrorMessage(createTagToResponseResponse);
logger.error({ errorMessage });
toast.error(errorMessage);
}
setIsLoadingTagOperation(false);
};
const errorMessage = getFormattedErrorMessage(createTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
setSearchValue("");
const handleAddTag = async (tagId: string) => {
setIsLoadingTagOperation(true);
setTagsState((prevTags) => [
...prevTags,
{
tagId,
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
},
]);
try {
await createTagToResponseAction({ responseId, tagId });
updateFetchedResponses();
setSearchValue("");
setOpen(false);
} catch (error) {
toast.error(t("environments.surveys.responses.an_error_occurred_adding_the_tag"));
console.error("Error adding tag:", error);
// Revert the tag if the action failed
setTagsState((prevTags) => prevTags.filter((tag) => tag.tagId !== tagId));
} finally {
setIsLoadingTagOperation(false);
}
};
return (
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="cursor-pointer p-0"
onClick={() => {
router.push(`/environments/${environmentId}/project/tags`);
}}>
<SettingsIcon className="h-5 w-5 text-slate-500 hover:text-slate-600" />
</Button>
)}
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-3">
<div className="flex flex-wrap items-center gap-2">
{tagsState?.map((tag) => (
<Tag
@@ -136,37 +140,35 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
tags={tagsState}
setTagsState={setTagsState}
highlight={tagIdToHighlight === tag.tagId}
allowDelete={!isReadOnly}
allowDelete={!isReadOnly && !isLoadingTagOperation}
/>
))}
{!isReadOnly && (
<TagsCombobox
open={open}
open={open && !isLoadingTagOperation}
setOpen={setOpen}
searchValue={searchValue}
setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={handleCreateTag}
addTag={(tagId) => {
setTagsState((prevTags) => [
...prevTags,
{
tagId,
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
},
]);
createTagToResponseAction({ responseId, tagId }).then(() => {
updateFetchedResponses();
setSearchValue("");
setOpen(false);
});
}}
addTag={handleAddTag}
/>
)}
</div>
{!isReadOnly && (
<Button
variant="ghost"
size="sm"
className="flex-shrink-0"
onClick={() => {
router.push(`/environments/${environmentId}/project/tags`);
}}>
<SettingsIcon className="h-4 w-4" />
</Button>
)}
</div>
);
};

View File

@@ -1,7 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
@@ -42,46 +41,58 @@ export const SingleResponseCard = ({
setSelectedResponseId,
locale,
}: SingleResponseCardProps) => {
const hasQuotas = (response.quotas && response.quotas.length > 0) ?? false;
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
const { t } = useTranslation();
const environmentId = survey.environmentId;
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
let skippedQuestions: string[][] = [];
let temp: string[] = [];
const skippedQuestions: string[][] = useMemo(() => {
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
if (temp.length > 0) {
if (shouldReverse) temp.reverse();
result.push([...temp]);
temp.length = 0;
}
};
if (response.finished) {
survey.questions.forEach((question) => {
if (!isValidValue(response.data[question.id])) {
temp.push(question.id);
} else if (temp.length > 0) {
skippedQuestions.push([...temp]);
temp = [];
const processFinishedResponse = () => {
const result: string[][] = [];
let temp: string[] = [];
for (const question of survey.questions) {
if (isValidValue(response.data[question.id])) {
flushTemp(temp, result);
} else {
temp.push(question.id);
}
}
});
} else {
for (let index = survey.questions.length - 1; index >= 0; index--) {
const question = survey.questions[index];
if (
!response.data[question.id] &&
(skippedQuestions.length === 0 ||
(skippedQuestions.length > 0 && !isValidValue(response.data[question.id])))
) {
temp.push(question.id);
} else if (temp.length > 0) {
temp.reverse();
skippedQuestions.push([...temp]);
temp = [];
flushTemp(temp, result);
return result;
};
const processUnfinishedResponse = () => {
const result: string[][] = [];
let temp: string[] = [];
for (let index = survey.questions.length - 1; index >= 0; index--) {
const question = survey.questions[index];
const hasNoData = !response.data[question.id];
const shouldSkip = hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
if (shouldSkip) {
temp.push(question.id);
} else {
flushTemp(temp, result, true);
}
}
}
}
// Handle the case where the last entries are empty
if (temp.length > 0) {
skippedQuestions.push(temp);
}
flushTemp(temp, result);
return result;
};
return response.finished ? processFinishedResponse() : processUnfinishedResponse();
}, [response.id, response.finished, response.data, survey.questions]);
const handleDeleteResponse = async () => {
setIsDeleting(true);
@@ -91,7 +102,6 @@ export const SingleResponseCard = ({
}
await deleteResponseAction({ responseId: response.id, decrementQuotas });
updateResponseList?.([response.id]);
router.refresh();
if (setSelectedResponseId) setSelectedResponseId(null);
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
setDeleteDialogOpen(false);

View File

@@ -1,43 +1,67 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization } from "@/lib/organization/service";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: STRIPE_API_VERSION,
});
export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata || !checkoutSession.metadata.organizationId)
if (!checkoutSession.metadata?.organizationId)
throw new ResourceNotFoundError("No organizationId found in checkout session", checkoutSession.id);
const stripeSubscriptionObject = await stripe.subscriptions.retrieve(
checkoutSession.subscription as string
);
const { customer: stripeCustomer } = (await stripe.checkout.sessions.retrieve(checkoutSession.id, {
expand: ["customer"],
})) as { customer: Stripe.Customer };
const organization = await getOrganization(checkoutSession.metadata!.organizationId);
const organization = await getOrganization(checkoutSession.metadata.organizationId);
if (!organization)
throw new ResourceNotFoundError("Organization not found", checkoutSession.metadata.organizationId);
await stripe.subscriptions.update(stripeSubscriptionObject.id, {
metadata: {
organizationId: organization.id,
responses: checkoutSession.metadata.responses,
miu: checkoutSession.metadata.miu,
const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string, {
expand: ["items.data.price"],
});
let period: "monthly" | "yearly" = "monthly";
if (subscription.items?.data && subscription.items.data.length > 0) {
const firstItem = subscription.items.data[0];
const interval = firstItem.price?.recurring?.interval;
period = interval === "year" ? "yearly" : "monthly";
}
await updateOrganization(checkoutSession.metadata.organizationId, {
billing: {
...organization.billing,
stripeCustomerId: checkoutSession.customer as string,
plan: PROJECT_FEATURE_KEYS.STARTUP,
period,
limits: {
projects: BILLING_LIMITS.STARTUP.PROJECTS,
monthly: {
responses: BILLING_LIMITS.STARTUP.RESPONSES,
miu: BILLING_LIMITS.STARTUP.MIU,
},
},
periodStart: new Date(),
},
});
await stripe.customers.update(stripeCustomer.id, {
name: organization.name,
metadata: { organizationId: organization.id },
invoice_settings: {
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
logger.info(
{
organizationId: checkoutSession.metadata.organizationId,
plan: PROJECT_FEATURE_KEYS.STARTUP,
period,
checkoutSessionId: checkoutSession.id,
},
});
"Subscription activated"
);
const stripeCustomer = await stripe.customers.retrieve(checkoutSession.customer as string);
if (stripeCustomer && !stripeCustomer.deleted) {
await stripe.customers.update(stripeCustomer.id, {
name: organization.name,
metadata: { organizationId: organization.id },
});
}
};

View File

@@ -52,13 +52,12 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
t("environments.settings.billing.7500_contacts"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.attribute_based_targeting"),
],
};
const customPlan: TPricingPlan = {
id: "enterprise",
id: "custom",
name: t("environments.settings.billing.custom"),
featured: false,
CTA: t("common.request_pricing"),
@@ -69,6 +68,7 @@ export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } =>
},
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.custom_response_limit"),
t("environments.settings.billing.custom_contacts_limit"),
t("environments.settings.billing.custom_project_limit"),

View File

@@ -16,22 +16,17 @@ export const createSubscription = async (
try {
const organization = await getOrganization(organizationId);
if (!organization) throw new Error("Organization not found.");
let isNewOrganization =
!organization.billing.stripeCustomerId ||
!(await stripe.customers.retrieve(organization.billing.stripeCustomerId));
const priceObject = (
await stripe.prices.list({
lookup_keys: [priceLookupKey],
expand: ["data.product"],
})
).data[0];
if (!priceObject) throw new Error("Price not found");
const responses = parseInt((priceObject.product as Stripe.Product).metadata.responses);
const miu = parseInt((priceObject.product as Stripe.Product).metadata.miu);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
// Always create a checkout session - let Stripe handle existing customers
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
@@ -41,63 +36,20 @@ export const createSubscription = async (
],
success_url: `${WEBAPP_URL}/billing-confirmation?environmentId=${environmentId}`,
cancel_url: `${WEBAPP_URL}/environments/${environmentId}/settings/billing`,
customer: organization.billing.stripeCustomerId ?? undefined,
allow_promotion_codes: true,
subscription_data: {
metadata: { organizationId },
trial_period_days: 30,
trial_period_days: 15,
},
metadata: { organizationId, responses, miu },
metadata: { organizationId },
billing_address_collection: "required",
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
payment_method_data: { allow_redisplay: "always" },
...(!isNewOrganization && {
customer: organization.billing.stripeCustomerId ?? undefined,
customer_update: {
name: "auto",
},
}),
};
// if the organization has never purchased a plan then we just create a new session and store their stripe customer id
if (isNewOrganization) {
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
}
const existingSubscription = await stripe.subscriptions.list({
customer: organization.billing.stripeCustomerId as string,
});
if (existingSubscription.data?.length > 0) {
const existingSubscriptionItem = existingSubscription.data[0].items.data[0];
await stripe.subscriptions.update(existingSubscription.data[0].id, {
items: [
{
id: existingSubscriptionItem.id,
deleted: true,
},
{
price: priceObject.id,
},
],
cancel_at_period_end: false,
});
} else {
// Create a new checkout again if there is no active subscription
const session = await stripe.checkout.sessions.create(checkoutSessionCreateParams);
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
}
return {
status: 200,
data: "Congrats! Added to your existing subscription!",
newPlan: false,
url: "",
};
return { status: 200, data: "Your Plan has been upgraded!", newPlan: true, url: session.url };
} catch (err) {
logger.error(err, "Error creating subscription");
return {

View File

@@ -1,32 +1,68 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
export const handleInvoiceFinalized = async (event: Stripe.Event) => {
const invoice = event.data.object as Stripe.Invoice;
const stripeSubscriptionDetails = invoice.subscription_details;
const organizationId = stripeSubscriptionDetails?.metadata?.organizationId;
if (!organizationId) {
throw new Error("No organizationId found in subscription");
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) {
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
return { status: 400, message: "No subscription ID found in invoice" };
}
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
try {
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
apiVersion: STRIPE_API_VERSION,
});
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const organizationId = subscription.metadata?.organizationId;
if (!organizationId) {
logger.warn(
{
subscriptionId,
invoiceId: invoice.id,
},
"No organizationId found in subscription metadata"
);
return { status: 400, message: "No organizationId found in subscription" };
}
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const periodStartTimestamp = invoice.lines.data[0]?.period?.start;
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
await updateOrganization(organizationId, {
billing: {
...organization.billing,
periodStart,
},
});
logger.info(
{
organizationId,
periodStart,
invoiceId: invoice.id,
},
"Billing period updated successfully"
);
return { status: 200, message: "Billing period updated successfully" };
} catch (error) {
logger.error(error, "Error updating billing period", {
invoiceId: invoice.id,
subscriptionId,
});
return { status: 500, message: "Error updating billing period" };
}
const periodStartTimestamp = invoice.lines.data[0].period.start;
const periodStart = periodStartTimestamp ? new Date(periodStartTimestamp * 1000) : new Date();
await updateOrganization(organizationId, {
...organization,
billing: {
...organization.billing,
stripeCustomerId: invoice.customer as string,
periodStart,
},
});
return { status: 200, message: "success" };
};

View File

@@ -4,7 +4,6 @@ import { STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed";
import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized";
import { handleSubscriptionCreatedOrUpdated } from "@/modules/ee/billing/api/lib/subscription-created-or-updated";
import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
@@ -20,7 +19,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
event = stripe.webhooks.constructEvent(requestBody, stripeSignature, webhookSecret);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
if (err! instanceof Error) logger.error(err, "Error in Stripe webhook handler");
if (err instanceof Error) logger.error(err, "Error in Stripe webhook handler");
return { status: 400, message: `Webhook Error: ${errorMessage}` };
}
@@ -28,11 +27,6 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleCheckoutSessionCompleted(event);
} else if (event.type === "invoice.finalized") {
await handleInvoiceFinalized(event);
} else if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated"
) {
await handleSubscriptionCreatedOrUpdated(event);
} else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event);
}

View File

@@ -1,125 +0,0 @@
import Stripe from "stripe";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TOrganizationBillingPeriod,
TOrganizationBillingPlan,
ZOrganizationBillingPeriod,
ZOrganizationBillingPlan,
} from "@formbricks/types/organizations";
import { PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants";
import { env } from "@/lib/env";
import { getOrganization, updateOrganization } from "@/lib/organization/service";
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: STRIPE_API_VERSION,
});
export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) => {
const stripeSubscriptionObject = event.data.object as Stripe.Subscription;
const organizationId = stripeSubscriptionObject.metadata.organizationId;
if (
!["active", "trialing"].includes(stripeSubscriptionObject.status) ||
stripeSubscriptionObject.cancel_at_period_end
) {
return;
}
if (!organizationId) {
logger.error({ event, organizationId }, "No organizationId found in subscription");
return { status: 400, message: "skipping, no organizationId found" };
}
const organization = await getOrganization(organizationId);
if (!organization) throw new ResourceNotFoundError("Organization not found", organizationId);
const subscriptionPrice = stripeSubscriptionObject.items.data[0].price;
const product = await stripe.products.retrieve(subscriptionPrice.product as string);
if (!product)
throw new ResourceNotFoundError(
"Product not found",
stripeSubscriptionObject.items.data[0].price.product.toString()
);
let period: TOrganizationBillingPeriod = "monthly";
const periodParsed = ZOrganizationBillingPeriod.safeParse(subscriptionPrice.metadata.period);
if (periodParsed.success) {
period = periodParsed.data;
}
let updatedBillingPlan: TOrganizationBillingPlan = organization.billing.plan;
let responses: number | null = null;
let miu: number | null = null;
let projects: number | null = null;
if (product.metadata.responses === "unlimited") {
responses = null;
} else if (parseInt(product.metadata.responses) > 0) {
responses = parseInt(product.metadata.responses);
} else {
logger.error({ responses: product.metadata.responses }, "Invalid responses metadata in product");
throw new Error("Invalid responses metadata in product");
}
if (product.metadata.miu === "unlimited") {
miu = null;
} else if (parseInt(product.metadata.miu) > 0) {
miu = parseInt(product.metadata.miu);
} else {
logger.error({ miu: product.metadata.miu }, "Invalid miu metadata in product");
throw new Error("Invalid miu metadata in product");
}
if (product.metadata.projects === "unlimited") {
projects = null;
} else if (parseInt(product.metadata.projects) > 0) {
projects = parseInt(product.metadata.projects);
} else {
logger.error({ projects: product.metadata.projects }, "Invalid projects metadata in product");
throw new Error("Invalid projects metadata in product");
}
const plan = ZOrganizationBillingPlan.parse(product.metadata.plan);
switch (plan) {
case PROJECT_FEATURE_KEYS.FREE:
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
break;
case PROJECT_FEATURE_KEYS.STARTUP:
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
break;
case PROJECT_FEATURE_KEYS.ENTERPRISE:
updatedBillingPlan = PROJECT_FEATURE_KEYS.ENTERPRISE;
break;
}
await updateOrganization(organizationId, {
billing: {
...organization.billing,
stripeCustomerId: stripeSubscriptionObject.customer as string,
plan: updatedBillingPlan,
period,
limits: {
projects,
monthly: {
responses,
miu,
},
},
},
});
await stripe.customers.update(stripeSubscriptionObject.customer as string, {
name: organization.name,
metadata: { organizationId: organization.id },
invoice_settings: {
default_payment_method: stripeSubscriptionObject.default_payment_method as string,
},
});
};

View File

@@ -30,4 +30,12 @@ export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
period: "monthly",
},
});
logger.info(
{
organizationId,
subscriptionId: stripeSubscriptionObject.id,
},
"Subscription cancelled - downgraded to FREE plan"
);
};

View File

@@ -19,7 +19,7 @@ interface PricingCardProps {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
ENTERPRISE: string;
CUSTOM: string;
};
}
@@ -33,17 +33,21 @@ export const PricingCard = ({
}: PricingCardProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const displayPrice = (() => {
if (plan.id === projectFeatureKeys.CUSTOM) {
return plan.price.monthly;
}
return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
})();
const isCurrentPlan = useMemo(() => {
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
return true;
}
if (
organization.billing.plan === projectFeatureKeys.ENTERPRISE &&
plan.id === projectFeatureKeys.ENTERPRISE
) {
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) {
return true;
}
@@ -53,7 +57,7 @@ export const PricingCard = ({
organization.billing.plan,
plan.id,
planPeriod,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
]);
@@ -62,7 +66,7 @@ export const PricingCard = ({
return null;
}
if (plan.id === projectFeatureKeys.ENTERPRISE) {
if (plan.id === projectFeatureKeys.CUSTOM) {
return (
<Button
variant="outline"
@@ -97,7 +101,7 @@ export const PricingCard = ({
<Button
loading={loading}
onClick={() => {
setUpgradeModalOpen(true);
setContactModalOpen(true);
}}
className="flex justify-center">
{t("environments.settings.billing.switch_plan")}
@@ -115,7 +119,7 @@ export const PricingCard = ({
plan.featured,
plan.href,
plan.id,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.CUSTOM,
projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP,
t,
@@ -151,13 +155,9 @@ export const PricingCard = ({
plan.featured ? "text-slate-900" : "text-slate-800",
"text-4xl font-bold tracking-tight"
)}>
{plan.id !== projectFeatureKeys.ENTERPRISE
? planPeriod === "monthly"
? plan.price.monthly
: plan.price.yearly
: plan.price.monthly}
{displayPrice}
</p>
{plan.id !== projectFeatureKeys.ENTERPRISE && (
{plan.id !== projectFeatureKeys.CUSTOM && (
<div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"}
@@ -203,28 +203,13 @@ export const PricingCard = ({
</div>
<ConfirmationModal
title={t("environments.settings.billing.switch_plan")}
buttonText={t("common.confirm")}
onConfirm={async () => {
setLoading(true);
await onUpgrade();
setLoading(false);
setUpgradeModalOpen(false);
}}
open={upgradeModalOpen}
setOpen={setUpgradeModalOpen}
body={t("environments.settings.billing.switch_plan_confirmation_text", {
plan: plan.name,
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
period:
planPeriod === "monthly"
? t("environments.settings.billing.per_month")
: t("environments.settings.billing.per_year"),
})}
title="Please reach out to us"
open={contactModalOpen}
setOpen={setContactModalOpen}
onConfirm={() => setContactModalOpen(false)}
buttonText="Close"
buttonVariant="default"
buttonLoading={loading}
closeOnOutsideClick={false}
hideCloseButton
body="To switch your billing rhythm, please reach out to hola@formbricks.com"
/>
</div>
);

View File

@@ -26,7 +26,7 @@ interface PricingTableProps {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
ENTERPRISE: string;
CUSTOM: string;
};
hasBillingRights: boolean;
}
@@ -127,11 +127,11 @@ export const PricingTable = ({
};
const responsesUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.responses === null;
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null;
const peopleUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.miu === null;
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null;
const projectsUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.projects === null;
organization.billing.plan === "custom" && organization.billing.limits.projects === null;
return (
<main>

View File

@@ -92,7 +92,7 @@ export const UploadContactsAttributeCombobox = ({
}}
/>
</div>
<CommandList>
<CommandList className="border-0">
<CommandGroup>
{keys.map((tag) => {
return (

View File

@@ -94,7 +94,7 @@ describe("License Utils", () => {
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getRemoveBrandingPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
@@ -129,7 +129,7 @@ describe("License Utils", () => {
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getWhiteLabelPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
@@ -154,27 +154,17 @@ describe("License Utils", () => {
expect(result).toBe(true);
});
test("should return true if license active, accessControl enabled and plan is SCALE (cloud)", async () => {
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active, accessControl enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active, accessControl enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
test("should return false if license active, accessControl enabled but plan is not CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
@@ -184,6 +174,16 @@ describe("License Utils", () => {
expect(result).toBe(false);
});
test("should return true if license active, accessControl enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getAccessControlPermission(mockOrganization.billing.plan);
@@ -211,7 +211,7 @@ describe("License Utils", () => {
test("should return true if license active and plan is not FREE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getBiggerUploadFileSizePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
@@ -243,27 +243,17 @@ describe("License Utils", () => {
expect(result).toBe(true);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is SCALE (cloud)", async () => {
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active, multiLanguageSurveys enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
test("should return false if license active, multiLanguageSurveys enabled but plan is not CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
@@ -273,6 +263,16 @@ describe("License Utils", () => {
expect(result).toBe(false);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
@@ -420,17 +420,17 @@ describe("License Utils", () => {
vi.mocked(constants).IS_RECAPTCHA_CONFIGURED = true; // reset for other tests
});
test("should return true if license active, feature enabled, and plan is SCALE (cloud)", async () => {
test("should return true if license active, feature enabled, and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, spamProtection: true },
});
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.SCALE);
const result = await getIsSpamProtectionEnabled(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return false if license active, feature enabled, but plan is not SCALE or ENTERPRISE (cloud)", async () => {
test("should return false if license active, feature enabled, but plan is not CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,

View File

@@ -111,9 +111,7 @@ export const getIsSpamProtectionEnabled = async (
if (IS_FORMBRICKS_CLOUD) {
return (
license.active &&
!!license.features?.spamProtection &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
license.active && !!license.features?.spamProtection && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM
);
}
@@ -122,11 +120,7 @@ export const getIsSpamProtectionEnabled = async (
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};

View File

@@ -2,6 +2,7 @@
// Pull in the mocked implementations to configure them in tests
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
@@ -12,12 +13,24 @@ import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { environmentIdLayoutChecks, getEnvironmentAuth } from "./utils";
// Pull in the mocked implementations to configure them in tests
import {
environmentIdLayoutChecks,
getEnvironmentAuth,
getEnvironmentLayoutData,
getEnvironmentWithRelations,
} from "./utils";
// Mock all external dependencies
vi.mock("@/lingodotdev/server", () => ({
@@ -58,6 +71,8 @@ vi.mock("@/lib/membership/utils", () => ({
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
getMonthlyOrganizationResponseCount: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
@@ -68,12 +83,36 @@ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getAccessControlPermission: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
}));
vi.mock("@formbricks/types/errors", () => ({
AuthorizationError: class AuthorizationError extends Error {},
DatabaseError: class DatabaseError extends Error {},
}));
describe("utils.ts", () => {
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
// Provide default mocks for successful scenario
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user123" } });
vi.mocked(getEnvironment).mockResolvedValue({ id: "env123" } as TEnvironment);
@@ -96,6 +135,16 @@ describe("utils.ts", () => {
});
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
vi.mocked(getUser).mockResolvedValue({ id: "user123" } as TUser);
vi.mocked(getEnterpriseLicense).mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "none",
} as any);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(0);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(0);
});
describe("getEnvironmentAuth", () => {
@@ -170,4 +219,434 @@ describe("utils.ts", () => {
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found");
});
});
describe("getEnvironmentWithRelations", () => {
const mockPrismaData = {
id: "env123",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
type: "production" as const,
projectId: "proj123",
appSetupCompleted: true,
project: {
id: "proj123",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
name: "Test Project",
organizationId: "org123",
languages: ["en"],
recontactDays: 7,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight" as const,
clickOutsideClose: true,
darkOverlay: false,
styling: {},
logo: null,
environments: [
{
id: "env123",
type: "production" as const,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
projectId: "proj123",
appSetupCompleted: true,
},
{
id: "env456",
type: "development" as const,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
projectId: "proj123",
appSetupCompleted: false,
},
],
organization: {
id: "org123",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
name: "Test Organization",
billing: { plan: "free" },
isAIEnabled: false,
whitelabel: false,
memberships: [
{
userId: "user123",
organizationId: "org123",
accepted: true,
role: "owner" as const,
},
],
},
},
};
beforeEach(() => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockPrismaData as any);
});
test("returns combined environment, project, organization, and membership data", async () => {
const result = await getEnvironmentWithRelations("env123", "user123");
expect(result).toBeDefined();
expect(result!.environment.id).toBe("env123");
expect(result!.environment.type).toBe("production");
expect(result!.project.id).toBe("proj123");
expect(result!.project.name).toBe("Test Project");
expect(result!.organization.id).toBe("org123");
expect(result!.organization.name).toBe("Test Organization");
expect(result!.environments).toHaveLength(2);
expect(result!.membership).toEqual({
userId: "user123",
organizationId: "org123",
accepted: true,
role: "owner",
});
});
test("fetches only current user's membership using database-level filtering", async () => {
await getEnvironmentWithRelations("env123", "user123");
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: "env123" },
select: expect.objectContaining({
project: expect.objectContaining({
select: expect.objectContaining({
organization: expect.objectContaining({
select: expect.objectContaining({
memberships: expect.objectContaining({
where: { userId: "user123" },
take: 1,
}),
}),
}),
}),
}),
}),
});
});
test("returns null when environment not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
const result = await getEnvironmentWithRelations("env123", "user123");
expect(result).toBeNull();
});
test("returns null membership when user has no membership", async () => {
const dataWithoutMembership = {
...mockPrismaData,
project: {
...mockPrismaData.project,
organization: {
...mockPrismaData.project.organization,
memberships: [], // No memberships
},
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(dataWithoutMembership as any);
const result = await getEnvironmentWithRelations("env123", "user123");
expect(result!.membership).toBeNull();
});
test("throws error on database failure", async () => {
// Mock a database error
const dbError = new Error("Database connection failed");
vi.mocked(prisma.environment.findUnique).mockRejectedValueOnce(dbError);
// Verify function throws (specific error type depends on Prisma error detection)
await expect(getEnvironmentWithRelations("env123", "user123")).rejects.toThrow();
});
// Note: Input validation for environmentId and userId is handled by
// getEnvironmentLayoutData (the parent function), not here.
// See getEnvironmentLayoutData tests for validation coverage.
});
describe("getEnvironmentLayoutData", () => {
beforeEach(() => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
id: "env123",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
type: "production",
projectId: "proj123",
appSetupCompleted: true,
project: {
id: "proj123",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
name: "Test Project",
organizationId: "org123",
languages: ["en"],
recontactDays: 7,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
styling: {},
logo: null,
environments: [
{
id: "env123",
type: "production",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
projectId: "proj123",
appSetupCompleted: true,
},
],
organization: {
id: "org123",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-02"),
name: "Test Organization",
billing: { plan: "free", limits: {} },
isAIEnabled: false,
whitelabel: false,
memberships: [
{
userId: "user123",
organizationId: "org123",
accepted: true,
role: "owner",
},
],
},
},
} as any);
});
test("returns complete layout data on success", async () => {
const result = await getEnvironmentLayoutData("env123", "user123");
expect(result).toBeDefined();
expect(result.session).toBeDefined();
expect(result.user).toBeDefined();
expect(result.environment).toBeDefined();
expect(result.project).toBeDefined();
expect(result.organization).toBeDefined();
expect(result.environments).toBeDefined();
expect(result.membership).toBeDefined();
expect(result.isAccessControlAllowed).toBeDefined();
expect(result.projectPermission).toBeDefined();
expect(result.license).toBeDefined();
expect(result.peopleCount).toBe(0);
expect(result.responseCount).toBe(0);
});
test("validates environmentId input", async () => {
await expect(getEnvironmentLayoutData("", "user123")).rejects.toThrow();
});
test("validates userId input", async () => {
await expect(getEnvironmentLayoutData("env123", "")).rejects.toThrow();
});
test("throws error if session not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.session_not_found");
});
test("throws error if userId doesn't match session", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "different-user" } } as any);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("User ID mismatch");
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValueOnce(null);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.user_not_found");
});
test("throws error if environment data not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
"common.environment_not_found"
);
});
test("throws AuthorizationError if user has no environment access", async () => {
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(AuthorizationError);
});
test("throws error if membership not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
id: "env123",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj123",
appSetupCompleted: true,
project: {
id: "proj123",
name: "Test Project",
organizationId: "org123",
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 7,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
styling: {},
logo: null,
environments: [],
organization: {
id: "org123",
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "free", limits: {} },
isAIEnabled: false,
whitelabel: false,
memberships: [], // No membership
},
},
} as any);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
"common.membership_not_found"
);
});
test("fetches user before auth check, then environment data after authorization", async () => {
await getEnvironmentLayoutData("env123", "user123");
// User is fetched first (needed for auth check)
expect(getUser).toHaveBeenCalledWith("user123");
// Environment data is fetched after authorization passes
expect(prisma.environment.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "env123" },
})
);
});
test("fetches permissions and license data in parallel", async () => {
await getEnvironmentLayoutData("env123", "user123");
expect(getAccessControlPermission).toHaveBeenCalled();
expect(getProjectPermissionByUserId).toHaveBeenCalledWith("user123", "proj123");
expect(getEnterpriseLicense).toHaveBeenCalled();
});
test("fetches cloud metrics when IS_FORMBRICKS_CLOUD is true", async () => {
// Mock IS_FORMBRICKS_CLOUD to be true
const constantsMock = await import("@/lib/constants");
vi.mocked(constantsMock).IS_FORMBRICKS_CLOUD = true;
await getEnvironmentLayoutData("env123", "user123");
expect(getMonthlyActiveOrganizationPeopleCount).toHaveBeenCalledWith("org123");
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith("org123");
});
test("caches results per environmentId and userId", async () => {
// Call twice with same parameters
await getEnvironmentLayoutData("env123", "user123");
await getEnvironmentLayoutData("env123", "user123");
// Due to React.cache, database should only be queried once
// Note: React.cache behavior is per-request in production, but in tests
// we can verify the function was called multiple times
expect(prisma.environment.findUnique).toHaveBeenCalled();
});
test("returns different data for different environmentIds", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
id: "env123",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj123",
appSetupCompleted: true,
project: {
id: "proj123",
name: "Project 1",
organizationId: "org123",
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 7,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
styling: {},
logo: null,
environments: [],
organization: {
id: "org123",
name: "Org 1",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "free", limits: {} },
isAIEnabled: false,
whitelabel: false,
memberships: [{ userId: "user123", organizationId: "org123", role: "owner", accepted: true }],
},
},
} as any);
const result1 = await getEnvironmentLayoutData("env123", "user123");
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce({
id: "env456",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "proj456",
appSetupCompleted: true,
project: {
id: "proj456",
name: "Project 2",
organizationId: "org456",
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 7,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
styling: {},
logo: null,
environments: [],
organization: {
id: "org456",
name: "Org 2",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "pro", limits: {} },
isAIEnabled: true,
whitelabel: true,
memberships: [{ userId: "user123", organizationId: "org456", role: "member", accepted: true }],
},
},
} as any);
const result2 = await getEnvironmentLayoutData("env456", "user123");
expect(result1.environment.id).not.toBe(result2.environment.id);
});
});
});

View File

@@ -1,18 +1,30 @@
import { Prisma } from "@prisma/client";
import { getServerSession } from "next-auth";
import { cache as reactCache } from "react";
import { AuthorizationError } from "@formbricks/types/errors";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, DatabaseError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { validateInputs } from "@/lib/utils/validate";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { TEnvironmentAuth } from "../types/environment-auth";
import { TEnvironmentAuth, TEnvironmentLayoutData } from "../types/environment-auth";
/**
* Common utility to fetch environment data and perform authorization checks
@@ -103,3 +115,215 @@ export const environmentIdLayoutChecks = async (environmentId: string) => {
return { t, session, user, organization };
};
/**
* Fetches environment with related project, organization, environments, and current user's membership
* in a single optimized database query.
* Returns data with proper types matching TEnvironment, TProject, TOrganization.
*
* Note: Validation is handled by parent function (getEnvironmentLayoutData)
*/
export const getEnvironmentWithRelations = reactCache(async (environmentId: string, userId: string) => {
try {
const data = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
// Environment fields
id: true,
createdAt: true,
updatedAt: true,
type: true,
projectId: true,
appSetupCompleted: true,
// Project via relation (nested select)
project: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
organizationId: true,
languages: true,
recontactDays: true,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: true,
placement: true,
clickOutsideClose: true,
darkOverlay: true,
styling: true,
logo: true,
// All project environments
environments: {
select: {
id: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
appSetupCompleted: true,
},
},
// Organization via relation
organization: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
billing: true,
isAIEnabled: true,
whitelabel: true,
// Current user's membership only (filtered at DB level)
memberships: {
where: {
userId: userId,
},
select: {
userId: true,
organizationId: true,
accepted: true,
role: true,
},
take: 1, // Only need one result
},
},
},
},
},
},
});
if (!data) return null;
// Extract and return properly typed data
return {
environment: {
id: data.id,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
type: data.type,
projectId: data.projectId,
appSetupCompleted: data.appSetupCompleted,
},
project: {
id: data.project.id,
createdAt: data.project.createdAt,
updatedAt: data.project.updatedAt,
name: data.project.name,
organizationId: data.project.organizationId,
languages: data.project.languages,
recontactDays: data.project.recontactDays,
linkSurveyBranding: data.project.linkSurveyBranding,
inAppSurveyBranding: data.project.inAppSurveyBranding,
config: data.project.config,
placement: data.project.placement,
clickOutsideClose: data.project.clickOutsideClose,
darkOverlay: data.project.darkOverlay,
styling: data.project.styling,
logo: data.project.logo,
environments: data.project.environments,
},
organization: {
id: data.project.organization.id,
createdAt: data.project.organization.createdAt,
updatedAt: data.project.organization.updatedAt,
name: data.project.organization.name,
billing: data.project.organization.billing,
isAIEnabled: data.project.organization.isAIEnabled,
whitelabel: data.project.organization.whitelabel,
},
environments: data.project.environments,
membership: data.project.organization.memberships[0] || null, // First (and only) membership or null
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting environment with relations");
throw new DatabaseError(error.message);
}
throw error;
}
});
/**
* Fetches all data required for environment layout rendering.
* Consolidates multiple queries and eliminates duplicates.
* Does NOT fetch switcher data (organizations/projects lists) - those are lazy-loaded.
*
* Note: userId is included in cache key to make it explicit that results are user-specific,
* even though React.cache() is per-request and doesn't leak across users.
*/
export const getEnvironmentLayoutData = reactCache(
async (environmentId: string, userId: string): Promise<TEnvironmentLayoutData> => {
validateInputs([environmentId, ZId]);
validateInputs([userId, ZId]);
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new Error(t("common.session_not_found"));
}
// Verify userId matches session (safety check)
if (session.user.id !== userId) {
throw new Error("User ID mismatch with session");
}
// Get user first (lightweight query needed for subsequent checks)
const user = await getUser(userId); // 1 DB query
if (!user) {
throw new Error(t("common.user_not_found"));
}
// Authorization check before expensive data fetching
const hasAccess = await hasUserEnvironmentAccess(userId, environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const relationData = await getEnvironmentWithRelations(environmentId, userId);
if (!relationData) {
throw new Error(t("common.environment_not_found"));
}
const { environment, project, organization, environments, membership } = relationData;
// Validate user's membership was found
if (!membership) {
throw new Error(t("common.membership_not_found"));
}
// Fetch remaining data in parallel
const [isAccessControlAllowed, projectPermission, license] = await Promise.all([
getAccessControlPermission(organization.billing.plan), // No DB query (logic only)
getProjectPermissionByUserId(userId, environment.projectId), // 1 DB query
getEnterpriseLicense(), // Externally cached
]);
// Conditional queries for Formbricks Cloud
let peopleCount = 0;
let responseCount = 0;
if (IS_FORMBRICKS_CLOUD) {
[peopleCount, responseCount] = await Promise.all([
getMonthlyActiveOrganizationPeopleCount(organization.id),
getMonthlyOrganizationResponseCount(organization.id),
]);
}
return {
session,
user,
environment,
project,
organization,
environments,
membership,
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
};
}
);

View File

@@ -1,10 +1,21 @@
import { Session } from "next-auth";
import { z } from "zod";
import { ZEnvironment } from "@formbricks/types/environment";
import { ZMembership } from "@formbricks/types/memberships";
import { ZOrganization } from "@formbricks/types/organizations";
import { ZProject } from "@formbricks/types/project";
import { ZUser } from "@formbricks/types/user";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { TEnvironment, ZEnvironment } from "@formbricks/types/environment";
import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
import { TProject, ZProject } from "@formbricks/types/project";
import { TUser, ZUser } from "@formbricks/types/user";
import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
// Type for the enterprise license returned by getEnterpriseLicense()
type TEnterpriseLicense = {
active: boolean;
features: TEnterpriseLicenseFeatures | null;
lastChecked: Date;
isPendingDowngrade: boolean;
fallbackLevel: string;
};
export const ZEnvironmentAuth = z.object({
environment: ZEnvironment,
@@ -27,3 +38,25 @@ export const ZEnvironmentAuth = z.object({
});
export type TEnvironmentAuth = z.infer<typeof ZEnvironmentAuth>;
/**
* Complete layout data type for environment pages.
* Includes all data needed for layout rendering.
*
* Note: organizations and projects lists are NOT included - they are lazy-loaded
* in switcher dropdowns only when needed.
*/
export type TEnvironmentLayoutData = {
session: Session;
user: TUser;
environment: TEnvironment;
project: TProject; // Current project with full details
organization: TOrganization;
environments: TEnvironment[]; // All project environments for switcher
membership: TMembership;
isAccessControlAllowed: boolean;
projectPermission: TTeamPermission | null;
license: TEnterpriseLicense;
peopleCount: number;
responseCount: number;
};

View File

@@ -46,7 +46,7 @@ export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) =>
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
</div>
<CommandList>
<CommandList className="border-0">
<CommandEmpty>
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
</CommandEmpty>

View File

@@ -4,7 +4,7 @@ import { Project } from "@prisma/client";
import { isEqual } from "lodash";
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
@@ -67,6 +67,7 @@ export const SurveyMenuBar = ({
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
const [isSurveySaving, setIsSurveySaving] = useState(false);
const isSuccessfullySavedRef = useRef(false);
useEffect(() => {
if (audiencePrompt && activeId === "settings") {
@@ -78,9 +79,21 @@ export const SurveyMenuBar = ({
setIsLinkSurvey(localSurvey.type === "link");
}, [localSurvey.type]);
// Reset the successfully saved flag when survey prop updates (page refresh complete)
useEffect(() => {
if (isSuccessfullySavedRef.current) {
isSuccessfullySavedRef.current = false;
}
}, [survey]);
useEffect(() => {
const warningText = t("environments.surveys.edit.unsaved_changes_warning");
const handleWindowClose = (e: BeforeUnloadEvent) => {
// Skip warning if we just successfully saved
if (isSuccessfullySavedRef.current) {
return;
}
if (!isEqual(localSurvey, survey)) {
e.preventDefault();
return (e.returnValue = warningText);
@@ -249,6 +262,8 @@ export const SurveyMenuBar = ({
if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data);
toast.success(t("environments.surveys.edit.changes_saved"));
// Set flag to prevent beforeunload warning during router.refresh()
isSuccessfullySavedRef.current = true;
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
@@ -298,6 +313,8 @@ export const SurveyMenuBar = ({
segment,
});
setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
} catch (error) {
console.error(error);

View File

@@ -10,6 +10,8 @@ vi.mock("@/lib/constants", async () => {
IS_FORMBRICKS_CLOUD: true,
PROJECT_FEATURE_KEYS: {
FREE: "free",
STARTUP: "startup",
CUSTOM: "custom",
},
};
});
@@ -24,8 +26,13 @@ describe("getSurveyFollowUpsPermission", () => {
expect(result).toBe(false);
});
test("should return true for non-free plan on Formbricks Cloud", async () => {
test("should return false for startup plan on Formbricks Cloud", async () => {
const result = await getSurveyFollowUpsPermission("startup" as TOrganizationBillingPlan);
expect(result).toBe(false);
});
test("should return true for custom plan on Formbricks Cloud", async () => {
const result = await getSurveyFollowUpsPermission("custom" as TOrganizationBillingPlan);
expect(result).toBe(true);
});

View File

@@ -4,6 +4,6 @@ import { IS_FORMBRICKS_CLOUD, PROJECT_FEATURE_KEYS } from "@/lib/constants";
export const getSurveyFollowUpsPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
if (IS_FORMBRICKS_CLOUD) return billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
return true;
};

View File

@@ -32,6 +32,16 @@ vi.mock("@/lib/styling/constants", () => ({
},
}));
// Mock recall utility
vi.mock("@/lib/utils/recall", () => ({
recallToHeadline: vi.fn((headline) => headline),
}));
// Mock text content extraction
vi.mock("@formbricks/types/surveys/validation", () => ({
getTextContent: vi.fn((text) => text),
}));
describe("Metadata Utils", () => {
// Reset all mocks before each test
beforeEach(() => {
@@ -173,6 +183,75 @@ describe("Metadata Utils", () => {
WEBAPP_URL: "https://test.formbricks.com",
}));
});
test("handles welcome card headline with HTML content", async () => {
const { getTextContent } = await import("@formbricks/types/surveys/validation");
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
metadata: {},
languages: [],
welcomeCard: {
enabled: true,
timeToFinish: false,
showResponseCount: false,
headline: {
default: "<p>Welcome <strong>Headline</strong></p>",
},
html: {
default: "Welcome Description",
},
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getTextContent).mockReturnValue("Welcome Headline");
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getTextContent).toHaveBeenCalled();
expect(result.title).toBe("Welcome Headline");
});
test("handles welcome card headline with recall variables", async () => {
const { recallToHeadline } = await import("@/lib/utils/recall");
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
metadata: {},
languages: [],
welcomeCard: {
enabled: true,
timeToFinish: false,
showResponseCount: false,
headline: {
default: "Welcome #recall:name/fallback:User#",
},
html: {
default: "Welcome Description",
},
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(recallToHeadline).mockReturnValue({
default: "Welcome @User",
});
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(recallToHeadline).toHaveBeenCalledWith(
mockSurvey.welcomeCard.headline,
mockSurvey,
false,
"default"
);
expect(result.title).toBe("Welcome @User");
});
});
describe("getSurveyOpenGraphMetadata", () => {

View File

@@ -1,8 +1,10 @@
import { Metadata } from "next";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { recallToHeadline } from "@/lib/utils/recall";
import { getSurvey } from "@/modules/survey/lib/survey";
type TBasicSurveyMetadata = {
@@ -48,7 +50,9 @@ export const getBasicSurveyMetadata = async (
const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
const titleFromWelcome =
welcomeCard?.enabled && welcomeCard.headline
? getLocalizedValue(welcomeCard.headline, langCode) || ""
? getTextContent(
getLocalizedValue(recallToHeadline(welcomeCard.headline, survey, false, langCode), langCode)
) || ""
: undefined;
let title = titleFromMetadata || titleFromWelcome || survey.name;

View File

@@ -1,11 +0,0 @@
"use client";
export const LinkSurveyLoading = () => {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-1/2 w-3/4 flex-col sm:w-1/2 lg:w-1/4">
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-slate-200 text-slate-900"></div>
</div>
</div>
);
};

View File

@@ -35,9 +35,10 @@ const BreadcrumbItem = React.forwardRef<
<li
ref={ref}
className={cn(
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1",
!isHighlighted && "hover:bg-white hover:outline hover:outline-slate-300",
isActive && "bg-slate-100 outline outline-slate-300",
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
isHighlighted && "bg-red-800 text-white outline hover:bg-red-700 hover:outline-red-800",
className
)}
{...props}

View File

@@ -1,7 +1,6 @@
"use client";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import * as React from "react";
import {
Dialog,
@@ -60,17 +59,14 @@ function CommandInput({
...props
}: React.ComponentProps<typeof CommandPrimitive.Input> & { hidden?: boolean }) {
return (
<div data-slot="command-input-wrapper" className={cn("flex items-center")}>
<SearchIcon className="h-4 w-4 shrink-0 text-slate-500" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"outline-hidden flex h-9 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
);
}
@@ -78,7 +74,10 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
className={cn(
"max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden rounded-md border border-slate-300 bg-white",
className
)}
{...props}
/>
);
@@ -116,7 +115,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[selected=true]:cursor-pointer data-[selected=true]:bg-slate-100 data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
@@ -137,11 +136,11 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandShortcut,
CommandList,
CommandSeparator,
CommandShortcut,
};

View File

@@ -36,7 +36,7 @@ export const DataTableToolbar = <T,>({
const router = useRouter();
return (
<div className="sticky top-0 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
<SelectedRowSettings
table={table}

View File

@@ -265,7 +265,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
<button autoFocus className="sr-only" aria-hidden type="button" />
)}
<CommandList className="p-1">
<CommandList className="border-0 p-1">
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
</CommandEmpty>

View File

@@ -128,8 +128,8 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
</div>
{open && selectableOptions.length > 0 && !disabled && (
<div className="relative mt-2">
<CommandList>
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md border bg-white shadow-md outline-none">
<CommandList className="border-0">
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
<CommandGroup className="h-full overflow-auto">
{selectableOptions.map((option) => (
<CommandItem

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>

View File

@@ -104,7 +104,7 @@ export const TagsCombobox = ({
}}
/>
</div>
<CommandList>
<CommandList className="border-0">
<CommandGroup>
{tagsToSearch?.map((tag) => {
return (

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
export const ZOrganizationBillingPlan = z.enum(["free", "startup", "scale", "enterprise"]);
export const ZOrganizationBillingPlan = z.enum(["free", "startup", "custom"]);
export type TOrganizationBillingPlan = z.infer<typeof ZOrganizationBillingPlan>;
export const ZOrganizationBillingPeriod = z.enum(["monthly", "yearly"]);