mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
Compare commits
6 Commits
feat-reset
...
devin/1735
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
272846a7ad | ||
|
|
64ed3e231c | ||
|
|
baa58b766d | ||
|
|
1bb09dbd94 | ||
|
|
6d441874c6 | ||
|
|
9dd94467a9 |
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
type: feature
|
||||
projects: "formbricks/21"
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Task (internal)
|
||||
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
|
||||
type: task
|
||||
body:
|
||||
- type: textarea
|
||||
id: task-summary
|
||||
attributes:
|
||||
label: Task description
|
||||
description: A clear detailed-rich description of the task.
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,10 +1,9 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
if [ -f "$(git rev-parse --show-toplevel)/.env" ]; then
|
||||
set -a
|
||||
. .env
|
||||
. "$(git rev-parse --show-toplevel)/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
@@ -18,4 +17,4 @@ if [ -f branch.json ]; then
|
||||
pnpm run tolgee-pull
|
||||
git add apps/web/locales
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -94,7 +94,6 @@ describe("LandingSidebar component", () => {
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,6 @@ export const LandingSidebar = ({
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
|
||||
@@ -11,21 +11,22 @@ export const ActionClassDataRow = ({
|
||||
locale: TUserLocale;
|
||||
}) => {
|
||||
return (
|
||||
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-start py-3 pl-6 text-sm">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="break-words text-xs text-slate-400">{actionClass.description}</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="text-xs text-slate-400">{actionClass.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -221,6 +221,7 @@ describe("MainNavigation", () => {
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
@@ -242,18 +243,23 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
// Verify localStorage.removeItem is called with the correct key
|
||||
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
|
||||
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
// Clean up spy
|
||||
removeItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
@@ -390,13 +391,14 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
|
||||
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const surveyId = "survey1";
|
||||
@@ -246,204 +246,4 @@ describe("NotificationSwitch", () => {
|
||||
});
|
||||
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Failed to update notification settings" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Database connection failed" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
||||
renderSwitch({
|
||||
surveyOrProjectOrOrganizationId: projectId,
|
||||
notificationSettings: initialSettings,
|
||||
notificationType: "weeklySummary",
|
||||
});
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Permission denied" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
||||
renderSwitch({
|
||||
surveyOrProjectOrOrganizationId: organizationId,
|
||||
notificationSettings: initialSettings,
|
||||
notificationType: "unsubscribedOrganizationIds",
|
||||
});
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Permission denied", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction returns null", async () => {
|
||||
const mockErrorResponse = { serverError: "An error occurred" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction returns undefined", async () => {
|
||||
const mockErrorResponse = { serverError: "An error occurred" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => {
|
||||
const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Invalid input", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction throws an exception", async () => {
|
||||
const mockErrorResponse = { serverError: "Network error" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Network error", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("switch remains enabled after error occurs", async () => {
|
||||
const mockErrorResponse = { serverError: "Failed to update" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to update", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error
|
||||
});
|
||||
|
||||
test("shows error toast with validation errors for specific fields", async () => {
|
||||
const mockErrorResponse = {
|
||||
validationErrors: {
|
||||
notificationSettings: {
|
||||
_errors: ["Invalid notification settings"],
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
@@ -26,7 +24,6 @@ export const NotificationSwitch = ({
|
||||
}: NotificationSwitchProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedOrganizationIds"
|
||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||
@@ -53,20 +50,7 @@ export const NotificationSwitch = ({
|
||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
||||
}
|
||||
|
||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
if (updatedNotificationSettingsActionResponse?.data) {
|
||||
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
||||
id: "notification-switch",
|
||||
});
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse);
|
||||
toast.error(errorMessage, {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}
|
||||
await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings });
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -120,6 +104,9 @@ export const NotificationSwitch = ({
|
||||
disabled={isLoading}
|
||||
onCheckedChange={async () => {
|
||||
await handleSwitchChange();
|
||||
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
@@ -162,21 +162,3 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging(
|
||||
"passwordReset",
|
||||
"user",
|
||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
|
||||
|
||||
const mockUser = {
|
||||
@@ -24,8 +24,6 @@ const mockUser = {
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
|
||||
// Mock window.location.reload
|
||||
const originalLocation = window.location;
|
||||
beforeEach(() => {
|
||||
@@ -37,11 +35,6 @@ beforeEach(() => {
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||
updateUserAction: vi.fn(),
|
||||
resetPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/actions", () => ({
|
||||
forgotPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
@@ -57,13 +50,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
test("renders with initial user data and updates successfully", async () => {
|
||||
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={true}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
expect(nameInput).toHaveValue(mockUser.name);
|
||||
@@ -104,13 +91,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
const errorMessage = "Update failed";
|
||||
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
await userEvent.clear(nameInput);
|
||||
@@ -128,13 +109,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("update button is disabled initially and enables on change", async () => {
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
const updateButton = screen.getByText("common.update");
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
@@ -142,68 +117,4 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.type(nameInput, " updated");
|
||||
expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("reset password button works", async () => {
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button handles error correctly", async () => {
|
||||
const errorMessage = "Reset failed";
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button shows loading state", async () => {
|
||||
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
expect(resetButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
@@ -23,7 +22,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
||||
@@ -31,17 +30,13 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
|
||||
});
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
interface IEditProfileDetailsFormProps {
|
||||
user: TUser;
|
||||
isPasswordResetEnabled?: boolean;
|
||||
emailVerificationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const EditProfileDetailsForm = ({
|
||||
user,
|
||||
isPasswordResetEnabled,
|
||||
emailVerificationDisabled,
|
||||
}: IEditProfileDetailsFormProps) => {
|
||||
}: {
|
||||
user: TUser;
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
@@ -55,8 +50,6 @@ export const EditProfileDetailsForm = ({
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
@@ -97,7 +90,6 @@ export const EditProfileDetailsForm = ({
|
||||
redirectUrl: "/email-change-without-verification-success",
|
||||
redirect: true,
|
||||
callbackUrl: "/email-change-without-verification-success",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -129,28 +121,6 @@ export const EditProfileDetailsForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
setIsResettingPassword(true);
|
||||
|
||||
const result = await resetPasswordAction();
|
||||
if (result?.data) {
|
||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
||||
|
||||
await signOutWithAudit({
|
||||
reason: "password_reset",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(t(errorMessage));
|
||||
}
|
||||
|
||||
setIsResettingPassword(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
@@ -235,26 +205,6 @@ export const EditProfileDetailsForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isPasswordResetEnabled && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t("auth.forgot-password.reset_password_description")}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
loading={isResettingPassword}
|
||||
disabled={isResettingPassword}
|
||||
size="default"
|
||||
variant="secondary">
|
||||
{t("auth.forgot-password.reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
|
||||
@@ -12,8 +12,7 @@ import Page from "./page";
|
||||
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: 1,
|
||||
PASSWORD_RESET_DISABLED: 1,
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
EMAIL_VERIFICATION_DISABLED: true,
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -32,8 +32,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
@@ -44,11 +42,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
<SettingsCard
|
||||
title={t("environments.settings.profile.personal_information")}
|
||||
description={t("environments.settings.profile.update_personal_info")}>
|
||||
<EditProfileDetailsForm
|
||||
user={user}
|
||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
||||
/>
|
||||
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.avatar")}
|
||||
|
||||
@@ -176,8 +176,8 @@ describe("ShareEmbedSurvey", () => {
|
||||
));
|
||||
});
|
||||
|
||||
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
|
||||
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
|
||||
@@ -188,18 +188,6 @@ describe("ShareEmbedSurvey", () => {
|
||||
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
|
||||
});
|
||||
|
||||
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
|
||||
// For app surveys, ShareSurveyLink should not be rendered
|
||||
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
|
||||
});
|
||||
|
||||
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
|
||||
@@ -217,7 +205,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
});
|
||||
|
||||
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
|
||||
@@ -231,7 +219,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
});
|
||||
|
||||
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
|
||||
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
|
||||
@@ -269,8 +257,8 @@ describe("ShareEmbedSurvey", () => {
|
||||
};
|
||||
expect(embedViewProps.tabs.length).toBe(3);
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
||||
expect(embedViewProps.tabs[0].id).toBe("link");
|
||||
expect(embedViewProps.activeId).toBe("link");
|
||||
expect(embedViewProps.tabs[0].id).toBe("email");
|
||||
expect(embedViewProps.activeId).toBe("email");
|
||||
});
|
||||
|
||||
test("correctly configures for 'web' survey type in embed view", () => {
|
||||
|
||||
@@ -47,14 +47,13 @@ export const ShareEmbedSurvey = ({
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
|
||||
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
|
||||
{
|
||||
id: "link",
|
||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
|
||||
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
|
||||
|
||||
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
|
||||
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
|
||||
[t, isSingleUseLinkSurvey, survey.type]
|
||||
@@ -109,26 +108,24 @@ export const ShareEmbedSurvey = ({
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
|
||||
{showView === "start" ? (
|
||||
<div className="flex h-full max-w-full flex-col overflow-hidden">
|
||||
{survey.type === "link" && (
|
||||
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
|
||||
<DialogTitle>
|
||||
<p className="pt-2 text-xl font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.your_survey_is_public")} 🎉
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
|
||||
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
|
||||
<div className="h-full max-w-full overflow-hidden">
|
||||
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
||||
<DialogTitle>
|
||||
<p className="pt-2 text-xl font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.your_survey_is_public")} 🎉
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-[300px] flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 lg:h-3/5">
|
||||
<p className="-mt-8 text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
@@ -118,13 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<Button variant="secondary" className="h-6 w-6">
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,13 +69,13 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
const mockPush = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
usePathname: () => "/current-path",
|
||||
usePathname: () => "/current",
|
||||
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
|
||||
}));
|
||||
|
||||
// Mock copySurveyLink to return a predictable string
|
||||
@@ -131,281 +131,280 @@ const dummySurvey = {
|
||||
id: "survey123",
|
||||
type: "link",
|
||||
environmentId: "env123",
|
||||
status: "inProgress",
|
||||
resultShareKey: null,
|
||||
status: "active",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const dummyAppSurvey = {
|
||||
id: "survey123",
|
||||
type: "app",
|
||||
environmentId: "env123",
|
||||
status: "inProgress",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
||||
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
||||
|
||||
describe("SurveyAnalysisCTA", () => {
|
||||
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("calls copySurveyLink and clipboard.writeText on success", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error toast on failure", async () => {
|
||||
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||
expect(writeTextMock).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// New tests for squarePenIcon and edit functionality
|
||||
describe("SurveyAnalysisCTA - Edit functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockSearchParams.delete("share"); // reset params
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Edit functionality", () => {
|
||||
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Check if dialog is shown
|
||||
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
|
||||
expect(dialogTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("navigates directly to edit page when response count = 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={0}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Should navigate directly to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("doesn't show edit button when isReadOnly is true", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={true}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
const editButton = screen.queryByRole("button", { name: "common.edit" });
|
||||
expect(editButton).not.toBeInTheDocument();
|
||||
});
|
||||
// Check if dialog is shown
|
||||
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
|
||||
expect(dialogTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Duplicate functionality", () => {
|
||||
test("duplicates survey and redirects on primary button click", async () => {
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue({
|
||||
data: { id: "newSurvey456" },
|
||||
});
|
||||
test("navigates directly to edit page when response count = 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={0}
|
||||
/>
|
||||
);
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
|
||||
environmentId: "env123",
|
||||
surveyId: "survey123",
|
||||
targetEnvironmentId: "env123",
|
||||
});
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/env123/surveys/newSurvey456/edit");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error toast on duplication failure", async () => {
|
||||
const error = { error: "Duplication failed" };
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue(error);
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Duplication failed");
|
||||
});
|
||||
});
|
||||
// Should navigate directly to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||
);
|
||||
});
|
||||
|
||||
describe("Share button and modal", () => {
|
||||
test("opens share modal when 'Share survey' button is clicked", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
test("doesn't show edit button when isReadOnly is true", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={true}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
const shareButton = screen.getByText("environments.surveys.summary.share_survey");
|
||||
fireEvent.click(shareButton);
|
||||
// Try to find the edit button (it shouldn't exist)
|
||||
const editButton = screen.queryByRole("button", { name: "common.edit" });
|
||||
expect(editButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The share button opens the embed modal, not a URL
|
||||
// We can verify this by checking that the ShareEmbedSurvey component is rendered
|
||||
// with the embed modal open
|
||||
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ShareEmbedSurvey component when share modal is open", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Assuming ShareEmbedSurvey renders a dialog with a specific title when open
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
// Updated test description to mention EditPublicSurveyAlertDialog
|
||||
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("General UI and visibility", () => {
|
||||
test("shows public results badge when resultShareKey is present", () => {
|
||||
const surveyWithShareKey = { ...dummySurvey, resultShareKey: "someKey" } as TSurvey;
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={surveyWithShareKey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.results_are_public")).toBeInTheDocument();
|
||||
test("duplicates survey successfully and navigates to edit page", async () => {
|
||||
// Mock the API response
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
data: { id: "duplicated-survey-456" },
|
||||
});
|
||||
|
||||
test("shows SurveyStatusDropdown for non-draft surveys", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
// Find and click the edit button to show dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Find and click the duplicate button in dialog
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Verify the API was called with correct parameters
|
||||
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
|
||||
environmentId: dummyEnvironment.id,
|
||||
surveyId: dummySurvey.id,
|
||||
targetEnvironmentId: dummyEnvironment.id,
|
||||
});
|
||||
|
||||
test("does not show SurveyStatusDropdown for draft surveys", () => {
|
||||
const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey;
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={draftSurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
// Verify success toast was shown
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
|
||||
|
||||
// Verify navigation to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("shows error toast when duplication fails with error object", async () => {
|
||||
// Mock API failure with error object
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
error: "Test error message",
|
||||
});
|
||||
|
||||
test("hides status dropdown and edit actions when isReadOnly is true", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={true}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click duplicate
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Verify error toast
|
||||
expect(toast.error).toHaveBeenCalledWith("Test error message");
|
||||
});
|
||||
|
||||
test("navigates to edit page when cancel button is clicked in dialog", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click edit (cancel) button
|
||||
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButtonInDialog);
|
||||
|
||||
// Verify navigation
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("shows loading state when duplicating survey", async () => {
|
||||
// Create a promise that we can resolve manually
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
test("shows preview button for link surveys", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.preview" })).toBeInTheDocument();
|
||||
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click duplicate
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Button should now be in loading state
|
||||
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise!({
|
||||
data: { id: "duplicated-survey-456" },
|
||||
});
|
||||
|
||||
test("hides preview button for app surveys", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummyAppSurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "common.preview" })).not.toBeInTheDocument();
|
||||
// Wait for the promise to resolve
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,13 @@ import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Code2Icon, Eye, LinkIcon, SquarePenIcon, UsersRound } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -56,6 +57,7 @@ export const SurveyAnalysisCTA = ({
|
||||
});
|
||||
|
||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -77,6 +79,22 @@ export const SurveyAnalysisCTA = ({
|
||||
setModalState((prev) => ({ ...prev, share: open }));
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
refreshSingleUseId()
|
||||
.then((newId) => {
|
||||
const linkToCopy = copySurveyLink(surveyUrl, newId);
|
||||
return navigator.clipboard.writeText(linkToCopy);
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
|
||||
console.error(err);
|
||||
});
|
||||
setModalState((prev) => ({ ...prev, dropdown: false }));
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||
@@ -116,6 +134,24 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: Eye,
|
||||
tooltip: t("common.preview"),
|
||||
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
tooltip: t("common.copy_link"),
|
||||
onClick: handleCopyLink,
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
icon: Code2Icon,
|
||||
tooltip: t("common.embed"),
|
||||
onClick: () => handleModalState("embed")(true),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: BellRing,
|
||||
tooltip: t("environments.surveys.summary.configure_alerts"),
|
||||
@@ -123,10 +159,13 @@ export const SurveyAnalysisCTA = ({
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
tooltip: t("common.preview"),
|
||||
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
||||
isVisible: survey.type === "link",
|
||||
icon: UsersRound,
|
||||
tooltip: t("environments.surveys.summary.send_to_panel"),
|
||||
onClick: () => {
|
||||
handleModalState("panel")(true);
|
||||
setModalState((prev) => ({ ...prev, dropdown: false }));
|
||||
},
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
@@ -156,13 +195,6 @@ export const SurveyAnalysisCTA = ({
|
||||
)}
|
||||
|
||||
<IconBar actions={iconActions} />
|
||||
<Button
|
||||
className="h-10"
|
||||
onClick={() => {
|
||||
setModalState((prev) => ({ ...prev, embed: true }));
|
||||
}}>
|
||||
{t("environments.surveys.summary.share_survey")}
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
<>
|
||||
|
||||
@@ -274,7 +274,7 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
|
||||
key: `fb:env:${environmentId}:state`,
|
||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,8 +83,9 @@ export const getEnvironmentState = async (
|
||||
{
|
||||
// Use enterprise-grade cache key pattern
|
||||
key: createCacheKey.environment.state(environmentId),
|
||||
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
|
||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||
// 30 minutes TTL ensures fresh data for hourly SDK checks
|
||||
// Balances performance with freshness requirements
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ export const POST = withApiLogging(
|
||||
}
|
||||
|
||||
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
||||
const environmentId = actionClassInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
@@ -62,14 +70,6 @@ export const POST = withApiLogging(
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = inputValidation.data.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
|
||||
auditLog.targetId = actionClass.id;
|
||||
auditLog.newObject = actionClass;
|
||||
|
||||
@@ -186,18 +186,6 @@ describe("Response Lib Tests", () => {
|
||||
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
|
||||
});
|
||||
|
||||
test("should handle RelatedRecordDoesNotExist error with specific message", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", {
|
||||
code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist
|
||||
clientVersion: "2.0",
|
||||
});
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist");
|
||||
});
|
||||
|
||||
test("should handle generic errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
|
||||
@@ -12,7 +12,6 @@ import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
@@ -177,9 +176,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
|
||||
throw new DatabaseError("Display ID does not exist");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,10 +149,6 @@ export const POST = withApiLogging(
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
} else if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return {
|
||||
@@ -162,7 +158,7 @@ export const POST = withApiLogging(
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse("An unexpected error occurred while creating the response"),
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -65,8 +65,7 @@ export const validateSingleFile = (
|
||||
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
|
||||
};
|
||||
|
||||
export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => {
|
||||
if (!data) return true;
|
||||
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => {
|
||||
for (const key of Object.keys(data)) {
|
||||
const question = questions?.find((q) => q.id === key);
|
||||
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createOrganization,
|
||||
getOrganization,
|
||||
getOrganizationsByUserId,
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
updateOrganization,
|
||||
} from "./service";
|
||||
import { createOrganization, getOrganization, getOrganizationsByUserId, updateOrganization } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -20,16 +13,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Organization Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -266,62 +252,4 @@ describe("Organization Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeOrganizationMembersToSurveyResponses", () => {
|
||||
test("should subscribe user to survey responses when not unsubscribed", async () => {
|
||||
const mockUser = {
|
||||
id: "user-123",
|
||||
notificationSettings: {
|
||||
alert: { "existing-survey-id": true },
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [], // User is subscribed to all organizations
|
||||
},
|
||||
} as any;
|
||||
|
||||
const surveyId = "survey-123";
|
||||
const userId = "user-123";
|
||||
const organizationId = "org-123";
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
|
||||
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
|
||||
|
||||
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
});
|
||||
expect(updateUser).toHaveBeenCalledWith(userId, {
|
||||
notificationSettings: {
|
||||
alert: {
|
||||
"existing-survey-id": true,
|
||||
"survey-123": true,
|
||||
},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should not subscribe user when unsubscribed from organization", async () => {
|
||||
const mockUser = {
|
||||
id: "user-123",
|
||||
notificationSettings: {
|
||||
alert: { "existing-survey-id": true },
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: ["org-123"], // User has unsubscribed from this organization
|
||||
},
|
||||
} as any;
|
||||
|
||||
const surveyId = "survey-123";
|
||||
const userId = "user-123";
|
||||
const organizationId = "org-123";
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
|
||||
|
||||
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
|
||||
|
||||
// Should not call updateUser because user is unsubscribed from this organization
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
|
||||
}
|
||||
},
|
||||
"reset_password": "Passwort zurücksetzen",
|
||||
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
|
||||
"reset_password": "Passwort zurücksetzen"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Konto erstellen",
|
||||
@@ -192,6 +191,7 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"embed": "Einbetten",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
@@ -1786,7 +1786,6 @@
|
||||
"setup_instructions": "Einrichtung",
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_results": "Ergebnisse teilen",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"share_the_link": "Teile den Link",
|
||||
"share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"text": "You can now log in with your new password"
|
||||
}
|
||||
},
|
||||
"reset_password": "Reset password",
|
||||
"reset_password_description": "You will be logged out to reset your password."
|
||||
"reset_password": "Reset password"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Create an account",
|
||||
@@ -192,6 +191,7 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"embed": "Embed",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
@@ -1786,7 +1786,6 @@
|
||||
"setup_instructions": "Setup instructions",
|
||||
"setup_integrations": "Setup integrations",
|
||||
"share_results": "Share results",
|
||||
"share_survey": "Share survey",
|
||||
"share_the_link": "Share the link",
|
||||
"share_the_link_to_get_responses": "Share the link to get responses",
|
||||
"show_all_responses_that_match": "Show all responses that match",
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
}
|
||||
},
|
||||
"reset_password": "Réinitialiser le mot de passe",
|
||||
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
|
||||
"reset_password": "Réinitialiser le mot de passe"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Créer un compte",
|
||||
@@ -192,6 +191,7 @@
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
"embed": "Intégrer",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
@@ -1786,7 +1786,6 @@
|
||||
"setup_instructions": "Instructions d'installation",
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_results": "Partager les résultats",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"share_the_link": "Partager le lien",
|
||||
"share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"text": "Agora você pode fazer login com sua nova senha"
|
||||
}
|
||||
},
|
||||
"reset_password": "Redefinir senha",
|
||||
"reset_password_description": "Você será desconectado para redefinir sua senha."
|
||||
"reset_password": "Redefinir senha"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Cria uma conta",
|
||||
@@ -192,6 +191,7 @@
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"embed": "incorporar",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
@@ -356,7 +356,7 @@
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"styling": "Estilização",
|
||||
"styling": "estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
"survey": "Pesquisa",
|
||||
@@ -368,7 +368,7 @@
|
||||
"survey_paused": "Pesquisa pausada.",
|
||||
"survey_scheduled": "Pesquisa agendada.",
|
||||
"survey_type": "Tipo de Pesquisa",
|
||||
"surveys": "Pesquisas",
|
||||
"surveys": "pesquisas",
|
||||
"switch_organization": "Mudar organização",
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deletados com sucesso",
|
||||
@@ -1786,7 +1786,6 @@
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Compartilhar resultados",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"share_the_link": "Compartilha o link",
|
||||
"share_the_link_to_get_responses": "Compartilha o link pra receber respostas",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"text": "Pode agora iniciar sessão com a sua nova palavra-passe"
|
||||
}
|
||||
},
|
||||
"reset_password": "Redefinir palavra-passe",
|
||||
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
|
||||
"reset_password": "Redefinir palavra-passe"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Criar uma conta",
|
||||
@@ -192,6 +191,7 @@
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"embed": "Incorporar",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
@@ -1786,7 +1786,6 @@
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Partilhar resultados",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"share_the_link": "Partilhar o link",
|
||||
"share_the_link_to_get_responses": "Partilhe o link para obter respostas",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"text": "您現在可以使用新密碼登入"
|
||||
}
|
||||
},
|
||||
"reset_password": "重設密碼",
|
||||
"reset_password_description": "您將被登出以重設您的密碼。"
|
||||
"reset_password": "重設密碼"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "建立帳戶",
|
||||
@@ -192,6 +191,7 @@
|
||||
"e_commerce": "電子商務",
|
||||
"edit": "編輯",
|
||||
"email": "電子郵件",
|
||||
"embed": "嵌入",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
@@ -1786,7 +1786,6 @@
|
||||
"setup_instructions": "設定說明",
|
||||
"setup_integrations": "設定整合",
|
||||
"share_results": "分享結果",
|
||||
"share_survey": "分享問卷",
|
||||
"share_the_link": "分享連結",
|
||||
"share_the_link_to_get_responses": "分享連結以取得回應",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -99,6 +100,8 @@ describe("DeleteAccountModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
const input = screen.getByTestId("deleteAccountConfirmation");
|
||||
fireEvent.change(input, { target: { value: mockUser.email } });
|
||||
|
||||
@@ -110,8 +113,8 @@ describe("DeleteAccountModal", () => {
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
@@ -148,13 +151,15 @@ describe("DeleteAccountModal", () => {
|
||||
const form = screen.getByTestId("deleteAccountForm");
|
||||
fireEvent.submit(form);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -38,11 +39,12 @@ export const DeleteAccountModal = ({
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Prevent NextAuth automatic redirect
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
// Manual redirect after signOut completes
|
||||
|
||||
@@ -33,11 +33,10 @@ export const validateOtherOptionLengthForMultipleChoice = ({
|
||||
surveyQuestions,
|
||||
responseLanguage,
|
||||
}: {
|
||||
responseData?: TResponseData;
|
||||
responseData: TResponseData;
|
||||
surveyQuestions: TSurveyQuestion[];
|
||||
responseLanguage?: string;
|
||||
}): string | undefined => {
|
||||
if (!responseData) return undefined;
|
||||
for (const [questionId, answer] of Object.entries(responseData)) {
|
||||
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||
if (!question) continue;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ContactAttributeKey, Prisma } from "@prisma/client";
|
||||
import { ContactAttributeKey } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -54,7 +55,7 @@ export const updateContactAttributeKey = async (
|
||||
|
||||
return ok(updatedKey);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
@@ -105,7 +106,7 @@ export const deleteContactAttributeKey = async (
|
||||
|
||||
return ok(deletedKey);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
id: ZContactAttributeKeyIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API - Contact Attribute Keys"],
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute key retrieved successfully.",
|
||||
@@ -33,7 +33,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateContactAttributeKey",
|
||||
summary: "Update a contact attribute key",
|
||||
description: "Updates a contact attribute key in the database.",
|
||||
tags: ["Management API - Contact Attribute Keys"],
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZContactAttributeKeyIdSchema,
|
||||
@@ -64,7 +64,7 @@ export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteContactAttributeKey",
|
||||
summary: "Delete a contact attribute key",
|
||||
description: "Deletes a contact attribute key from the database.",
|
||||
tags: ["Management API - Contact Attribute Keys"],
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZContactAttributeKeyIdSchema,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
|
||||
import { ContactAttributeKey, Prisma } from "@prisma/client";
|
||||
import { ContactAttributeKey } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -43,12 +44,12 @@ const mockUpdateInput: TContactAttributeKeyUpdateSchema = {
|
||||
description: "User's verified email address",
|
||||
};
|
||||
|
||||
const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
const prismaUniqueConstraintError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ContactAttributeKey, Prisma } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -57,7 +58,7 @@ export const createContactAttributeKey = async (
|
||||
|
||||
return ok(createdContactAttributeKey);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactAttributeKeys",
|
||||
summary: "Get contact attribute keys",
|
||||
description: "Gets contact attribute keys from the database.",
|
||||
tags: ["Management API - Contact Attribute Keys"],
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
query: ZGetContactAttributeKeysFilter.sourceType(),
|
||||
},
|
||||
@@ -36,7 +36,7 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContactAttributeKey",
|
||||
summary: "Create a contact attribute key",
|
||||
description: "Creates a contact attribute key in the database.",
|
||||
tags: ["Management API - Contact Attribute Keys"],
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact attribute key to create",
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
TContactAttributeKeyInput,
|
||||
TGetContactAttributeKeysFilter,
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
|
||||
import { ContactAttributeKey, Prisma } from "@prisma/client";
|
||||
import { ContactAttributeKey } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -105,7 +106,7 @@ describe("createContactAttributeKey", () => {
|
||||
});
|
||||
|
||||
test("returns conflict error when key already exists", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
@@ -128,7 +129,7 @@ describe("createContactAttributeKey", () => {
|
||||
});
|
||||
|
||||
test("returns not found error when related record does not exist", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
tags: ["Management API - Contact Attributes"],
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact retrieved successfully.",
|
||||
@@ -29,7 +29,7 @@ export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteContactAttribute",
|
||||
summary: "Delete a contact attribute",
|
||||
description: "Deletes a contact attribute from the database.",
|
||||
tags: ["Management API - Contact Attributes"],
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
@@ -51,7 +51,7 @@ export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateContactAttribute",
|
||||
summary: "Update a contact attribute",
|
||||
description: "Updates a contact attribute in the database.",
|
||||
tags: ["Management API - Contact Attributes"],
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactAttributes",
|
||||
summary: "Get contact attributes",
|
||||
description: "Gets contact attributes from the database.",
|
||||
tags: ["Management API - Contact Attributes"],
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestParams: {
|
||||
query: ZGetContactAttributesFilter,
|
||||
},
|
||||
@@ -36,7 +36,7 @@ export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContactAttribute",
|
||||
summary: "Create a contact attribute",
|
||||
description: "Creates a contact attribute in the database.",
|
||||
tags: ["Management API - Contact Attributes"],
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact attribute to create",
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getContactEndpoint: ZodOpenApiOperationObject = {
|
||||
contactId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
tags: ["Management API - Contacts"],
|
||||
tags: ["Management API > Contacts"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact retrieved successfully.",
|
||||
@@ -29,7 +29,7 @@ export const deleteContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteContact",
|
||||
summary: "Delete a contact",
|
||||
description: "Deletes a contact from the database.",
|
||||
tags: ["Management API - Contacts"],
|
||||
tags: ["Management API > Contacts"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactId: z.string().cuid2(),
|
||||
@@ -51,7 +51,7 @@ export const updateContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateContact",
|
||||
summary: "Update a contact",
|
||||
description: "Updates a contact in the database.",
|
||||
tags: ["Management API - Contacts"],
|
||||
tags: ["Management API > Contacts"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactId: z.string().cuid2(),
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getContactsEndpoint: ZodOpenApiOperationObject = {
|
||||
requestParams: {
|
||||
query: ZGetContactsFilter,
|
||||
},
|
||||
tags: ["Management API - Contacts"],
|
||||
tags: ["Management API > Contacts"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contacts retrieved successfully.",
|
||||
@@ -33,7 +33,7 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContact",
|
||||
summary: "Create a contact",
|
||||
description: "Creates a contact in the database.",
|
||||
tags: ["Management API - Contacts"],
|
||||
tags: ["Management API > Contacts"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact to create",
|
||||
|
||||
@@ -25,9 +25,7 @@ export const getEnvironmentId = async (
|
||||
*/
|
||||
export const getEnvironmentIdFromSurveyIds = async (
|
||||
surveyIds: string[]
|
||||
): Promise<Result<string | null, ApiErrorResponseV2>> => {
|
||||
if (surveyIds.length === 0) return ok(null);
|
||||
|
||||
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
@@ -19,7 +19,7 @@ export const deleteDisplay = async (displayId: string): Promise<Result<boolean,
|
||||
|
||||
return ok(true);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
|
||||
@@ -14,7 +14,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
id: ZResponseIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API - Responses"],
|
||||
tags: ["Management API > Responses"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Response retrieved successfully.",
|
||||
@@ -31,7 +31,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteResponse",
|
||||
summary: "Delete a response",
|
||||
description: "Deletes a response from the database.",
|
||||
tags: ["Management API - Responses"],
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZResponseIdSchema,
|
||||
@@ -53,7 +53,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateResponse",
|
||||
summary: "Update a response",
|
||||
description: "Updates a response in the database.",
|
||||
tags: ["Management API - Responses"],
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZResponseIdSchema,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
|
||||
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
|
||||
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Prisma, Response } from "@prisma/client";
|
||||
import { Response } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -55,7 +56,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
|
||||
|
||||
return ok(deletedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
@@ -88,7 +89,7 @@ export const updateResponse = async (
|
||||
|
||||
return ok(updatedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { displayId, mockDisplay } from "./__mocks__/display.mock";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -39,7 +39,7 @@ describe("Display Lib", () => {
|
||||
|
||||
test("return a not_found error when the display is not found", async () => {
|
||||
vi.mocked(prisma.display.delete).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Display not found", {
|
||||
new PrismaClientKnownRequestError("Display not found", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -154,7 +154,7 @@ describe("Response Lib", () => {
|
||||
|
||||
test("handle prisma client error code P2025", async () => {
|
||||
vi.mocked(prisma.response.delete).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Response not found", {
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
@@ -208,7 +208,7 @@ describe("Response Lib", () => {
|
||||
|
||||
test("return a not_found error when the response is not found", async () => {
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Response not found", {
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
requestParams: {
|
||||
query: ZGetResponsesFilter.sourceType(),
|
||||
},
|
||||
tags: ["Management API - Responses"],
|
||||
tags: ["Management API > Responses"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Responses retrieved successfully.",
|
||||
@@ -33,7 +33,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createResponse",
|
||||
summary: "Create a response",
|
||||
description: "Creates a response in the database.",
|
||||
tags: ["Management API - Responses"],
|
||||
tags: ["Management API > Responses"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The response to create",
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
|
||||
requestParams: {
|
||||
path: ZContactLinkParams,
|
||||
},
|
||||
tags: ["Management API - Surveys - Contact Links"],
|
||||
tags: ["Management API > Surveys > Contact Links"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Personalized survey link retrieved successfully.",
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactLinksBySegment",
|
||||
summary: "Get survey links for contacts in a segment",
|
||||
description: "Generates personalized survey links for contacts in a segment.",
|
||||
tags: ["Management API - Surveys - Contact Links"],
|
||||
tags: ["Management API > Surveys > Contact Links"],
|
||||
requestParams: {
|
||||
path: ZContactLinksBySegmentParams,
|
||||
query: ZContactLinksBySegmentQuery,
|
||||
|
||||
@@ -13,7 +13,7 @@ export const getSurveyEndpoint: ZodOpenApiOperationObject = {
|
||||
id: surveyIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API - Surveys"],
|
||||
tags: ["Management API > Surveys"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Response retrieved successfully.",
|
||||
@@ -30,7 +30,7 @@ export const deleteSurveyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteSurvey",
|
||||
summary: "Delete a survey",
|
||||
description: "Deletes a survey from the database.",
|
||||
tags: ["Management API - Surveys"],
|
||||
tags: ["Management API > Surveys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: surveyIdSchema,
|
||||
@@ -52,7 +52,7 @@ export const updateSurveyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateSurvey",
|
||||
summary: "Update a survey",
|
||||
description: "Updates a survey in the database.",
|
||||
tags: ["Management API - Surveys"],
|
||||
tags: ["Management API > Surveys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: surveyIdSchema,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// import {
|
||||
// deleteSurveyEndpoint,
|
||||
// getSurveyEndpoint,
|
||||
// updateSurveyEndpoint,
|
||||
// } from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
|
||||
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
|
||||
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
|
||||
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
|
||||
@@ -12,7 +17,7 @@ export const getSurveysEndpoint: ZodOpenApiOperationObject = {
|
||||
requestParams: {
|
||||
query: ZGetSurveysFilter,
|
||||
},
|
||||
tags: ["Management API - Surveys"],
|
||||
tags: ["Management API > Surveys"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Surveys retrieved successfully.",
|
||||
@@ -29,7 +34,7 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createSurvey",
|
||||
summary: "Create a survey",
|
||||
description: "Creates a survey in the database.",
|
||||
tags: ["Management API - Surveys"],
|
||||
tags: ["Management API > Surveys"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The survey to create",
|
||||
|
||||
@@ -14,7 +14,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
id: ZWebhookIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API - Webhooks"],
|
||||
tags: ["Management API > Webhooks"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhook retrieved successfully.",
|
||||
@@ -31,7 +31,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteWebhook",
|
||||
summary: "Delete a webhook",
|
||||
description: "Deletes a webhook from the database.",
|
||||
tags: ["Management API - Webhooks"],
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZWebhookIdSchema,
|
||||
@@ -53,7 +53,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateWebhook",
|
||||
summary: "Update a webhook",
|
||||
description: "Updates a webhook in the database.",
|
||||
tags: ["Management API - Webhooks"],
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZWebhookIdSchema,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma, WebhookSource } from "@prisma/client";
|
||||
import { WebhookSource } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const mockedPrismaWebhookUpdateReturn = {
|
||||
@@ -13,7 +14,7 @@ export const mockedPrismaWebhookUpdateReturn = {
|
||||
surveyIds: [],
|
||||
};
|
||||
|
||||
export const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
|
||||
export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "PrismaClient 4.0.0",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -44,7 +45,7 @@ export const updateWebhook = async (
|
||||
|
||||
return ok(updatedWebhook);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
@@ -72,7 +73,7 @@ export const deleteWebhook = async (webhookId: string): Promise<Result<Webhook,
|
||||
|
||||
return ok(deletedWebhook);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
|
||||
@@ -75,14 +75,13 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
);
|
||||
}
|
||||
|
||||
const surveysEnvironmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
// get surveys environment
|
||||
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!surveysEnvironmentIdResult.ok) {
|
||||
return handleApiError(request, surveysEnvironmentIdResult.error, auditLog);
|
||||
if (!surveysEnvironmentId.ok) {
|
||||
return handleApiError(request, surveysEnvironmentId.error, auditLog);
|
||||
}
|
||||
|
||||
const surveysEnvironmentId = surveysEnvironmentIdResult.data;
|
||||
|
||||
// get webhook environment
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
|
||||
@@ -102,7 +101,7 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
}
|
||||
|
||||
// check if webhook environment matches the surveys environment
|
||||
if (surveysEnvironmentId && webhook.data.environmentId !== surveysEnvironmentId) {
|
||||
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
|
||||
requestParams: {
|
||||
query: ZGetWebhooksFilter.sourceType(),
|
||||
},
|
||||
tags: ["Management API - Webhooks"],
|
||||
tags: ["Management API > Webhooks"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhooks retrieved successfully.",
|
||||
@@ -33,7 +33,7 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createWebhook",
|
||||
summary: "Create a webhook",
|
||||
description: "Creates a webhook in the database.",
|
||||
tags: ["Management API - Webhooks"],
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The webhook to create",
|
||||
|
||||
@@ -57,12 +57,10 @@ export const POST = async (request: NextRequest) =>
|
||||
);
|
||||
}
|
||||
|
||||
if (body.surveyIds && body.surveyIds.length > 0) {
|
||||
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
|
||||
|
||||
@@ -66,43 +66,43 @@ const document = createDocument({
|
||||
description: "Operations for managing your API key.",
|
||||
},
|
||||
{
|
||||
name: "Management API - Responses",
|
||||
name: "Management API > Responses",
|
||||
description: "Operations for managing responses.",
|
||||
},
|
||||
{
|
||||
name: "Management API - Contacts",
|
||||
name: "Management API > Contacts",
|
||||
description: "Operations for managing contacts.",
|
||||
},
|
||||
{
|
||||
name: "Management API - Contact Attributes",
|
||||
name: "Management API > Contact Attributes",
|
||||
description: "Operations for managing contact attributes.",
|
||||
},
|
||||
{
|
||||
name: "Management API - Contact Attribute Keys",
|
||||
description: "Operations for managing contact attribute keys.",
|
||||
name: "Management API > Contact Attributes Keys",
|
||||
description: "Operations for managing contact attributes keys.",
|
||||
},
|
||||
{
|
||||
name: "Management API - Surveys",
|
||||
name: "Management API > Surveys",
|
||||
description: "Operations for managing surveys.",
|
||||
},
|
||||
{
|
||||
name: "Management API - Surveys - Contact Links",
|
||||
name: "Management API > Surveys > Contact Links",
|
||||
description: "Operations for generating personalized survey links for contacts.",
|
||||
},
|
||||
{
|
||||
name: "Management API - Webhooks",
|
||||
name: "Management API > Webhooks",
|
||||
description: "Operations for managing webhooks.",
|
||||
},
|
||||
{
|
||||
name: "Organizations API - Teams",
|
||||
name: "Organizations API > Teams",
|
||||
description: "Operations for managing teams.",
|
||||
},
|
||||
{
|
||||
name: "Organizations API - Project Teams",
|
||||
name: "Organizations API > Project Teams",
|
||||
description: "Operations for managing project teams.",
|
||||
},
|
||||
{
|
||||
name: "Organizations API - Users",
|
||||
name: "Organizations API > Users",
|
||||
description: "Operations for managing users.",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -20,7 +20,7 @@ export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API - Project Teams"],
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Project teams retrieved successfully.",
|
||||
@@ -42,7 +42,7 @@ export const createProjectTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API - Project Teams"],
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The project team to create",
|
||||
@@ -68,7 +68,7 @@ export const deleteProjectTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteProjectTeam",
|
||||
summary: "Delete a project team",
|
||||
description: "Deletes a project team from the database.",
|
||||
tags: ["Organizations API - Project Teams"],
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
requestParams: {
|
||||
query: ZGetProjectTeamUpdateFilter.required(),
|
||||
path: z.object({
|
||||
@@ -91,7 +91,7 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateProjectTeam",
|
||||
summary: "Update a project team",
|
||||
description: "Updates a project team in the database.",
|
||||
tags: ["Organizations API - Project Teams"],
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API - Teams"],
|
||||
tags: ["Organizations API > Teams"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Team retrieved successfully.",
|
||||
@@ -33,7 +33,7 @@ export const deleteTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteTeam",
|
||||
summary: "Delete a team",
|
||||
description: "Deletes a team from the database.",
|
||||
tags: ["Organizations API - Teams"],
|
||||
tags: ["Organizations API > Teams"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZTeamIdSchema,
|
||||
@@ -56,7 +56,7 @@ export const updateTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateTeam",
|
||||
summary: "Update a team",
|
||||
description: "Updates a team in the database.",
|
||||
tags: ["Organizations API - Teams"],
|
||||
tags: ["Organizations API > Teams"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZTeamIdSchema,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Prisma, Team } from "@prisma/client";
|
||||
import { Team } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -50,7 +51,7 @@ export const deleteTeam = async (
|
||||
|
||||
return ok(deletedTeam);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
@@ -88,7 +89,7 @@ export const updateTeam = async (
|
||||
|
||||
return ok(updatedTeam);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -74,7 +74,7 @@ describe("Teams Lib", () => {
|
||||
|
||||
test("returns not_found error on known prisma error", async () => {
|
||||
(prisma.team.delete as any).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
new PrismaClientKnownRequestError("Not found", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {},
|
||||
@@ -120,7 +120,7 @@ describe("Teams Lib", () => {
|
||||
|
||||
test("returns not_found error when update fails due to missing team", async () => {
|
||||
(prisma.team.update as any).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
new PrismaClientKnownRequestError("Not found", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {},
|
||||
|
||||
@@ -24,7 +24,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = {
|
||||
}),
|
||||
query: ZGetTeamsFilter.sourceType(),
|
||||
},
|
||||
tags: ["Organizations API - Teams"],
|
||||
tags: ["Organizations API > Teams"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Teams retrieved successfully.",
|
||||
@@ -46,7 +46,7 @@ export const createTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API - Teams"],
|
||||
tags: ["Organizations API > Teams"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The team to create",
|
||||
|
||||
@@ -20,7 +20,7 @@ export const getUsersEndpoint: ZodOpenApiOperationObject = {
|
||||
}),
|
||||
query: ZGetUsersFilter.sourceType(),
|
||||
},
|
||||
tags: ["Organizations API - Users"],
|
||||
tags: ["Organizations API > Users"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Users retrieved successfully.",
|
||||
@@ -42,7 +42,7 @@ export const createUserEndpoint: ZodOpenApiOperationObject = {
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API - Users"],
|
||||
tags: ["Organizations API > Users"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The user to create",
|
||||
@@ -73,7 +73,7 @@ export const updateUserEndpoint: ZodOpenApiOperationObject = {
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API - Users"],
|
||||
tags: ["Organizations API > Users"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The user to update",
|
||||
|
||||
@@ -13,13 +13,7 @@ export const logSignOutAction = async (
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
context: {
|
||||
reason?:
|
||||
| "user_initiated"
|
||||
| "account_deletion"
|
||||
| "email_change"
|
||||
| "session_timeout"
|
||||
| "forced_logout"
|
||||
| "password_reset";
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
const ZForgotPasswordAction = z.object({
|
||||
@@ -15,15 +13,9 @@ const ZForgotPasswordAction = z.object({
|
||||
export const forgotPasswordAction = actionClient
|
||||
.schema(ZForgotPasswordAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
|
||||
if (user && user.identityProvider === "email") {
|
||||
if (user) {
|
||||
await sendForgotPasswordEmail(user);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
interface UseSignOutOptions {
|
||||
reason?:
|
||||
| "user_initiated"
|
||||
| "account_deletion"
|
||||
| "email_change"
|
||||
| "session_timeout"
|
||||
| "forced_logout"
|
||||
| "password_reset";
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
redirect?: boolean;
|
||||
callbackUrl?: string;
|
||||
clearEnvironmentId?: boolean;
|
||||
}
|
||||
|
||||
interface SessionUser {
|
||||
@@ -44,10 +36,6 @@ export const useSignOut = (sessionUser?: SessionUser | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.clearEnvironmentId) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
}
|
||||
|
||||
// Call NextAuth signOut
|
||||
return await signOut({
|
||||
redirect: options?.redirect,
|
||||
|
||||
@@ -78,7 +78,6 @@ export const getUserByEmail = reactCache(async (email: string) => {
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -283,13 +283,7 @@ export const logSignOut = (
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
context?: {
|
||||
reason?:
|
||||
| "user_initiated"
|
||||
| "account_deletion"
|
||||
| "email_change"
|
||||
| "session_timeout"
|
||||
| "forced_logout"
|
||||
| "password_reset";
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
@@ -48,9 +48,7 @@ describe("EmailChangeSignIn", () => {
|
||||
expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false });
|
||||
});
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false });
|
||||
});
|
||||
|
||||
test("handles failed email change verification", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock("../../../ee/license-check/lib/utils", () => ({
|
||||
getIsAuditLogsEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { audit: vi.fn(), error: vi.fn() },
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const validEvent = {
|
||||
@@ -37,7 +37,7 @@ describe("logAuditEvent", () => {
|
||||
test("logs event if access is granted and event is valid", async () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
await logAuditEvent(validEvent);
|
||||
expect(logger.audit).toHaveBeenCalledWith(validEvent);
|
||||
expect(logger.info).toHaveBeenCalledWith(validEvent, "Audit event logged");
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("logAuditEvent", () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
const invalidEvent = { ...validEvent, action: "invalid.action" };
|
||||
await logAuditEvent(invalidEvent as any);
|
||||
expect(logger.audit).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -53,12 +53,12 @@ describe("logAuditEvent", () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
const event = { ...validEvent, organizationId: UNKNOWN_DATA };
|
||||
await logAuditEvent(event);
|
||||
expect(logger.audit).toHaveBeenCalledWith(event);
|
||||
expect(logger.info).toHaveBeenCalledWith(event, "Audit event logged");
|
||||
});
|
||||
|
||||
test("does not throw if logger.audit throws", async () => {
|
||||
test("does not throw if logger.info throws", async () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
logger.audit.mockImplementation(() => {
|
||||
logger.info.mockImplementation(() => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
await logAuditEvent(validEvent);
|
||||
|
||||
@@ -11,7 +11,7 @@ const validateEvent = (event: TAuditLogEvent): void => {
|
||||
export const logAuditEvent = async (event: TAuditLogEvent): Promise<void> => {
|
||||
try {
|
||||
validateEvent(event);
|
||||
logger.audit(event);
|
||||
logger.info(event, "Audit event logged");
|
||||
} catch (error) {
|
||||
// Log error to application logger but don't throw
|
||||
// This ensures audit logging failures don't break the application
|
||||
|
||||
@@ -50,7 +50,6 @@ export const ZAuditAction = z.enum([
|
||||
"twoFactorRequired",
|
||||
"emailVerificationAttempted",
|
||||
"userSignedOut",
|
||||
"passwordReset",
|
||||
]);
|
||||
export const ZActor = z.enum(["user", "api", "system"]);
|
||||
export const ZAuditStatus = z.enum(["success", "failure"]);
|
||||
|
||||
@@ -34,8 +34,8 @@ export const getSegments = reactCache((environmentId: string) =>
|
||||
},
|
||||
{
|
||||
key: createCacheKey.environment.segments(environmentId),
|
||||
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
|
||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||
// 30 minutes TTL - segment definitions change infrequently
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { ContactAttributeKey, Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributeKey, TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { TContactAttributeKeyUpdateInput } from "../types/contact-attribute-keys";
|
||||
import {
|
||||
createContactAttributeKey,
|
||||
deleteContactAttributeKey,
|
||||
getContactAttributeKey,
|
||||
updateContactAttributeKey,
|
||||
@@ -100,6 +101,79 @@ describe("getContactAttributeKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createContactAttributeKey", () => {
|
||||
const type: TContactAttributeKeyType = "custom";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should create and return a new contact attribute key", async () => {
|
||||
const createdAttributeKey = { ...mockContactAttributeKey, id: "new_cak_id", key: mockKey, type };
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5); // Below limit
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(createdAttributeKey);
|
||||
|
||||
const result = await createContactAttributeKey(mockEnvironmentId, mockKey, type);
|
||||
|
||||
expect(result).toEqual(createdAttributeKey);
|
||||
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({
|
||||
where: { environmentId: mockEnvironmentId },
|
||||
});
|
||||
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
key: mockKey,
|
||||
name: mockKey, // As per implementation
|
||||
type,
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw OperationNotAllowedError if max attribute classes reached", async () => {
|
||||
// MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT is mocked to 10
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(10);
|
||||
|
||||
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({
|
||||
where: { environmentId: mockEnvironmentId },
|
||||
});
|
||||
expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw Prisma error if prisma.contactAttributeKey.count fails", async () => {
|
||||
const errorMessage = "Prisma count error";
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError(errorMessage, {
|
||||
code: "P1000",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(prismaError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError if Prisma create fails", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5); // Below limit
|
||||
const errorMessage = "Prisma create error";
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2000", clientVersion: "test" })
|
||||
);
|
||||
|
||||
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(DatabaseError);
|
||||
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(errorMessage);
|
||||
});
|
||||
|
||||
test("should throw generic error if non-Prisma error occurs during create", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5);
|
||||
const errorMessage = "Some other error during create";
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(Error);
|
||||
await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteContactAttributeKey", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import {
|
||||
TContactAttributeKey,
|
||||
TContactAttributeKeyType,
|
||||
ZContactAttributeKeyType,
|
||||
} from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TContactAttributeKeyUpdateInput,
|
||||
ZContactAttributeKeyUpdateInput,
|
||||
@@ -29,6 +34,48 @@ export const getContactAttributeKey = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const createContactAttributeKey = async (
|
||||
environmentId: string,
|
||||
key: string,
|
||||
type: TContactAttributeKeyType
|
||||
): Promise<TContactAttributeKey | null> => {
|
||||
validateInputs([environmentId, ZId], [key, ZString], [type, ZContactAttributeKeyType]);
|
||||
|
||||
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (contactAttributeKeysCount >= MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
|
||||
throw new OperationNotAllowedError(
|
||||
`Maximum number of attribute classes (${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT}) reached for environment ${environmentId}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const contactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
name: key,
|
||||
type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return contactAttributeKey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteContactAttributeKey = async (
|
||||
contactAttributeKeyId: string
|
||||
): Promise<TContactAttributeKey> => {
|
||||
@@ -63,8 +110,6 @@ export const updateContactAttributeKey = async (
|
||||
},
|
||||
data: {
|
||||
description: data.description,
|
||||
name: data.name,
|
||||
key: data.key,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,14 +5,12 @@ export const ZContactAttributeKeyCreateInput = z.object({
|
||||
description: z.string().optional(),
|
||||
type: z.enum(["custom"]),
|
||||
environmentId: z.string(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
export type TContactAttributeKeyCreateInput = z.infer<typeof ZContactAttributeKeyCreateInput>;
|
||||
|
||||
export const ZContactAttributeKeyUpdateInput = z.object({
|
||||
description: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TContactAttributeKeyUpdateInput = z.infer<typeof ZContactAttributeKeyUpdateInput>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -28,28 +27,8 @@ describe("getContactAttributeKeys", () => {
|
||||
test("should return contact attribute keys when found", async () => {
|
||||
const mockEnvironmentIds = ["env1", "env2"];
|
||||
const mockAttributeKeys = [
|
||||
{
|
||||
id: "key1",
|
||||
environmentId: "env1",
|
||||
name: "Key One",
|
||||
key: "keyOne",
|
||||
type: "custom" as TContactAttributeKeyType,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
description: null,
|
||||
isUnique: false,
|
||||
},
|
||||
{
|
||||
id: "key2",
|
||||
environmentId: "env2",
|
||||
name: "Key Two",
|
||||
key: "keyTwo",
|
||||
type: "custom" as TContactAttributeKeyType,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
description: null,
|
||||
isUnique: false,
|
||||
},
|
||||
{ id: "key1", environmentId: "env1", name: "Key One", key: "keyOne", type: "custom" },
|
||||
{ id: "key2", environmentId: "env2", name: "Key Two", key: "keyTwo", type: "custom" },
|
||||
];
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockAttributeKeys);
|
||||
|
||||
@@ -100,31 +79,25 @@ describe("createContactAttributeKey", () => {
|
||||
description: null,
|
||||
};
|
||||
|
||||
const createInput: TContactAttributeKeyCreateInput = {
|
||||
key,
|
||||
type,
|
||||
environmentId,
|
||||
name: key,
|
||||
description: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should create and return a new contact attribute key", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(mockCreatedAttributeKey);
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue({
|
||||
...mockCreatedAttributeKey,
|
||||
description: null, // ensure description is explicitly null if that's the case
|
||||
});
|
||||
|
||||
const result = await createContactAttributeKey(environmentId, createInput);
|
||||
const result = await createContactAttributeKey(environmentId, key, type);
|
||||
|
||||
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ where: { environmentId } });
|
||||
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
key: createInput.key,
|
||||
name: createInput.name || createInput.key,
|
||||
type: createInput.type,
|
||||
description: createInput.description || "",
|
||||
key,
|
||||
name: key,
|
||||
type,
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
});
|
||||
@@ -134,7 +107,7 @@ describe("createContactAttributeKey", () => {
|
||||
test("should throw OperationNotAllowedError if max attribute classes reached", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT);
|
||||
|
||||
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(
|
||||
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ where: { environmentId } });
|
||||
@@ -148,8 +121,8 @@ describe("createContactAttributeKey", () => {
|
||||
new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2000", clientVersion: "test" })
|
||||
);
|
||||
|
||||
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(errorMessage);
|
||||
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(DatabaseError);
|
||||
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(errorMessage);
|
||||
});
|
||||
|
||||
test("should throw generic error if non-Prisma error occurs during create", async () => {
|
||||
@@ -157,55 +130,7 @@ describe("createContactAttributeKey", () => {
|
||||
const errorMessage = "Some other create error";
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(Error);
|
||||
await expect(createContactAttributeKey(environmentId, createInput)).rejects.toThrow(errorMessage);
|
||||
});
|
||||
|
||||
test("should use key as name when name is not provided", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(mockCreatedAttributeKey);
|
||||
|
||||
const inputWithoutName: TContactAttributeKeyCreateInput = {
|
||||
key,
|
||||
type,
|
||||
environmentId,
|
||||
description: "",
|
||||
};
|
||||
|
||||
await createContactAttributeKey(environmentId, inputWithoutName);
|
||||
|
||||
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
key: inputWithoutName.key,
|
||||
name: inputWithoutName.key, // Should fall back to key when name is not provided
|
||||
type: inputWithoutName.type,
|
||||
description: inputWithoutName.description || "",
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should use empty string for description when description is not provided", async () => {
|
||||
vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(mockCreatedAttributeKey);
|
||||
|
||||
const inputWithoutDescription: TContactAttributeKeyCreateInput = {
|
||||
key,
|
||||
type,
|
||||
environmentId,
|
||||
name: "Test Name",
|
||||
};
|
||||
|
||||
await createContactAttributeKey(environmentId, inputWithoutDescription);
|
||||
|
||||
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
key: inputWithoutDescription.key,
|
||||
name: inputWithoutDescription.name,
|
||||
type: inputWithoutDescription.type,
|
||||
description: "", // Should fall back to empty string when description is not provided
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
});
|
||||
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(Error);
|
||||
await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import {
|
||||
TContactAttributeKey,
|
||||
TContactAttributeKeyType,
|
||||
ZContactAttributeKeyType,
|
||||
} from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
@@ -26,8 +30,11 @@ export const getContactAttributeKeys = reactCache(
|
||||
|
||||
export const createContactAttributeKey = async (
|
||||
environmentId: string,
|
||||
data: TContactAttributeKeyCreateInput
|
||||
key: string,
|
||||
type: TContactAttributeKeyType
|
||||
): Promise<TContactAttributeKey | null> => {
|
||||
validateInputs([environmentId, ZId], [key, ZString], [type, ZContactAttributeKeyType]);
|
||||
|
||||
const contactAttributeKeysCount = await prisma.contactAttributeKey.count({
|
||||
where: {
|
||||
environmentId,
|
||||
@@ -43,10 +50,9 @@ export const createContactAttributeKey = async (
|
||||
try {
|
||||
const contactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key: data.key,
|
||||
name: data.name ?? data.key,
|
||||
type: data.type,
|
||||
description: data.description ?? "",
|
||||
key,
|
||||
name: key,
|
||||
type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
@@ -58,10 +64,6 @@ export const createContactAttributeKey = async (
|
||||
return contactAttributeKey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new DatabaseError("Attribute key already exists");
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -75,7 +75,7 @@ export const POST = withApiLogging(
|
||||
),
|
||||
};
|
||||
}
|
||||
const environmentId = inputValidation.data.environmentId;
|
||||
const environmentId = contactAttibuteKeyInput.environmentId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
@@ -84,7 +84,11 @@ export const POST = withApiLogging(
|
||||
};
|
||||
}
|
||||
|
||||
const contactAttributeKey = await createContactAttributeKey(environmentId, inputValidation.data);
|
||||
const contactAttributeKey = await createContactAttributeKey(
|
||||
environmentId,
|
||||
inputValidation.data.key,
|
||||
inputValidation.data.type
|
||||
);
|
||||
|
||||
if (!contactAttributeKey) {
|
||||
return {
|
||||
|
||||
@@ -73,7 +73,7 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["Management API - Contacts"],
|
||||
tags: ["Management API > Contacts"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contacts uploaded successfully.",
|
||||
|
||||
@@ -170,7 +170,7 @@ export function TargetingCard({
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="inline-flex px-4 py-6">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
|
||||
@@ -60,7 +60,11 @@ const getRatingContent = (scale: string, i: number, range: number, isColorCoding
|
||||
);
|
||||
}
|
||||
if (scale === "number") {
|
||||
return <Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">{i + 1}</Text>;
|
||||
return (
|
||||
<Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">
|
||||
{i + 1}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (scale === "star") {
|
||||
return <Text className="m-auto text-3xl">⭐</Text>;
|
||||
@@ -228,8 +232,8 @@ export async function PreviewEmailTemplate({
|
||||
{ "rounded-l-lg border-l": i === 0 },
|
||||
{ "rounded-r-lg": i === firstQuestion.range - 1 },
|
||||
firstQuestion.isColorCodingEnabled &&
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "star" && "border-transparent"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}
|
||||
|
||||
@@ -89,31 +89,6 @@ describe("RecallItemSelect", () => {
|
||||
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("do not render questions if questionId is 'start' (welcome card)", async () => {
|
||||
render(
|
||||
<RecallItemSelect
|
||||
localSurvey={mockSurvey}
|
||||
questionId="start"
|
||||
addRecallItem={mockAddRecallItem}
|
||||
setShowRecallItemSelect={mockSetShowRecallItemSelect}
|
||||
recallItems={mockRecallItems}
|
||||
selectedLanguageCode="en"
|
||||
hiddenFields={mockSurvey.hiddenFields}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_Question 2_")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("filters recall items based on search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -109,9 +109,6 @@ export const RecallItemSelect = ({
|
||||
}, [localSurvey.variables, recallItemIds]);
|
||||
|
||||
const surveyQuestionRecallItems = useMemo(() => {
|
||||
const isWelcomeCard = questionId === "start";
|
||||
if (isWelcomeCard) return [];
|
||||
|
||||
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
|
||||
const idx = isEndingCard
|
||||
? localSurvey.questions.length
|
||||
|
||||
@@ -309,7 +309,7 @@ export const QuestionFormInput = ({
|
||||
onAddFallback={() => {
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
isRecallAllowed={id === "headline" || id === "subheader"}
|
||||
isRecallAllowed={!isWelcomeCard && (id === "headline" || id === "subheader")}
|
||||
usedLanguageCode={usedLanguageCode}
|
||||
render={({
|
||||
value,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { subscribeOrganizationMembersToSurveyResponses } from "./organization";
|
||||
import { updateUser } from "./user";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./user", () => ({
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("subscribeOrganizationMembersToSurveyResponses", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("subscribes user to survey responses successfully", async () => {
|
||||
const mockUser = {
|
||||
id: "user-123",
|
||||
notificationSettings: {
|
||||
alert: { "existing-survey-id": true },
|
||||
weeklySummary: {},
|
||||
},
|
||||
};
|
||||
|
||||
const surveyId = "survey-123";
|
||||
const userId = "user-123";
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
|
||||
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
|
||||
|
||||
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId);
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith(userId, {
|
||||
notificationSettings: {
|
||||
alert: {
|
||||
"existing-survey-id": true,
|
||||
"survey-123": true,
|
||||
},
|
||||
weeklySummary: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("creates notification settings if user doesn't have any", async () => {
|
||||
const mockUser = {
|
||||
id: "user-123",
|
||||
notificationSettings: null,
|
||||
};
|
||||
|
||||
const surveyId = "survey-123";
|
||||
const userId = "user-123";
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
|
||||
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
|
||||
|
||||
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith(userId, {
|
||||
notificationSettings: {
|
||||
alert: {
|
||||
"survey-123": true,
|
||||
},
|
||||
weeklySummary: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError if user is not found", async () => {
|
||||
const surveyId = "survey-123";
|
||||
const userId = "nonexistent-user";
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(subscribeOrganizationMembersToSurveyResponses(surveyId, userId)).rejects.toThrow(
|
||||
new ResourceNotFoundError("User", userId)
|
||||
);
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("propagates errors from database operations", async () => {
|
||||
const surveyId = "survey-123";
|
||||
const userId = "user-123";
|
||||
const dbError = new Error("Database error");
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(dbError);
|
||||
|
||||
await expect(subscribeOrganizationMembersToSurveyResponses(surveyId, userId)).rejects.toThrow(dbError);
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { updateUser } from "@/modules/survey/components/template-list/lib/user";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
export const subscribeOrganizationMembersToSurveyResponses = async (
|
||||
surveyId: string,
|
||||
createdBy: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const surveyCreator = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
if (!surveyCreator) {
|
||||
throw new ResourceNotFoundError("User", createdBy);
|
||||
}
|
||||
|
||||
const defaultSettings = { alert: {}, weeklySummary: {} };
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...defaultSettings,
|
||||
...surveyCreator.notificationSettings,
|
||||
};
|
||||
|
||||
updatedNotificationSettings.alert[surveyId] = true;
|
||||
|
||||
await updateUser(surveyCreator.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
getOrganizationByEnvironmentId,
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { selectSurvey } from "@/modules/survey/lib/survey";
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -23,15 +21,19 @@ vi.mock("@/lib/survey/utils", () => ({
|
||||
checkForInvalidImagesInQuestions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
vi.mock("@/modules/survey/components/template-list/lib/organization", () => ({
|
||||
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/action-class", () => ({
|
||||
getActionClasses: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/organization", () => ({
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
getOrganizationAIKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
selectSurvey: {
|
||||
id: true,
|
||||
@@ -84,7 +86,8 @@ describe("survey module", () => {
|
||||
// Mock dependencies
|
||||
const mockActionClasses: ActionClass[] = [];
|
||||
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123", name: "Org" } as any);
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
|
||||
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123", name: "Org" } as any);
|
||||
|
||||
const mockCreatedSurvey = {
|
||||
id: "survey-123",
|
||||
@@ -106,7 +109,8 @@ describe("survey module", () => {
|
||||
|
||||
// Verify results
|
||||
expect(getActionClasses).toHaveBeenCalledWith(environmentId);
|
||||
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
|
||||
expect(getOrganizationIdFromEnvironmentId).toHaveBeenCalledWith(environmentId);
|
||||
expect(getOrganizationAIKeys).toHaveBeenCalledWith("org-123");
|
||||
expect(prisma.survey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
name: surveyBody.name,
|
||||
@@ -118,11 +122,7 @@ describe("survey module", () => {
|
||||
});
|
||||
expect(prisma.segment.create).toHaveBeenCalled();
|
||||
expect(prisma.survey.update).toHaveBeenCalled();
|
||||
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalledWith(
|
||||
"survey-123",
|
||||
"user-123",
|
||||
"org-123"
|
||||
);
|
||||
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalledWith("survey-123", "user-123");
|
||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(
|
||||
environmentId,
|
||||
"survey created",
|
||||
@@ -143,7 +143,8 @@ describe("survey module", () => {
|
||||
};
|
||||
|
||||
vi.mocked(getActionClasses).mockResolvedValue([]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
|
||||
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123" } as any);
|
||||
vi.mocked(prisma.survey.create).mockResolvedValue({
|
||||
id: "survey-123",
|
||||
environmentId,
|
||||
@@ -171,7 +172,8 @@ describe("survey module", () => {
|
||||
};
|
||||
|
||||
vi.mocked(getActionClasses).mockResolvedValue([]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
|
||||
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123" } as any);
|
||||
vi.mocked(prisma.survey.create).mockResolvedValue({
|
||||
id: "survey-123",
|
||||
environmentId,
|
||||
@@ -202,7 +204,8 @@ describe("survey module", () => {
|
||||
};
|
||||
|
||||
vi.mocked(getActionClasses).mockResolvedValue([]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
|
||||
vi.mocked(getOrganizationAIKeys).mockResolvedValue(null);
|
||||
|
||||
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
@@ -217,11 +220,12 @@ describe("survey module", () => {
|
||||
};
|
||||
|
||||
vi.mocked(getActionClasses).mockResolvedValue([]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("org-123");
|
||||
vi.mocked(getOrganizationAIKeys).mockResolvedValue({ id: "org-123" } as any);
|
||||
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
clientVersion: "4.8.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.create).mockRejectedValue(prismaError);
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
getOrganizationByEnvironmentId,
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { selectSurvey } from "@/modules/survey/lib/survey";
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -45,7 +43,8 @@ export const createSurvey = async (
|
||||
};
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const organization = await getOrganizationAIKeys(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
@@ -119,7 +118,7 @@ export const createSurvey = async (
|
||||
};
|
||||
|
||||
if (createdBy) {
|
||||
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
||||
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy);
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixLabelChoice } from "./matrix-label-choice";
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
ENCRYPTION_KEY: "test",
|
||||
ENTERPRISE_LICENSE_KEY: "test",
|
||||
GITHUB_ID: "test",
|
||||
GITHUB_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
}));
|
||||
|
||||
// Mock tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === "environments.surveys.edit.row_idx") {
|
||||
return `Row ${params?.rowIndex}`;
|
||||
}
|
||||
if (key === "environments.surveys.edit.column_idx") {
|
||||
return `Column ${params?.columnIndex}`;
|
||||
}
|
||||
if (key === "environments.surveys.edit.delete_row") {
|
||||
return "Delete row";
|
||||
}
|
||||
if (key === "environments.surveys.edit.delete_column") {
|
||||
return "Delete column";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock QuestionFormInput component
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, onKeyDown }) => (
|
||||
<div data-testid={`question-input-${id}`}>
|
||||
<input
|
||||
data-testid={`input-${id}`}
|
||||
onChange={(e) => {
|
||||
if (updateMatrixLabel) {
|
||||
const type = id.startsWith("row") ? "row" : "column";
|
||||
const index = parseInt(id.split("-")[1]);
|
||||
updateMatrixLabel(index, type, { default: e.target.value });
|
||||
}
|
||||
}}
|
||||
value={value?.default || ""}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock TooltipRenderer component
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: vi.fn(({ children }) => <div data-testid="tooltip-renderer">{children}</div>),
|
||||
}));
|
||||
|
||||
// Mock validation
|
||||
vi.mock("../lib/validation", () => ({
|
||||
isLabelValidForAllLanguages: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Mock survey languages
|
||||
const mockSurveyLanguages: TSurveyLanguage[] = [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
alias: "English",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock matrix question
|
||||
const mockQuestion: TSurveyMatrixQuestion = {
|
||||
id: "matrix-1",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: createI18nString("Matrix Question", ["en"]),
|
||||
required: false,
|
||||
logic: [],
|
||||
rows: [
|
||||
createI18nString("Row 1", ["en"]),
|
||||
createI18nString("Row 2", ["en"]),
|
||||
createI18nString("Row 3", ["en"]),
|
||||
],
|
||||
columns: [
|
||||
createI18nString("Column 1", ["en"]),
|
||||
createI18nString("Column 2", ["en"]),
|
||||
createI18nString("Column 3", ["en"]),
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
// Mock survey
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
questions: [mockQuestion],
|
||||
languages: mockSurveyLanguages,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const defaultProps = {
|
||||
labelIdx: 0,
|
||||
type: "row" as const,
|
||||
questionIdx: 0,
|
||||
updateMatrixLabel: vi.fn(),
|
||||
handleDeleteLabel: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
isInvalid: false,
|
||||
localSurvey: mockSurvey,
|
||||
selectedLanguageCode: "en",
|
||||
setSelectedLanguageCode: vi.fn(),
|
||||
question: mockQuestion,
|
||||
locale: "en-US" as TUserLocale,
|
||||
};
|
||||
|
||||
const renderWithDndContext = (props = {}) => {
|
||||
const finalProps = { ...defaultProps, ...props };
|
||||
return render(
|
||||
<DndContext>
|
||||
<SortableContext
|
||||
items={[`${finalProps.type}-${finalProps.labelIdx}`]}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
<MatrixLabelChoice {...finalProps} />
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
describe("MatrixLabelChoice", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Row type", () => {
|
||||
test("renders the row choice with drag handle and input", () => {
|
||||
renderWithDndContext({ type: "row" });
|
||||
|
||||
expect(screen.getByDisplayValue("Row 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows delete button when there are more than 2 rows", () => {
|
||||
renderWithDndContext({ type: "row" });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides delete button when there are only 2 rows", () => {
|
||||
const questionWith2Rows = {
|
||||
...mockQuestion,
|
||||
rows: [createI18nString("Row 1", ["en"]), createI18nString("Row 2", ["en"])],
|
||||
};
|
||||
|
||||
renderWithDndContext({ type: "row", question: questionWith2Rows });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("calls handleDeleteLabel when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDeleteLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ type: "row", handleDeleteLabel });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeDefined();
|
||||
|
||||
await user.click(deleteButton!);
|
||||
|
||||
expect(handleDeleteLabel).toHaveBeenCalledWith("row", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column type", () => {
|
||||
test("renders the column choice with drag handle and input", () => {
|
||||
renderWithDndContext({ type: "column" });
|
||||
|
||||
expect(screen.getByDisplayValue("Column 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows delete button when there are more than 2 columns", () => {
|
||||
renderWithDndContext({ type: "column" });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides delete button when there are only 2 columns", () => {
|
||||
const questionWith2Columns = {
|
||||
...mockQuestion,
|
||||
columns: [createI18nString("Column 1", ["en"]), createI18nString("Column 2", ["en"])],
|
||||
};
|
||||
|
||||
renderWithDndContext({ type: "column", question: questionWith2Columns });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("calls handleDeleteLabel when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDeleteLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ type: "column", handleDeleteLabel });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeDefined();
|
||||
|
||||
await user.click(deleteButton!);
|
||||
|
||||
expect(handleDeleteLabel).toHaveBeenCalledWith("column", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Common functionality", () => {
|
||||
test("calls updateMatrixLabel when input value changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateMatrixLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ updateMatrixLabel });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Updated Row");
|
||||
|
||||
expect(updateMatrixLabel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls handleKeyDown when Enter key is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleKeyDown = vi.fn();
|
||||
|
||||
renderWithDndContext({ handleKeyDown });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
await user.type(input, "{Enter}");
|
||||
|
||||
expect(handleKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applies invalid styling when isInvalid is true", () => {
|
||||
renderWithDndContext({ isInvalid: true });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { GripVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixLabelChoiceProps {
|
||||
labelIdx: number;
|
||||
type: "row" | "column";
|
||||
questionIdx: number;
|
||||
updateMatrixLabel: (index: number, type: "row" | "column", data: TI18nString) => void;
|
||||
handleDeleteLabel: (type: "row" | "column", index: number) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, type: "row" | "column") => void;
|
||||
isInvalid: boolean;
|
||||
localSurvey: TSurvey;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
question: TSurveyMatrixQuestion;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const MatrixLabelChoice = ({
|
||||
labelIdx,
|
||||
type,
|
||||
questionIdx,
|
||||
updateMatrixLabel,
|
||||
handleDeleteLabel,
|
||||
handleKeyDown,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
question,
|
||||
locale,
|
||||
}: MatrixLabelChoiceProps) => {
|
||||
const { t } = useTranslate();
|
||||
const labels = type === "row" ? question.rows : question.columns;
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: `${type}-${labelIdx}`,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
|
||||
{/* drag handle */}
|
||||
<div {...listeners} {...attributes}>
|
||||
<GripVerticalIcon className="h-4 w-4 cursor-move text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-2">
|
||||
<QuestionFormInput
|
||||
key={`${type}-${labelIdx}`}
|
||||
id={`${type}-${labelIdx}`}
|
||||
placeholder={t(`environments.surveys.edit.${type}_idx`, {
|
||||
[`${type}Index`]: labelIdx + 1,
|
||||
})}
|
||||
label=""
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={labels[labelIdx]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid && !isLabelValidForAllLanguages(labels[labelIdx], surveyLanguages)}
|
||||
onKeyDown={(e) => handleKeyDown(e, type)}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{labels.length > 2 && (
|
||||
<TooltipRenderer tooltipContent={t(`environments.surveys.edit.delete_${type}`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label={`Delete ${type}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel(type, labelIdx);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { MatrixLabelChoice } from "@/modules/survey/editor/components/matrix-label-choice";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface MatrixLabelSectionProps {
|
||||
type: "row" | "column";
|
||||
labels: TI18nString[];
|
||||
question: TSurveyMatrixQuestion;
|
||||
questionIdx: number;
|
||||
updateMatrixLabel: (index: number, type: "row" | "column", data: TI18nString) => void;
|
||||
handleDeleteLabel: (type: "row" | "column", index: number) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, type: "row" | "column") => void;
|
||||
handleAddLabel: (type: "row" | "column") => void;
|
||||
onDragEnd: (event: any) => void;
|
||||
isInvalid: boolean;
|
||||
localSurvey: TSurvey;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
locale: TUserLocale;
|
||||
parent: any;
|
||||
}
|
||||
|
||||
export const MatrixLabelSection = ({
|
||||
type,
|
||||
labels,
|
||||
question,
|
||||
questionIdx,
|
||||
updateMatrixLabel,
|
||||
handleDeleteLabel,
|
||||
handleKeyDown,
|
||||
handleAddLabel,
|
||||
onDragEnd,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
parent,
|
||||
}: MatrixLabelSectionProps) => {
|
||||
const { t } = useTranslate();
|
||||
const labelKey = type === "row" ? "rows" : "columns";
|
||||
const addKey = type === "row" ? "add_row" : "add_column";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={labelKey}>{t(`environments.surveys.edit.${labelKey}`)}</Label>
|
||||
<div className="mt-2" id={labelKey}>
|
||||
<DndContext id={`matrix-${labelKey}`} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={labels.map((_, idx) => `${type}-${idx}`)}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-2" ref={parent}>
|
||||
{labels.map((_, index) => (
|
||||
<MatrixLabelChoice
|
||||
key={`${type}-${index}`}
|
||||
labelIdx={index}
|
||||
type={type}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
handleDeleteLabel={handleDeleteLabel}
|
||||
handleKeyDown={handleKeyDown}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
question={question}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-2 w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel(type);
|
||||
}}>
|
||||
<PlusIcon />
|
||||
{t(`environments.surveys.edit.${addKey}`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user