diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx index 48562dc15c..b7f0c92018 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx @@ -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) => {
- {/* 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 */} { + 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); + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx index d2807c3269..e1928b5960 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx @@ -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 (
{IS_FORMBRICKS_CLOUD && ( @@ -122,26 +67,24 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
{children}
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index c54f43d783..1fc5cec100 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -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; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx index 94f06d7a07..85c1f083db 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx @@ -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} diff --git a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx index bd7e1a9a83..dd7fd851bb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx @@ -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(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>
- {currentOrganization.name} + {organizationName} {isPending && } {isOrganizationDropdownOpen ? ( @@ -142,30 +183,52 @@ export const OrganizationBreadcrumb = ({ {t("common.choose_organization")}
- - {organizations.map((org) => ( - handleOrganizationChange(org.id)} - className="cursor-pointer"> - {org.name} - - ))} - - {isMultiOrgEnabled && ( - setOpenCreateOrganizationModal(true)} - className="cursor-pointer"> - {t("common.create_new_organization")} - - + {isLoadingOrganizations && ( +
+ +
+ )} + {!isLoadingOrganizations && loadError && ( +
+

{loadError}

+ +
+ )} + {!isLoadingOrganizations && !loadError && ( + <> + + {organizations.map((org) => ( + handleOrganizationChange(org.id)} + className="cursor-pointer"> + {org.name} + + ))} + + {isMultiOrgEnabled && ( + setOpenCreateOrganizationModal(true)} + className="cursor-pointer"> + {t("common.create_new_organization")} + + + )} + )} )} {currentEnvironmentId && (
- + {showOrganizationDropdown && }
{t("common.organization_settings")} diff --git a/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx b/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx index 097b885ddb..7bad5faedd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/project-and-org-switch.tsx @@ -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 = ({ 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(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>
- {currentProject.name} + {projectName} {isPending && } {isProjectDropdownOpen ? ( @@ -181,26 +214,48 @@ export const ProjectBreadcrumb = ({ {t("common.choose_project")}
- - {projects.map((proj) => ( - handleProjectChange(proj.id)} - className="cursor-pointer"> -
- {proj.name} -
-
- ))} -
- {isOwnerOrManager && ( - - {t("common.add_new_project")} - - + {isLoadingProjects && ( +
+ +
+ )} + {!isLoadingProjects && loadError && ( +
+

{loadError}

+ +
+ )} + {!isLoadingProjects && !loadError && ( + <> + + {projects.map((proj) => ( + handleProjectChange(proj.id)} + className="cursor-pointer"> +
+ {proj.name} +
+
+ ))} +
+ {isOwnerOrManager && ( + + {t("common.add_new_project")} + + + )} + )} diff --git a/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx index 0bc15edbbe..202029a7ab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx @@ -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 ( diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index feb25e8fa9..ff2f4a07c5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -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 ( + session={layoutData.session} + user={layoutData.user} + organization={layoutData.organization}> - - - {children} - + + {children} ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx index 16373ad08c..656bf10708 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx @@ -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(null); + const [isNavigating, setIsNavigating] = useState(false); + + const idToIndexMap = useMemo(() => { + const map = new Map(); + 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 ( @@ -81,6 +100,11 @@ export const ResponseCardModal = ({ Survey Response Details + + + Response {currentIndex + 1} of {responses.length} + + -
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index f8c8c92645..43dc716858 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -122,12 +122,11 @@ export const ResponsePage = ({ useEffect(() => { setPage(1); setHasMore(true); - setResponses([]); }, [filters]); return ( <> -
+
( + +
+
+); + 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({}); const [rowSelection, setRowSelection] = useState({}); const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false); - const [selectedResponseId, setSelectedResponseId] = useState(null); const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null; const [isExpanded, setIsExpanded] = useState(null); const [columnOrder, setColumnOrder] = useState([]); @@ -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: () => ( - -
-
- ), + cell: SkeletonCell, })) : columns, [columns, isFetchingFirstPage] @@ -247,8 +261,8 @@ export const ResponseTable = ({ ))} - - + {/* disable auto animation if there are more than 200 responses for performance optimizations */} + 200 ? undefined : parent}> {table.getRowModel().rows.map((row) => ( ))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx index aee5297421..ed84ae5708 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx @@ -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; 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 = ({ ); }; + +export const ResponseTableCell = React.memo(ResponseTableCellComponent); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index 7f87fb50df..da03cb95f4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -312,6 +312,14 @@ export const generateResponseTableColumns = ( }, }; + const singleUseIdColumn: ColumnDef = { + accessorKey: "singleUseId", + header: () =>
{t("environments.surveys.responses.single_use_id")}
, + cell: ({ row }) => { + return

{row.original.singleUseId}

; + }, + }; + const quotasColumn: ColumnDef = { 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, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx index caabbc6db1..5c0336f0d7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx @@ -86,7 +86,7 @@ export const MultipleChoiceSummary = ({ } />
- {results.map((result, resultsIdx) => { + {results.map((result) => { const choiceId = getChoiceIdByValue(result.value, questionSummary.question); return ( @@ -107,7 +107,7 @@ export const MultipleChoiceSummary = ({

- {results.length - resultsIdx} - {result.value} + {result.value}

{choiceId && }
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx index 5647bb7603..59c9d5b531 100755 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -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.FROM); const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState(false); + const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState(false); const [hoveredRange, setHoveredRange] = useState(null); const [isDownloading, setIsDownloading] = useState(false); @@ -270,201 +270,179 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { useClickOutside(datePickerRef, () => handleDatePickerClose()); return ( - <> -
-
- - { - value && handleDatePickerClose(); - setIsFilterDropDownOpen(value); - }}> - -
- - {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} - - {isFilterDropDownOpen ? ( - - ) : ( - - )} -
-
- - { - setFilterRange(getFilterDropDownLabels(t).ALL_TIME); - setDateRange({ from: undefined, to: getTodayDate() }); - }}> -

{getFilterDropDownLabels(t).ALL_TIME}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS); - setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() }); - }}> -

{getFilterDropDownLabels(t).LAST_7_DAYS}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS); - setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() }); - }}> -

{getFilterDropDownLabels(t).LAST_30_DAYS}

-
- { - setFilterRange(getFilterDropDownLabels(t).THIS_MONTH); - setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() }); - }}> -

{getFilterDropDownLabels(t).THIS_MONTH}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_MONTH); - setDateRange({ - from: startOfMonth(subMonths(new Date(), 1)), - to: endOfMonth(subMonths(getTodayDate(), 1)), - }); - }}> -

{getFilterDropDownLabels(t).LAST_MONTH}

-
- { - setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER); - setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) }); - }}> -

{getFilterDropDownLabels(t).THIS_QUARTER}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER); - setDateRange({ - from: startOfQuarter(subQuarters(new Date(), 1)), - to: endOfQuarter(subQuarters(getTodayDate(), 1)), - }); - }}> -

{getFilterDropDownLabels(t).LAST_QUARTER}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS); - setDateRange({ - from: startOfMonth(subMonths(new Date(), 6)), - to: endOfMonth(getTodayDate()), - }); - }}> -

{getFilterDropDownLabels(t).LAST_6_MONTHS}

-
- { - setFilterRange(getFilterDropDownLabels(t).THIS_YEAR); - setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) }); - }}> -

{getFilterDropDownLabels(t).THIS_YEAR}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_YEAR); - setDateRange({ - from: startOfYear(subYears(new Date(), 1)), - to: endOfYear(subYears(getTodayDate(), 1)), - }); - }}> -

{getFilterDropDownLabels(t).LAST_YEAR}

-
- { - setIsDatePickerOpen(true); - setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE); - setSelectingDate(DateSelected.FROM); - }}> -

- {getFilterDropDownLabels(t).CUSTOM_RANGE} -

-
-
-
- { - value && handleDatePickerClose(); - }}> - -
-
- {t("common.download")} - {isDownloading ? ( - - ) : ( - - )} -
- -
-
+
+
+ + { + value && handleDatePickerClose(); + setIsFilterDropDownOpen(value); + }}> + + + {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} + + + + { + setFilterRange(getFilterDropDownLabels(t).ALL_TIME); + setDateRange({ from: undefined, to: getTodayDate() }); + }}> +

{getFilterDropDownLabels(t).ALL_TIME}

+
+ { + setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS); + setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() }); + }}> +

{getFilterDropDownLabels(t).LAST_7_DAYS}

+
+ { + setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS); + setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() }); + }}> +

{getFilterDropDownLabels(t).LAST_30_DAYS}

+
+ { + setFilterRange(getFilterDropDownLabels(t).THIS_MONTH); + setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() }); + }}> +

{getFilterDropDownLabels(t).THIS_MONTH}

+
+ { + setFilterRange(getFilterDropDownLabels(t).LAST_MONTH); + setDateRange({ + from: startOfMonth(subMonths(new Date(), 1)), + to: endOfMonth(subMonths(getTodayDate(), 1)), + }); + }}> +

{getFilterDropDownLabels(t).LAST_MONTH}

+
+ { + setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER); + setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) }); + }}> +

{getFilterDropDownLabels(t).THIS_QUARTER}

+
+ { + setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER); + setDateRange({ + from: startOfQuarter(subQuarters(new Date(), 1)), + to: endOfQuarter(subQuarters(getTodayDate(), 1)), + }); + }}> +

{getFilterDropDownLabels(t).LAST_QUARTER}

+
+ { + setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS); + setDateRange({ + from: startOfMonth(subMonths(new Date(), 6)), + to: endOfMonth(getTodayDate()), + }); + }}> +

{getFilterDropDownLabels(t).LAST_6_MONTHS}

+
+ { + setFilterRange(getFilterDropDownLabels(t).THIS_YEAR); + setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) }); + }}> +

{getFilterDropDownLabels(t).THIS_YEAR}

+
+ { + setFilterRange(getFilterDropDownLabels(t).LAST_YEAR); + setDateRange({ + from: startOfYear(subYears(new Date(), 1)), + to: endOfYear(subYears(getTodayDate(), 1)), + }); + }}> +

{getFilterDropDownLabels(t).LAST_YEAR}

+
+ { + setIsDatePickerOpen(true); + setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE); + setSelectingDate(DateSelected.FROM); + }}> +

{getFilterDropDownLabels(t).CUSTOM_RANGE}

+
+
+
+ { + value && handleDatePickerClose(); + setIsDownloadDropDownOpen(value); + }}> + + + + {t("common.download")} + {isDownloading && } + + + - - { - await handleDownloadResponses(FilterDownload.ALL, "csv"); - }}> -

{t("environments.surveys.summary.all_responses_csv")}

-
- { - await handleDownloadResponses(FilterDownload.ALL, "xlsx"); - }}> -

{t("environments.surveys.summary.all_responses_excel")}

-
- { - await handleDownloadResponses(FilterDownload.FILTER, "csv"); - }}> -

{t("environments.surveys.summary.filtered_responses_csv")}

-
- { - await handleDownloadResponses(FilterDownload.FILTER, "xlsx"); - }}> -

{t("environments.surveys.summary.filtered_responses_excel")}

-
-
-
-
- {isDatePickerOpen && ( -
- handleDateChange(date)} - onDayMouseEnter={handleDateHoveredChange} - onDayMouseLeave={() => setHoveredRange(null)} - classNames={{ - day_today: "hover:bg-slate-200 bg-white", - }} - /> -
- )} + + { + await handleDownloadResponses(FilterDownload.ALL, "csv"); + }}> +

{t("environments.surveys.summary.all_responses_csv")}

+
+ { + await handleDownloadResponses(FilterDownload.ALL, "xlsx"); + }}> +

{t("environments.surveys.summary.all_responses_excel")}

+
+ { + await handleDownloadResponses(FilterDownload.FILTER, "csv"); + }}> +

{t("environments.surveys.summary.filtered_responses_csv")}

+
+ { + await handleDownloadResponses(FilterDownload.FILTER, "xlsx"); + }}> +

{t("environments.surveys.summary.filtered_responses_excel")}

+
+
+
- + {isDatePickerOpen && ( +
+ handleDateChange(date)} + onDayMouseEnter={handleDateHoveredChange} + onDayMouseLeave={() => setHoveredRange(null)} + classNames={{ + day_today: "hover:bg-slate-200 bg-white", + }} + /> +
+ )} +
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx index 8ee2d92ef6..84fa049b3f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx @@ -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(false); - const commandRef = React.useRef(null); - const [searchQuery, setSearchQuery] = React.useState(""); - 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) ? ( -

{filterComboBoxValue}

- ) : ( -
- {typeof filterComboBoxValue !== "string" && - filterComboBoxValue?.map((o, index) => ( - - ))} -
+ 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) => ( + ); - 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 ( +
+ {filterComboBoxValue.map((value, index) => renderTag(value, index))} +
+ ); + }; + + // Render the appropriate content based on filterComboBoxValue state + const renderComboBoxContent = () => { + if (!filterComboBoxValue || filterComboBoxValue.length === 0) { + return ( +

+ {t("common.select")}... +

); } - if (!isMultiple) { - setOpen(false); + + if (Array.isArray(filterComboBoxValue)) { + return renderMultiSelectTags(); } + + return

{filterComboBoxValue}

; }; return ( -
- {filterOptions && filterOptions?.length <= 1 ? ( -
-

{filterValue}

+
+ {filterOptions && filterOptions.length <= 1 ? ( +
+

{filterValue}

) : ( { - value && setOpen(false); - setOpenFilterValue(value); + if (value) setOpen(false); }}> -
- {!filterValue ? ( -

{t("common.select")}...

- ) : ( -

{filterValue}

- )} - {filterOptions && filterOptions.length > 1 && ( - <> - {openFilterValue ? ( - - ) : ( - - )} - - )} -
+ {filterValue ? ( +

{filterValue}

+ ) : ( +

{t("common.select")}...

+ )} + {filterOptions && filterOptions.length > 1 && ( + + )}
- + {filterOptions?.map((o, index) => ( onChangeFilterValue(o)}> {o} @@ -166,78 +211,78 @@ export const QuestionFilterComboBox = ({
)} + {isTextInputField ? ( 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" /> ) : ( - + + {/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
- {filterComboBoxValue && filterComboBoxValue.length > 0 ? ( - filterComboBoxItem - ) : ( - + "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" )} - + onClick={handleOpenDropdown} + onKeyDown={(e) => { + const isActivationKey = e.key === "Enter" || e.key === " "; + if (isActivationKey && !isComboBoxDisabled) { + e.preventDefault(); + handleOpenDropdown(); + } + }}> +
{renderComboBoxContent()}
+ +
-
- {open && ( -
- -
- setSearchQuery(e.target.value)} - className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300" - /> -
- {t("common.no_result_found")} - - {filteredOptions?.map((o, index) => ( + + {open && ( +
+ + + {t("common.no_result_found")} + + {filteredOptions?.map((o) => { + const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o; + return ( commandItemOnSelect(o)} + key={optionValue} + onSelect={() => handleCommandItemSelect(o)} className="cursor-pointer"> - {typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o} + {optionValue} - ))} - - -
- )} -
+ ); + })} + + +
+ )}
)}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx index ea96d06d63..edcfd09676 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx @@ -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 ? : null; + return IconComponent ? : null; +}; + +const getIconBackground = (type: OptionsType | string): string => { + const backgroundMap: Record = { + [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) => { - 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 ( -
- {getIconType()} -

+

+ + {getDisplayIcon()} + +

{typeof label === "string" ? label : getLocalizedValue(label, "default")}

@@ -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 ( - - -
- {open && ( -
- - {t("common.no_result_found")} - {options?.map((data) => ( - - {data?.option.length > 0 && ( - {data.header}

}> - {data?.option?.map((o, i) => ( - { - setInputValue(""); - onChangeValue(o); - setOpen(false); - }} - className="cursor-pointer"> - - - ))} -
- )} -
- ))} -
-
- )} +
+ + {open && ( +
+ + {t("common.no_result_found")} + {options?.map((data) => ( + + {data?.option.length > 0 && ( + {data.header}

}> + {data?.option?.map((o) => ( + { + setInputValue(""); + onChangeValue(o); + setOpen(false); + }}> + + + ))} +
+ )} +
+ ))} +
+
+ )}
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx index 1a82db9ec3..a1d8c841b6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx @@ -31,6 +31,32 @@ export type QuestionFilterOptions = { id: string; }; +interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes { + isOpen: boolean; + children: React.ReactNode; +} + +export const PopoverTriggerButton = React.forwardRef( + ({ isOpen, children, ...props }, ref) => ( + + ) +); + +PopoverTriggerButton.displayName = "PopoverTriggerButton"; + interface ResponseFilterProps { survey: TSurvey; } @@ -108,7 +134,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { useEffect(() => { if (!isOpen) { clearItem(); - handleApplyFilters(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); @@ -127,8 +152,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { }; const handleClearAllFilters = () => { - setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" })); - setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" })); + const clearedFilters = { filter: [], responseStatus: "all" as const }; + setFilterValue(clearedFilters); + setSelectedFilter(clearedFilters); setIsOpen(false); }; @@ -184,9 +210,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { }; const handleOpenChange = (open: boolean) => { - if (!open) { - handleApplyFilters(); - } setIsOpen(open); }; @@ -196,36 +219,26 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { return ( - - + + Filter {filterValue.filter.length > 0 && `(${filterValue.filter.length})`} - -
- {isOpen ? ( - - ) : ( - - )} -
+
event.preventDefault()}> -
-

+

+

{t("environments.surveys.summary.show_all_responses_that_match")}

-

- {t("environments.surveys.summary.show_all_responses_where")} -