Compare commits

...

29 Commits

Author SHA1 Message Date
Dhruwang
a4563819eb fix: build 2025-11-14 11:50:55 +05:30
Dhruwang
31f02aa53c prisma 7 2025-11-13 11:10:29 +05:30
Johannes
12e703c02b feat: add scroll indicator button to scrollable container (#6803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-11 11:59:58 +00:00
Johannes
07065f2675 fix: include responseStatus filter in active filter count display (#6809)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-11 11:05:02 +00:00
Johannes
7ca45cefeb fix: copy recontact options when copying surveys between environments (#6802) 2025-11-11 10:39:37 +00:00
Dhruwang Jariwala
4df28878db fix: preview animation fix (duplicate) (#6784)
Co-authored-by: Praveen Thanikachalam <100035228+prave01@users.noreply.github.com>
2025-11-06 20:16:26 +00:00
Johannes
b355d05b25 fix: Tweak Recontact UI (#6783)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-06 14:53:29 +00:00
Matti Nannt
e757e9aec9 fix: serve logo from self-hosted instance instead of external S3 bucket (#6781) 2025-11-05 14:57:44 +00:00
Dhruwang Jariwala
cf4119baf6 fix: update issue in welcome card (#6779) 2025-11-05 13:42:12 +00:00
Johannes
6be2ae3071 chore: update wording & UI tweak for easier SDK setup (#6777) 2025-11-05 06:10:14 +00:00
Dhruwang Jariwala
600b793641 chore: recalibrate survey editor width to 2/3 editor and 1/3 preview (#6772) 2025-11-04 09:10:31 +00:00
Dhruwang Jariwala
cde03b6997 fix: duplicate survey issue (#6774) 2025-11-04 08:19:25 +00:00
Anshuman Pandey
00371bfb01 docs: minio intructions for docker setup (#6773)
Co-authored-by: Akhilesh Patidar <akhileshpatidar989368@gmail.com>
Co-authored-by: Akhilesh <126186908+Akhileshait@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-04 06:23:05 +00:00
Johannes
6be6782531 docs: improve API docs for better DX (#6760)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-31 11:59:40 +00:00
Pyrrian
3ae4f8aa68 fix: nindent typo in securityContext helm chart (#6753) 2025-10-31 12:35:20 +01:00
Thomas Brugman
3d3c69a92b feat: Add Dutch language support. (#6737) 2025-10-31 12:35:08 +01:00
dependabot[bot]
b1b94eaa66 chore(deps): bump next-auth from 4.24.11 to 4.24.12 in /apps/web in the npm_and_yarn group across 1 directory (#6751)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-30 13:09:31 +00:00
Marc T.
67cc96449d fix: allow access of /animated-bgs/** from public url (#6748)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-30 12:21:50 +00:00
Dhruwang Jariwala
bf41a53b86 fix: survey ui loading issue (#6755) 2025-10-30 07:32:44 +00:00
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
Johannes
906b4da33c fix: execute pipeline on Create Response of Management API (#6712)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-27 17:34:00 +00:00
Aashish
33b9ee3a50 fix: enter button event applying to preview on right side when enter in welcome card editor #6739 (#6740)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-27 16:53:12 +00:00
Dhruwang Jariwala
5a693a548c fix: 1135 translation updates (#6743) 2025-10-27 10:52:04 +00:00
487 changed files with 85130 additions and 5603 deletions

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { getTeamsByOrganizationId } from "./onboarding";

View File

@@ -1,8 +1,8 @@
"use server";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";

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 { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "./organization";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { getProjectsByUserId } from "./project";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getWebhookCountBySource } from "./webhook";

View File

@@ -1,6 +1,6 @@
import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { User } from "@formbricks/database/generated/client";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";

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
@@ -93,6 +96,7 @@ export const mapResponsesToTableData = (
? t("environments.surveys.responses.completed")
: t("environments.surveys.responses.not_completed"),
responseId: response.id,
singleUseId: response.singleUseId,
tags: response.tags,
variables: survey.variables.reduce(
(acc, curr) => {
@@ -126,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 (
@@ -146,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

@@ -312,6 +312,14 @@ export const generateResponseTableColumns = (
},
};
const singleUseIdColumn: ColumnDef<TResponseTableData> = {
accessorKey: "singleUseId",
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>;
},
};
const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota",
header: t("common.quota"),
@@ -409,6 +417,7 @@ export const generateResponseTableColumns = (
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
singleUseIdColumn,
dateColumn,
...(showQuotasColumn ? [quotasColumn] : []),
statusColumn,

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

@@ -96,7 +96,6 @@ export const SurveyAnalysisCTA = ({
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
environmentId: environment.id,
surveyId: surveyId,
targetEnvironmentId: environment.id,
});

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey, getQuotasSummary } from "./survey";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { convertFloatTo2Decimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses";

View File

@@ -1,8 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {

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);
};
@@ -194,38 +217,30 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
setFilterValue(selectedFilter);
}, [selectedFilter]);
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
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>
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>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</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 +300,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,6 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { PipelineTriggers, Webhook } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";

View File

@@ -1,4 +1,4 @@
import { Organization } from "@prisma/client";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { getContact, getContactByUserId } from "./contact";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
@@ -78,10 +78,7 @@ export const createResponseWithQuotaEvaluation = async (
return txResponse;
};
export const createResponse = async (
responseInput: TResponseInput,
tx: Prisma.TransactionClient
): Promise<TResponse> => {
export const createResponse = async (responseInput: TResponseInput, tx: any): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");

View File

@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TResponseInput } from "@formbricks/types/responses";

View File

@@ -1,9 +1,9 @@
"use server";
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -16,7 +16,7 @@ export const GET = withV1ApiWrapper({
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
const actionClasses = await getActionClasses(environmentIds as string[]);
return {
response: responses.successResponse(actionClasses),

View File

@@ -5,6 +5,7 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -151,6 +152,23 @@ export const PUT = withV1ApiWrapper({
const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data);
auditLog.newObject = updated;
sendToPipeline({
event: "responseUpdated",
environmentId: result.survey.environmentId,
surveyId: result.survey.id,
response: updated,
});
if (updated.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: result.survey.environmentId,
surveyId: result.survey.id,
response: updated,
});
}
return {
response: responses.successResponse(updated),
};

View File

@@ -1,6 +1,6 @@
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Organization, Prisma, Response as ResponsePrisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
@@ -88,10 +88,9 @@ export const createResponseWithQuotaEvaluation = async (
return txResponse;
};
export const createResponse = async (
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const createResponse = async (responseInput: TResponseInput, tx?: any): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");

View File

@@ -5,6 +5,7 @@ import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/res
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -48,7 +49,11 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
const environmentResponses = await getResponsesByEnvironmentIds(
environmentIds as string[],
limit,
offset
);
allResponses.push(...environmentResponses);
}
return {
@@ -156,6 +161,23 @@ export const POST = withV1ApiWrapper({
const response = await createResponseWithQuotaEvaluation(responseInput);
auditLog.targetId = response.id;
auditLog.newObject = response;
sendToPipeline({
event: "responseCreated",
environmentId: surveyResult.survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (response.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: surveyResult.survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
return {
response: responses.successResponse(response, true),
};

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -27,7 +27,7 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
const surveys = await getSurveys(environmentIds as string[], limit, offset);
return {
response: responses.successResponse(surveys),

View File

@@ -1,7 +1,7 @@
import { Prisma, Webhook } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { deleteWebhook, getWebhook } from "./webhook";

View File

@@ -1,5 +1,5 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,7 +1,7 @@
import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma, WebhookSource } from "@formbricks/database/generated/client";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";

View File

@@ -1,5 +1,5 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";

View File

@@ -13,7 +13,7 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
const webhooks = await getWebhooks(environmentIds as string[]);
return {
response: responses.successResponse(webhooks),
};

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,

View File

@@ -1,6 +1,6 @@
import { Organization } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { getOrganizationBillingByEnvironmentId } from "./organization";

View File

@@ -1,6 +1,6 @@
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
export const getOrganizationBillingByEnvironmentId = reactCache(

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
@@ -86,10 +86,9 @@ const buildPrismaResponseData = (
};
};
export const createResponse = async (
responseInput: TResponseInputV2,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const createResponse = async (responseInput: TResponseInputV2, tx?: any): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");

View File

@@ -1,5 +1,5 @@
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";

View File

@@ -25,6 +25,10 @@ export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
export type TApiKeyAuthentication = TAuthenticationApiKey | null;
export type TSessionAuthentication = Session | null;
// Helper type to properly narrow NonNullable<TApiKeyAuthentication> to TAuthenticationApiKey
// This ensures TypeScript properly infers nested properties like environmentPermissions
export type TNonNullableApiKeyAuthentication = NonNullable<TApiKeyAuthentication> & TAuthenticationApiKey;
// Interface for handler function parameters
export interface THandlerParams<TProps = unknown> {
req?: NextRequest;
@@ -272,6 +276,15 @@ const getRouteType = (
*
*/
export const withV1ApiWrapper: {
// More specific overload for TAuthenticationApiKey (non-null) - must come first for proper type inference
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication: TAuthenticationApiKey }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (

View File

@@ -1,5 +1,5 @@
import { PipelineTriggers } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PipelineTriggers } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { TPipelineInput } from "@/app/lib/types/pipelines";

View File

@@ -1,4 +1,4 @@
import { PipelineTriggers } from "@prisma/client";
import { PipelineTriggers } from "@formbricks/database/generated/client";
import { TResponse } from "@formbricks/types/responses";
export interface TPipelineInput {

View File

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

View File

@@ -7,7 +7,7 @@
},
"locale": {
"source": "en-US",
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW"]
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW", "nl-NL"]
},
"version": 1.8
}

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
@@ -717,14 +721,14 @@ checksums:
environments/project/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
environments/project/app-connection/app_connection_description: 01327bfae3da950d796890b6605afed2
environments/project/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
environments/project/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
environments/project/app-connection/formbricks_sdk_not_connected_description: 666b2b25f06e76554cc2d60f925bcd4b
environments/project/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
environments/project/app-connection/how_to_setup: 3bad40037f280b47fe6418fcbeb4c717
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
@@ -741,7 +745,7 @@ checksums:
environments/project/general/project_deleted_successfully: dbedf0f0739b822f3951de4aeb2fc26f
environments/project/general/project_name_settings_description: 079c6380ad539543a9aa8772bc1b0fa2
environments/project/general/project_name_updated_successfully: f95f70f4a49d451dc0441a51d05a3aa3
environments/project/general/recontact_waiting_time: 9c5ebb18960dec73def053de89e63272
environments/project/general/recontact_waiting_time: 0566dc710b4b9644e276e311b419c4c0
environments/project/general/recontact_waiting_time_settings_description: 8922cde1f95777f9a2747fb4bed57ab5
environments/project/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
environments/project/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
@@ -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
@@ -912,15 +915,12 @@ checksums:
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
environments/settings/billing/monthly_identified_users: 0795735f6b241d31edac576a77dd7e55
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
environments/settings/billing/plan_upgraded_successfully: 52e2a258cc9ca8a512c288bf6f18cf37
environments/settings/billing/premium_support_with_slas: 2e33d4442c16bfececa6cae7b2081e5d
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
environments/settings/billing/startup: 4c4ac5a0b9dc62100bca6c6465f31c4c
environments/settings/billing/startup_description: 964fcb2c77f49b80266c94606e3f4506
environments/settings/billing/switch_plan: fb3e1941051a4273ca29224803570f4b
environments/settings/billing/switch_plan_confirmation_text: 910a6df56964619975c6ed5651a55db7
environments/settings/billing/team_access_roles: 1cc4af14e589f6c09ab92a4f21958049
environments/settings/billing/unable_to_upgrade_plan: 50fc725609411d139e534c85eeb2879e
environments/settings/billing/unlimited_miu: 29c3f5bd01c2a09fdf1d3601665ce90f
@@ -1141,7 +1141,6 @@ checksums:
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
environments/surveys/edit/always_show_survey: b0ae6a873ce2eeb0aea2e6d4cb04c540
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
@@ -1224,8 +1223,7 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 8b4623eab862615fa60064400008eb23
environments/surveys/edit/decide_how_often_people_can_answer_this_survey: 58427b0f0a7a258c24fa2acd9913e95e
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
@@ -1253,7 +1251,7 @@ checksums:
environments/surveys/edit/equals_one_of: 369a451add4b79bc003f952f0e1bfcc9
environments/surveys/edit/error_publishing_survey: bf9fab1d8ea7132a2e9b4b7b09f18b1f
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: c6668f9cf127fd922bec695dc548fe12
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
@@ -1324,8 +1322,9 @@ checksums:
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 33f0320ec85067a06198a841348e9fc6
environments/surveys/edit/ignore_waiting_time_between_surveys: 8145b6aef535fde5ee54dea63e66f64a
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/ignore_global_waiting_time: 1e7f1465aeb6d26c325ad7f135b207a8
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
environments/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031
environments/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884
@@ -1392,9 +1391,10 @@ checksums:
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
environments/surveys/edit/overwrites_waiting_period_between_surveys_to_x_days: 8d5596b024cbe8c82b021dcf6c73ba05
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
@@ -1451,7 +1451,8 @@ checksums:
environments/surveys/edit/range: 1fad969ecf3de1c21df046b93053c422
environments/surveys/edit/recall_data: 39beabd626c0af15316885cff5d5d9b8
environments/surveys/edit/recall_information_from: 884cfd143456fab1a91f0744cc92f0c8
environments/surveys/edit/recontact_options: 0f570378a531da60448fde37abd50214
environments/surveys/edit/recontact_options_section: 57a23e1bcab6baa484b27b615e6c906a
environments/surveys/edit/recontact_options_section_description: 1e04011440c339a3b5cfff12d55b7f12
environments/surveys/edit/redirect_thank_you_card: 09f721c4b62e2584e40a53507092ea83
environments/surveys/edit/redirect_to_url: f17d726bbc3391561447b3f4010635cf
environments/surveys/edit/remove_description: b52de820b4bbcb354eb62246c4112a9a
@@ -1460,6 +1461,8 @@ checksums:
environments/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
environments/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a
environments/surveys/edit/reset_to_theme_styles_main_text: d86fb2213d3b2efbd0361526dc6cb27b
environments/surveys/edit/respect_global_waiting_time: 850e7e64ec890c591b2d07741ef26e11
environments/surveys/edit/respect_global_waiting_time_description: 5235fee102d619cb391c5aa2c75b61be
environments/surveys/edit/response_limit_can_t_be_set_to_0: 278664873ee3b1046dbcb58848efc12a
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
@@ -1484,7 +1487,7 @@ checksums:
environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
environments/surveys/edit/show_multiple_times: 5e6e0244c20feca78723c79aa1ddcf62
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
@@ -1514,13 +1517,12 @@ checksums:
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 219b15081cbafaa391e266bd2cc4c9d4
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: c145b7be481ae1fe6f66298d9a5cf838
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
environments/surveys/edit/this_extension_is_already_added: 201d636539836c95958e28cecd8f3240
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
environments/surveys/edit/this_setting_overwrites_your: 6f980149a5a4adc2cfe3dac4f367e7e5
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
@@ -1531,7 +1533,7 @@ checksums:
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
@@ -1539,7 +1541,6 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/use_with_caution: 7c35d3ad68dd001e53cbd9d57c96af91
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
@@ -1549,11 +1550,13 @@ checksums:
environments/surveys/edit/variable_used_in_recall_welcome: 60321b2f40ae01cd10f99ed77bb986ba
environments/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
environments/surveys/edit/waiting_period: 21775d12b2cb831134b1f47450eaf1f3
environments/surveys/edit/waiting_time_across_surveys: 5c5a7653d797c86c4008f13a40434ad8
environments/surveys/edit/waiting_time_across_surveys_description: 1bbee2fee49f842056547c336f8fd788
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
environments/surveys/edit/when_conditions_match_waiting_time_will_be_ignored_and_survey_shown: e7fe9c56664da4670e52e38656d8705d
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations: b12b28699e02ff9ba69bcbae838ba5da
@@ -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

@@ -1,6 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import { PrismaClient } from "@formbricks/database/generated/client";
export const prisma = mockDeep<PrismaClient>();

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";

View File

@@ -1,9 +1,9 @@
"use server";
import "server-only";
import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ActionClass, Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";

View File

@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";

View File

@@ -19,8 +19,7 @@ export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
// Other
export const CRON_SECRET = env.CRON_SECRET;
export const DEFAULT_BRAND_COLOR = "#64748b";
export const FB_LOGO_URL =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
export const FB_LOGO_URL = `${WEBAPP_URL}/logo-transparent.png`;
export const PRIVACY_URL = env.PRIVACY_URL;
export const TERMS_URL = env.TERMS_URL;
@@ -170,6 +169,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"de-DE",
"pt-BR",
"fr-FR",
"nl-NL",
"zh-Hant-TW",
"pt-PT",
"ro-RO",
@@ -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

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
@@ -42,7 +42,9 @@ export const getDisplayCountBySurveyId = reactCache(
}
);
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const deleteDisplay = async (displayId: string, tx?: any): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
const prismaClient = tx ?? prisma;

View File

@@ -9,9 +9,9 @@ import {
mockSurveyId,
} from "./__mocks__/data.mock";
import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { createDisplay } from "@/app/api/v1/client/[environmentId]/displays/lib/display";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "./auth";

View File

@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";

View File

@@ -1,6 +1,6 @@
import { EnvironmentType, Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EnvironmentType, Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment, getEnvironments, updateEnvironment } from "./service";

View File

@@ -1,8 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import type {

View File

@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {

View File

@@ -137,6 +137,7 @@ export const appLanguages = [
"ro-RO": "Engleză (SUA)",
"ja-JP": "英語(米国)",
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
},
},
{
@@ -151,6 +152,7 @@ export const appLanguages = [
"ro-RO": "Germană",
"ja-JP": "ドイツ語",
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
},
},
{
@@ -165,6 +167,7 @@ export const appLanguages = [
"ro-RO": "Portugheză (Brazilia)",
"ja-JP": "ポルトガル語(ブラジル)",
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
},
},
{
@@ -179,6 +182,7 @@ export const appLanguages = [
"ro-RO": "Franceză",
"ja-JP": "フランス語",
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
},
},
{
@@ -193,6 +197,7 @@ export const appLanguages = [
"ro-RO": "Chineză (Tradicională)",
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
},
},
{
@@ -207,6 +212,7 @@ export const appLanguages = [
"ro-RO": "Portugheză (Portugalia)",
"ja-JP": "ポルトガル語(ポルトガル)",
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
},
},
{
@@ -221,6 +227,7 @@ export const appLanguages = [
"ro-RO": "Română",
"ja-JP": "ルーマニア語",
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
},
},
{
@@ -235,6 +242,7 @@ export const appLanguages = [
"ro-RO": "Japoneză",
"ja-JP": "日本語",
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
},
},
{
@@ -249,6 +257,22 @@ export const appLanguages = [
"ro-RO": "Chineză (Simplificată)",
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
"de-DE": "Niederländisch",
"pt-BR": "Holandês",
"fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês",
"ro-RO": "Olandeză",
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
},
},
];

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
// Function to check if there are any users in the database

View File

@@ -1,6 +1,6 @@
import { IntegrationType, Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { IntegrationType, Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationInput } from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";

View File

@@ -6,9 +6,9 @@ import {
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { getProject } from "@/lib/project/service";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { createMembership, getMembershipByUserIdOrganizationId } from "./service";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";

View File

@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";

View File

@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";

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