mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 06:52:01 -05:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e92e51b030 | |||
| e29a67b1f6 | |||
| 78f5de2f35 | |||
| b1a35d4a69 | |||
| 2166c44470 | |||
| 080cf741e9 | |||
| 8881691509 | |||
| 3045f4437f | |||
| 91ace0e821 | |||
| 6ef281647a | |||
| 0aaaaa54ee | |||
| b1f78e7bf2 | |||
| 7086ce2ca3 | |||
| 8f8b549b1d | |||
| 28514487e0 |
@@ -18,7 +18,6 @@ apps/web/
|
|||||||
│ ├── (app)/ # Main application routes
|
│ ├── (app)/ # Main application routes
|
||||||
│ ├── (auth)/ # Authentication routes
|
│ ├── (auth)/ # Authentication routes
|
||||||
│ ├── api/ # API routes
|
│ ├── api/ # API routes
|
||||||
│ └── share/ # Public sharing routes
|
|
||||||
├── components/ # Shared components
|
├── components/ # Shared components
|
||||||
├── lib/ # Utility functions and services
|
├── lib/ # Utility functions and services
|
||||||
└── modules/ # Feature-specific modules
|
└── modules/ # Feature-specific modules
|
||||||
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
|
|||||||
### Dynamic Routes
|
### Dynamic Routes
|
||||||
- `[environmentId]` - Environment-specific routes
|
- `[environmentId]` - Environment-specific routes
|
||||||
- `[surveyId]` - Survey-specific routes
|
- `[surveyId]` - Survey-specific routes
|
||||||
- `[sharingKey]` - Public sharing routes
|
|
||||||
|
|
||||||
## Service Layer Pattern
|
## Service Layer Pattern
|
||||||
|
|
||||||
|
|||||||
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
|
|||||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test sharing mode
|
|
||||||
vi.mocked(useParams).mockReturnValue({
|
|
||||||
surveyId: "123",
|
|
||||||
sharingKey: "share-123"
|
|
||||||
});
|
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
import { createRequire } from "module";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used to resolve the absolute path of a package.
|
* 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.
|
* 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")));
|
return dirname(require.resolve(join(value, "package.json")));
|
||||||
};
|
}
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
alert: {
|
alert: {
|
||||||
...user.notificationSettings?.alert,
|
...user.notificationSettings?.alert,
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
...user.notificationSettings?.weeklySummary,
|
|
||||||
[project.id]: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateUser(user.id, {
|
await updateUser(user.id, {
|
||||||
|
|||||||
+134
-3
@@ -9,8 +9,12 @@ import {
|
|||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/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 { 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 { cleanup, render, screen } from "@testing-library/react";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
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", () => ({
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||||
getOrganizationProjectsLimit: vi.fn(),
|
getOrganizationProjectsLimit: vi.fn(),
|
||||||
|
getRoleManagementPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||||
getProjectPermissionByUserId: vi.fn(),
|
getProjectPermissionByUserId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
||||||
|
getTeamsByOrganizationId: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock("@/tolgee/server", () => ({
|
vi.mock("@/tolgee/server", () => ({
|
||||||
getTranslate: async () => (key: string) => key,
|
getTranslate: async () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
|
|
||||||
// Mock components
|
// Mock components
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
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", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||||
@@ -104,7 +118,7 @@ const mockUser = {
|
|||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|
||||||
const mockOrganization = {
|
const mockOrganization = {
|
||||||
@@ -156,6 +170,17 @@ const mockProjectPermission = {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
const mockOrganizationTeams = [
|
||||||
|
{
|
||||||
|
id: "team-1",
|
||||||
|
name: "Development Team",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team-2",
|
||||||
|
name: "Marketing Team",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const mockSession: Session = {
|
const mockSession: Session = {
|
||||||
user: {
|
user: {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
@@ -176,6 +201,8 @@ describe("EnvironmentLayout", () => {
|
|||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||||
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
||||||
|
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||||
mockIsDevelopment = false;
|
mockIsDevelopment = false;
|
||||||
mockIsFormbricksCloud = false;
|
mockIsFormbricksCloud = false;
|
||||||
});
|
});
|
||||||
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
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 () => {
|
test("throws error if user not found", async () => {
|
||||||
vi.mocked(getUser).mockResolvedValue(null);
|
vi.mocked(getUser).mockResolvedValue(null);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
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 { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-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"));
|
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),
|
getUserProjects(user.id, organization.id),
|
||||||
getEnvironments(environment.projectId),
|
getEnvironments(environment.projectId),
|
||||||
|
getRoleManagementPermission(organization.billing.plan),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!projects || !environments || !organizations) {
|
if (!projects || !environments || !organizations) {
|
||||||
@@ -117,6 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
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 { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
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,
|
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
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}>
|
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||||
Project Switcher
|
Project Switcher
|
||||||
|
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
|
||||||
|
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -106,7 +117,7 @@ const mockUser = {
|
|||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "other",
|
objective: "other",
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
@@ -146,6 +157,7 @@ const defaultProps = {
|
|||||||
membershipRole: "owner" as const,
|
membershipRole: "owner" as const,
|
||||||
organizationProjectsLimit: 5,
|
organizationProjectsLimit: 5,
|
||||||
isLicenseActive: true,
|
isLicenseActive: true,
|
||||||
|
canDoRoleManagement: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("MainNavigation", () => {
|
describe("MainNavigation", () => {
|
||||||
@@ -334,4 +346,23 @@ describe("MainNavigation", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ interface NavigationProps {
|
|||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
|
canDoRoleManagement: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
@@ -80,6 +81,7 @@ export const MainNavigation = ({
|
|||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
|
canDoRoleManagement,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -323,6 +325,7 @@ export const MainNavigation = ({
|
|||||||
isTextVisible={isTextVisible}
|
isTextVisible={isTextVisible}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
-2
@@ -220,7 +220,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -258,7 +257,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
-1
@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
|
|||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
segment: null,
|
segment: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
|
|||||||
-2
@@ -236,7 +236,6 @@ const surveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -272,7 +271,6 @@ const surveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
|
|||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
segment: null,
|
segment: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
|
|||||||
-2
@@ -226,7 +226,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -264,7 +263,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
styling: null,
|
styling: null,
|
||||||
segment: null,
|
segment: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
closeOnDate: null,
|
closeOnDate: null,
|
||||||
runOnDate: null,
|
runOnDate: null,
|
||||||
|
|||||||
-1
@@ -49,7 +49,6 @@ const mockUser = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
|
|||||||
-166
@@ -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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-59
@@ -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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-39
@@ -29,7 +29,6 @@ const organizationId = "org1";
|
|||||||
|
|
||||||
const baseNotificationSettings: TUserNotificationSettings = {
|
const baseNotificationSettings: TUserNotificationSettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
|
|||||||
expect(switchInput.checked).toBe(false);
|
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)", () => {
|
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
|
||||||
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
||||||
renderSwitch({
|
renderSwitch({
|
||||||
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
|
|||||||
expect(toast.success).not.toHaveBeenCalled();
|
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 () => {
|
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
||||||
const mockErrorResponse = { serverError: "Permission denied" };
|
const mockErrorResponse = { serverError: "Permission denied" };
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
|
|||||||
interface NotificationSwitchProps {
|
interface NotificationSwitchProps {
|
||||||
surveyOrProjectOrOrganizationId: string;
|
surveyOrProjectOrOrganizationId: string;
|
||||||
notificationSettings: TUserNotificationSettings;
|
notificationSettings: TUserNotificationSettings;
|
||||||
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
|
notificationType: "alert" | "unsubscribedOrganizationIds";
|
||||||
autoDisableNotificationType?: string;
|
autoDisableNotificationType?: string;
|
||||||
autoDisableNotificationElementId?: string;
|
autoDisableNotificationElementId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
-12
@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
|
|||||||
.getByText("environments.settings.notifications.email_alerts_surveys")
|
.getByText("environments.settings.notifications.email_alerts_surveys")
|
||||||
.closest("div[class*='rounded-xl']"); // Find parent card
|
.closest("div[class*='rounded-xl']"); // Find parent card
|
||||||
expect(alertsCard).toBeInTheDocument();
|
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
-5
@@ -14,11 +14,6 @@ const Loading = () => {
|
|||||||
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
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" }],
|
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 (
|
return (
|
||||||
|
|||||||
+1
-31
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { EditAlerts } from "./components/EditAlerts";
|
import { EditAlerts } from "./components/EditAlerts";
|
||||||
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
import { Membership } from "./types";
|
import { Membership } from "./types";
|
||||||
|
|
||||||
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
vi.mock("./components/EditAlerts", () => ({
|
vi.mock("./components/EditAlerts", () => ({
|
||||||
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
|
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
|
||||||
}));
|
}));
|
||||||
vi.mock("./components/EditWeeklySummary", () => ({
|
|
||||||
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
|
|
||||||
}));
|
|
||||||
vi.mock("./components/IntegrationsTip", () => ({
|
vi.mock("./components/IntegrationsTip", () => ({
|
||||||
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
|
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
|
||||||
}));
|
}));
|
||||||
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: { "survey-old": true },
|
alert: { "survey-old": true },
|
||||||
weeklySummary: { "project-old": true },
|
|
||||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||||
expect(screen.getByText("IntegrationsTipComponent")).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
|
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
|
||||||
// after `setCompleteNotificationSettings` processes it.
|
// after `setCompleteNotificationSettings` processes it.
|
||||||
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
|
|||||||
// It iterates memberships, then projects, then environments, then surveys.
|
// It iterates memberships, then projects, then environments, then surveys.
|
||||||
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
|
// `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.
|
// 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 = {
|
const finalExpectedSettings = {
|
||||||
alert: {
|
alert: {
|
||||||
"survey-1": false,
|
"survey-1": false,
|
||||||
"survey-2": false,
|
"survey-2": false,
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
"project-1": false,
|
|
||||||
},
|
|
||||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
|
|||||||
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
|
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
|
||||||
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
|
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
|
||||||
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
|
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 () => {
|
test("throws error if session is not found", async () => {
|
||||||
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
|
|||||||
render(PageComponent);
|
render(PageComponent);
|
||||||
|
|
||||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||||
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const expectedEmptySettings = {
|
const expectedEmptySettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
|
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
|
||||||
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
||||||
expect(editAlertsCall.memberships).toEqual([]);
|
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 () => {
|
test("handles legacy notification settings correctly", async () => {
|
||||||
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
|
|||||||
id: "user-legacy",
|
id: "user-legacy",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
|
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
|
||||||
weeklySummary: { "project-1": true },
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
} as any, // To allow legacy structure
|
} as any, // To allow legacy structure
|
||||||
};
|
};
|
||||||
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
|
|||||||
"survey-1": true, // Should be true due to legacy setting
|
"survey-1": true, // Should be true due to legacy setting
|
||||||
"survey-2": false, // Default for other surveys in membership
|
"survey-2": false, // Default for other surveys in membership
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
"project-1": true, // From user's weeklySummary
|
|
||||||
},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
-10
@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
import { EditAlerts } from "./components/EditAlerts";
|
import { EditAlerts } from "./components/EditAlerts";
|
||||||
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
|
||||||
import { IntegrationsTip } from "./components/IntegrationsTip";
|
import { IntegrationsTip } from "./components/IntegrationsTip";
|
||||||
import type { Membership } from "./types";
|
import type { Membership } from "./types";
|
||||||
|
|
||||||
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
|
|||||||
): TUserNotificationSettings => {
|
): TUserNotificationSettings => {
|
||||||
const newNotificationSettings = {
|
const newNotificationSettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||||
};
|
};
|
||||||
for (const membership of memberships) {
|
for (const membership of memberships) {
|
||||||
for (const project of membership.organization.projects) {
|
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
|
// set default values for alerts
|
||||||
for (const environment of project.environments) {
|
for (const environment of project.environments) {
|
||||||
for (const survey of environment.surveys) {
|
for (const survey of environment.surveys) {
|
||||||
@@ -183,11 +178,6 @@ const Page = async (props) => {
|
|||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<IntegrationsTip environmentId={params.environmentId} />
|
<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>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ const mockUser = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ const mockUser = {
|
|||||||
id: "user1",
|
id: "user1",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ const mockUser = {
|
|||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ const mockUser = {
|
|||||||
imageUrl: "http://example.com/avatar.png",
|
imageUrl: "http://example.com/avatar.png",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ const mockUser = {
|
|||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "other",
|
objective: "other",
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|||||||
-18
@@ -58,7 +58,6 @@ vi.mock("@/lib/env", () => ({
|
|||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||||
vi.mock("@/app/lib/surveys/surveys");
|
vi.mock("@/app/lib/surveys/surveys");
|
||||||
vi.mock("@/app/share/[sharingKey]/actions");
|
|
||||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||||
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
||||||
}));
|
}));
|
||||||
@@ -112,7 +111,6 @@ const mockSurvey = {
|
|||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
|
||||||
segment: null,
|
segment: null,
|
||||||
resultShareKey: null,
|
|
||||||
closeOnDate: null,
|
closeOnDate: null,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
autoComplete: null,
|
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 () => {
|
test("displays correct response count string in label for various scenarios", async () => {
|
||||||
mockUsePathname.mockReturnValue(
|
mockUsePathname.mockReturnValue(
|
||||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
|
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
|
||||||
|
|||||||
+2
-5
@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
|
|||||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { InboxIcon, PresentationIcon } from "lucide-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";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
interface SurveyAnalysisNavigationProps {
|
interface SurveyAnalysisNavigationProps {
|
||||||
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
|
|||||||
}: SurveyAnalysisNavigationProps) => {
|
}: SurveyAnalysisNavigationProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslate();
|
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 = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
|
|||||||
-1
@@ -50,7 +50,6 @@ const mockSurvey = {
|
|||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
pin: null,
|
pin: null,
|
||||||
recontactDays: null,
|
recontactDays: null,
|
||||||
resultShareKey: null,
|
|
||||||
runOnDate: null,
|
runOnDate: null,
|
||||||
showLanguageSwitch: false,
|
showLanguageSwitch: false,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
|
|||||||
+1
-2
@@ -113,7 +113,6 @@ const mockSurvey = {
|
|||||||
singleUse: null,
|
singleUse: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
|
||||||
styling: null,
|
styling: null,
|
||||||
@@ -139,7 +138,7 @@ const mockUser = {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "increase_conversion",
|
objective: "increase_conversion",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|
||||||
const mockEnvironmentTags: TTag[] = [
|
const mockEnvironmentTags: TTag[] = [
|
||||||
|
|||||||
-1
@@ -88,7 +88,6 @@ const mockSurvey = {
|
|||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
|||||||
-34
@@ -28,19 +28,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
|
|||||||
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
|
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", () => ({
|
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||||
getFormattedFilters: vi.fn(),
|
getFormattedFilters: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
|
||||||
getResponseCountBySurveySharingKeyAction: vi.fn(),
|
|
||||||
getResponsesBySurveySharingKeyAction: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/recall", () => ({
|
vi.mock("@/lib/utils/recall", () => ({
|
||||||
replaceHeadlineRecall: vi.fn((survey) => survey),
|
replaceHeadlineRecall: vi.fn((survey) => survey),
|
||||||
}));
|
}));
|
||||||
@@ -64,12 +55,6 @@ const mockGetResponseCountAction = vi.mocked(
|
|||||||
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
|
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
|
||||||
.getResponseCountAction
|
.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 mockUseParams = vi.mocked((await import("next/navigation")).useParams);
|
||||||
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
|
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
|
||||||
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
|
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
|
||||||
@@ -150,8 +135,6 @@ describe("ResponsePage", () => {
|
|||||||
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
|
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
|
||||||
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
|
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
|
||||||
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
|
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
|
||||||
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
|
|
||||||
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
|
|
||||||
mockGetFormattedFilters.mockReturnValue({});
|
mockGetFormattedFilters.mockReturnValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,28 +142,11 @@ describe("ResponsePage", () => {
|
|||||||
render(<ResponsePage {...defaultProps} />);
|
render(<ResponsePage {...defaultProps} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(mockGetResponsesAction).toHaveBeenCalled();
|
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 () => {
|
test("fetches next page of responses", async () => {
|
||||||
const { rerender } = render(<ResponsePage {...defaultProps} />);
|
const { rerender } = render(<ResponsePage {...defaultProps} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
+17
-47
@@ -4,11 +4,9 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
|
|||||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
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 { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
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 { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
@@ -20,7 +18,6 @@ interface ResponsePageProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
publicDomain: string;
|
|
||||||
user?: TUser;
|
user?: TUser;
|
||||||
environmentTags: TTag[];
|
environmentTags: TTag[];
|
||||||
responsesPerPage: number;
|
responsesPerPage: number;
|
||||||
@@ -32,17 +29,12 @@ export const ResponsePage = ({
|
|||||||
environment,
|
environment,
|
||||||
survey,
|
survey,
|
||||||
surveyId,
|
surveyId,
|
||||||
publicDomain,
|
|
||||||
user,
|
user,
|
||||||
environmentTags,
|
environmentTags,
|
||||||
responsesPerPage,
|
responsesPerPage,
|
||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
}: ResponsePageProps) => {
|
}: ResponsePageProps) => {
|
||||||
const params = useParams();
|
|
||||||
const sharingKey = params.sharingKey as string;
|
|
||||||
const isSharingPage = !!sharingKey;
|
|
||||||
|
|
||||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||||
@@ -63,30 +55,20 @@ export const ResponsePage = ({
|
|||||||
|
|
||||||
let newResponses: TResponse[] = [];
|
let newResponses: TResponse[] = [];
|
||||||
|
|
||||||
if (isSharingPage) {
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
surveyId,
|
||||||
sharingKey: sharingKey,
|
limit: responsesPerPage,
|
||||||
limit: responsesPerPage,
|
offset: (newPage - 1) * responsesPerPage,
|
||||||
offset: (newPage - 1) * responsesPerPage,
|
filterCriteria: filters,
|
||||||
filterCriteria: filters,
|
});
|
||||||
});
|
newResponses = getResponsesActionResponse?.data || [];
|
||||||
newResponses = getResponsesActionResponse?.data || [];
|
|
||||||
} else {
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
|
||||||
surveyId,
|
|
||||||
limit: responsesPerPage,
|
|
||||||
offset: (newPage - 1) * responsesPerPage,
|
|
||||||
filterCriteria: filters,
|
|
||||||
});
|
|
||||||
newResponses = getResponsesActionResponse?.data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
setResponses([...responses, ...newResponses]);
|
setResponses([...responses, ...newResponses]);
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
|
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||||
|
|
||||||
const deleteResponses = (responseIds: string[]) => {
|
const deleteResponses = (responseIds: string[]) => {
|
||||||
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||||
@@ -114,25 +96,14 @@ export const ResponsePage = ({
|
|||||||
setFetchingFirstPage(true);
|
setFetchingFirstPage(true);
|
||||||
let responses: TResponse[] = [];
|
let responses: TResponse[] = [];
|
||||||
|
|
||||||
if (isSharingPage) {
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
surveyId,
|
||||||
sharingKey,
|
limit: responsesPerPage,
|
||||||
limit: responsesPerPage,
|
offset: 0,
|
||||||
offset: 0,
|
filterCriteria: filters,
|
||||||
filterCriteria: filters,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
responses = getResponsesActionResponse?.data || [];
|
responses = getResponsesActionResponse?.data || [];
|
||||||
} else {
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
|
||||||
surveyId,
|
|
||||||
limit: responsesPerPage,
|
|
||||||
offset: 0,
|
|
||||||
filterCriteria: filters,
|
|
||||||
});
|
|
||||||
|
|
||||||
responses = getResponsesActionResponse?.data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responses.length < responsesPerPage) {
|
if (responses.length < responsesPerPage) {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
@@ -143,7 +114,7 @@ export const ResponsePage = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchInitialResponses();
|
fetchInitialResponses();
|
||||||
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
|
}, [surveyId, filters, responsesPerPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -155,7 +126,6 @@ export const ResponsePage = ({
|
|||||||
<>
|
<>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<CustomFilter survey={surveyMemoized} />
|
<CustomFilter survey={surveyMemoized} />
|
||||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
|
|
||||||
</div>
|
</div>
|
||||||
<ResponseDataView
|
<ResponseDataView
|
||||||
survey={survey}
|
survey={survey}
|
||||||
|
|||||||
-1
@@ -156,7 +156,6 @@ const mockSurvey = {
|
|||||||
projectOverwrites: null,
|
projectOverwrites: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
-3
@@ -120,7 +120,6 @@ vi.mock("next/navigation", () => ({
|
|||||||
useParams: () => ({
|
useParams: () => ({
|
||||||
environmentId: "test-env-id",
|
environmentId: "test-env-id",
|
||||||
surveyId: "test-survey-id",
|
surveyId: "test-survey-id",
|
||||||
sharingKey: null,
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -232,12 +231,10 @@ describe("ResponsesPage", () => {
|
|||||||
environment: mockEnvironment,
|
environment: mockEnvironment,
|
||||||
survey: mockSurvey,
|
survey: mockSurvey,
|
||||||
surveyId: mockSurveyId,
|
surveyId: mockSurveyId,
|
||||||
publicDomain: mockPublicDomain,
|
|
||||||
environmentTags: mockTags,
|
environmentTags: mockTags,
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
responsesPerPage: 10,
|
responsesPerPage: 10,
|
||||||
locale: mockLocale,
|
locale: mockLocale,
|
||||||
isReadOnly: false,
|
|
||||||
}),
|
}),
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|||||||
-1
@@ -70,7 +70,6 @@ const Page = async (props) => {
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyId={params.surveyId}
|
surveyId={params.surveyId}
|
||||||
publicDomain={publicDomain}
|
|
||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
user={user}
|
user={user}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
|
|||||||
-139
@@ -14,7 +14,6 @@ import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
|||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||||
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||||
import { customAlphabet } from "nanoid";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||||
@@ -65,144 +64,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZGenerateResultShareUrlAction = z.object({
|
|
||||||
surveyId: 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),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const survey = await getSurvey(parsedInput.surveyId);
|
|
||||||
if (!survey) {
|
|
||||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultShareKey = customAlphabet(
|
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
|
||||||
20
|
|
||||||
)();
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
|
||||||
ctx.auditLoggingCtx.oldObject = survey;
|
|
||||||
|
|
||||||
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 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 ZResetSurveyAction = z.object({
|
const ZResetSurveyAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
|
|||||||
-150
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-97
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-1
@@ -74,7 +74,6 @@ describe("SuccessMessage", () => {
|
|||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
hiddenFields: { enabled: false, fieldIds: [] },
|
hiddenFields: { enabled: false, fieldIds: [] },
|
||||||
variables: [],
|
variables: [],
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
|||||||
-1
@@ -177,7 +177,6 @@ const mockSurvey = {
|
|||||||
autoClose: null,
|
autoClose: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
resultShareKey: null,
|
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
|
|||||||
-54
@@ -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
|
// Mock components
|
||||||
vi.mock(
|
vi.mock(
|
||||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
|
"@/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>,
|
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
|
// Mock context
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||||
useResponseFilter: () => ({
|
useResponseFilter: () => ({
|
||||||
@@ -172,7 +131,6 @@ describe("SummaryPage", () => {
|
|||||||
webAppUrl: "https://app.example.com",
|
webAppUrl: "https://app.example.com",
|
||||||
totalResponseCount: 50,
|
totalResponseCount: 50,
|
||||||
locale,
|
locale,
|
||||||
isReadOnly: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test("renders loading state initially", () => {
|
test("renders loading state initially", () => {
|
||||||
@@ -191,7 +149,6 @@ describe("SummaryPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -214,15 +171,4 @@ describe("SummaryPage", () => {
|
|||||||
// Drop-offs should now be visible
|
// Drop-offs should now be visible
|
||||||
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-26
@@ -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 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 { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
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 { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
@@ -36,9 +34,7 @@ interface SummaryPageProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
publicDomain: string;
|
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isReadOnly: boolean;
|
|
||||||
initialSurveySummary?: TSurveySummary;
|
initialSurveySummary?: TSurveySummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +42,9 @@ export const SummaryPage = ({
|
|||||||
environment,
|
environment,
|
||||||
survey,
|
survey,
|
||||||
surveyId,
|
surveyId,
|
||||||
publicDomain,
|
|
||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
|
||||||
initialSurveySummary,
|
initialSurveySummary,
|
||||||
}: SummaryPageProps) => {
|
}: SummaryPageProps) => {
|
||||||
const params = useParams();
|
|
||||||
const sharingKey = params.sharingKey as string;
|
|
||||||
const isSharingPage = !!sharingKey;
|
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||||
@@ -87,17 +77,10 @@ export const SummaryPage = ({
|
|||||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
let updatedSurveySummary;
|
let updatedSurveySummary;
|
||||||
|
|
||||||
if (isSharingPage) {
|
updatedSurveySummary = await getSurveySummaryAction({
|
||||||
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
|
surveyId,
|
||||||
sharingKey,
|
filterCriteria: currentFilters,
|
||||||
filterCriteria: currentFilters,
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updatedSurveySummary = await getSurveySummaryAction({
|
|
||||||
surveyId,
|
|
||||||
filterCriteria: currentFilters,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||||
setSurveySummary(surveySummary);
|
setSurveySummary(surveySummary);
|
||||||
@@ -109,7 +92,7 @@ export const SummaryPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchSummary();
|
fetchSummary();
|
||||||
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||||
|
|
||||||
const surveyMemoized = useMemo(() => {
|
const surveyMemoized = useMemo(() => {
|
||||||
return replaceHeadlineRecall(survey, "default");
|
return replaceHeadlineRecall(survey, "default");
|
||||||
@@ -132,9 +115,6 @@ export const SummaryPage = ({
|
|||||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<CustomFilter survey={surveyMemoized} />
|
<CustomFilter survey={surveyMemoized} />
|
||||||
{!isReadOnly && !isSharingPage && (
|
|
||||||
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ScrollToTop containerId="mainContent" />
|
<ScrollToTop containerId="mainContent" />
|
||||||
<SummaryList
|
<SummaryList
|
||||||
|
|||||||
-16
@@ -284,7 +284,6 @@ const mockSurvey: TSurvey = {
|
|||||||
recaptcha: null,
|
recaptcha: null,
|
||||||
isSingleResponsePerEmailEnabled: false,
|
isSingleResponsePerEmailEnabled: false,
|
||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
resultShareKey: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUser: TUser = {
|
const mockUser: TUser = {
|
||||||
@@ -305,12 +304,8 @@ const mockUser: TUser = {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {
|
alert: {
|
||||||
weeklySummary: true,
|
|
||||||
responseFinished: true,
|
responseFinished: true,
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
test: true,
|
|
||||||
},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -379,14 +374,6 @@ describe("SurveyAnalysisCTA", () => {
|
|||||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
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 () => {
|
test("opens share modal when share button is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||||
@@ -510,7 +497,6 @@ describe("SurveyAnalysisCTA", () => {
|
|||||||
environmentId: "test-env-id",
|
environmentId: "test-env-id",
|
||||||
triggers: [],
|
triggers: [],
|
||||||
segment: null,
|
segment: null,
|
||||||
resultShareKey: null,
|
|
||||||
languages: [],
|
languages: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -592,7 +578,6 @@ describe("SurveyAnalysisCTA", () => {
|
|||||||
environmentId: "test-env-id",
|
environmentId: "test-env-id",
|
||||||
triggers: [],
|
triggers: [],
|
||||||
segment: null,
|
segment: null,
|
||||||
resultShareKey: null,
|
|
||||||
languages: [],
|
languages: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -626,7 +611,6 @@ describe("SurveyAnalysisCTA", () => {
|
|||||||
environmentId: "test-env-id",
|
environmentId: "test-env-id",
|
||||||
triggers: [],
|
triggers: [],
|
||||||
segment: null,
|
segment: null,
|
||||||
resultShareKey: null,
|
|
||||||
languages: [],
|
languages: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-11
@@ -8,7 +8,6 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|||||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||||
@@ -66,7 +65,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
const [isResetting, setIsResetting] = useState(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
const { organizationId, project } = useEnvironment();
|
const { organizationId, project } = useEnvironment();
|
||||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||||
|
|
||||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
|
|
||||||
@@ -185,15 +184,6 @@ export const SurveyAnalysisCTA = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
<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" && (
|
{!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
||||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||||
)}
|
)}
|
||||||
@@ -222,6 +212,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
segments={segments}
|
segments={segments}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SuccessMessage environment={environment} survey={survey} />
|
<SuccessMessage environment={environment} survey={survey} />
|
||||||
|
|||||||
-2
@@ -227,7 +227,6 @@ const mockSurvey: TSurvey = {
|
|||||||
recaptcha: null,
|
recaptcha: null,
|
||||||
isSingleResponsePerEmailEnabled: false,
|
isSingleResponsePerEmailEnabled: false,
|
||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
resultShareKey: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAppSurvey: TSurvey = {
|
const mockAppSurvey: TSurvey = {
|
||||||
@@ -253,7 +252,6 @@ const mockUser: TUser = {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+4
@@ -34,6 +34,7 @@ interface ShareSurveyModalProps {
|
|||||||
segments: TSegment[];
|
segments: TSegment[];
|
||||||
isContactsEnabled: boolean;
|
isContactsEnabled: boolean;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
|
isReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareSurveyModal = ({
|
export const ShareSurveyModal = ({
|
||||||
@@ -46,6 +47,7 @@ export const ShareSurveyModal = ({
|
|||||||
segments,
|
segments,
|
||||||
isContactsEnabled,
|
isContactsEnabled,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
|
isReadOnly,
|
||||||
}: ShareSurveyModalProps) => {
|
}: ShareSurveyModalProps) => {
|
||||||
const environmentId = survey.environmentId;
|
const environmentId = survey.environmentId;
|
||||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||||
@@ -75,6 +77,7 @@ export const ShareSurveyModal = ({
|
|||||||
setSurveyUrl,
|
setSurveyUrl,
|
||||||
locale: user.locale,
|
locale: user.locale,
|
||||||
surveyUrl,
|
surveyUrl,
|
||||||
|
isReadOnly,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -192,6 +195,7 @@ export const ShareSurveyModal = ({
|
|||||||
tabs={linkTabs}
|
tabs={linkTabs}
|
||||||
handleViewChange={handleViewChange}
|
handleViewChange={handleViewChange}
|
||||||
handleEmbedViewWithTab={handleEmbedViewWithTab}
|
handleEmbedViewWithTab={handleEmbedViewWithTab}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -23,6 +23,7 @@ interface AnonymousLinksTabProps {
|
|||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
setSurveyUrl: (url: string) => void;
|
setSurveyUrl: (url: string) => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
|
isReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnonymousLinksTab = ({
|
export const AnonymousLinksTab = ({
|
||||||
@@ -31,6 +32,7 @@ export const AnonymousLinksTab = ({
|
|||||||
publicDomain,
|
publicDomain,
|
||||||
setSurveyUrl,
|
setSurveyUrl,
|
||||||
locale,
|
locale,
|
||||||
|
isReadOnly,
|
||||||
}: AnonymousLinksTabProps) => {
|
}: AnonymousLinksTabProps) => {
|
||||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -220,6 +222,7 @@ export const AnonymousLinksTab = ({
|
|||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
+3
@@ -16,6 +16,7 @@ interface SuccessViewProps {
|
|||||||
tabs: { id: string; label: string; icon: React.ElementType }[];
|
tabs: { id: string; label: string; icon: React.ElementType }[];
|
||||||
handleViewChange: (view: string) => void;
|
handleViewChange: (view: string) => void;
|
||||||
handleEmbedViewWithTab: (tabId: string) => void;
|
handleEmbedViewWithTab: (tabId: string) => void;
|
||||||
|
isReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SuccessView: React.FC<SuccessViewProps> = ({
|
export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||||
@@ -27,6 +28,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
tabs,
|
tabs,
|
||||||
handleViewChange,
|
handleViewChange,
|
||||||
handleEmbedViewWithTab,
|
handleEmbedViewWithTab,
|
||||||
|
isReadOnly,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const environmentId = survey.environmentId;
|
const environmentId = survey.environmentId;
|
||||||
@@ -44,6 +46,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
enforceSurveyUrlWidth
|
enforceSurveyUrlWidth
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
-1
@@ -81,7 +81,6 @@ const mockSurvey = {
|
|||||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
resultShareKey: null,
|
|
||||||
variables: [],
|
variables: [],
|
||||||
segment: null,
|
segment: null,
|
||||||
autoClose: null,
|
autoClose: null,
|
||||||
|
|||||||
-1
@@ -93,7 +93,6 @@ const mockBaseSurvey: TSurvey = {
|
|||||||
environmentId: "env_123",
|
environmentId: "env_123",
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
resultShareKey: null,
|
|
||||||
pin: null,
|
pin: null,
|
||||||
createdBy: "user_123",
|
createdBy: "user_123",
|
||||||
isSingleResponsePerEmailEnabled: false,
|
isSingleResponsePerEmailEnabled: false,
|
||||||
|
|||||||
-1
@@ -66,7 +66,6 @@ describe("Utils Tests", () => {
|
|||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayOption: "displayOnce",
|
displayOption: "displayOnce",
|
||||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
-4
@@ -111,7 +111,6 @@ vi.mock("next/navigation", () => ({
|
|||||||
useParams: () => ({
|
useParams: () => ({
|
||||||
environmentId: "test-environment-id",
|
environmentId: "test-environment-id",
|
||||||
surveyId: "test-survey-id",
|
surveyId: "test-survey-id",
|
||||||
sharingKey: null,
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -145,7 +144,6 @@ const mockSurvey = {
|
|||||||
delay: 0,
|
delay: 0,
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
resultShareKey: null,
|
|
||||||
runOnDate: null,
|
runOnDate: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
@@ -249,8 +247,6 @@ describe("SurveyPage", () => {
|
|||||||
environment: mockEnvironment,
|
environment: mockEnvironment,
|
||||||
survey: mockSurvey,
|
survey: mockSurvey,
|
||||||
surveyId: mockSurveyId,
|
surveyId: mockSurveyId,
|
||||||
publicDomain: "http://localhost:3000",
|
|
||||||
isReadOnly: false,
|
|
||||||
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
||||||
initialSurveySummary: mockSurveySummary,
|
initialSurveySummary: mockSurveySummary,
|
||||||
})
|
})
|
||||||
|
|||||||
-2
@@ -70,8 +70,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyId={params.surveyId}
|
surveyId={params.surveyId}
|
||||||
publicDomain={publicDomain}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
locale={user.locale ?? DEFAULT_LOCALE}
|
locale={user.locale ?? DEFAULT_LOCALE}
|
||||||
initialSurveySummary={initialSurveySummary}
|
initialSurveySummary={initialSurveySummary}
|
||||||
/>
|
/>
|
||||||
|
|||||||
-11
@@ -102,7 +102,6 @@ const mockSurvey = {
|
|||||||
autoComplete: null,
|
autoComplete: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
triggers: [],
|
triggers: [],
|
||||||
@@ -157,16 +156,6 @@ describe("CustomFilter", () => {
|
|||||||
expect(screen.getByText(`Select first date - ${format(mockDateToday, "dd LLL")}`)).toBeInTheDocument();
|
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)", () => {
|
test("useEffect logic for resetState and firstMountRef (as per current component code)", () => {
|
||||||
// This test verifies the current behavior of the useEffects related to firstMountRef.
|
// 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,
|
// Based on the component's code, resetState() is not expected to be called by these effects,
|
||||||
|
|||||||
+39
-46
@@ -32,7 +32,6 @@ import {
|
|||||||
subYears,
|
subYears,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -125,8 +124,6 @@ const getDateRangeLabel = (from: Date, to: Date, t: TFnType) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||||
const params = useParams();
|
|
||||||
const isSharingPage = !!params.sharingKey;
|
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
|
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
|
||||||
const [filterRange, setFilterRange] = useState(
|
const [filterRange, setFilterRange] = useState(
|
||||||
@@ -385,51 +382,47 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{!isSharingPage && (
|
<DropdownMenu
|
||||||
<DropdownMenu
|
onOpenChange={(value) => {
|
||||||
onOpenChange={(value) => {
|
value && handleDatePickerClose();
|
||||||
value && handleDatePickerClose();
|
}}>
|
||||||
}}>
|
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||||
<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="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">
|
||||||
<div className="hidden w-full items-center justify-between sm:flex">
|
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<DownloadIcon className="block h-4 sm:hidden" />
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
<DownloadIcon className="block h-4 sm:hidden" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
||||||
}}>
|
}}>
|
||||||
<p className="text-slate-700">
|
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||||
{t("environments.surveys.summary.filtered_responses_excel")}
|
</DropdownMenuItem>
|
||||||
</p>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuItem>
|
</DropdownMenu>
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{isDatePickerOpen && (
|
{isDatePickerOpen && (
|
||||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||||
|
|||||||
+13
-9
@@ -21,7 +21,7 @@ describe("QuestionFilterComboBox", () => {
|
|||||||
|
|
||||||
test("renders select placeholders", () => {
|
test("renders select placeholders", () => {
|
||||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
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 () => {
|
test("calls onChangeFilterValue when selecting filter", async () => {
|
||||||
@@ -72,17 +72,21 @@ describe("QuestionFilterComboBox", () => {
|
|||||||
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
|
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prevent combo-box opening when filterValue is Submitted", async () => {
|
test("combobox is disabled when filterValue is 'Submitted' for NPS questions", async () => {
|
||||||
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
|
const props = { ...defaultProps, type: "nps", filterValue: "Submitted" } as any;
|
||||||
render(<QuestionFilterComboBox {...props} />);
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
const comboBoxOpenerButton = screen.getAllByRole("button")[1];
|
||||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
expect(comboBoxOpenerButton).toBeDisabled();
|
||||||
|
await userEvent.click(comboBoxOpenerButton);
|
||||||
|
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prevent combo-box opening when filterValue is Skipped", async () => {
|
test("combobox is disabled when filterValue is 'Skipped' for rating questions", async () => {
|
||||||
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
|
const props = { ...defaultProps, type: "rating", filterValue: "Skipped" } as any;
|
||||||
render(<QuestionFilterComboBox {...props} />);
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
const comboBoxOpenerButton = screen.getAllByRole("button")[1];
|
||||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
expect(comboBoxOpenerButton).toBeDisabled();
|
||||||
|
await userEvent.click(comboBoxOpenerButton);
|
||||||
|
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
-30
@@ -1,7 +1,6 @@
|
|||||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
@@ -19,10 +18,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", (
|
|||||||
getSurveyFilterDataAction: vi.fn(),
|
getSurveyFilterDataAction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
|
||||||
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||||
generateQuestionAndFilterOptions: vi.fn(),
|
generateQuestionAndFilterOptions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -235,29 +230,4 @@ describe("ResponseFilter", () => {
|
|||||||
|
|
||||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
|
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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-12
@@ -7,7 +7,6 @@ import {
|
|||||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
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 { useTranslate } from "@tolgee/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||||
@@ -33,10 +31,7 @@ interface ResponseFilterProps {
|
|||||||
|
|
||||||
export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const params = useParams();
|
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
const sharingKey = params.sharingKey as string;
|
|
||||||
const isSharingPage = !!sharingKey;
|
|
||||||
|
|
||||||
const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter();
|
const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter();
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
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
|
// Fetch the initial data for the filter and load it into the state
|
||||||
const handleInitialData = async () => {
|
const handleInitialData = async () => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const surveyFilterData = isSharingPage
|
const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id });
|
||||||
? await getSurveyFilterDataBySurveySharingKeyAction({
|
|
||||||
sharingKey,
|
|
||||||
environmentId: survey.environmentId,
|
|
||||||
})
|
|
||||||
: await getSurveyFilterDataAction({ surveyId: survey.id });
|
|
||||||
|
|
||||||
if (!surveyFilterData?.data) return;
|
if (!surveyFilterData?.data) return;
|
||||||
|
|
||||||
@@ -68,7 +58,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleInitialData();
|
handleInitialData();
|
||||||
}, [isOpen, isSharingPage, setSelectedOptions, sharingKey, survey]);
|
}, [isOpen, setSelectedOptions, survey]);
|
||||||
|
|
||||||
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||||
if (filterValue.filter[index].questionType) {
|
if (filterValue.filter[index].questionType) {
|
||||||
|
|||||||
-261
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-146
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-1
@@ -87,7 +87,6 @@ const baseSurvey: TSurvey = {
|
|||||||
isSingleUse: false,
|
isSingleUse: false,
|
||||||
segment: null,
|
segment: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
resultShareKey: null,
|
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
verifyEmail: null,
|
verifyEmail: null,
|
||||||
pin: null,
|
pin: null,
|
||||||
|
|||||||
-1
@@ -78,7 +78,6 @@ const mockSurvey: TSurvey = {
|
|||||||
isSingleResponsePerEmailEnabled: false,
|
isSingleResponsePerEmailEnabled: false,
|
||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [
|
languages: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ describe("SurveyLayout", () => {
|
|||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
showLanguageSwitch: false,
|
showLanguageSwitch: false,
|
||||||
recaptcha: null,
|
recaptcha: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ export const mockSurvey: TSurvey = {
|
|||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
},
|
},
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
showLanguageSwitch: null,
|
showLanguageSwitch: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
triggers: [],
|
triggers: [],
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ const mockSurvey = {
|
|||||||
environmentId: "env1",
|
environmentId: "env1",
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
resultShareKey: null,
|
|
||||||
pin: null,
|
pin: null,
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -89,7 +89,6 @@ const baseSurvey: TSurvey = {
|
|||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||||
endings: [],
|
endings: [],
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ const baseSurvey: TSurvey = {
|
|||||||
autoComplete: null,
|
autoComplete: null,
|
||||||
segment: null,
|
segment: null,
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const attributes: TAttributes = {
|
const attributes: TAttributes = {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { validateInputs } from "@/lib/utils/validate";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
|
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getContactByUserId } from "./contact";
|
import { getContactByUserId } from "./contact";
|
||||||
|
|
||||||
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<{ id: string }> => {
|
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({
|
const display = await prisma.display.create({
|
||||||
data: {
|
data: {
|
||||||
survey: {
|
survey: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
|||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||||
import { InvalidInputError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
@@ -52,11 +52,11 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||||
return responses.successResponse(response, true);
|
return responses.successResponse(response, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvalidInputError) {
|
if (error instanceof ResourceNotFoundError) {
|
||||||
return responses.badRequestResponse(error.message);
|
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
|
||||||
} else {
|
} else {
|
||||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ const mockSurveys: TSurvey[] = [
|
|||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
segment: null,
|
segment: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const mockDeletedSurveyAppPrivateSegment = {
|
|||||||
type: "app",
|
type: "app",
|
||||||
segment: { id: segmentId, isPrivate: true },
|
segment: { id: segmentId, isPrivate: true },
|
||||||
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
|
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
|
||||||
resultShareKey: "shareKey123",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockDeletedSurveyLink = {
|
const mockDeletedSurveyLink = {
|
||||||
@@ -46,7 +45,6 @@ const mockDeletedSurveyLink = {
|
|||||||
type: "link",
|
type: "link",
|
||||||
segment: null,
|
segment: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
resultShareKey: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("deleteSurvey", () => {
|
describe("deleteSurvey", () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { validateInputs } from "@/lib/utils/validate";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
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 { TDisplayCreateInputV2 } from "../types/display";
|
||||||
import { doesContactExist } from "./contact";
|
import { doesContactExist } from "./contact";
|
||||||
import { createDisplay } from "./display";
|
import { createDisplay } from "./display";
|
||||||
@@ -16,6 +16,9 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
display: {
|
display: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
},
|
},
|
||||||
|
survey: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -43,17 +46,32 @@ const mockDisplay = {
|
|||||||
id: displayId,
|
id: displayId,
|
||||||
contactId,
|
contactId,
|
||||||
surveyId,
|
surveyId,
|
||||||
|
responseId: null,
|
||||||
|
status: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockDisplayWithoutContact = {
|
const mockDisplayWithoutContact = {
|
||||||
id: displayId,
|
id: displayId,
|
||||||
contactId: null,
|
contactId: null,
|
||||||
surveyId,
|
surveyId,
|
||||||
|
responseId: null,
|
||||||
|
status: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockSurvey = {
|
||||||
|
id: surveyId,
|
||||||
|
name: "Test Survey",
|
||||||
|
environmentId,
|
||||||
|
} as any;
|
||||||
|
|
||||||
describe("createDisplay", () => {
|
describe("createDisplay", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should create a display with contactId successfully", async () => {
|
test("should create a display with contactId successfully", async () => {
|
||||||
@@ -119,7 +137,19 @@ describe("createDisplay", () => {
|
|||||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
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", {
|
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||||
code: "P2002",
|
code: "P2002",
|
||||||
clientVersion: "2.0.0",
|
clientVersion: "2.0.0",
|
||||||
|
|||||||
@@ -5,17 +5,27 @@ import {
|
|||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { doesContactExist } from "./contact";
|
import { doesContactExist } from "./contact";
|
||||||
|
|
||||||
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
|
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
|
||||||
validateInputs([displayInput, ZDisplayCreateInputV2]);
|
validateInputs([displayInput, ZDisplayCreateInputV2]);
|
||||||
|
|
||||||
const { contactId, surveyId } = displayInput;
|
const { contactId, surveyId, environmentId } = displayInput;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
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({
|
const display = await prisma.display.create({
|
||||||
data: {
|
data: {
|
||||||
survey: {
|
survey: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
|||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { InvalidInputError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
@@ -52,11 +52,11 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||||
return responses.successResponse(response, true);
|
return responses.successResponse(response, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvalidInputError) {
|
if (error instanceof ResourceNotFoundError) {
|
||||||
return responses.badRequestResponse(error.message);
|
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
|
||||||
} else {
|
} else {
|
||||||
logger.error({ error, url: request.url }, "Error creating display");
|
logger.error({ error, url: request.url }, "Error creating display");
|
||||||
return responses.internalServerErrorResponse(error.message);
|
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const mockSurvey: TSurvey = {
|
|||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
segment: null,
|
segment: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
|
|||||||
@@ -3582,7 +3582,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
|
|||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
},
|
},
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
languages: [],
|
languages: [],
|
||||||
triggers: [],
|
triggers: [],
|
||||||
showLanguageSwitch: false,
|
showLanguageSwitch: false,
|
||||||
|
|||||||
@@ -101,12 +101,6 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return true for share routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/share/abc123/summary")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/share/xyz789/responses")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/share/anything")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return false for admin-only routes", () => {
|
test("should return false for admin-only routes", () => {
|
||||||
expect(isPublicDomainRoute("/")).toBe(false);
|
expect(isPublicDomainRoute("/")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/environments/123")).toBe(false);
|
expect(isPublicDomainRoute("/environments/123")).toBe(false);
|
||||||
@@ -155,7 +149,6 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/share/abc/summary", true)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||||
// Static assets not tested - middleware doesn't run on them
|
// Static assets not tested - middleware doesn't run on them
|
||||||
});
|
});
|
||||||
@@ -181,7 +174,6 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/share/abc/summary", false)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ const PUBLIC_ROUTES = {
|
|||||||
API_ROUTES: [
|
API_ROUTES: [
|
||||||
/^\/api\/v[12]\/client\//, // /api/v1/client/** and /api/v2/client/**
|
/^\/api\/v[12]\/client\//, // /api/v1/client/** and /api/v2/client/**
|
||||||
],
|
],
|
||||||
|
|
||||||
// Share routes
|
|
||||||
SHARE_ROUTES: [
|
|
||||||
/^\/share\//, // /share/** - shared survey results
|
|
||||||
],
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const COMMON_ROUTES = {
|
const COMMON_ROUTES = {
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ describe("Page", () => {
|
|||||||
objective: null,
|
objective: null,
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
@@ -171,7 +170,6 @@ describe("Page", () => {
|
|||||||
objective: null,
|
objective: null,
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
@@ -261,7 +259,6 @@ describe("Page", () => {
|
|||||||
objective: null,
|
objective: null,
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
@@ -351,7 +348,6 @@ describe("Page", () => {
|
|||||||
objective: null,
|
objective: null,
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
|
||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
|
||||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
|
||||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
|
||||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
|
|
||||||
type Params = Promise<{
|
|
||||||
sharingKey: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
interface ResponsesPageProps {
|
|
||||||
params: Params;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Page = async (props: ResponsesPageProps) => {
|
|
||||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
|
||||||
|
|
||||||
const t = await getTranslate();
|
|
||||||
const params = await props.params;
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
|
||||||
|
|
||||||
if (!surveyId) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
const survey = await getSurvey(surveyId);
|
|
||||||
if (!survey) {
|
|
||||||
throw new Error(t("common.survey_not_found"));
|
|
||||||
}
|
|
||||||
const environmentId = survey.environmentId;
|
|
||||||
const [environment, project, tags] = await Promise.all([
|
|
||||||
getEnvironment(environmentId),
|
|
||||||
getProjectByEnvironmentId(environmentId),
|
|
||||||
getTagsByEnvironmentId(environmentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
|
||||||
const publicDomain = getPublicDomain();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full justify-center">
|
|
||||||
<PageContentWrapper className="w-full">
|
|
||||||
<PageHeader pageTitle={survey.name}>
|
|
||||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="responses" />
|
|
||||||
</PageHeader>
|
|
||||||
<ResponsePage
|
|
||||||
environment={environment}
|
|
||||||
survey={survey}
|
|
||||||
surveyId={surveyId}
|
|
||||||
publicDomain={publicDomain}
|
|
||||||
environmentTags={tags}
|
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
|
||||||
locale={locale}
|
|
||||||
isReadOnly={true}
|
|
||||||
/>
|
|
||||||
</PageContentWrapper>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
|
||||||
// Import mocked functions
|
|
||||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
|
||||||
import { cleanup } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock all dependencies to avoid server-side environment issues
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
IS_PRODUCTION: false,
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
SHORT_URL_BASE: "http://localhost:3000",
|
|
||||||
ENCRYPTION_KEY: "test-key",
|
|
||||||
RATE_LIMITING_DISABLED: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
IS_FORMBRICKS_CLOUD: "0",
|
|
||||||
NODE_ENV: "test",
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
SHORT_URL_BASE: "http://localhost:3000",
|
|
||||||
ENCRYPTION_KEY: "test-key",
|
|
||||||
RATE_LIMITING_DISABLED: "false",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock rate limiting dependencies
|
|
||||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
|
||||||
applyIPRateLimit: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
|
||||||
rateLimitConfigs: {
|
|
||||||
share: {
|
|
||||||
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock other dependencies
|
|
||||||
vi.mock("@/lib/survey/service", () => ({
|
|
||||||
getSurveyIdByResultShareKey: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Share Summary Page Rate Limiting", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Rate Limiting Configuration", () => {
|
|
||||||
test("should have correct rate limit config for share URLs", () => {
|
|
||||||
expect(rateLimitConfigs.share.url).toEqual({
|
|
||||||
interval: 60,
|
|
||||||
allowedPerInterval: 30,
|
|
||||||
namespace: "share:url",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should apply rate limiting function correctly", async () => {
|
|
||||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
|
||||||
|
|
||||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
|
||||||
|
|
||||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
|
||||||
interval: 60,
|
|
||||||
allowedPerInterval: 30,
|
|
||||||
namespace: "share:url",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw rate limit error when limit exceeded", async () => {
|
|
||||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
|
||||||
new Error("Maximum number of requests reached. Please try again later.")
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
|
|
||||||
"Maximum number of requests reached. Please try again later."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Share Key Validation Flow", () => {
|
|
||||||
test("should validate sharing key after rate limiting", async () => {
|
|
||||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
|
||||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
|
|
||||||
|
|
||||||
// Simulate the flow: rate limit first, then validate sharing key
|
|
||||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey("test-sharing-key-123");
|
|
||||||
|
|
||||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
|
|
||||||
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("test-sharing-key-123");
|
|
||||||
expect(surveyId).toBe("survey123");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle invalid sharing keys after rate limiting", async () => {
|
|
||||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
|
||||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey("invalid-key");
|
|
||||||
|
|
||||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
|
|
||||||
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("invalid-key");
|
|
||||||
expect(surveyId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Security Considerations", () => {
|
|
||||||
test("should rate limit all requests regardless of sharing key validity", async () => {
|
|
||||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
|
||||||
|
|
||||||
// Test with valid sharing key
|
|
||||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
|
|
||||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
|
||||||
await getSurveyIdByResultShareKey("valid-key");
|
|
||||||
|
|
||||||
// Test with invalid sharing key
|
|
||||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
|
|
||||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
|
||||||
await getSurveyIdByResultShareKey("invalid-key");
|
|
||||||
|
|
||||||
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not expose internal errors when rate limited", async () => {
|
|
||||||
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
|
|
||||||
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
|
|
||||||
|
|
||||||
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
|
|
||||||
"Maximum number of requests reached. Please try again later."
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure no other operations are performed
|
|
||||||
expect(getSurveyIdByResultShareKey).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
|
||||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
|
||||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
|
||||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
|
||||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
|
||||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
|
|
||||||
type Params = Promise<{
|
|
||||||
sharingKey: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
interface SummaryPageProps {
|
|
||||||
params: Params;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Page = async (props: SummaryPageProps) => {
|
|
||||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
|
||||||
|
|
||||||
const t = await getTranslate();
|
|
||||||
const params = await props.params;
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
|
||||||
|
|
||||||
if (!surveyId) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const survey = await getSurvey(surveyId);
|
|
||||||
if (!survey) {
|
|
||||||
throw new Error(t("common.survey_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const environmentId = survey.environmentId;
|
|
||||||
|
|
||||||
const [environment, project] = await Promise.all([
|
|
||||||
getEnvironment(environmentId),
|
|
||||||
getProjectByEnvironmentId(environmentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
|
||||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full justify-center">
|
|
||||||
<PageContentWrapper className="w-full">
|
|
||||||
<PageHeader pageTitle={survey.name}>
|
|
||||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="summary" />
|
|
||||||
</PageHeader>
|
|
||||||
<SummaryPage
|
|
||||||
environment={environment}
|
|
||||||
survey={survey}
|
|
||||||
surveyId={survey.id}
|
|
||||||
publicDomain={publicDomain}
|
|
||||||
isReadOnly={true}
|
|
||||||
locale={DEFAULT_LOCALE}
|
|
||||||
initialSurveySummary={initialSurveySummary}
|
|
||||||
/>
|
|
||||||
</PageContentWrapper>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { getResponseCountBySurveyId, getResponseFilteringValues, getResponses } from "@/lib/response/service";
|
|
||||||
import { getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
|
||||||
import { actionClient } from "@/lib/utils/action-client";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
|
||||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
|
||||||
import { getSurveySummary } from "../../(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
|
||||||
|
|
||||||
const ZGetResponsesBySurveySharingKeyAction = z.object({
|
|
||||||
sharingKey: z.string(),
|
|
||||||
limit: z.number().optional(),
|
|
||||||
offset: z.number().optional(),
|
|
||||||
filterCriteria: ZResponseFilterCriteria.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getResponsesBySurveySharingKeyAction = actionClient
|
|
||||||
.schema(ZGetResponsesBySurveySharingKeyAction)
|
|
||||||
.action(async ({ parsedInput }) => {
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
|
||||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
|
||||||
|
|
||||||
const responses = await getResponses(
|
|
||||||
surveyId,
|
|
||||||
parsedInput.limit,
|
|
||||||
parsedInput.offset,
|
|
||||||
parsedInput.filterCriteria
|
|
||||||
);
|
|
||||||
return responses;
|
|
||||||
});
|
|
||||||
|
|
||||||
const ZGetSummaryBySurveySharingKeyAction = z.object({
|
|
||||||
sharingKey: z.string(),
|
|
||||||
filterCriteria: ZResponseFilterCriteria.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getSummaryBySurveySharingKeyAction = actionClient
|
|
||||||
.schema(ZGetSummaryBySurveySharingKeyAction)
|
|
||||||
.action(async ({ parsedInput }) => {
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
|
||||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
|
||||||
|
|
||||||
return getSurveySummary(surveyId, parsedInput.filterCriteria);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ZGetResponseCountBySurveySharingKeyAction = z.object({
|
|
||||||
sharingKey: z.string(),
|
|
||||||
filterCriteria: ZResponseFilterCriteria.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getResponseCountBySurveySharingKeyAction = actionClient
|
|
||||||
.schema(ZGetResponseCountBySurveySharingKeyAction)
|
|
||||||
.action(async ({ parsedInput }) => {
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
|
||||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
|
||||||
|
|
||||||
return getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ZGetSurveyFilterDataBySurveySharingKeyAction = z.object({
|
|
||||||
sharingKey: z.string(),
|
|
||||||
environmentId: ZId,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getSurveyFilterDataBySurveySharingKeyAction = actionClient
|
|
||||||
.schema(ZGetSurveyFilterDataBySurveySharingKeyAction)
|
|
||||||
.action(async ({ parsedInput }) => {
|
|
||||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
|
||||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
|
||||||
|
|
||||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }] = await Promise.all([
|
|
||||||
getTagsByEnvironmentId(parsedInput.environmentId),
|
|
||||||
getResponseFilteringValues(surveyId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { environmentTags: tags, attributes, meta, hiddenFields };
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
robots: { index: false, follow: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
const EnvironmentLayout = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 bg-slate-50">
|
|
||||||
<ResponseFilterProvider>{children}</ResponseFilterProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EnvironmentLayout;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const NotFound = async () => {
|
|
||||||
const t = await getTranslate();
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
|
||||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
|
|
||||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
|
|
||||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
|
||||||
{t("share.page_not_found_description")}
|
|
||||||
</p>
|
|
||||||
<Link href={"/"}>
|
|
||||||
<Button className="mt-8">{t("share.back_to_home")}</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock the redirect function
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Import the page component
|
|
||||||
const PageComponent = (await import("./page")).default;
|
|
||||||
|
|
||||||
describe("Share Redirect Page", () => {
|
|
||||||
test("should redirect to summary page without rate limiting", async () => {
|
|
||||||
const mockParams = Promise.resolve({ sharingKey: "test-sharing-key-123" });
|
|
||||||
|
|
||||||
await PageComponent({ params: mockParams });
|
|
||||||
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/share/test-sharing-key-123/summary");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle different sharing keys", async () => {
|
|
||||||
const testCases = ["abc123", "survey-key-456", "long-sharing-key-with-dashes-789"];
|
|
||||||
|
|
||||||
for (const sharingKey of testCases) {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
const mockParams = Promise.resolve({ sharingKey });
|
|
||||||
|
|
||||||
await PageComponent({ params: mockParams });
|
|
||||||
|
|
||||||
expect(redirect).toHaveBeenCalledWith(`/share/${sharingKey}/summary`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should be lightweight and not perform any rate limiting", async () => {
|
|
||||||
// This test ensures the page doesn't import or use rate limiting
|
|
||||||
const mockParams = Promise.resolve({ sharingKey: "test-key" });
|
|
||||||
|
|
||||||
// Measure execution time to ensure it's very fast (< 10ms)
|
|
||||||
const startTime = performance.now();
|
|
||||||
await PageComponent({ params: mockParams });
|
|
||||||
const endTime = performance.now();
|
|
||||||
|
|
||||||
const executionTime = endTime - startTime;
|
|
||||||
expect(executionTime).toBeLessThan(10); // Should be very fast since it's just a redirect
|
|
||||||
expect(redirect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
type Params = Promise<{
|
|
||||||
sharingKey: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const Page = async (props: { params: Params }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
return redirect(`/share/${params.sharingKey}/summary`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
mockDisplayInputWithUserId,
|
mockDisplayInputWithUserId,
|
||||||
mockDisplayWithPersonId,
|
mockDisplayWithPersonId,
|
||||||
mockEnvironment,
|
mockEnvironment,
|
||||||
|
mockEnvironmentId,
|
||||||
|
mockSurveyId,
|
||||||
} from "./__mocks__/data.mock";
|
} from "./__mocks__/data.mock";
|
||||||
import { prisma } from "@/lib/__mocks__/database";
|
import { prisma } from "@/lib/__mocks__/database";
|
||||||
import { createDisplay } from "@/app/api/v1/client/[environmentId]/displays/lib/display";
|
import { createDisplay } from "@/app/api/v1/client/[environmentId]/displays/lib/display";
|
||||||
@@ -26,20 +28,25 @@ afterEach(() => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma.contact.findFirst.mockResolvedValue(mockContact);
|
prisma.contact.findFirst.mockResolvedValue(mockContact);
|
||||||
|
prisma.survey.findUnique.mockResolvedValue({
|
||||||
|
id: mockSurveyId,
|
||||||
|
name: "Test Survey",
|
||||||
|
environmentId: mockEnvironmentId,
|
||||||
|
} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Tests for createDisplay service", () => {
|
describe("Tests for createDisplay service", () => {
|
||||||
describe("Happy Path", () => {
|
describe("Happy Path", () => {
|
||||||
test("Creates a new display when a userId exists", async () => {
|
test("Creates a new display when a userId exists", async () => {
|
||||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
prisma.environment.findUnique.mockResolvedValue(mockEnvironment as any);
|
||||||
prisma.display.create.mockResolvedValue(mockDisplayWithPersonId);
|
prisma.display.create.mockResolvedValue(mockDisplayWithPersonId as any);
|
||||||
|
|
||||||
const display = await createDisplay(mockDisplayInputWithUserId);
|
const display = await createDisplay(mockDisplayInputWithUserId);
|
||||||
expect(display).toEqual(mockDisplayWithPersonId);
|
expect(display).toEqual(mockDisplayWithPersonId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Creates a new display when a userId does not exists", async () => {
|
test("Creates a new display when a userId does not exists", async () => {
|
||||||
prisma.display.create.mockResolvedValue(mockDisplay);
|
prisma.display.create.mockResolvedValue(mockDisplay as any);
|
||||||
|
|
||||||
const display = await createDisplay(mockDisplayInput);
|
const display = await createDisplay(mockDisplayInput);
|
||||||
expect(display).toEqual(mockDisplay);
|
expect(display).toEqual(mockDisplay);
|
||||||
@@ -51,7 +58,7 @@ describe("Tests for createDisplay service", () => {
|
|||||||
|
|
||||||
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||||
const mockErrorMessage = "Mock error message";
|
const mockErrorMessage = "Mock error message";
|
||||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
prisma.environment.findUnique.mockResolvedValue(mockEnvironment as any);
|
||||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||||
code: PrismaErrorType.UniqueConstraintViolation,
|
code: PrismaErrorType.UniqueConstraintViolation,
|
||||||
clientVersion: "0.0.1",
|
clientVersion: "0.0.1",
|
||||||
@@ -74,7 +81,7 @@ describe("Tests for createDisplay service", () => {
|
|||||||
describe("Tests for delete display service", () => {
|
describe("Tests for delete display service", () => {
|
||||||
describe("Happy Path", () => {
|
describe("Happy Path", () => {
|
||||||
test("Deletes a display", async () => {
|
test("Deletes a display", async () => {
|
||||||
prisma.display.delete.mockResolvedValue(mockDisplay);
|
prisma.display.delete.mockResolvedValue(mockDisplay as any);
|
||||||
|
|
||||||
const display = await deleteDisplay(mockDisplay.id);
|
const display = await deleteDisplay(mockDisplay.id);
|
||||||
expect(display).toEqual(mockDisplay);
|
expect(display).toEqual(mockDisplay);
|
||||||
|
|||||||
@@ -316,7 +316,6 @@ export const mockSurvey: TSurvey = {
|
|||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
},
|
},
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
triggers: [],
|
triggers: [],
|
||||||
languages: mockSurveyLanguages,
|
languages: mockSurveyLanguages,
|
||||||
segment: null,
|
segment: null,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user