Compare commits

...

34 Commits

Author SHA1 Message Date
Victor Hugo dos Santos
e29a67b1f6 chore: run checks for PR 6304 (#6309)
Co-authored-by: ompharate <ompharate31@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-29 22:20:01 +00:00
Anshuman Pandey
78f5de2f35 fix: adds swift and kotlin language conventions to formbricks docs (#6316) 2025-07-29 11:09:01 +00:00
Dhruwang Jariwala
b1a35d4a69 fix: unformatted db message in client display api (#6176) 2025-07-29 04:08:16 +00:00
Dhruwang Jariwala
2166c44470 feat: ID badge component (#6281) 2025-07-28 09:44:43 +00:00
Anshuman Pandey
080cf741e9 fix: adds api v1/responses docs for limit and skip parameters (#6314) 2025-07-28 07:44:04 +00:00
Anshuman Pandey
8881691509 refactor: refurbish logic editor UI (#6216) 2025-07-25 12:05:49 +00:00
Anshuman Pandey
3045f4437f fix: fixes status schedule updation (#6312) 2025-07-25 10:27:28 +00:00
Dhruwang Jariwala
91ace0e821 fix: scroll to bottom on error (#6301) 2025-07-25 09:11:41 +00:00
Dhruwang Jariwala
6ef281647a fix: unauthorised error on survey list page (#6302) 2025-07-25 06:10:48 +00:00
Dhruwang Jariwala
0aaaaa54ee chore: Don't force Project Onboarding for each project (#6299)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-25 06:10:30 +00:00
Harsh Bhat
b1f78e7bf2 docs: webhook payload (#6307) 2025-07-25 06:00:00 +00:00
Piyush Gupta
7086ce2ca3 fix: removes unused translations (#6308) 2025-07-24 12:55:02 +00:00
Piyush Gupta
8f8b549b1d chore: Remove the public result sharing page. (#6298)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-24 12:06:59 +00:00
Piyush Gupta
28514487e0 chore: sunset weekly summary (#6282) 2025-07-24 12:01:39 +00:00
Piyush Gupta
ee20af54c3 feat: adds an underline option in the rich text editor (#6274)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-23 10:54:05 +00:00
Johannes
d08ec4c9ab docs: Fix domain split docs (#6300)
Co-authored-by: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com>
2025-07-23 03:54:53 -07:00
Piyush Gupta
891c83e232 fix: CTA question button URL validation (#6284)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-23 05:48:18 +00:00
Johannes
0b02b00b72 fix: link input length and accessibility error (#6283)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-23 05:01:16 +00:00
Harsh Thakur
a217cdd501 fix: email embed preview spacing issue (#6262) 2025-07-22 17:14:07 +00:00
om pharate
ebe50a4821 fix: render copy link button based on single use survey (#6288) 2025-07-22 16:31:54 +00:00
Johannes
cb68d9defc chore: enable blank issue (#6291) 2025-07-22 10:02:49 -07:00
Victor Hugo dos Santos
c42a706789 fix: Experimental workflow package.json version update (#6287) 2025-07-22 15:19:38 +00:00
Anshuman Pandey
3803111b19 fix: fixes personalized links when single use id is enabled (#6270) 2025-07-22 12:08:45 +00:00
Dhruwang Jariwala
30fdcff737 feat: reset survey (#6267) 2025-07-22 12:04:26 +00:00
Dhruwang Jariwala
e83cfa85a4 fix: github annotations (#6240) 2025-07-22 10:38:34 +00:00
Piyush Gupta
eee9ee8995 chore: Replaces Unkey and Update rate limiting in the management API v2. (#6273) 2025-07-22 09:33:29 +00:00
Dhruwang Jariwala
ed89f12af8 chore: rate limiting for server actions (#6271) 2025-07-22 09:18:12 +00:00
Piyush Gupta
f043314537 fix: required action revert logic (#6269) 2025-07-22 04:10:09 +00:00
Victor Hugo dos Santos
2ce842dd8d chore: updated SAML SSO docs (#6280) 2025-07-22 04:09:11 +00:00
Johannes
43b43839c5 chore: auto-add bug to eng project (#6277) 2025-07-21 08:33:27 -07:00
Piyush Gupta
8b6e3fec37 fix: response filters icons and text (#6266) 2025-07-21 08:48:10 +00:00
Anshuman Pandey
31bcf98779 fix: fixes PIN 4 digit length error (#6265) 2025-07-21 07:30:03 +00:00
Matti Nannt
b35cabcbcc chore(infra): enable cluster public access to mitigate tailscale issues (#6264) 2025-07-19 08:53:31 +02:00
Matti Nannt
4f435f1a1f fix: enable Tailscale subnet routes for EKS access (#6263) 2025-07-18 21:32:01 +02:00
310 changed files with 7740 additions and 7095 deletions

View File

@@ -18,7 +18,6 @@ apps/web/
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components
├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
### Dynamic Routes
- `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern

View File

@@ -291,11 +291,6 @@ test("handles different modes", async () => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {

View File

@@ -194,9 +194,6 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=

View File

@@ -1,6 +1,7 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug
projects: "formbricks/8"
labels: ["bug"]
body:
- type: textarea

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Questions
url: https://github.com/formbricks/formbricks/discussions

View File

@@ -52,6 +52,7 @@ jobs:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
args: --accept-routes
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0

View File

@@ -76,12 +76,9 @@ jobs:
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
env:
VERSION: ${{ env.VERSION }}
run: |
cd ./apps/web
npm version $VERSION --no-git-tag-version
echo "Updated version to: $(npm pkg get version)"
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set Sentry environment in .env
run: |

View File

@@ -1,13 +1,16 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path";
const require = createRequire(import.meta.url);
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
const getAbsolutePath = (value: string) => {
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json")));
};
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],

View File

@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {

View File

@@ -9,8 +9,12 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import {
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -49,10 +53,14 @@ vi.mock("@/lib/membership/utils", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
getRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
getTeamsByOrganizationId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
@@ -104,7 +118,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
} as unknown as TUser;
const mockOrganization = {
@@ -156,6 +170,17 @@ const mockProjectPermission = {
role: "admin",
} as any;
const mockOrganizationTeams = [
{
id: "team-1",
name: "Development Team",
},
{
id: "team-2",
name: "Marketing Team",
},
];
const mockSession: Session = {
user: {
id: "user-1",
@@ -176,6 +201,8 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("passes canDoRoleManagement props to MainNavigation", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
});
test("handles empty organizationTeams array", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles null organizationTeams", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles canDoRoleManagement false", async () => {
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();

View File

@@ -13,7 +13,10 @@ import {
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import {
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
@@ -48,9 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found"));
}
const [projects, environments] = await Promise.all([
const [projects, environments, canDoRoleManagement] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
getRoleManagementPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
@@ -117,6 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
canDoRoleManagement={canDoRoleManagement}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<TopControlBar

View File

@@ -1,4 +1,5 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
@@ -52,9 +53,19 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
canDoRoleManagement,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
</div>
),
}));
@@ -106,7 +117,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;
@@ -146,6 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
canDoRoleManagement: true,
};
describe("MainNavigation", () => {
@@ -334,4 +346,23 @@ describe("MainNavigation", () => {
});
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles canDoRoleManagement false", () => {
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
});
});

View File

@@ -66,6 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
canDoRoleManagement: boolean;
}
export const MainNavigation = ({
@@ -80,6 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
canDoRoleManagement,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -323,6 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
canDoRoleManagement={canDoRoleManagement}
/>
)}

View File

@@ -7,6 +7,7 @@ import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
@@ -35,6 +36,7 @@ export const EnvironmentContextWrapper = ({
() => ({
environment,
project,
organizationId: project.organizationId,
}),
[environment, project]
);

View File

@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
locale: TUserLocale;
}
const tableHeaders = [
"common.survey",
"environments.integrations.airtable.table_name",
"common.questions",
"common.updated_at",
];
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslate();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{t(header)}
{header}
</div>
))}
</div>

View File

@@ -220,7 +220,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -258,7 +257,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,

View File

@@ -236,7 +236,6 @@ const surveys: TSurvey[] = [
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -272,7 +271,6 @@ const surveys: TSurvey[] = [
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,

View File

@@ -226,7 +226,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -264,7 +263,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
languages: [],
styling: null,
segment: null,
resultShareKey: null,
displayPercentage: null,
closeOnDate: null,
runOnDate: null,

View File

@@ -49,7 +49,6 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "project_manager",

View File

@@ -1,166 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { EditWeeklySummary } from "./EditWeeklySummary";
vi.mock("lucide-react", () => ({
UsersIcon: () => <div data-testid="users-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="link">
{children}
</a>
),
}));
const mockNotificationSwitch = vi.fn();
vi.mock("./NotificationSwitch", () => ({
NotificationSwitch: (props: any) => {
mockNotificationSwitch(props);
return (
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
NotificationSwitch
</div>
);
},
}));
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockT,
}),
}));
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {
proj1: true,
proj3: false,
},
unsubscribedOrganizationIds: [],
},
role: "project_manager",
objective: "other",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
} as unknown as TUser;
const mockMemberships: Membership[] = [
{
organization: {
id: "org1",
name: "Organization 1",
projects: [
{ id: "proj1", name: "Project 1", environments: [] },
{ id: "proj2", name: "Project 2", environments: [] },
],
},
},
{
organization: {
id: "org2",
name: "Organization 2",
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
},
},
];
const environmentId = "test-env-id";
describe("EditWeeklySummary", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with multiple memberships and projects", () => {
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
expect(screen.getByText("Organization 1")).toBeInTheDocument();
expect(screen.getByText("Project 1")).toBeInTheDocument();
expect(screen.getByText("Project 2")).toBeInTheDocument();
expect(screen.getByText("Organization 2")).toBeInTheDocument();
expect(screen.getByText("Project 3")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj1",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj2",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj3",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
const inviteLinks = screen.getAllByTestId("link");
expect(inviteLinks.length).toBe(mockMemberships.length);
inviteLinks.forEach((link) => {
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
expect(link).toHaveTextContent("common.invite_them");
});
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
expect(
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
).toBe(mockMemberships.length);
});
test("renders correctly with no memberships", () => {
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
});
test("renders correctly when an organization has no projects", () => {
const membershipsWithNoProjects: Membership[] = [
{
organization: {
id: "org3",
name: "Organization No Projects",
projects: [],
},
},
];
render(
<EditWeeklySummary
memberships={membershipsWithNoProjects}
user={mockUser}
environmentId={environmentId}
/>
);
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
});
});

View File

@@ -1,59 +0,0 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: TUser;
environmentId: string;
}
export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => {
const { t } = useTranslate();
return (
<>
{memberships.map((membership) => (
<div key={membership.organization.id}>
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
<UsersIcon className="h-6 w-7 text-slate-600" />
<p className="text-slate-800">{membership.organization.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("common.project")}</div>
<div className="col-span-1 text-center">{t("common.weekly_summary")}</div>
</div>
<div className="space-y-1 p-2">
{membership.organization.projects.map((project) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center justify-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={project.id}>
<div className="col-span-2">{project?.name}</div>
<div className="col-span-1 flex items-center justify-center">
<NotificationSwitch
surveyOrProjectOrOrganizationId={project.id}
notificationSettings={user.notificationSettings!}
notificationType={"weeklySummary"}
/>
</div>
</div>
))}
</div>
<p className="pb-3 pl-4 text-xs text-slate-400">
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
{t("common.invite_them")}
</Link>
</p>
</div>
</div>
))}
</>
);
};

View File

@@ -29,7 +29,6 @@ const organizationId = "org1";
const baseNotificationSettings: TUserNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
expect(switchInput.checked).toBe(false);
});
test("renders with initial checked state for 'weeklySummary' (true)", () => {
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: settings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText(
"toggle notification settings for weeklySummary"
) as HTMLInputElement;
expect(switchInput.checked).toBe(true);
});
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
const mockErrorResponse = { serverError: "Database connection failed" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: initialSettings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
});
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);

View File

@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string;
autoDisableNotificationElementId?: string;
}

View File

@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
.getByText("environments.settings.notifications.email_alerts_surveys")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(alertsCard).toBeInTheDocument();
// Check for Weekly Summary LoadingCard
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
const weeklySummaryCard = screen
.getByText("environments.settings.notifications.weekly_summary_projects")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(weeklySummaryCard).toBeInTheDocument();
});
});

View File

@@ -14,11 +14,6 @@ const Loading = () => {
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
{
title: t("environments.settings.notifications.weekly_summary_projects"),
description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
];
return (

View File

@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import Page from "./page";
import { Membership } from "./types";
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
vi.mock("./components/EditAlerts", () => ({
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
}));
vi.mock("./components/EditWeeklySummary", () => ({
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
}));
vi.mock("./components/IntegrationsTip", () => ({
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
}));
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
email: "test@example.com",
notificationSettings: {
alert: { "survey-old": true },
weeklySummary: { "project-old": true },
unsubscribedOrganizationIds: ["org-unsubscribed"],
},
};
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
).toBeInTheDocument();
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
// after `setCompleteNotificationSettings` processes it.
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
// It iterates memberships, then projects, then environments, then surveys.
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
// This means only survey IDs found in memberships will be in the new `alert` object.
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
const finalExpectedSettings = {
alert: {
"survey-1": false,
"survey-2": false,
},
weeklySummary: {
"project-1": false,
},
unsubscribedOrganizationIds: ["org-unsubscribed"],
};
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
});
test("throws error if session is not found", async () => {
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
render(PageComponent);
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
const expectedEmptySettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editAlertsCall.memberships).toEqual([]);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editWeeklySummaryCall.memberships).toEqual([]);
});
test("handles legacy notification settings correctly", async () => {
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
id: "user-legacy",
notificationSettings: {
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
weeklySummary: { "project-1": true },
unsubscribedOrganizationIds: [],
} as any, // To allow legacy structure
};
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
"survey-1": true, // Should be true due to legacy setting
"survey-2": false, // Default for other surveys in membership
},
weeklySummary: {
"project-1": true, // From user's weeklySummary
},
unsubscribedOrganizationIds: [],
};

View File

@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import { IntegrationsTip } from "./components/IntegrationsTip";
import type { Membership } from "./types";
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
for (const project of membership.organization.projects) {
// set default values for weekly summary
newNotificationSettings.weeklySummary[project.id] =
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false;
// set default values for alerts
for (const environment of project.environments) {
for (const survey of environment.surveys) {
@@ -183,11 +178,6 @@ const Page = async (props) => {
/>
</SettingsCard>
<IntegrationsTip environmentId={params.environmentId} />
<SettingsCard
title={t("environments.settings.notifications.weekly_summary_projects")}
description={t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")}>
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
</PageContentWrapper>
);
};

View File

@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
AuthenticationError,
AuthorizationError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
TUserPersonalInfoUpdateInput,
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
)
);
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),

View File

@@ -20,7 +20,7 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

@@ -15,7 +15,7 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -13,7 +13,7 @@ const mockUser = {
locale: "en-US",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

@@ -76,7 +76,7 @@ const mockUser = {
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",

View File

@@ -129,7 +129,7 @@ const mockUser = {
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;

View File

@@ -58,7 +58,6 @@ vi.mock("@/lib/env", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys");
vi.mock("@/app/share/[sharingKey]/actions");
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
}));
@@ -112,7 +111,6 @@ const mockSurvey = {
surveyClosedMessage: null,
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
segment: null,
resultShareKey: null,
closeOnDate: null,
delay: 0,
autoComplete: null,
@@ -171,22 +169,6 @@ describe("SurveyAnalysisNavigation", () => {
);
});
test("renders navigation correctly for sharing page", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(MockSecondaryNavigation).toHaveBeenCalled();
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
});
test("displays correct response count string in label for various scenarios", async () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`

View File

@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { usePathname } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps {
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslate();
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const navigation = [
{

View File

@@ -50,7 +50,6 @@ const mockSurvey = {
isBackButtonHidden: false,
pin: null,
recontactDays: null,
resultShareKey: null,
runOnDate: null,
showLanguageSwitch: false,
singleUse: null,

View File

@@ -113,7 +113,6 @@ const mockSurvey = {
singleUse: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
styling: null,
@@ -139,7 +138,7 @@ const mockUser = {
updatedAt: new Date(),
role: "project_manager",
objective: "increase_conversion",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [

View File

@@ -88,7 +88,6 @@ const mockSurvey = {
surveyClosedMessage: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;

View File

@@ -28,19 +28,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
ResultsShareButton: vi.fn(() => <div data-testid="results-share-button">ResultsShareButton</div>),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
getFormattedFilters: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(),
getResponsesBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
@@ -64,12 +55,6 @@ const mockGetResponseCountAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
.getResponseCountAction
);
const mockGetResponsesBySurveySharingKeyAction = vi.mocked(
(await import("@/app/share/[sharingKey]/actions")).getResponsesBySurveySharingKeyAction
);
const mockGetResponseCountBySurveySharingKeyAction = vi.mocked(
(await import("@/app/share/[sharingKey]/actions")).getResponseCountBySurveySharingKeyAction
);
const mockUseParams = vi.mocked((await import("next/navigation")).useParams);
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
@@ -150,8 +135,6 @@ describe("ResponsePage", () => {
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
mockGetFormattedFilters.mockReturnValue({});
});
@@ -159,28 +142,11 @@ describe("ResponsePage", () => {
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
expect(mockGetResponsesAction).toHaveBeenCalled();
});
test("does not render ResultsShareButton when isReadOnly is true", async () => {
render(<ResponsePage {...defaultProps} isReadOnly={true} />);
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
});
test("does not render ResultsShareButton when on sharing page", async () => {
mockUseParams.mockReturnValue({ sharingKey: "share123" });
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
});
test("fetches next page of responses", async () => {
const { rerender } = render(<ResponsePage {...defaultProps} />);
await waitFor(() => {

View File

@@ -4,11 +4,9 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
@@ -20,7 +18,6 @@ interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
publicDomain: string;
user?: TUser;
environmentTags: TTag[];
responsesPerPage: number;
@@ -32,17 +29,12 @@ export const ResponsePage = ({
environment,
survey,
surveyId,
publicDomain,
user,
environmentTags,
responsesPerPage,
locale,
isReadOnly,
}: ResponsePageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -63,30 +55,20 @@ export const ResponsePage = ({
let newResponses: TResponse[] = [];
if (isSharingPage) {
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
sharingKey: sharingKey,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
} else {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
}
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
setHasMore(false);
}
setResponses([...responses, ...newResponses]);
setPage(newPage);
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
}, [filters, page, responses, responsesPerPage, surveyId]);
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
@@ -114,25 +96,14 @@ export const ResponsePage = ({
setFetchingFirstPage(true);
let responses: TResponse[] = [];
if (isSharingPage) {
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
sharingKey,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
responses = getResponsesActionResponse?.data || [];
} else {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
responses = getResponsesActionResponse?.data || [];
}
responses = getResponsesActionResponse?.data || [];
if (responses.length < responsesPerPage) {
setHasMore(false);
@@ -143,7 +114,7 @@ export const ResponsePage = ({
}
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
@@ -155,7 +126,6 @@ export const ResponsePage = ({
<>
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
</div>
<ResponseDataView
survey={survey}

View File

@@ -156,7 +156,6 @@ const mockSurvey = {
projectOverwrites: null,
singleUse: null,
pin: null,
resultShareKey: null,
surveyClosedMessage: null,
welcomeCard: {
enabled: false,

View File

@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
@@ -115,7 +120,6 @@ vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-env-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
@@ -178,6 +182,7 @@ describe("ResponsesPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
});
@@ -206,6 +211,8 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
publicDomain: mockPublicDomain,
responseCount: 10,
displayCount: 5,
}),
undefined
);
@@ -224,12 +231,10 @@ describe("ResponsesPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
publicDomain: mockPublicDomain,
environmentTags: mockTags,
user: mockUser,
responsesPerPage: 10,
locale: mockLocale,
isReadOnly: false,
}),
undefined
);

View File

@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -40,6 +41,7 @@ const Page = async (props) => {
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
@@ -56,6 +58,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -67,7 +70,6 @@ const Page = async (props) => {
environment={environment}
survey={survey}
surveyId={params.surveyId}
publicDomain={publicDomain}
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}

View File

@@ -14,10 +14,10 @@ import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -64,143 +64,60 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
);
});
const ZGenerateResultShareUrlAction = z.object({
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
const ZGetResultShareUrlAction = z.object({
surveyId: ZId,
});
export const getResultShareUrlAction = authenticatedActionClient
.schema(ZGetResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
return survey.resultShareKey;
});
const ZDeleteResultShareUrlAction = z.object({
surveyId: ZId,
});
export const deleteResultShareUrlAction = authenticatedActionClient
.schema(ZDeleteResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
)
);
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,

View File

@@ -1,150 +0,0 @@
import { ShareSurveyResults } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock Button
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, onClick, asChild, ...props }: any) => {
if (asChild) {
// For 'asChild', Button renders its children, potentially passing props via Slot.
// Mocking simply renders children inside a div that can receive Button's props.
return <div {...props}>{children}</div>;
}
return (
<button onClick={onClick} {...props}>
{children}
</button>
);
}),
}));
// Mock Dialog
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, ...props }) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: vi.fn(() => ({
t: (key: string) => key,
})),
}));
// Mock Next Link
vi.mock("next/link", () => ({
default: vi.fn(({ children, href, target, rel, ...props }) => (
<a href={href} target={target} rel={rel} {...props}>
{children}
</a>
)),
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockSetOpen = vi.fn();
const mockHandlePublish = vi.fn();
const mockHandleUnpublish = vi.fn();
const surveyUrl = "https://app.formbricks.com/s/some-survey-id";
const defaultProps = {
open: true,
setOpen: mockSetOpen,
handlePublish: mockHandlePublish,
handleUnpublish: mockHandleUnpublish,
showPublishModal: false,
surveyUrl: "",
};
describe("ShareSurveyResults", () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock navigator.clipboard
Object.defineProperty(global.navigator, "clipboard", {
value: {
writeText: vi.fn(() => Promise.resolve()),
},
configurable: true,
});
});
afterEach(() => {
cleanup();
});
test("renders publish warning when showPublishModal is false", async () => {
render(<ShareSurveyResults {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.publish_to_web_warning_description")
).toBeInTheDocument();
const publishButton = screen.getByText("environments.surveys.summary.publish_to_web");
expect(publishButton).toBeInTheDocument();
await userEvent.click(publishButton);
expect(mockHandlePublish).toHaveBeenCalledTimes(1);
});
test("renders survey public info when showPublishModal is true and surveyUrl is provided", async () => {
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl={surveyUrl} />);
expect(screen.getByText("environments.surveys.summary.survey_results_are_public")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")
).toBeInTheDocument();
expect(screen.getByText(surveyUrl)).toBeInTheDocument();
const copyButton = screen.getByRole("button", { name: "Copy survey link to clipboard" });
expect(copyButton).toBeInTheDocument();
await userEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("common.link_copied");
const unpublishButton = screen.getByText("environments.surveys.summary.unpublish_from_web");
expect(unpublishButton).toBeInTheDocument();
await userEvent.click(unpublishButton);
expect(mockHandleUnpublish).toHaveBeenCalledTimes(1);
const viewSiteLink = screen.getByText("environments.surveys.summary.view_site");
expect(viewSiteLink).toBeInTheDocument();
const anchor = viewSiteLink.closest("a");
expect(anchor).toHaveAttribute("href", surveyUrl);
expect(anchor).toHaveAttribute("target", "_blank");
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
});
test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")
).not.toBeInTheDocument();
});
test("renders publish warning if surveyUrl is empty even if showPublishModal is true", () => {
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl="" />);
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")
).not.toBeInTheDocument();
});
});

View File

@@ -1,97 +0,0 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { Clipboard } from "lucide-react";
import Link from "next/link";
import { toast } from "react-hot-toast";
interface ShareEmbedSurveyProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
handlePublish: () => void;
handleUnpublish: () => void;
showPublishModal: boolean;
surveyUrl: string;
}
export const ShareSurveyResults = ({
open,
setOpen,
handlePublish,
handleUnpublish,
showPublishModal,
surveyUrl,
}: ShareEmbedSurveyProps) => {
const { t } = useTranslate();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogBody>
{showPublishModal && surveyUrl ? (
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="text-primary h-20 w-20" />
<div>
<p className="text-primary text-lg font-medium">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div>
</div>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};

View File

@@ -74,7 +74,6 @@ describe("SuccessMessage", () => {
surveyClosedMessage: null,
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;

View File

@@ -177,7 +177,6 @@ const mockSurvey = {
autoClose: null,
triggers: [],
languages: [],
resultShareKey: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,

View File

@@ -44,43 +44,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/
}),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }),
getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({
data: {
meta: {
completedPercentage: 80,
completedResponses: 40,
displayCount: 50,
dropOffPercentage: 20,
dropOffCount: 10,
startsPercentage: 100,
totalResponses: 50,
ttcAverage: 120,
},
dropOff: [
{
questionId: "q1",
headline: "Question 1",
questionType: "openText",
ttc: 20000,
impressions: 50,
dropOffCount: 5,
dropOffPercentage: 10,
},
],
summary: [
{
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
responseCount: 45,
type: "openText",
samples: [],
},
],
},
}),
}));
// Mock components
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
@@ -125,10 +88,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
}));
// Mock context
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: () => ({
@@ -172,7 +131,6 @@ describe("SummaryPage", () => {
webAppUrl: "https://app.example.com",
totalResponseCount: 50,
locale,
isReadOnly: false,
};
test("renders loading state initially", () => {
@@ -191,7 +149,6 @@ describe("SummaryPage", () => {
});
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
});
@@ -214,15 +171,4 @@ describe("SummaryPage", () => {
// Drop-offs should now be visible
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
});
test("doesn't show share button in read-only mode", async () => {
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
// Wait for loading to complete
await waitFor(() => {
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
});
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
});

View File

@@ -5,11 +5,9 @@ import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
@@ -36,9 +34,7 @@ interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
publicDomain: string;
locale: TUserLocale;
isReadOnly: boolean;
initialSurveySummary?: TSurveySummary;
}
@@ -46,15 +42,9 @@ export const SummaryPage = ({
environment,
survey,
surveyId,
publicDomain,
locale,
isReadOnly,
initialSurveySummary,
}: SummaryPageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
@@ -87,17 +77,10 @@ export const SummaryPage = ({
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
if (isSharingPage) {
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
sharingKey,
filterCriteria: currentFilters,
});
} else {
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
}
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
@@ -109,7 +92,7 @@ export const SummaryPage = ({
};
fetchSummary();
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -132,9 +115,6 @@ export const SummaryPage = ({
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && (
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
)}
</div>
<ScrollToTop containerId="mainContent" />
<SummaryList

View File

@@ -33,6 +33,18 @@ vi.mock("@tolgee/react", () => ({
if (key === "environments.surveys.edit.caution_edit_duplicate") {
return "Duplicate & Edit";
}
if (key === "environments.surveys.summary.reset_survey") {
return "Reset survey";
}
if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") {
return "Delete all existing responses and displays";
}
if (key === "environments.surveys.summary.reset_survey_warning") {
return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone.";
}
if (key === "environments.surveys.summary.survey_reset_successfully") {
return "Survey reset successfully! 5 responses and 3 displays were deleted.";
}
return key;
},
}),
@@ -40,12 +52,14 @@ vi.mock("@tolgee/react", () => ({
// Mock Next.js hooks
const mockPush = vi.fn();
const mockRefresh = vi.fn();
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
const mockSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
refresh: mockRefresh,
}),
usePathname: () => mockPathname,
useSearchParams: () => mockSearchParams,
@@ -69,6 +83,10 @@ vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn(),
}));
vi.mock("../actions", () => ({
resetSurveyAction: vi.fn(),
}));
// Mock the useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: vi.fn(() => ({
@@ -147,6 +165,34 @@ vi.mock("@/modules/ui/components/badge", () => ({
),
}));
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
ConfirmationModal: ({
open,
setOpen,
title,
text,
buttonText,
onConfirm,
buttonVariant,
buttonLoading,
}: any) => (
<div
data-testid="confirmation-modal"
data-open={open}
data-loading={buttonLoading}
data-variant={buttonVariant}>
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-text">{text}</div>
<button type="button" onClick={onConfirm} data-testid="confirm-button">
{buttonText}
</button>
<button type="button" onClick={() => setOpen(false)} data-testid="cancel-button">
Cancel
</button>
</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, className }: any) => (
<button type="button" data-testid="button" onClick={onClick} className={className}>
@@ -178,9 +224,17 @@ vi.mock("@/modules/ui/components/iconbar", () => ({
vi.mock("lucide-react", () => ({
BellRing: () => <svg data-testid="bell-ring-icon" />,
Eye: () => <svg data-testid="eye-icon" />,
ListRestart: () => <svg data-testid="list-restart-icon" />,
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
useEnvironment: vi.fn(() => ({
organizationId: "test-organization-id",
project: { id: "test-project-id" },
})),
}));
// Mock data
const mockEnvironment: TEnvironment = {
id: "test-env-id",
@@ -230,7 +284,6 @@ const mockSurvey: TSurvey = {
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
resultShareKey: null,
};
const mockUser: TUser = {
@@ -251,12 +304,8 @@ const mockUser: TUser = {
isActive: true,
notificationSettings: {
alert: {
weeklySummary: true,
responseFinished: true,
},
weeklySummary: {
test: true,
},
unsubscribedOrganizationIds: [],
},
};
@@ -270,6 +319,7 @@ const defaultProps = {
user: mockUser,
publicDomain: "https://example.com",
responseCount: 0,
displayCount: 0,
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
@@ -286,19 +336,19 @@ describe("SurveyAnalysisCTA", () => {
});
test("renders share survey button", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByText("Share survey")).toBeInTheDocument();
});
test("renders success message component", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("success-message")).toBeInTheDocument();
});
test("renders survey status dropdown when app setup is completed", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
@@ -310,7 +360,7 @@ describe("SurveyAnalysisCTA", () => {
});
test("renders icon bar with correct actions", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
@@ -324,17 +374,9 @@ describe("SurveyAnalysisCTA", () => {
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
});
test("shows public results badge when resultShareKey exists", () => {
const surveyWithShareKey = { ...mockSurvey, resultShareKey: "share-key" };
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithShareKey} />);
expect(screen.getByTestId("badge")).toBeInTheDocument();
expect(screen.getByText("Results are public")).toBeInTheDocument();
});
test("opens share modal when share button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -344,7 +386,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal when share param is true", () => {
mockSearchParams.set("share", "true");
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
@@ -352,7 +394,7 @@ describe("SurveyAnalysisCTA", () => {
test("navigates to edit when edit button is clicked and no responses", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
@@ -363,14 +405,15 @@ describe("SurveyAnalysisCTA", () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
// With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button)
await user.click(screen.getByTestId("icon-bar-action-2"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
});
test("navigates to notifications when bell icon is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByTestId("icon-bar-action-0"));
@@ -391,7 +434,7 @@ describe("SurveyAnalysisCTA", () => {
});
test("does not show icon bar actions when read-only", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isReadOnly={true} />);
const iconBar = screen.getByTestId("icon-bar");
expect(iconBar).toBeInTheDocument();
@@ -402,7 +445,7 @@ describe("SurveyAnalysisCTA", () => {
test("handles modal close correctly", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Verify modal is open initially
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
@@ -429,13 +472,13 @@ describe("SurveyAnalysisCTA", () => {
test("does not show status dropdown when app setup is not completed", () => {
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} environment={environmentWithoutAppSetup} />);
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
});
test("renders correctly with all props", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByText("Share survey")).toBeInTheDocument();
@@ -454,7 +497,6 @@ describe("SurveyAnalysisCTA", () => {
environmentId: "test-env-id",
triggers: [],
segment: null,
resultShareKey: null,
languages: [],
},
});
@@ -536,7 +578,6 @@ describe("SurveyAnalysisCTA", () => {
environmentId: "test-env-id",
triggers: [],
segment: null,
resultShareKey: null,
languages: [],
},
}),
@@ -570,7 +611,6 @@ describe("SurveyAnalysisCTA", () => {
environmentId: "test-env-id",
triggers: [],
segment: null,
resultShareKey: null,
languages: [],
},
});
@@ -579,8 +619,8 @@ describe("SurveyAnalysisCTA", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
// Click edit button to open dialog (should be icon-bar-action-2 with responses)
await user.click(screen.getByTestId("icon-bar-action-2"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
// Click primary button (duplicate & edit)
@@ -647,7 +687,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal with correct modal view when share button clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -669,24 +709,28 @@ describe("SurveyAnalysisCTA", () => {
});
test("does not render share modal when user is null", () => {
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} user={null as any} />);
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
});
test("renders with different isFormbricksCloud values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
const { rerender } = render(
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={true} />
);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("renders with different isContactsEnabled values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
const { rerender } = render(
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={true} />
);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
@@ -701,7 +745,7 @@ describe("SurveyAnalysisCTA", () => {
test("handles modal state changes correctly", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Open modal via share button
await user.click(screen.getByText("Share survey"));
@@ -714,7 +758,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal via share button", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -726,7 +770,7 @@ describe("SurveyAnalysisCTA", () => {
test("closes share modal and updates modal state", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Modal should be open initially due to share param
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
@@ -738,19 +782,19 @@ describe("SurveyAnalysisCTA", () => {
});
test("handles empty segments array", () => {
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} segments={[]} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("handles zero response count", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} responseCount={0} />);
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
});
test("shows all icon actions for non-readonly app survey", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Should show bell (notifications) and edit actions
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
@@ -766,4 +810,236 @@ describe("SurveyAnalysisCTA", () => {
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
});
// Reset Survey Feature Tests
test("shows reset survey button when responses exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeInTheDocument();
});
test("shows reset survey button when displays exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} displayCount={3} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeInTheDocument();
});
test("hides reset survey button when no responses or displays exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} displayCount={0} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeUndefined();
});
test("hides reset survey button for read-only users", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} responseCount={5} displayCount={3} />);
// For read-only users, there should be no icon bar actions
expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0);
});
test("opens reset confirmation modal when reset button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays");
expect(screen.getByTestId("modal-text")).toHaveTextContent(
"Resetting a survey removes all responses and metadata of this survey. This cannot be undone."
);
});
test("executes reset survey action when confirmed", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(mockResetSurveyAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
organizationId: "test-organization-id",
projectId: "test-project-id",
});
expect(toast.default.success).toHaveBeenCalledWith(
"Survey reset successfully! 5 responses and 3 displays were deleted."
);
});
test("handles reset survey action error", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: undefined,
serverError: "Reset failed",
validationErrors: undefined,
bindArgsValidationErrors: [],
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(toast.default.error).toHaveBeenCalledWith("Error message");
});
test("shows loading state during reset operation", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
// Mock a delayed response
mockResetSurveyAction.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
}),
100
)
)
);
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
// Check loading state
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true");
});
test("closes reset modal after successful reset", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
// Confirm reset - wait for the action to complete
await user.click(screen.getByTestId("confirm-button"));
// Wait for the action to complete and the modal to close
await vi.waitFor(() => {
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
});
});
test("cancels reset operation when cancel button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
// Cancel reset
await user.click(screen.getByTestId("cancel-button"));
// Modal should be closed
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
});
test("shows destructive button variant for reset confirmation", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive");
});
test("refreshes page after successful reset", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(mockRefresh).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
@@ -7,11 +8,11 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -19,6 +20,7 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -27,6 +29,7 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
displayCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
@@ -44,6 +47,7 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
displayCount,
segments,
isContactsEnabled,
isFormbricksCloud,
@@ -57,8 +61,11 @@ export const SurveyAnalysisCTA = ({
start: searchParams.get("share") === "true",
share: false,
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { refreshSingleUseId } = useSingleUseId(survey);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -118,6 +125,29 @@ export const SurveyAnalysisCTA = ({
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const handleResetSurvey = async () => {
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
toast.success(
t("environments.surveys.summary.survey_reset_successfully", {
responseCount: result.data.deletedResponsesCount,
displayCount: result.data.deletedDisplaysCount,
})
);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsResetting(false);
setIsResetModalOpen(false);
};
const iconActions = [
{
icon: BellRing,
@@ -134,6 +164,12 @@ export const SurveyAnalysisCTA = ({
},
isVisible: survey.type === "link",
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
},
{
icon: SquarePenIcon,
tooltip: t("common.edit"),
@@ -148,15 +184,6 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.resultShareKey && (
<Badge
type="warning"
size="normal"
className="rounded-lg"
text={t("environments.surveys.summary.results_are_public")}
/>
)}
{!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown environment={environment} survey={survey} />
)}
@@ -185,6 +212,7 @@ export const SurveyAnalysisCTA = ({
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
@@ -202,6 +230,17 @@ export const SurveyAnalysisCTA = ({
secondaryButtonText={t("common.edit")}
/>
)}
<ConfirmationModal
open={isResetModalOpen}
setOpen={setIsResetModalOpen}
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
text={t("environments.surveys.summary.reset_survey_warning")}
buttonText={t("environments.surveys.summary.reset_survey")}
onConfirm={handleResetSurvey}
buttonVariant="destructive"
buttonLoading={isResetting}
/>
</div>
);
};

View File

@@ -227,7 +227,6 @@ const mockSurvey: TSurvey = {
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
resultShareKey: null,
};
const mockAppSurvey: TSurvey = {
@@ -253,7 +252,6 @@ const mockUser: TUser = {
isActive: true,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
};

View File

@@ -34,6 +34,7 @@ interface ShareSurveyModalProps {
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
isReadOnly: boolean;
}
export const ShareSurveyModal = ({
@@ -46,6 +47,7 @@ export const ShareSurveyModal = ({
segments,
isContactsEnabled,
isFormbricksCloud,
isReadOnly,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -75,6 +77,7 @@ export const ShareSurveyModal = ({
setSurveyUrl,
locale: user.locale,
surveyUrl,
isReadOnly,
},
},
{
@@ -192,6 +195,7 @@ export const ShareSurveyModal = ({
tabs={linkTabs}
handleViewChange={handleViewChange}
handleEmbedViewWithTab={handleEmbedViewWithTab}
isReadOnly={isReadOnly}
/>
);
}

View File

@@ -23,6 +23,7 @@ interface AnonymousLinksTabProps {
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
isReadOnly: boolean;
}
export const AnonymousLinksTab = ({
@@ -31,6 +32,7 @@ export const AnonymousLinksTab = ({
publicDomain,
setSurveyUrl,
locale,
isReadOnly,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
@@ -220,6 +222,7 @@ export const AnonymousLinksTab = ({
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
isReadOnly={isReadOnly}
/>
<div className="w-full">

View File

@@ -5,6 +5,7 @@ import {
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
@@ -21,10 +22,12 @@ export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: Disabl
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
<DialogHeader className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
<DialogHeader>
<DialogTitle className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
</DialogTitle>
</DialogHeader>
<DialogBody>

View File

@@ -16,6 +16,7 @@ interface SuccessViewProps {
tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: string) => void;
isReadOnly: boolean;
}
export const SuccessView: React.FC<SuccessViewProps> = ({
@@ -27,6 +28,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
tabs,
handleViewChange,
handleEmbedViewWithTab,
isReadOnly,
}) => {
const { t } = useTranslate();
const environmentId = survey.environmentId;
@@ -43,6 +45,8 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
enforceSurveyUrlWidth
isReadOnly={isReadOnly}
/>
</div>
)}

View File

@@ -81,7 +81,6 @@ const mockSurvey = {
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
variables: [],
segment: null,
autoClose: null,

View File

@@ -0,0 +1,83 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
deleteMany: vi.fn(),
},
display: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
});
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
describe("Happy Path", () => {
test("Deletes all responses and displays for a survey", async () => {
const { prisma } = await import("@formbricks/database");
// Mock $transaction to return the results directly
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result).toEqual({
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
});
});
test("Handles case with no responses or displays to delete", async () => {
const { prisma } = await import("@formbricks/database");
// Mock $transaction to return zero counts
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
expect(result).toEqual({
deletedResponsesCount: 0,
deletedDisplaysCount: 0,
});
});
});
describe("Sad Path", () => {
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const { prisma } = await import("@formbricks/database");
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const { prisma } = await import("@formbricks/database");
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
});
});
});

View File

@@ -0,0 +1,36 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export const deleteResponsesAndDisplaysForSurvey = async (
surveyId: string
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
try {
// Delete all responses for this survey
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
prisma.response.deleteMany({
where: {
surveyId: surveyId,
},
}),
prisma.display.deleteMany({
where: {
surveyId: surveyId,
},
}),
]);
return {
deletedResponsesCount: deletedResponsesCount.count,
deletedDisplaysCount: deletedDisplaysCount.count,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -93,7 +93,6 @@ const mockBaseSurvey: TSurvey = {
environmentId: "env_123",
singleUse: null,
surveyClosedMessage: null,
resultShareKey: null,
pin: null,
createdBy: "user_123",
isSingleResponsePerEmailEnabled: false,

View File

@@ -66,7 +66,6 @@ describe("Utils Tests", () => {
singleUse: null,
styling: null,
surveyClosedMessage: null,
resultShareKey: null,
displayOption: "displayOnce",
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
createdAt: new Date(),

View File

@@ -111,7 +111,6 @@ vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-environment-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
@@ -145,7 +144,6 @@ const mockSurvey = {
delay: 0,
displayPercentage: null,
languages: [],
resultShareKey: null,
runOnDate: null,
singleUse: null,
surveyClosedMessage: null,
@@ -249,8 +247,6 @@ describe("SurveyPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
publicDomain: "http://localhost:3000",
isReadOnly: false,
locale: mockUser.locale ?? DEFAULT_LOCALE,
initialSurveySummary: mockSurveySummary,
})

View File

@@ -58,6 +58,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -69,8 +70,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
environment={environment}
survey={survey}
surveyId={params.surveyId}
publicDomain={publicDomain}
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
/>

View File

@@ -102,7 +102,6 @@ const mockSurvey = {
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
displayPercentage: null,
languages: [],
triggers: [],
@@ -157,16 +156,6 @@ describe("CustomFilter", () => {
expect(screen.getByText(`Select first date - ${format(mockDateToday, "dd LLL")}`)).toBeInTheDocument();
});
test("does not render download button on sharing page", () => {
vi.mocked(useParams).mockReturnValue({
environmentId: "test-env",
surveyId: "test-survey",
sharingKey: "test-share-key",
});
render(<CustomFilter survey={mockSurvey} />);
expect(screen.queryByText("common.download")).not.toBeInTheDocument();
});
test("useEffect logic for resetState and firstMountRef (as per current component code)", () => {
// This test verifies the current behavior of the useEffects related to firstMountRef.
// Based on the component's code, resetState() is not expected to be called by these effects,

View File

@@ -32,7 +32,6 @@ import {
subYears,
} from "date-fns";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -125,8 +124,6 @@ const getDateRangeLabel = (from: Date, to: Date, t: TFnType) => {
};
export const CustomFilter = ({ survey }: CustomFilterProps) => {
const params = useParams();
const isSharingPage = !!params.sharingKey;
const { t } = useTranslate();
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
const [filterRange, setFilterRange] = useState(
@@ -281,7 +278,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: t(filterRange)}
: filterRange}
</span>
{isFilterDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
@@ -296,28 +293,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).ALL_TIME)}</p>
<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">{t(getFilterDropDownLabels(t).LAST_7_DAYS)}</p>
<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">{t(getFilterDropDownLabels(t).LAST_30_DAYS)}</p>
<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">{t(getFilterDropDownLabels(t).THIS_MONTH)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -327,14 +324,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfMonth(subMonths(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_MONTH)}</p>
<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">{t(getFilterDropDownLabels(t).THIS_QUARTER)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -344,7 +341,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_QUARTER)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -354,14 +351,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfMonth(getTodayDate()),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}</p>
<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">{t(getFilterDropDownLabels(t).THIS_YEAR)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -371,7 +368,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfYear(subYears(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_YEAR)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -380,56 +377,52 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">
{t(getFilterDropDownLabels(t).CUSTOM_RANGE)}
{getFilterDropDownLabels(t).CUSTOM_RANGE}
</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isSharingPage && (
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<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>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
</div>
<DownloadIcon className="block h-4 sm:hidden" />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<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>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">
{t("environments.surveys.summary.filtered_responses_excel")}
</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(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">

View File

@@ -21,7 +21,7 @@ describe("QuestionFilterComboBox", () => {
test("renders select placeholders", () => {
render(<QuestionFilterComboBox {...defaultProps} />);
expect(screen.getAllByText(/common.select\.../).length).toBe(2);
expect(screen.getAllByText("common.select...").length).toBe(2);
});
test("calls onChangeFilterValue when selecting filter", async () => {
@@ -72,17 +72,21 @@ describe("QuestionFilterComboBox", () => {
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
});
test("prevent combo-box opening when filterValue is Submitted", async () => {
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
test("combobox is disabled when filterValue is 'Submitted' for NPS questions", async () => {
const props = { ...defaultProps, type: "nps", filterValue: "Submitted" } as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
const comboBoxOpenerButton = screen.getAllByRole("button")[1];
expect(comboBoxOpenerButton).toBeDisabled();
await userEvent.click(comboBoxOpenerButton);
expect(screen.queryByText("X")).not.toBeInTheDocument();
});
test("prevent combo-box opening when filterValue is Skipped", async () => {
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
test("combobox is disabled when filterValue is 'Skipped' for rating questions", async () => {
const props = { ...defaultProps, type: "rating", filterValue: "Skipped" } as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
const comboBoxOpenerButton = screen.getAllByRole("button")[1];
expect(comboBoxOpenerButton).toBeDisabled();
await userEvent.click(comboBoxOpenerButton);
expect(screen.queryByText("X")).not.toBeInTheDocument();
});
});

View File

@@ -15,11 +15,13 @@ import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import {
AirplayIcon,
ArrowUpFromDotIcon,
CheckIcon,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
FlagIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -89,8 +91,9 @@ const questionIcons = {
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: GlobeIcon,
source: ArrowUpFromDotIcon,
action: MousePointerClickIcon,
country: FlagIcon,
// others
Language: LanguagesIcon,
@@ -132,10 +135,16 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
return "bg-amber-500";
}
};
const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined;
return label === "os" ? "uppercase" : "capitalize";
};
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="ml-3 truncate text-sm text-slate-600">
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p>
</div>

View File

@@ -1,7 +1,6 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -19,10 +18,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", (
getSurveyFilterDataAction: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
generateQuestionAndFilterOptions: vi.fn(),
}));
@@ -235,29 +230,4 @@ describe("ResponseFilter", () => {
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
});
test("uses sharing key action when on sharing page", async () => {
vi.mocked(useParams).mockReturnValue({
environmentId: "env1",
surveyId: "survey1",
sharingKey: "share123",
});
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
sharingKey: "share123",
environmentId: "env1",
});
});
});

View File

@@ -7,7 +7,6 @@ import {
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
@@ -15,7 +14,6 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import { useParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
@@ -33,10 +31,7 @@ interface ResponseFilterProps {
export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const { t } = useTranslate();
const params = useParams();
const [parent] = useAutoAnimate();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter();
const [isOpen, setIsOpen] = useState<boolean>(false);
@@ -46,12 +41,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
if (isOpen) {
const surveyFilterData = isSharingPage
? await getSurveyFilterDataBySurveySharingKeyAction({
sharingKey,
environmentId: survey.environmentId,
})
: await getSurveyFilterDataAction({ surveyId: survey.id });
const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id });
if (!surveyFilterData?.data) return;
@@ -68,7 +58,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
handleInitialData();
}, [isOpen, isSharingPage, setSelectedOptions, sharingKey, survey]);
}, [isOpen, setSelectedOptions, survey]);
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
if (filterValue.filter[index].questionType) {

View File

@@ -1,261 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ResultsShareButton } from "./ResultsShareButton";
// Mock actions
const mockDeleteResultShareUrlAction = vi.fn();
const mockGenerateResultShareUrlAction = vi.fn();
const mockGetResultShareUrlAction = vi.fn();
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
deleteResultShareUrlAction: (...args) => mockDeleteResultShareUrlAction(...args),
generateResultShareUrlAction: (...args) => mockGenerateResultShareUrlAction(...args),
getResultShareUrlAction: (...args) => mockGetResultShareUrlAction(...args),
}));
// Mock helper
const mockGetFormattedErrorMessage = vi.fn((error) => error?.message || "An error occurred");
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (error) => mockGetFormattedErrorMessage(error),
}));
// Mock UI components
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children }) => <div data-testid="dropdown-menu">{children}</div>,
DropdownMenuContent: ({ children, align }) => (
<div data-testid="dropdown-menu-content" data-align={align}>
{children}
</div>
),
DropdownMenuItem: ({ children, onClick, icon }) => (
<button data-testid="dropdown-menu-item" onClick={onClick}>
{icon}
{children}
</button>
),
DropdownMenuTrigger: ({ children }) => <div data-testid="dropdown-menu-trigger">{children}</div>,
}));
// Mock Tolgee
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: mockT }),
}));
// Mock icons
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
DownloadIcon: () => <div data-testid="download-icon" />,
GlobeIcon: () => <div data-testid="globe-icon" />,
LinkIcon: () => <div data-testid="link-icon" />,
}));
// Mock toast
const mockToastSuccess = vi.fn();
const mockToastError = vi.fn();
vi.mock("react-hot-toast", () => ({
default: {
success: (...args) => mockToastSuccess(...args),
error: (...args) => mockToastError(...args),
},
}));
// Mock ShareSurveyResults component
const mockShareSurveyResults = vi.fn();
vi.mock("../(analysis)/summary/components/ShareSurveyResults", () => ({
ShareSurveyResults: (props) => {
mockShareSurveyResults(props);
return props.open ? (
<div data-testid="share-survey-results-modal">
<span>ShareSurveyResults Modal</span>
<button onClick={() => props.setOpen(false)}>Close Modal</button>
<button data-testid="handle-publish-button" onClick={props.handlePublish}>
Publish
</button>
<button data-testid="handle-unpublish-button" onClick={props.handleUnpublish}>
Unpublish
</button>
</div>
) : null;
},
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [],
hiddenFields: { enabled: false },
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
languages: [],
triggers: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
styling: null,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
variables: [],
closeOnDate: null,
} as unknown as TSurvey;
const webAppUrl = "https://app.formbricks.com";
const originalLocation = window.location;
describe("ResultsShareButton", () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock window.location.href
Object.defineProperty(window, "location", {
writable: true,
value: { ...originalLocation, href: "https://app.formbricks.com/surveys/survey1" },
});
// Mock navigator.clipboard
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
writable: true,
});
});
afterEach(() => {
cleanup();
Object.defineProperty(window, "location", {
writable: true,
value: originalLocation,
});
});
test("renders initial state and fetches sharing key (no existing key)", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
expect(mockGetResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
await waitFor(() => {
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
});
});
test("handles copy private link to clipboard", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("common.copy_link")
);
expect(copyLinkButton).toBeInTheDocument();
await userEvent.click(copyLinkButton!);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.href);
expect(mockToastSuccess).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("handles copy public link to clipboard", async () => {
const shareKey = "publicShareKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.copy_link_to_public_results")
);
expect(copyPublicLinkButton).toBeInTheDocument();
await userEvent.click(copyPublicLinkButton!);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`${webAppUrl}/share/${shareKey}`);
expect(mockToastSuccess).toHaveBeenCalledWith(
"environments.surveys.summary.link_to_public_results_copied"
);
});
test("handles publish to web successfully", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.publish_to_web")
);
await userEvent.click(publishButton!);
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("handle-publish-button"));
expect(mockGenerateResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
await waitFor(() => {
expect(mockShareSurveyResults).toHaveBeenCalledWith(
expect.objectContaining({
surveyUrl: `${webAppUrl}/share/newShareKey`,
showPublishModal: true,
})
);
});
});
test("handles unpublish from web successfully", async () => {
const shareKey = "toUnpublishKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.unpublish_from_web")
);
await userEvent.click(unpublishButton!);
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("handle-unpublish-button"));
expect(mockDeleteResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
expect(mockToastSuccess).toHaveBeenCalledWith("environments.surveys.results_unpublished_successfully");
await waitFor(() => {
expect(mockShareSurveyResults).toHaveBeenCalledWith(
expect.objectContaining({
showPublishModal: false,
})
);
});
});
test("opens and closes ShareSurveyResults modal", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.publish_to_web")
);
await userEvent.click(publishButton!);
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
expect(mockShareSurveyResults).toHaveBeenCalledWith(
expect.objectContaining({
open: true,
surveyUrl: "", // Initially empty as no key fetched yet for this flow
showPublishModal: false, // Initially false
})
);
await userEvent.click(screen.getByText("Close Modal"));
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
});
});

View File

@@ -1,146 +0,0 @@
"use client";
import {
deleteResultShareUrlAction,
generateResultShareUrlAction,
getResultShareUrlAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { CopyIcon, DownloadIcon, GlobeIcon, LinkIcon } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurveyResults";
interface ResultsShareButtonProps {
survey: TSurvey;
publicDomain: string;
}
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
const { t } = useTranslate();
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
const [showPublishModal, setShowPublishModal] = useState(false);
const [surveyUrl, setSurveyUrl] = useState("");
const handlePublish = async () => {
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
if (resultShareKeyResponse?.data) {
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
setShowPublishModal(true);
} else {
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
toast.error(errorMessage);
}
};
const handleUnpublish = () => {
deleteResultShareUrlAction({ surveyId: survey.id }).then((deleteResultShareUrlResponse) => {
if (deleteResultShareUrlResponse?.data) {
toast.success(t("environments.surveys.results_unpublished_successfully"));
setShowPublishModal(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteResultShareUrlResponse);
toast.error(errorMessage);
}
});
};
useEffect(() => {
const fetchSharingKey = async () => {
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
if (resultShareUrlResponse?.data) {
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
setShowPublishModal(true);
}
};
fetchSharingKey();
}, [survey.id, publicDomain]);
const copyUrlToClipboard = () => {
if (typeof window !== "undefined") {
const currentUrl = window.location.href;
navigator.clipboard
.writeText(currentUrl)
.then(() => {
toast.success(t("common.copied_to_clipboard"));
})
.catch(() => {
toast.error(t("environments.surveys.failed_to_copy_link_to_results"));
});
} else {
toast.error(t("environments.surveys.failed_to_copy_url"));
}
};
return (
<div>
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] 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("environments.surveys.summary.share_results")}
</span>
<LinkIcon className="ml-2 h-4 w-4" />
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{survey.resultShareKey ? (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("environments.surveys.summary.link_to_public_results_copied"));
}}
icon={<CopyIcon className="ml-1.5 inline h-4 w-4" />}>
<p className="text-slate-700">
{t("environments.surveys.summary.copy_link_to_public_results")}
</p>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => {
copyUrlToClipboard();
}}
icon={<CopyIcon className="ml-1.5 h-4 w-4" />}>
<p className="flex items-center text-slate-700">{t("common.copy_link")}</p>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setShowResultsLinkModal(true);
}}
icon={<GlobeIcon className="ml-1.5 h-4 w-4" />}>
<p className="flex items-center text-slate-700">
{survey.resultShareKey
? t("environments.surveys.summary.unpublish_from_web")
: t("environments.surveys.summary.publish_to_web")}
</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{showResultsLinkModal && (
<ShareSurveyResults
open={showResultsLinkModal}
setOpen={setShowResultsLinkModal}
surveyUrl={surveyUrl}
handlePublish={handlePublish}
handleUnpublish={handleUnpublish}
showPublishModal={showPublishModal}
/>
)}
</div>
);
};

View File

@@ -87,7 +87,6 @@ const baseSurvey: TSurvey = {
isSingleUse: false,
segment: null,
surveyClosedMessage: null,
resultShareKey: null,
singleUse: null,
verifyEmail: null,
pin: null,

View File

@@ -78,7 +78,6 @@ const mockSurvey: TSurvey = {
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
pin: null,
resultShareKey: null,
displayPercentage: null,
languages: [
{

View File

@@ -68,7 +68,6 @@ describe("SurveyLayout", () => {
surveyClosedMessage: null,
singleUse: null,
pin: null,
resultShareKey: null,
showLanguageSwitch: false,
recaptcha: null,
languages: [],

View File

@@ -165,7 +165,6 @@ export const mockSurvey: TSurvey = {
isEncrypted: true,
},
pin: null,
resultShareKey: null,
showLanguageSwitch: null,
languages: [],
triggers: [],

View File

@@ -152,7 +152,6 @@ const mockSurvey = {
environmentId: "env1",
singleUse: null,
surveyClosedMessage: null,
resultShareKey: null,
pin: null,
} as unknown as TSurvey;

View File

@@ -1,276 +0,0 @@
import { convertResponseValue } from "@/lib/responses";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
TWeeklyEmailResponseData,
TWeeklySummaryEnvironmentData,
TWeeklySummarySurveyData,
} from "@formbricks/types/weekly-summary";
import { getNotificationResponse } from "./notificationResponse";
vi.mock("@/lib/responses", () => ({
convertResponseValue: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
describe("getNotificationResponse", () => {
afterEach(() => {
cleanup();
});
test("should return a notification response with calculated insights and survey data when provided with an environment containing multiple surveys", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(2);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(2);
expect(notificationResponse.insights.totalResponses).toBe(5);
expect(notificationResponse.insights.completionRate).toBe(60);
expect(notificationResponse.insights.numLiveSurvey).toBe(2);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
});
test("should calculate the correct completion rate and other insights when surveys have responses with varying statuses", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey3",
name: "Survey 3",
status: "inProgress",
questions: [
{
id: "question3",
headline: { default: "Question 3" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display3" }],
responses: [{ id: "response6", finished: false, data: { question3: "Answer 6" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(3);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(3);
expect(notificationResponse.insights.totalResponses).toBe(6);
expect(notificationResponse.insights.completionRate).toBe(50);
expect(notificationResponse.insights.numLiveSurvey).toBe(3);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
expect(notificationResponse.surveys[2].id).toBe("survey3");
expect(notificationResponse.surveys[2].name).toBe("Survey 3");
expect(notificationResponse.surveys[2].status).toBe("inProgress");
expect(notificationResponse.surveys[2].responseCount).toBe(1);
});
test("should return default insights and an empty surveys array when the environment contains no surveys", () => {
const mockEnvironment = {
id: "env1",
surveys: [],
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(0);
expect(notificationResponse.insights.totalCompletedResponses).toBe(0);
expect(notificationResponse.insights.totalDisplays).toBe(0);
expect(notificationResponse.insights.totalResponses).toBe(0);
expect(notificationResponse.insights.completionRate).toBe(0);
expect(notificationResponse.insights.numLiveSurvey).toBe(0);
});
test("should handle missing response data gracefully when a response doesn't contain data for a question ID", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: {} }, // Response missing data for question1
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
// Mock the convertResponseValue function to handle the missing data case
vi.mocked(convertResponseValue).mockReturnValue("");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys).toHaveLength(1);
expect(notificationResponse.surveys[0].responses).toHaveLength(1);
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("");
});
test("should handle unsupported question types gracefully", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "unsupported",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [{ id: "response1", finished: true, data: { question1: "Answer 1" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
vi.mocked(convertResponseValue).mockReturnValue("Unsupported Response");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("Unsupported Response");
});
});

View File

@@ -1,78 +0,0 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { convertResponseValue } from "@/lib/responses";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
TWeeklyEmailResponseData,
TWeeklySummaryEnvironmentData,
TWeeklySummaryNotificationDataSurvey,
TWeeklySummaryNotificationResponse,
TWeeklySummarySurveyResponseData,
} from "@formbricks/types/weekly-summary";
export const getNotificationResponse = (
environment: TWeeklySummaryEnvironmentData,
projectName: string
): TWeeklySummaryNotificationResponse => {
const insights = {
totalCompletedResponses: 0,
totalDisplays: 0,
totalResponses: 0,
completionRate: 0,
numLiveSurvey: 0,
};
const surveys: TWeeklySummaryNotificationDataSurvey[] = [];
// iterate through the surveys and calculate the overall insights
for (const survey of environment.surveys) {
const parsedSurvey = replaceHeadlineRecall(survey as unknown as TSurvey, "default") as TSurvey & {
responses: TWeeklyEmailResponseData[];
};
const surveyData: TWeeklySummaryNotificationDataSurvey = {
id: parsedSurvey.id,
name: parsedSurvey.name,
status: parsedSurvey.status,
responseCount: parsedSurvey.responses.length,
responses: [],
};
// iterate through the responses and calculate the survey insights
for (const response of parsedSurvey.responses) {
// only take the first 3 responses
if (surveyData.responses.length >= 3) {
break;
}
const surveyResponses: TWeeklySummarySurveyResponseData[] = [];
for (const question of parsedSurvey.questions) {
const headline = question.headline;
const responseValue = convertResponseValue(response.data[question.id], question);
const surveyResponse: TWeeklySummarySurveyResponseData = {
headline: getLocalizedValue(headline, "default"),
responseValue,
questionType: question.type,
};
surveyResponses.push(surveyResponse);
}
surveyData.responses = surveyResponses;
}
surveys.push(surveyData);
// calculate the overall insights
if (survey.status == "inProgress") {
insights.numLiveSurvey += 1;
}
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
insights.totalDisplays += survey.displays.length;
insights.totalResponses += survey.responses.length;
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100);
}
// build the notification response needed for the emails
const lastWeekDate = new Date();
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
return {
environmentId: environment.id,
currentDate: new Date(),
lastWeekDate,
projectName: projectName,
surveys,
insights,
};
};

View File

@@ -1,48 +0,0 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getOrganizationIds } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findMany: vi.fn(),
},
},
}));
describe("Organization", () => {
afterEach(() => {
cleanup();
});
test("getOrganizationIds should return an array of organization IDs when the database contains multiple organizations", async () => {
const mockOrganizations = [{ id: "org1" }, { id: "org2" }, { id: "org3" }];
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual(["org1", "org2", "org3"]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
test("getOrganizationIds should return an empty array when the database contains no organizations", async () => {
vi.mocked(prisma.organization.findMany).mockResolvedValue([]);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual([]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
});

View File

@@ -1,10 +0,0 @@
import { prisma } from "@formbricks/database";
export const getOrganizationIds = async (): Promise<string[]> => {
const organizations = await prisma.organization.findMany({
select: {
id: true,
},
});
return organizations.map((organization) => organization.id);
};

View File

@@ -1,570 +0,0 @@
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getProjectsByOrganizationId } from "./project";
const mockProjects = [
{
id: "project1",
name: "Project 1",
environments: [
{
id: "env1",
type: "production",
surveys: [],
attributeKeys: [],
},
],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project1: true,
},
},
locale: "en",
},
},
],
},
},
];
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // Set to 6 days ago to be within the last 7 days
const mockProjectsWithNoEnvironments = [
{
id: "project3",
name: "Project 3",
environments: [],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project3: true,
},
},
locale: "en",
},
},
],
},
},
];
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findMany: vi.fn(),
},
},
}));
describe("Project Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("getProjectsByOrganizationId", () => {
test("retrieves projects with environments, surveys, and organization memberships for a valid organization ID", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles date calculations correctly across DST boundaries", async () => {
const mockDate = new Date(2024, 10, 3, 0, 0, 0); // November 3, 2024, 00:00:00 (example DST boundary)
const sevenDaysAgo = new Date(mockDate);
sevenDaysAgo.setDate(mockDate.getDate() - 7);
vi.useFakeTimers();
vi.setSystemTime(mockDate);
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
await getProjectsByOrganizationId(organizationId);
expect(prisma.project.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
organizationId: organizationId,
},
select: expect.objectContaining({
environments: expect.objectContaining({
select: expect.objectContaining({
surveys: expect.objectContaining({
where: expect.objectContaining({
NOT: expect.objectContaining({
AND: expect.arrayContaining([
expect.objectContaining({ status: "completed" }),
expect.objectContaining({
responses: expect.objectContaining({
none: expect.objectContaining({
createdAt: expect.objectContaining({
gte: sevenDaysAgo,
}),
}),
}),
}),
]),
}),
}),
}),
}),
}),
}),
})
);
vi.useRealTimers();
});
test("includes surveys with 'completed' status but responses within the last 7 days", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("returns an empty array when an invalid organization ID is provided", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce([]);
const invalidOrganizationId = "invalidOrgId";
const projects = await getProjectsByOrganizationId(invalidOrganizationId);
expect(projects).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: invalidOrganizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles projects with no environments", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjectsWithNoEnvironments);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjectsWithNoEnvironments);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
});
});

View File

@@ -1,111 +0,0 @@
import { prisma } from "@formbricks/database";
import { TWeeklySummaryProjectData } from "@formbricks/types/weekly-summary";
export const getProjectsByOrganizationId = async (
organizationId: string
): Promise<TWeeklySummaryProjectData[]> => {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return await prisma.project.findMany({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: sevenDaysAgo,
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
};

View File

@@ -1,70 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { CRON_SECRET } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "@/modules/email";
import { headers } from "next/headers";
import { getNotificationResponse } from "./lib/notificationResponse";
import { getOrganizationIds } from "./lib/organization";
import { getProjectsByOrganizationId } from "./lib/project";
const BATCH_SIZE = 500;
export const POST = async (): Promise<Response> => {
const headersList = await headers();
// Check authentication
if (headersList.get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const emailSendingPromises: Promise<void>[] = [];
// Fetch all organization IDs
const organizationIds = await getOrganizationIds();
// Paginate through organizations
for (let i = 0; i < organizationIds.length; i += BATCH_SIZE) {
const batchedOrganizationIds = organizationIds.slice(i, i + BATCH_SIZE);
// Fetch projects for batched organizations asynchronously
const batchedProjectsPromises = batchedOrganizationIds.map((organizationId) =>
getProjectsByOrganizationId(organizationId)
);
const batchedProjects = await Promise.all(batchedProjectsPromises);
for (const projects of batchedProjects) {
for (const project of projects) {
const organizationMembers = project.organization.memberships;
const organizationMembersWithNotificationEnabled = organizationMembers.filter(
(member) =>
member.user.notificationSettings?.weeklySummary &&
member.user.notificationSettings.weeklySummary[project.id]
);
if (organizationMembersWithNotificationEnabled.length === 0) continue;
const notificationResponse = getNotificationResponse(project.environments[0], project.name);
if (notificationResponse.insights.numLiveSurvey === 0) {
for (const organizationMember of organizationMembersWithNotificationEnabled) {
if (await hasUserEnvironmentAccess(organizationMember.user.id, project.environments[0].id)) {
emailSendingPromises.push(
sendNoLiveSurveyNotificationEmail(organizationMember.user.email, notificationResponse)
);
}
}
continue;
}
for (const organizationMember of organizationMembersWithNotificationEnabled) {
if (await hasUserEnvironmentAccess(organizationMember.user.id, project.environments[0].id)) {
emailSendingPromises.push(
sendWeeklySummaryNotificationEmail(organizationMember.user.email, notificationResponse)
);
}
}
}
}
}
await Promise.all(emailSendingPromises);
return responses.successResponse({}, true);
};

View File

@@ -89,7 +89,6 @@ const baseSurvey: TSurvey = {
singleUse: null,
styling: null,
pin: null,
resultShareKey: null,
displayLimit: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],

View File

@@ -64,7 +64,6 @@ const baseSurvey: TSurvey = {
autoComplete: null,
segment: null,
pin: null,
resultShareKey: null,
};
const attributes: TAttributes = {

View File

@@ -0,0 +1,222 @@
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing
}));
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
create: vi.fn(),
},
display: {
create: vi.fn(),
},
survey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("./contact", () => ({
getContactByUserId: vi.fn(),
}));
const environmentId = "test-env-id";
const surveyId = "test-survey-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const displayId = "test-display-id";
const displayInput: TDisplayCreateInput = {
environmentId,
surveyId,
userId,
};
const displayInputWithoutUserId: TDisplayCreateInput = {
environmentId,
surveyId,
};
const mockContact = {
id: contactId,
environmentId,
userId,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockDisplay = {
id: displayId,
contactId,
surveyId,
responseId: null,
status: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockDisplayWithoutContact = {
id: displayId,
contactId: null,
surveyId,
responseId: null,
status: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
} as any;
describe("createDisplay", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
});
test("should create a display with existing contact successfully", async () => {
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(prisma.contact.create).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
contact: { connect: { id: contactId } },
},
select: { id: true, contactId: true, surveyId: true },
});
expect(result).toEqual(mockDisplay);
});
test("should create a display and new contact when contact does not exist", async () => {
vi.mocked(getContactByUserId).mockResolvedValue(null);
vi.mocked(prisma.contact.create).mockResolvedValue(mockContact);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(prisma.contact.create).toHaveBeenCalledWith({
data: {
environment: { connect: { id: environmentId } },
attributes: {
create: {
attributeKey: {
connect: { key_environmentId: { key: "userId", environmentId } },
},
value: userId,
},
},
},
});
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
contact: { connect: { id: contactId } },
},
select: { id: true, contactId: true, surveyId: true },
});
expect(result).toEqual(mockDisplay);
});
test("should create a display without contact when userId is not provided", async () => {
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact);
const result = await createDisplay(displayInputWithoutUserId);
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutUserId, expect.any(Object)]);
expect(getContactByUserId).not.toHaveBeenCalled();
expect(prisma.contact.create).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
},
select: { id: true, contactId: true, surveyId: true },
});
expect(result).toEqual(mockDisplayWithoutContact);
});
test("should throw ValidationError if validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(getContactByUserId).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw InvalidInputError when survey does not exist (RelatedRecordDoesNotExist)", async () => {
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, environmentId },
});
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(prisma.display.create).toHaveBeenCalled();
});
test("should throw original error on other errors during display creation", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(prisma.display.create).toHaveBeenCalled();
});
test("should throw error if getContactByUserId fails", async () => {
const contactError = new Error("Failed to get contact");
vi.mocked(getContactByUserId).mockRejectedValue(contactError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw error if contact creation fails", async () => {
const contactCreateError = new Error("Failed to create contact");
vi.mocked(getContactByUserId).mockResolvedValue(null);
vi.mocked(prisma.contact.create).mockRejectedValue(contactCreateError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCreateError);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId);
expect(prisma.contact.create).toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@ import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getContactByUserId } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<{ id: string }> => {
@@ -31,6 +31,16 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
}
}
const survey = await prisma.survey.findUnique({
where: {
id: surveyId,
environmentId,
},
});
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const display = await prisma.display.create({
data: {
survey: {

View File

@@ -4,7 +4,7 @@ import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { createDisplay } from "./lib/display";
interface Context {
@@ -52,11 +52,11 @@ export const POST = async (request: Request, context: Context): Promise<Response
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
} else {
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return responses.internalServerErrorResponse(error.message);
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
}
}
};

View File

@@ -108,7 +108,6 @@ const mockSurveys: TSurvey[] = [
triggers: [],
languages: [],
pin: null,
resultShareKey: null,
segment: null,
styling: null,
surveyClosedMessage: null,

View File

@@ -37,7 +37,6 @@ const mockDeletedSurveyAppPrivateSegment = {
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
resultShareKey: "shareKey123",
};
const mockDeletedSurveyLink = {
@@ -46,7 +45,6 @@ const mockDeletedSurveyLink = {
type: "link",
segment: null,
triggers: [],
resultShareKey: null,
};
describe("deleteSurvey", () => {

View File

@@ -2,7 +2,7 @@ import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
import { createDisplay } from "./display";
@@ -16,6 +16,9 @@ vi.mock("@formbricks/database", () => ({
display: {
create: vi.fn(),
},
survey: {
findUnique: vi.fn(),
},
},
}));
@@ -43,17 +46,32 @@ const mockDisplay = {
id: displayId,
contactId,
surveyId,
responseId: null,
status: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockDisplayWithoutContact = {
id: displayId,
contactId: null,
surveyId,
responseId: null,
status: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
} as any;
describe("createDisplay", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
});
test("should create a display with contactId successfully", async () => {
@@ -119,7 +137,19 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma known request error", async () => {
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId, environmentId },
});
expect(prisma.display.create).not.toHaveBeenCalled();
});
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "2.0.0",

View File

@@ -5,17 +5,27 @@ import {
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { doesContactExist } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInputV2]);
const { contactId, surveyId } = displayInput;
const { contactId, surveyId, environmentId } = displayInput;
try {
const contactExists = contactId ? await doesContactExist(contactId) : false;
const survey = await prisma.survey.findUnique({
where: {
id: surveyId,
environmentId,
},
});
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const display = await prisma.display.create({
data: {
survey: {

View File

@@ -4,7 +4,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { InvalidInputError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { createDisplay } from "./lib/display";
interface Context {
@@ -52,11 +52,11 @@ export const POST = async (request: Request, context: Context): Promise<Response
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
} else {
logger.error({ error, url: request.url }, "Error creating display");
return responses.internalServerErrorResponse(error.message);
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
}
}
};

View File

@@ -68,7 +68,6 @@ const mockSurvey: TSurvey = {
triggers: [],
languages: [],
pin: null,
resultShareKey: null,
segment: null,
styling: null,
surveyClosedMessage: null,

View File

@@ -21,8 +21,11 @@ import {
} from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
const defaultButtonLabel = "common.next";
const defaultBackButtonLabel = "common.back";
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceQuestion = ({
id,
@@ -63,8 +66,8 @@ export const buildMultipleChoiceQuestion = ({
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
return { id, label: createI18nString(choice, []) };
}),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
shuffleOption: shuffleOption || "none",
required: required ?? false,
logic,
@@ -103,8 +106,8 @@ export const buildOpenTextQuestion = ({
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
longAnswer,
logic,
@@ -151,8 +154,8 @@ export const buildRatingQuestion = ({
headline: createI18nString(headline, []),
scale,
range,
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
@@ -192,8 +195,8 @@ export const buildNPSQuestion = ({
type: TSurveyQuestionTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
@@ -228,8 +231,8 @@ export const buildConsentQuestion = ({
type: TSurveyQuestionTypeEnum.Consent,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
label: createI18nString(label, []),
logic,
@@ -266,8 +269,8 @@ export const buildCTAQuestion = ({
type: TSurveyQuestionTypeEnum.CTA,
html: html ? createI18nString(html, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,

View File

@@ -3582,7 +3582,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
isEncrypted: true,
},
pin: null,
resultShareKey: null,
languages: [],
triggers: [],
showLanguageSwitch: false,

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