mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 14:40:44 -06:00
Compare commits
21 Commits
configurab
...
simplify-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d40d4c6770 | ||
|
|
8723e3162e | ||
|
|
639e25d679 | ||
|
|
f7e5ef96d2 | ||
|
|
745f5487e9 | ||
|
|
0e7f3adf53 | ||
|
|
342d2b1fc4 | ||
|
|
15279685f7 | ||
|
|
12aa959f50 | ||
|
|
9478946c7a | ||
|
|
8560bbf28b | ||
|
|
df7afe1b64 | ||
|
|
df52b60d61 | ||
|
|
65b051f0eb | ||
|
|
7678084061 | ||
|
|
022d33d06f | ||
|
|
4d157bf8dc | ||
|
|
9fcbe4e8c5 | ||
|
|
5aeb92eb4f | ||
|
|
00dfa629b5 | ||
|
|
3ca471b6a2 |
6
.cursor/rules/testing.mdc
Normal file
6
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
|
||||
11
.env.example
11
.env.example
@@ -211,11 +211,8 @@ UNKEY_ROOT_KEY=
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT=1
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
# Configure the initial user and organization to be created on startup.
|
||||
# INITIAL_USER_EMAIL=
|
||||
# INITIAL_USER_PASSWORD=
|
||||
# INITIAL_ORGANIZATION_NAME=
|
||||
# INITIAL_PROJECT_NAME=
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
|
||||
@@ -142,12 +142,6 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
COPY --from=installer /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||
RUN chmod -R 755 ./node_modules/bcryptjs
|
||||
|
||||
COPY --from=installer /app/packages/database/zod ./packages/database/zod
|
||||
RUN chown -R nextjs:nextjs ./packages/database/zod && chmod -R 755 ./packages/database/zod
|
||||
|
||||
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
|
||||
RUN npm install -g prisma
|
||||
|
||||
@@ -172,6 +166,4 @@ CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
fi; \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
(cd packages/database && npm run db:initial-user-setup:deploy) && \
|
||||
|
||||
exec node apps/web/server.js
|
||||
@@ -85,6 +85,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -88,6 +88,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
|
||||
@@ -97,6 +97,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service");
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("AppConnectionPage Re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("GeneralSettingsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("LanguagesPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("ProjectLookSettingsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("TagsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("ProjectTeams re-export", () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
checkUserExistsByEmail,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { deleteFile } from "@/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZUserUpdateInput } from "@formbricks/types/user";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
return await updateUser(ctx.user.id, parsedInput);
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
|
||||
let payload: TUserUpdateInput = {
|
||||
name: parsedInput.name,
|
||||
locale: parsedInput.locale,
|
||||
};
|
||||
|
||||
if (inputEmail && ctx.user.email !== inputEmail) {
|
||||
// Check rate limit
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
|
||||
if (!parsedInput.password) {
|
||||
throw new AuthenticationError("Password is required to update email.");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError("Incorrect credentials");
|
||||
}
|
||||
|
||||
const doesUserExist = await checkUserExistsByEmail(inputEmail);
|
||||
|
||||
if (doesUserExist) {
|
||||
throw new InvalidInputError("This email is already in use");
|
||||
}
|
||||
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
}
|
||||
}
|
||||
|
||||
return await updateUser(ctx.user.id, payload);
|
||||
});
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
|
||||
@@ -50,11 +50,10 @@ 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} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
expect(nameInput).toHaveValue(mockUser.name);
|
||||
expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled();
|
||||
// Check initial language (English)
|
||||
expect(screen.getByText("English (US)")).toBeInTheDocument();
|
||||
|
||||
@@ -72,7 +71,11 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" });
|
||||
expect(updateUserAction).toHaveBeenCalledWith({
|
||||
name: "New Name",
|
||||
locale: "de-DE",
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
@@ -88,7 +91,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
const errorMessage = "Update failed";
|
||||
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
await userEvent.clear(nameInput);
|
||||
@@ -106,7 +109,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("update button is disabled initially and enables on change", async () => {
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
const updateButton = screen.getByText("common.update");
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -8,129 +10,214 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
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";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true });
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true });
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
|
||||
export const EditProfileDetailsForm = ({
|
||||
user,
|
||||
emailVerificationDisabled,
|
||||
}: {
|
||||
user: TUser;
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
defaultValues: { name: user.name, locale: user.locale || "en" },
|
||||
defaultValues: {
|
||||
name: user.name,
|
||||
locale: user.locale,
|
||||
email: user.email,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZEditProfileNameFormSchema),
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
const { t } = useTranslate();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleConfirmPassword = async (password: string) => {
|
||||
const values = form.getValues();
|
||||
const dirtyFields = form.formState.dirtyFields;
|
||||
|
||||
const emailChanged = "email" in dirtyFields;
|
||||
const nameChanged = "name" in dirtyFields;
|
||||
const localeChanged = "locale" in dirtyFields;
|
||||
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const locale = values.locale;
|
||||
|
||||
const data: TUserUpdateInput = {};
|
||||
|
||||
if (emailChanged) {
|
||||
data.email = email;
|
||||
data.password = password;
|
||||
}
|
||||
if (nameChanged) {
|
||||
data.name = name;
|
||||
}
|
||||
if (localeChanged) {
|
||||
data.locale = locale;
|
||||
}
|
||||
|
||||
const updatedUserResult = await updateUserAction(data);
|
||||
|
||||
if (updatedUserResult?.data) {
|
||||
if (!emailVerificationDisabled) {
|
||||
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
|
||||
} else {
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
await signOut({ redirect: false });
|
||||
router.push(`/email-change-without-verification-success`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedUserResult);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
const locale = data.locale;
|
||||
await updateUserAction({ name, locale });
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset({ name, locale });
|
||||
} catch (error) {
|
||||
toast.error(`${t("common.error")}: ${error.message}`);
|
||||
if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) {
|
||||
toast.error(t("auth.email-change.email_already_exists"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.email !== user.email) {
|
||||
setShowModal(true);
|
||||
} else {
|
||||
try {
|
||||
await updateUserAction({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
});
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset(data);
|
||||
} catch (error: any) {
|
||||
toast.error(`${t("common.error")}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.full_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder={t("common.full_name")}
|
||||
required
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.full_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
required
|
||||
placeholder={t("common.full_name")}
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* disabled email field */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="email">{t("common.email")}</Label>
|
||||
<Input type="email" id="email" defaultValue={user.email} disabled />
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
required
|
||||
isInvalid={!!form.formState.errors.email}
|
||||
disabled={user.identityProvider !== "email"}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left"
|
||||
variant="ghost">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((language) => language.code === field.value)?.label[field.value] ||
|
||||
"NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-40 bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
{appLanguages.map((language) => (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => field.onChange(language.code)}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{language.label[field.value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start">
|
||||
{appLanguages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => field.onChange(lang.code)}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label[field.value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<PasswordConfirmationModal
|
||||
open={showModal}
|
||||
setOpen={setShowModal}
|
||||
oldEmail={user.email}
|
||||
newEmail={form.getValues("email") || user.email}
|
||||
onConfirm={handleConfirmPassword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { PasswordConfirmationModal } from "./password-confirmation-modal";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, title }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="modal-close" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Mock the PasswordInput component
|
||||
vi.mock("@/modules/ui/components/password-input", () => ({
|
||||
PasswordInput: ({ onChange, value, placeholder }: any) => (
|
||||
<input
|
||||
type="password"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
data-testid="password-input"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PasswordConfirmationModal", () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
setOpen: vi.fn(),
|
||||
oldEmail: "old@example.com",
|
||||
newEmail: "new@example.com",
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders nothing when open is false", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders modal content when open is true", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays old and new email addresses", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByText("old@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("new@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows password input field", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(passwordInput).toHaveAttribute("placeholder", "*******");
|
||||
});
|
||||
|
||||
test("disables confirm button when form is not dirty", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("disables confirm button when old and new emails are the same", () => {
|
||||
render(
|
||||
<PasswordConfirmationModal {...defaultProps} oldEmail="same@example.com" newEmail="same@example.com" />
|
||||
);
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables confirm button when password is entered and emails are different", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "password123");
|
||||
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("shows error message when password is too short", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "short");
|
||||
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles cancel button click and resets form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "password123");
|
||||
|
||||
const cancelButton = screen.getByText("common.cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
|
||||
await waitFor(() => {
|
||||
expect(passwordInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ZUserPassword } from "@formbricks/types/user";
|
||||
|
||||
interface PasswordConfirmationModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
oldEmail: string;
|
||||
newEmail: string;
|
||||
onConfirm: (password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const PasswordConfirmationSchema = z.object({
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof PasswordConfirmationSchema>;
|
||||
|
||||
export const PasswordConfirmationModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
oldEmail,
|
||||
newEmail,
|
||||
onConfirm,
|
||||
}: PasswordConfirmationModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(PasswordConfirmationSchema),
|
||||
});
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async (data) => {
|
||||
try {
|
||||
await onConfirm(data.password);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
form.setError("password", {
|
||||
message: error instanceof Error ? error.message : "Authentication failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.email-change.confirm_password_description")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
|
||||
<p>
|
||||
<strong>{t("auth.email-change.old_email")}:</strong>
|
||||
<br /> {oldEmail.toLowerCase()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("auth.email-change.new_email")}:</strong>
|
||||
<br /> {newEmail.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-x-2 text-right">
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkUserExistsByEmail, verifyUserPassword } from "./user";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/user/cache", () => ({
|
||||
userCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `user-${id}-tag`),
|
||||
byEmail: vi.fn((email) => `user-email-${email}-tag`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// reactCache (from "react") and unstable_cache (from "next/cache") are mocked in vitestSetup.ts
|
||||
// to be pass-through, so the inner logic of cached functions is tested.
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUserExistsByEmail", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
test("should return true if user exists", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
id: "some-user-id",
|
||||
} as any);
|
||||
|
||||
const result = await checkUserExistsByEmail(email);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false if user does not exist", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await checkUserExistsByEmail(email);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
},
|
||||
[`getUserById-${userId}`],
|
||||
{
|
||||
tags: [userCache.tag.byId(userId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const checkUserExistsByEmail = reactCache(
|
||||
async (email: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!user;
|
||||
},
|
||||
[`checkUserExistsByEmail-${email}`],
|
||||
{
|
||||
tags: [userCache.tag.byEmail(email)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import Page from "./page";
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
EMAIL_VERIFICATION_DISABLED: true,
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
|
||||
|
||||
@@ -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 { IS_FORMBRICKS_CLOUD } 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";
|
||||
@@ -42,7 +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} />
|
||||
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.avatar")}
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("TeamsPage re-export", () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||
|
||||
@@ -1,487 +1,494 @@
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { ResponseTable } from "./ResponseTable";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
// Hoist variables used in mock factories
|
||||
const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => {
|
||||
const dndMock = vi.fn(({ children, onDragEnd }) => {
|
||||
// Store the onDragEnd prop to allow triggering it in tests
|
||||
(dndMock as any).lastOnDragEnd = onDragEnd;
|
||||
return <div data-testid="dnd-context">{children}</div>;
|
||||
});
|
||||
const sortableMock = vi.fn(({ children }) => <>{children}</>);
|
||||
const moveMock = vi.fn((array, from, to) => {
|
||||
const newArray = [...array];
|
||||
const [item] = newArray.splice(from, 1);
|
||||
newArray.splice(to, 0, item);
|
||||
return newArray;
|
||||
});
|
||||
return {
|
||||
DndContextMock: dndMock,
|
||||
SortableContextMock: sortableMock,
|
||||
arrayMoveMock: moveMock,
|
||||
};
|
||||
});
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
|
||||
return {
|
||||
...actual,
|
||||
DndContext: DndContextMock,
|
||||
useSensor: vi.fn(),
|
||||
useSensors: vi.fn(),
|
||||
closestCenter: vi.fn(),
|
||||
};
|
||||
});
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} data-testid="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock DndContext/SortableContext
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: any) => <div>{children}</div>,
|
||||
useSensor: vi.fn(),
|
||||
useSensors: vi.fn(() => "sensors"),
|
||||
closestCenter: vi.fn(),
|
||||
MouseSensor: vi.fn(),
|
||||
TouchSensor: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/modifiers", () => ({
|
||||
restrictToHorizontalAxis: vi.fn(),
|
||||
restrictToHorizontalAxis: "restrictToHorizontalAxis",
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: SortableContextMock,
|
||||
arrayMove: arrayMoveMock,
|
||||
horizontalListSortingStrategy: vi.fn(),
|
||||
SortableContext: ({ children }: any) => <>{children}</>,
|
||||
horizontalListSortingStrategy: "horizontalListSortingStrategy",
|
||||
arrayMove: vi.fn((arr, oldIndex, newIndex) => {
|
||||
const result = [...arr];
|
||||
const [removed] = result.splice(oldIndex, 1);
|
||||
result.splice(newIndex, 0, removed);
|
||||
return result;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AutoAnimate
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [vi.fn()],
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/data-table", () => ({
|
||||
DataTableHeader: ({ header }: any) => <th data-testid={`header-${header.id}`}>{header.id}</th>,
|
||||
DataTableSettingsModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="settings-modal">
|
||||
Settings Modal <button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null,
|
||||
DataTableToolbar: ({
|
||||
table,
|
||||
deleteRowsAction,
|
||||
downloadRowsAction,
|
||||
setIsTableSettingsModalOpen,
|
||||
setIsExpanded,
|
||||
isExpanded,
|
||||
}: any) => (
|
||||
<div data-testid="table-toolbar">
|
||||
<button
|
||||
data-testid="toggle-expand"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-pressed={isExpanded}>
|
||||
Toggle Expand
|
||||
</button>
|
||||
<button data-testid="open-settings" onClick={() => setIsTableSettingsModalOpen(true)}>
|
||||
Open Settings
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-rows"
|
||||
onClick={() => deleteRowsAction(Object.keys(table.getState().rowSelection))}>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button
|
||||
data-testid="download-csv"
|
||||
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "csv")}>
|
||||
Download CSV
|
||||
</button>
|
||||
<button
|
||||
data-testid="download-xlsx"
|
||||
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "xlsx")}>
|
||||
Download XLSX
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
|
||||
() => ({
|
||||
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
|
||||
ResponseCardModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="response-card-modal">
|
||||
Selected Response ID: {selectedResponseId}
|
||||
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
|
||||
<div data-testid="response-modal">
|
||||
Response Modal <button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
) : null,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
|
||||
() => ({
|
||||
ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => (
|
||||
<td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}>
|
||||
{typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())}
|
||||
ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => (
|
||||
<td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}>
|
||||
Cell Content
|
||||
</td>
|
||||
)),
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const mockGeneratedColumns = [
|
||||
{
|
||||
id: "select",
|
||||
header: () => "Select",
|
||||
cell: vi.fn(() => "SelectCell"),
|
||||
enableSorting: false,
|
||||
meta: { type: "select", questionType: null, hidden: false },
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: () => "Created At",
|
||||
cell: vi.fn(({ row }) => new Date(row.original.createdAt).toISOString()),
|
||||
enableSorting: true,
|
||||
meta: { type: "createdAt", questionType: null, hidden: false },
|
||||
},
|
||||
{
|
||||
id: "q1",
|
||||
header: () => "Question 1",
|
||||
cell: vi.fn(({ row }) => row.original.responseData.q1),
|
||||
enableSorting: true,
|
||||
meta: { type: "question", questionType: "openText", hidden: false },
|
||||
},
|
||||
];
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
|
||||
() => ({
|
||||
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
|
||||
generateResponseTableColumns: vi.fn(() => [
|
||||
{ id: "select", accessorKey: "select", header: "Select" },
|
||||
{ id: "createdAt", accessorKey: "createdAt", header: "Created At" },
|
||||
{ id: "person", accessorKey: "person", header: "Person" },
|
||||
{ id: "status", accessorKey: "status", header: "Status" },
|
||||
]),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/ui/components/table", () => ({
|
||||
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
|
||||
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
|
||||
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
|
||||
TableHeader: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
|
||||
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/skeleton", () => ({
|
||||
Skeleton: ({ children }: any) => <div data-testid="skeleton">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock the actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
|
||||
getResponsesDownloadUrlAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
|
||||
deleteResponseAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/data-table", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>();
|
||||
return {
|
||||
...actual,
|
||||
DataTableToolbar: vi.fn((props) => (
|
||||
<div data-testid="data-table-toolbar">
|
||||
<button data-testid="toolbar-expand-toggle" onClick={() => props.setIsExpanded(!props.isExpanded)}>
|
||||
Toggle Expand
|
||||
</button>
|
||||
<button data-testid="toolbar-open-settings" onClick={() => props.setIsTableSettingsModalOpen(true)}>
|
||||
Open Settings
|
||||
</button>
|
||||
<button
|
||||
data-testid="toolbar-delete-selected"
|
||||
onClick={() => props.deleteRows(props.table.getSelectedRowModel().rows.map((r) => r.id))}>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button data-testid="toolbar-delete-single" onClick={() => props.deleteAction("single_response_id")}>
|
||||
Delete Single Action
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
DataTableHeader: vi.fn(({ header }) => (
|
||||
<th
|
||||
data-testid={`header-${header.id}`}
|
||||
onClick={() => header.column.getToggleSortingHandler()?.(new MouseEvent("click"))}>
|
||||
{typeof header.column.columnDef.header === "function"
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
<button
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
data-testid={`resize-${header.id}`}>
|
||||
Resize
|
||||
</button>
|
||||
</th>
|
||||
)),
|
||||
DataTableSettingsModal: vi.fn(({ open, setOpen }) =>
|
||||
open ? (
|
||||
<div data-testid="data-table-settings-modal">
|
||||
<button onClick={() => setOpen(false)}>Close Settings</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: vi.fn(() => [vi.fn()]),
|
||||
// Mock helper functions
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: vi.fn((key) => key), // Simple pass-through mock
|
||||
}),
|
||||
}));
|
||||
|
||||
const localStorageMock = (() => {
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = String(value);
|
||||
}),
|
||||
clear: () => {
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
},
|
||||
removeItem: vi.fn((key: string) => {
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
}),
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
|
||||
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "" },
|
||||
html: { default: "" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
closeOnDate: null,
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
singleUse: { enabled: false, isEncrypted: true },
|
||||
triggers: [],
|
||||
languages: [],
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
} as unknown as TSurvey;
|
||||
// Mock Tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockResponses: TResponse[] = [
|
||||
{
|
||||
id: "res1",
|
||||
surveyId: "survey1",
|
||||
finished: true,
|
||||
data: { q1: "Response 1 Text" },
|
||||
createdAt: new Date("2023-01-01T10:00:00.000Z"),
|
||||
// Define mock data for tests
|
||||
const mockProps = {
|
||||
data: [
|
||||
{ responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" },
|
||||
{ responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" },
|
||||
] as any[],
|
||||
survey: {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "en",
|
||||
contact: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
{
|
||||
id: "res2",
|
||||
surveyId: "survey1",
|
||||
finished: false,
|
||||
data: { q1: "Response 2 Text" },
|
||||
createdAt: new Date("2023-01-02T10:00:00.000Z"),
|
||||
updatedAt: new Date(),
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "en",
|
||||
contact: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockResponseTableData: TResponseTableData[] = [
|
||||
{
|
||||
responseId: "res1",
|
||||
responseData: { q1: "Response 1 Text" },
|
||||
createdAt: new Date("2023-01-01T10:00:00.000Z"),
|
||||
status: "Completed",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
{
|
||||
responseId: "res2",
|
||||
responseData: { q1: "Response 2 Text" },
|
||||
createdAt: new Date("2023-01-02T10:00:00.000Z"),
|
||||
status: "Not Completed",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "user@test.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockEnvironmentTags: TTag[] = [
|
||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
|
||||
const defaultProps = {
|
||||
data: mockResponseTableData,
|
||||
survey: mockSurvey,
|
||||
responses: mockResponses,
|
||||
environment: mockEnvironment,
|
||||
user: mockUser,
|
||||
environmentTags: mockEnvironmentTags,
|
||||
name: "name",
|
||||
type: "link",
|
||||
environmentId: "env-1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
} as TSurvey,
|
||||
responses: [
|
||||
{ id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
|
||||
] as TResponse[],
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
environmentTags: [] as TTag[],
|
||||
isReadOnly: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasMore: true,
|
||||
hasMore: false,
|
||||
deleteResponses: vi.fn(),
|
||||
updateResponse: vi.fn(),
|
||||
isFetchingFirstPage: false,
|
||||
locale: mockLocale,
|
||||
locale: "en" as TUserLocale,
|
||||
};
|
||||
|
||||
// Setup a container for React Testing Library before each test
|
||||
beforeEach(() => {
|
||||
const container = document.createElement("div");
|
||||
container.id = "test-container";
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Reset all toast mocks before each test
|
||||
vi.mocked(toast.error).mockClear();
|
||||
vi.mocked(toast.success).mockClear();
|
||||
|
||||
// Create a mock anchor element for download tests
|
||||
const mockAnchor = {
|
||||
href: "",
|
||||
click: vi.fn(),
|
||||
style: {},
|
||||
};
|
||||
|
||||
// Update how we mock the document methods to avoid infinite recursion
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
|
||||
if (tagName === "a") return mockAnchor as any;
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
|
||||
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
|
||||
});
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
const container = document.getElementById("test-container");
|
||||
if (container) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
cleanup();
|
||||
vi.restoreAllMocks(); // Restore mocks after each test
|
||||
});
|
||||
|
||||
describe("ResponseTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
cleanup(); // Keep cleanup within describe as per instructions
|
||||
});
|
||||
|
||||
test("renders skeleton when isFetchingFirstPage is true", () => {
|
||||
render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />);
|
||||
// Check for skeleton elements (implementation detail, might need adjustment)
|
||||
// For now, check that data is not directly rendered
|
||||
expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument();
|
||||
// Check if table headers are still there
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
test("renders the table with data", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-toolbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("loads settings from localStorage on mount", () => {
|
||||
const savedOrder = ["q1", "createdAt", "select"];
|
||||
const savedVisibility = { createdAt: false };
|
||||
const savedExpanded = true;
|
||||
localStorageMock.setItem(`${mockSurvey.id}-columnOrder`, JSON.stringify(savedOrder));
|
||||
localStorageMock.setItem(`${mockSurvey.id}-columnVisibility`, JSON.stringify(savedVisibility));
|
||||
localStorageMock.setItem(`${mockSurvey.id}-rowExpand`, JSON.stringify(savedExpanded));
|
||||
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
|
||||
// Check if generateResponseTableColumns was called with the loaded expanded state
|
||||
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
|
||||
mockSurvey,
|
||||
savedExpanded,
|
||||
false,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test("saves settings to localStorage when they change", async () => {
|
||||
const { rerender } = render(<ResponseTable {...defaultProps} />);
|
||||
|
||||
// Simulate column order change via DND
|
||||
const dragEvent: DragEndEvent = {
|
||||
active: { id: "createdAt" },
|
||||
over: { id: "q1" },
|
||||
delta: { x: 0, y: 0 },
|
||||
activators: { x: 0, y: 0 },
|
||||
collisions: null,
|
||||
overNode: null,
|
||||
activeNode: null,
|
||||
} as any;
|
||||
act(() => {
|
||||
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
|
||||
});
|
||||
rerender(<ResponseTable {...defaultProps} />); // Rerender to reflect state change if necessary for useEffect
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
`${mockSurvey.id}-columnOrder`,
|
||||
JSON.stringify(["select", "q1", "createdAt"])
|
||||
);
|
||||
|
||||
// Simulate visibility change (e.g. via settings modal - direct state change for test)
|
||||
// This would typically happen via table.setColumnVisibility, which is internal to useReactTable
|
||||
// For this test, we'll assume a mechanism changes columnVisibility state
|
||||
// This part is hard to test without deeper mocking of useReactTable or exposing setColumnVisibility
|
||||
|
||||
// Simulate row expansion change
|
||||
await userEvent.click(screen.getByTestId("toolbar-expand-toggle")); // Toggle to true
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
|
||||
});
|
||||
|
||||
test("handles column drag and drop", () => {
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
const dragEvent: DragEndEvent = {
|
||||
active: { id: "createdAt" },
|
||||
over: { id: "q1" },
|
||||
delta: { x: 0, y: 0 },
|
||||
activators: { x: 0, y: 0 },
|
||||
collisions: null,
|
||||
overNode: null,
|
||||
activeNode: null,
|
||||
} as any;
|
||||
act(() => {
|
||||
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
|
||||
});
|
||||
expect(arrayMoveMock).toHaveBeenCalledWith(expect.arrayContaining(["createdAt", "q1"]), 1, 2); // Example indices
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
`${mockSurvey.id}-columnOrder`,
|
||||
JSON.stringify(["select", "q1", "createdAt"]) // Based on initial ['select', 'createdAt', 'q1']
|
||||
);
|
||||
});
|
||||
|
||||
test("interacts with DataTableToolbar: toggle expand, open settings, delete", async () => {
|
||||
const deleteResponsesMock = vi.fn();
|
||||
const deleteResponseActionMock = vi.mocked(deleteResponseAction);
|
||||
render(<ResponseTable {...defaultProps} deleteResponses={deleteResponsesMock} />);
|
||||
|
||||
// Toggle expand
|
||||
await userEvent.click(screen.getByTestId("toolbar-expand-toggle"));
|
||||
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
|
||||
mockSurvey,
|
||||
true,
|
||||
false,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
|
||||
|
||||
// Open settings
|
||||
await userEvent.click(screen.getByTestId("toolbar-open-settings"));
|
||||
expect(screen.getByTestId("data-table-settings-modal")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Close Settings"));
|
||||
expect(screen.queryByTestId("data-table-settings-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Delete selected (mock table selection)
|
||||
// This requires mocking table.getSelectedRowModel().rows
|
||||
// For simplicity, we assume the toolbar button calls deleteRows correctly
|
||||
// The mock for DataTableToolbar calls props.deleteRows with hardcoded IDs for now.
|
||||
// To test properly, we'd need to mock table.getSelectedRowModel
|
||||
// For now, let's assume the mock toolbar calls it.
|
||||
// await userEvent.click(screen.getByTestId("toolbar-delete-selected"));
|
||||
// expect(deleteResponsesMock).toHaveBeenCalledWith(["row1_id", "row2_id"]); // From mock toolbar
|
||||
|
||||
// Delete single action
|
||||
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
|
||||
expect(deleteResponseActionMock).toHaveBeenCalledWith({ responseId: "single_response_id" });
|
||||
});
|
||||
|
||||
test("calls fetchNextPage when 'Load More' is clicked", async () => {
|
||||
const fetchNextPageMock = vi.fn();
|
||||
render(<ResponseTable {...defaultProps} fetchNextPage={fetchNextPageMock} />);
|
||||
await userEvent.click(screen.getByText("common.load_more"));
|
||||
expect(fetchNextPageMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not show 'Load More' if hasMore is false", () => {
|
||||
render(<ResponseTable {...defaultProps} hasMore={false} />);
|
||||
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No results' when data is empty", () => {
|
||||
render(<ResponseTable {...defaultProps} data={[]} responses={[]} />);
|
||||
test("renders no results message when data is empty", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! });
|
||||
expect(screen.getByText("common.no_results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("deleteResponse function calls deleteResponseAction", async () => {
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
// This function is called by DataTableToolbar's deleteAction prop
|
||||
// We can trigger it via the mocked DataTableToolbar
|
||||
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
|
||||
expect(vi.mocked(deleteResponseAction)).toHaveBeenCalledWith({ responseId: "single_response_id" });
|
||||
test("renders load more button when hasMore is true", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
|
||||
expect(screen.getByText("common.load_more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls fetchNextPage when load more button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
|
||||
const loadMoreButton = screen.getByText("common.load_more");
|
||||
await userEvent.click(loadMoreButton);
|
||||
expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("opens settings modal when toolbar button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const openSettingsButton = screen.getByTestId("open-settings");
|
||||
await userEvent.click(openSettingsButton);
|
||||
expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("toggles expanded state when toolbar button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const toggleExpandButton = screen.getByTestId("toggle-expand");
|
||||
|
||||
// Initially might be null, first click should set it to true
|
||||
await userEvent.click(toggleExpandButton);
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String));
|
||||
});
|
||||
|
||||
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: "https://download.url/file.csv",
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey1",
|
||||
format: "csv",
|
||||
filterCriteria: { responseIds: [] },
|
||||
});
|
||||
|
||||
// Check if link was created and clicked
|
||||
expect(document.createElement).toHaveBeenCalledWith("a");
|
||||
const mockLink = document.createElement("a");
|
||||
expect(mockLink.href).toBe("https://download.url/file.csv");
|
||||
expect(document.body.appendChild).toHaveBeenCalled();
|
||||
expect(mockLink.click).toHaveBeenCalled();
|
||||
expect(document.body.removeChild).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: "https://download.url/file.xlsx",
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadXlsxButton = screen.getByTestId("download-xlsx");
|
||||
await userEvent.click(downloadXlsxButton);
|
||||
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey1",
|
||||
format: "xlsx",
|
||||
filterCriteria: { responseIds: [] },
|
||||
});
|
||||
|
||||
// Check if link was created and clicked
|
||||
expect(document.createElement).toHaveBeenCalledWith("a");
|
||||
const mockLink = document.createElement("a");
|
||||
expect(mockLink.href).toBe("https://download.url/file.xlsx");
|
||||
expect(document.body.appendChild).toHaveBeenCalled();
|
||||
expect(mockLink.click).toHaveBeenCalled();
|
||||
expect(document.body.removeChild).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test response modal
|
||||
test("opens and closes response modal when a cell is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const cell = screen.getByTestId("cell-resp1_select-resp1");
|
||||
await userEvent.click(cell);
|
||||
expect(screen.getByTestId("response-modal")).toBeInTheDocument();
|
||||
// Close the modal
|
||||
const closeButton = screen.getByText("Close");
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
// Modal should be closed now
|
||||
expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error toast when download action returns error", async () => {
|
||||
const errorMsg = "Download failed";
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
serverError: errorMsg,
|
||||
});
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg);
|
||||
|
||||
// Reset document.createElement spy to fix the last test
|
||||
vi.mocked(document.createElement).mockClear();
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows default error toast when download action returns no data", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
});
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("");
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error toast when download action throws exception", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create download link when download action fails", async () => {
|
||||
// Clear any previous calls to document.createElement
|
||||
vi.mocked(document.createElement).mockClear();
|
||||
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
serverError: "Download failed",
|
||||
});
|
||||
|
||||
// Create a fresh spy for createElement for this test only
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
// Check specifically for "a" element creation, not any element
|
||||
expect(createElementSpy).not.toHaveBeenCalledWith("a");
|
||||
});
|
||||
});
|
||||
|
||||
test("loads saved settings from localStorage on mount", () => {
|
||||
const columnOrder = ["status", "person", "createdAt", "select"];
|
||||
const columnVisibility = { status: false };
|
||||
const isExpanded = true;
|
||||
|
||||
mockLocalStorage.getItem.mockImplementation((key) => {
|
||||
if (key === "survey1-columnOrder") return JSON.stringify(columnOrder);
|
||||
if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility);
|
||||
if (key === "survey1-rowExpand") return JSON.stringify(isExpanded);
|
||||
return null;
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
|
||||
// Verify localStorage calls
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder");
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility");
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand");
|
||||
|
||||
// The mock for generateResponseTableColumns returns this order:
|
||||
// ["select", "createdAt", "person", "status"]
|
||||
// Only visible columns should be rendered, in this order
|
||||
const expectedHeaders = ["select", "createdAt", "person"];
|
||||
const headers = screen.getAllByTestId(/^header-/);
|
||||
expect(headers).toHaveLength(expectedHeaders.length);
|
||||
expectedHeaders.forEach((columnId, index) => {
|
||||
expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`);
|
||||
});
|
||||
|
||||
// Verify column visibility is applied
|
||||
const statusHeader = screen.queryByTestId("header-status");
|
||||
expect(statusHeader).not.toBeInTheDocument();
|
||||
|
||||
// Verify row expansion is applied
|
||||
const toggleExpandButton = screen.getByTestId("toggle-expand");
|
||||
expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
|
||||
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -25,15 +26,16 @@ import {
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
@@ -180,6 +182,32 @@ export const ResponseTable = ({
|
||||
await deleteResponseAction({ responseId });
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
|
||||
try {
|
||||
const downloadResponse = await getResponsesDownloadUrlAction({
|
||||
surveyId: survey.id,
|
||||
format: format,
|
||||
filterCriteria: { responseIds },
|
||||
});
|
||||
|
||||
if (downloadResponse?.data) {
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadResponse.data;
|
||||
link.download = "";
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
toast.error(t("environments.surveys.responses.error_downloading_responses"));
|
||||
}
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
toast.error(t("environments.surveys.responses.error_downloading_responses"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DndContext
|
||||
@@ -193,9 +221,10 @@ export const ResponseTable = ({
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRows={deleteResponses}
|
||||
deleteRowsAction={deleteResponses}
|
||||
type="response"
|
||||
deleteAction={deleteResponse}
|
||||
downloadRowsAction={downloadSelectedRows}
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
|
||||
@@ -11,7 +11,11 @@ vi.mock("lucide-react", () => ({
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }) => <>{children}</>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children, onClick }) => (
|
||||
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
TooltipContent: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
@@ -67,8 +71,10 @@ describe("SummaryMetadata", () => {
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,8 +107,10 @@ describe("SummaryMetadata", () => {
|
||||
};
|
||||
render(<Wrapper />);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
|
||||
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
@@ -117,15 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<button
|
||||
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"
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}>
|
||||
<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>
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Create a spy for refreshSingleUseId so we can override it in tests
|
||||
|
||||
@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
}, [surveyUrl]);
|
||||
}, [surveyUrl, t]);
|
||||
|
||||
const downloadQRCode = () => {
|
||||
try {
|
||||
|
||||
@@ -250,6 +250,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
if (responsesDownloadUrlResponse?.data) {
|
||||
const link = document.createElement("a");
|
||||
link.href = responsesDownloadUrlResponse.data;
|
||||
link.download = "";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import EmailChangeWithoutVerificationSuccessPage from "./page";
|
||||
|
||||
vi.mock("@/modules/auth/email-change-without-verification-success/page", () => ({
|
||||
EmailChangeWithoutVerificationSuccessPage: ({ children }) => (
|
||||
<div data-testid="email-change-success-page">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
const { getByTestId } = render(<EmailChangeWithoutVerificationSuccessPage />);
|
||||
expect(getByTestId("email-change-success-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
|
||||
|
||||
export default EmailChangeWithoutVerificationSuccessPage;
|
||||
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
|
||||
|
||||
export default VerifyEmailChangePage;
|
||||
@@ -7,39 +7,133 @@ export const GET = async (req: NextRequest) => {
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw={`flex flex-col w-full h-full items-center bg-[${brandColor}]/75 rounded-xl `}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
borderRadius: "0.75rem",
|
||||
}}>
|
||||
<div
|
||||
tw="flex flex-col w-[80%] h-[60%] bg-white rounded-xl mt-13 absolute left-12 top-3 opacity-20"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[84%] h-[60%] bg-white rounded-xl mt-12 absolute top-5 left-13 border-2 opacity-60"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[85%] h-[67%] items-center bg-white rounded-xl mt-8 absolute top-[2.3rem] left-14"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div tw="flex flex-col w-full">
|
||||
<div tw="flex flex-col md:flex-row w-full md:items-center justify-between ">
|
||||
<div tw="flex flex-col px-8">
|
||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
}}>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex justify-end mr-10 ">
|
||||
<div tw="flex rounded-2xl absolute -right-2 mt-2">
|
||||
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div tw="flex rounded-2xl shadow ">
|
||||
<a
|
||||
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
}}>
|
||||
Begin!
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const checkSurveyValidity = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
if (survey.type === "link" && survey.singleUse?.enabled) {
|
||||
if (!responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: sentryDsn,
|
||||
tracesSampleRate: 1,
|
||||
tracesSampleRate: 0,
|
||||
debug: false,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
@@ -81,6 +81,26 @@ describe("SentryProvider", () => {
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
|
||||
test("does not reinitialize Sentry when props change after initial render", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<SentryProvider sentryDsn="https://newDsn@o0.ingest.sentry.io/0" isEnabled={false}>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
@@ -109,4 +129,36 @@ describe("SentryProvider", () => {
|
||||
const hintWithoutError = { originalException: undefined };
|
||||
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly when hint.originalException is not an Error object", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
const config = initSpy.mock.calls[0][0];
|
||||
expect(config).toHaveProperty("beforeSend");
|
||||
const beforeSend = config.beforeSend;
|
||||
|
||||
if (!beforeSend) {
|
||||
throw new Error("beforeSend is not defined");
|
||||
}
|
||||
|
||||
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
|
||||
|
||||
const hintWithString = { originalException: "string exception" };
|
||||
expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNumber = { originalException: 123 };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNull = { originalException: null };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -282,10 +282,6 @@ export const SENTRY_DSN = env.SENTRY_DSN;
|
||||
|
||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1";
|
||||
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
|
||||
|
||||
//initial setup variables
|
||||
export const INITIAL_USER_EMAIL = env.INITIAL_USER_EMAIL;
|
||||
export const INITIAL_USER_PASSWORD = env.INITIAL_USER_PASSWORD;
|
||||
export const INITIAL_ORGANIZATION_NAME = env.INITIAL_ORGANIZATION_NAME;
|
||||
export const INITIAL_PROJECT_NAME = env.INITIAL_PROJECT_NAME;
|
||||
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
import { ZUserEmail, ZUserPassword } from "@formbricks/types/user";
|
||||
|
||||
export const env = createEnv({
|
||||
/*
|
||||
@@ -105,11 +104,8 @@ export const env = createEnv({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
DISABLE_USER_MANAGEMENT: z.enum(["1", "0"]).optional(),
|
||||
INITIAL_USER_EMAIL: ZUserEmail.optional(),
|
||||
INITIAL_USER_PASSWORD: ZUserPassword.optional(),
|
||||
INITIAL_ORGANIZATION_NAME: z.string().optional(),
|
||||
INITIAL_PROJECT_NAME: z.string().optional(),
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
|
||||
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -204,10 +200,7 @@ export const env = createEnv({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
DISABLE_USER_MANAGEMENT: process.env.DISABLE_USER_MANAGEMENT,
|
||||
INITIAL_USER_EMAIL: process.env.INITIAL_USER_EMAIL,
|
||||
INITIAL_USER_PASSWORD: process.env.INITIAL_USER_PASSWORD,
|
||||
INITIAL_ORGANIZATION_NAME: process.env.INITIAL_ORGANIZATION_NAME,
|
||||
INITIAL_PROJECT_NAME: process.env.INITIAL_PROJECT_NAME,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
|
||||
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import { env } from "@/lib/env";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
createInviteToken,
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
getEmailFromEmailToken,
|
||||
verifyEmailChangeToken,
|
||||
verifyInviteToken,
|
||||
verifyToken,
|
||||
verifyTokenForLinkSurvey,
|
||||
@@ -46,16 +48,6 @@ describe("JWT Functions", () => {
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createToken(mockUser.id, mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTokenForLinkSurvey", () => {
|
||||
@@ -65,18 +57,6 @@ describe("JWT Functions", () => {
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createTokenForLinkSurvey("test-survey-id", mockUser.email)).toThrow(
|
||||
"ENCRYPTION_KEY is not set"
|
||||
);
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEmailToken", () => {
|
||||
@@ -86,16 +66,6 @@ describe("JWT Functions", () => {
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
||||
const originalSecret = env.NEXTAUTH_SECRET;
|
||||
try {
|
||||
@@ -113,16 +83,6 @@ describe("JWT Functions", () => {
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => getEmailFromEmailToken("invalid-token")).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInviteToken", () => {
|
||||
@@ -132,18 +92,6 @@ describe("JWT Functions", () => {
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createInviteToken("test-invite-id", mockUser.email)).toThrow(
|
||||
"ENCRYPTION_KEY is not set"
|
||||
);
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyTokenForLinkSurvey", () => {
|
||||
@@ -192,4 +140,32 @@ describe("JWT Functions", () => {
|
||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyEmailChangeToken", () => {
|
||||
test("should verify and decrypt valid email change token", async () => {
|
||||
const userId = "test-user-id";
|
||||
const email = "test@example.com";
|
||||
const token = createEmailChangeToken(userId, email);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual({ id: userId, email });
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or missing fields", async () => {
|
||||
// Create a token with missing fields
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return original id/email if decryption fails", async () => {
|
||||
// Create a token with non-encrypted id/email
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
||||
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,27 +5,60 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const createToken = (userId: string, userEmail: string, options = {}): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
|
||||
|
||||
if (!payload?.id || !payload?.email) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
let decryptedId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = payload.id;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedEmail = payload.email;
|
||||
}
|
||||
|
||||
return {
|
||||
id: decryptedId,
|
||||
email: decryptedEmail,
|
||||
};
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
|
||||
const payload = {
|
||||
id: encryptedUserId,
|
||||
email: encryptedEmail,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
};
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -35,10 +68,6 @@ export const createEmailToken = (email: string): string => {
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -55,10 +84,6 @@ export const getEmailFromEmailToken = (token: string): string => {
|
||||
};
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -87,9 +112,6 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
// First decode to get the ID
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
@@ -127,10 +149,6 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
try {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
|
||||
|
||||
@@ -13,3 +13,21 @@ export const getAccessFlags = (role?: TOrganizationRole) => {
|
||||
isMember,
|
||||
};
|
||||
};
|
||||
|
||||
export const getUserManagementAccess = (
|
||||
role: TOrganizationRole,
|
||||
minimumRole: "owner" | "manager" | "disabled"
|
||||
): boolean => {
|
||||
// If minimum role is "disabled", no one has access
|
||||
if (minimumRole === "disabled") {
|
||||
return false;
|
||||
}
|
||||
if (minimumRole === "owner") {
|
||||
return role === "owner";
|
||||
}
|
||||
|
||||
if (minimumRole === "manager") {
|
||||
return role === "owner" || role === "manager";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,43 @@ export const calculateTtcTotal = (ttc: TResponseTtc) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const createFilterTags = (tags: TResponseFilterCriteria["tags"]) => {
|
||||
if (!tags) return [];
|
||||
|
||||
const filterTags: Record<string, any>[] = [];
|
||||
|
||||
if (tags?.applied) {
|
||||
const appliedTags = tags.applied.map((name) => ({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
filterTags.push(appliedTags);
|
||||
}
|
||||
|
||||
if (tags?.notApplied) {
|
||||
const notAppliedTags = {
|
||||
tags: {
|
||||
every: {
|
||||
tag: {
|
||||
name: {
|
||||
notIn: tags.notApplied,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
filterTags.push(notAppliedTags);
|
||||
}
|
||||
|
||||
return filterTags.flat();
|
||||
};
|
||||
|
||||
export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilterCriteria) => {
|
||||
const whereClause: Prisma.ResponseWhereInput["AND"] = [];
|
||||
|
||||
@@ -49,39 +86,9 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
|
||||
// For Tags
|
||||
if (filterCriteria?.tags) {
|
||||
const tags: Record<string, any>[] = [];
|
||||
|
||||
if (filterCriteria?.tags?.applied) {
|
||||
const appliedTags = filterCriteria.tags.applied.map((name) => ({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
tags.push(appliedTags);
|
||||
}
|
||||
|
||||
if (filterCriteria?.tags?.notApplied) {
|
||||
const notAppliedTags = {
|
||||
tags: {
|
||||
every: {
|
||||
tag: {
|
||||
name: {
|
||||
notIn: filterCriteria.tags.notApplied,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
tags.push(notAppliedTags);
|
||||
}
|
||||
|
||||
const tagFilters = createFilterTags(filterCriteria.tags);
|
||||
whereClause.push({
|
||||
AND: tags.flat(),
|
||||
AND: tagFilters,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,6 +449,13 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
AND: data,
|
||||
});
|
||||
}
|
||||
|
||||
// filter by explicit response IDs
|
||||
if (filterCriteria?.responseIds) {
|
||||
whereClause.push({
|
||||
id: { in: filterCriteria.responseIds },
|
||||
});
|
||||
}
|
||||
return { AND: whereClause };
|
||||
};
|
||||
|
||||
|
||||
@@ -15,12 +15,20 @@ import {
|
||||
describe("Time Utilities", () => {
|
||||
describe("convertDateString", () => {
|
||||
test("should format date string correctly", () => {
|
||||
expect(convertDateString("2024-03-20")).toBe("Mar 20, 2024");
|
||||
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateString("")).toBe("");
|
||||
});
|
||||
|
||||
test("should return null for null input", () => {
|
||||
expect(convertDateString(null as any)).toBe(null);
|
||||
});
|
||||
|
||||
test("should handle invalid date strings", () => {
|
||||
expect(convertDateString("not-a-date")).toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDateTimeString", () => {
|
||||
@@ -73,7 +81,7 @@ describe("Time Utilities", () => {
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("should format date correctly", () => {
|
||||
const date = new Date("2024-03-20");
|
||||
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
|
||||
expect(formatDate(date)).toBe("March 20, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,16 @@ import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, fr, pt, ptBR, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string) => {
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
if (dateString === null) return null;
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -9,18 +10,22 @@ import {
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
|
||||
export const actionClient = createSafeActionClient({
|
||||
handleServerError(e) {
|
||||
Sentry.captureException(e);
|
||||
|
||||
if (
|
||||
e instanceof ResourceNotFoundError ||
|
||||
e instanceof AuthorizationError ||
|
||||
e instanceof InvalidInputError ||
|
||||
e instanceof UnknownError ||
|
||||
e instanceof AuthenticationError ||
|
||||
e instanceof OperationNotAllowedError
|
||||
e instanceof OperationNotAllowedError ||
|
||||
e instanceof TooManyRequestsError
|
||||
) {
|
||||
return e.message;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export const useIntervalWhenFocused = (
|
||||
callback: () => void,
|
||||
@@ -8,7 +8,7 @@ export const useIntervalWhenFocused = (
|
||||
) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleFocus = () => {
|
||||
const handleFocus = useCallback(() => {
|
||||
if (isActive) {
|
||||
if (shouldExecuteImmediately) {
|
||||
// Execute the callback immediately when the tab comes into focus
|
||||
@@ -20,7 +20,7 @@ export const useIntervalWhenFocused = (
|
||||
callback();
|
||||
}, intervalDuration);
|
||||
}
|
||||
};
|
||||
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
|
||||
|
||||
const handleBlur = () => {
|
||||
// Clear the interval when the tab loses focus
|
||||
@@ -46,7 +46,7 @@ export const useIntervalWhenFocused = (
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [isActive, intervalDuration]);
|
||||
}, [isActive, intervalDuration, handleFocus]);
|
||||
};
|
||||
|
||||
export default useIntervalWhenFocused;
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
"continue_with_oidc": "Weiter mit {oidcDisplayName}",
|
||||
"continue_with_openid": "Login mit OpenID",
|
||||
"continue_with_saml": "Login mit SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst",
|
||||
"email_already_exists": "Diese E-Mail wird bereits verwendet",
|
||||
"email_change_success": "E-Mail erfolgreich geändert",
|
||||
"email_change_success_description": "Du hast deine E-Mail-Adresse erfolgreich geändert. Bitte logge dich mit deiner neuen E-Mail-Adresse ein.",
|
||||
"email_verification_failed": "E-Mail-Bestätigung fehlgeschlagen",
|
||||
"invalid_or_expired_token": "E-Mail-Änderung fehlgeschlagen. Dein Token ist ungültig oder abgelaufen.",
|
||||
"new_email": "Neue E-Mail",
|
||||
"old_email": "Alte E-Mail"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Zurück zum Login",
|
||||
"email-sent": {
|
||||
@@ -82,7 +92,7 @@
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.",
|
||||
"please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse",
|
||||
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
|
||||
"verification_email_successfully_sent": "Bestätigungs-E-Mail erfolgreich gesendet. Bitte überprüfe dein Postfach.",
|
||||
"verification_email_successfully_sent": "Bestätigungs-E-Mail an {email} gesendet. Bitte überprüfen Sie, um das Update abzuschließen.",
|
||||
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
|
||||
},
|
||||
@@ -451,6 +461,7 @@
|
||||
"live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten",
|
||||
"live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen",
|
||||
"live_survey_notification_view_response": "Antwort anzeigen",
|
||||
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
|
||||
"notification_footer_all_the_best": "Alles Gute,",
|
||||
"notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "Bitte ausstellen",
|
||||
@@ -500,6 +511,8 @@
|
||||
"verification_email_thanks": "Danke, dass Du deine E-Mail bestätigt hast!",
|
||||
"verification_email_to_fill_survey": "Um die Umfrage auszufüllen, klicke bitte auf den untenstehenden Button:",
|
||||
"verification_email_verify_email": "E-Mail bestätigen",
|
||||
"verification_new_email_subject": "E-Mail-Änderungsbestätigung",
|
||||
"verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.",
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",
|
||||
@@ -1667,6 +1680,7 @@
|
||||
"device": "Gerät",
|
||||
"device_info": "Geräteinfo",
|
||||
"email": "E-Mail",
|
||||
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
||||
"first_name": "Vorname",
|
||||
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
||||
"last_name": "Nachname",
|
||||
@@ -1764,6 +1778,8 @@
|
||||
"quickstart_web_apps": "Schnellstart: Web-Apps",
|
||||
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"send_preview": "Vorschau senden",
|
||||
"send_to_panel": "An das Panel senden",
|
||||
"setup_instructions": "Einrichtung",
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
"continue_with_oidc": "Continue with {oidcDisplayName}",
|
||||
"continue_with_openid": "Continue with OpenID",
|
||||
"continue_with_saml": "Continue with SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Please confirm your password before changing your email address",
|
||||
"email_already_exists": "This email is already in use",
|
||||
"email_change_success": "Email changed successfully",
|
||||
"email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.",
|
||||
"email_verification_failed": "Email verification failed",
|
||||
"invalid_or_expired_token": "Email change failed. Your token is invalid or expired.",
|
||||
"new_email": "New Email",
|
||||
"old_email": "Old Email"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Back to login",
|
||||
"email-sent": {
|
||||
@@ -82,7 +92,7 @@
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.",
|
||||
"please_confirm_your_email_address": "Please confirm your email address",
|
||||
"resend_verification_email": "Resend verification email",
|
||||
"verification_email_successfully_sent": "Verification email successfully sent. Please check your inbox.",
|
||||
"verification_email_successfully_sent": "Verification email sent to {email}. Please verify to complete the update.",
|
||||
"we_sent_an_email_to": "We sent an email to {email}. ",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
|
||||
},
|
||||
@@ -451,6 +461,7 @@
|
||||
"live_survey_notification_view_more_responses": "View {responseCount} more Responses",
|
||||
"live_survey_notification_view_previous_responses": "View previous responses",
|
||||
"live_survey_notification_view_response": "View Response",
|
||||
"new_email_verification_text": "To verify your new email address, please click the button below:",
|
||||
"notification_footer_all_the_best": "All the best,",
|
||||
"notification_footer_in_your_settings": "in your settings \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "please turn them off",
|
||||
@@ -500,6 +511,8 @@
|
||||
"verification_email_thanks": "Thanks for validating your email!",
|
||||
"verification_email_to_fill_survey": "To fill out the survey please click on the button below:",
|
||||
"verification_email_verify_email": "Verify email",
|
||||
"verification_new_email_subject": "Email change verification",
|
||||
"verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.",
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",
|
||||
@@ -1667,6 +1680,7 @@
|
||||
"device": "Device",
|
||||
"device_info": "Device info",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "An error occured while downloading responses",
|
||||
"first_name": "First Name",
|
||||
"how_to_identify_users": "How to identify users",
|
||||
"last_name": "Last Name",
|
||||
@@ -1764,6 +1778,8 @@
|
||||
"quickstart_web_apps": "Quickstart: Web apps",
|
||||
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
|
||||
"results_are_public": "Results are public",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"send_preview": "Send preview",
|
||||
"send_to_panel": "Send to panel",
|
||||
"setup_instructions": "Setup instructions",
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
"continue_with_oidc": "Continuer avec {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuer avec OpenID",
|
||||
"continue_with_saml": "Continuer avec SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail",
|
||||
"email_already_exists": "Cet e-mail est déjà utilisé",
|
||||
"email_change_success": "E-mail changé avec succès",
|
||||
"email_change_success_description": "Vous avez changé votre adresse e-mail avec succès. Veuillez vous connecter avec votre nouvelle adresse e-mail.",
|
||||
"email_verification_failed": "Échec de la vérification de l'email",
|
||||
"invalid_or_expired_token": "Échec du changement d'email. Votre jeton est invalide ou expiré.",
|
||||
"new_email": "Nouvel Email",
|
||||
"old_email": "Ancien Email"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Retour à la connexion",
|
||||
"email-sent": {
|
||||
@@ -82,7 +92,7 @@
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.",
|
||||
"please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.",
|
||||
"resend_verification_email": "Renvoyer l'email de vérification",
|
||||
"verification_email_successfully_sent": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
|
||||
"verification_email_successfully_sent": "Email de vérification envoyé à {email}. Veuillez vérifier pour compléter la mise à jour.",
|
||||
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
|
||||
},
|
||||
@@ -451,6 +461,7 @@
|
||||
"live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires",
|
||||
"live_survey_notification_view_previous_responses": "Voir les réponses précédentes",
|
||||
"live_survey_notification_view_response": "Voir la réponse",
|
||||
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
|
||||
"notification_footer_all_the_best": "Tous mes vœux,",
|
||||
"notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "veuillez les éteindre",
|
||||
@@ -500,6 +511,8 @@
|
||||
"verification_email_thanks": "Merci de valider votre email !",
|
||||
"verification_email_to_fill_survey": "Pour remplir le questionnaire, veuillez cliquer sur le bouton ci-dessous :",
|
||||
"verification_email_verify_email": "Vérifier l'email",
|
||||
"verification_new_email_subject": "Vérification du changement d'email",
|
||||
"verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.",
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",
|
||||
@@ -1667,6 +1680,7 @@
|
||||
"device": "Dispositif",
|
||||
"device_info": "Informations sur l'appareil",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
||||
"first_name": "Prénom",
|
||||
"how_to_identify_users": "Comment identifier les utilisateurs",
|
||||
"last_name": "Nom de famille",
|
||||
@@ -1764,6 +1778,8 @@
|
||||
"quickstart_web_apps": "Démarrage rapide : Applications web",
|
||||
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
|
||||
"results_are_public": "Les résultats sont publics.",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
"send_preview": "Envoyer un aperçu",
|
||||
"send_to_panel": "Envoyer au panneau",
|
||||
"setup_instructions": "Instructions d'installation",
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
"continue_with_oidc": "Continuar com {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuar com OpenID",
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail",
|
||||
"email_already_exists": "Este e-mail já está em uso",
|
||||
"email_change_success": "E-mail alterado com sucesso",
|
||||
"email_change_success_description": "Você alterou seu endereço de e-mail com sucesso. Por favor, faça login com seu novo endereço de e-mail.",
|
||||
"email_verification_failed": "Falha na verificação do e-mail",
|
||||
"invalid_or_expired_token": "Falha na alteração do e-mail. Seu token é inválido ou expirou.",
|
||||
"new_email": "Novo Email",
|
||||
"old_email": "Email Antigo"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Voltar para o login",
|
||||
"email-sent": {
|
||||
@@ -82,7 +92,7 @@
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail",
|
||||
"resend_verification_email": "Reenviar e-mail de verificação",
|
||||
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique sua caixa de entrada.",
|
||||
"verification_email_successfully_sent": "E-mail de verificação enviado para {email}. Verifique para concluir a atualização.",
|
||||
"we_sent_an_email_to": "Enviamos um email para {email}",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
|
||||
},
|
||||
@@ -451,6 +461,7 @@
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desliga eles",
|
||||
@@ -500,6 +511,8 @@
|
||||
"verification_email_thanks": "Valeu por validar seu e-mail!",
|
||||
"verification_email_to_fill_survey": "Para preencher a pesquisa, por favor clique no botão abaixo:",
|
||||
"verification_email_verify_email": "Verificar e-mail",
|
||||
"verification_new_email_subject": "Verificação de alteração de e-mail",
|
||||
"verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",
|
||||
@@ -1667,6 +1680,7 @@
|
||||
"device": "dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "Ocorreu um erro ao baixar respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar usuários",
|
||||
"last_name": "Sobrenome",
|
||||
@@ -1764,6 +1778,8 @@
|
||||
"quickstart_web_apps": "Início rápido: Aplicativos web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar prévia",
|
||||
"send_to_panel": "Enviar para o painel",
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
"continue_with_oidc": "Continuar com {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuar com OpenID",
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email",
|
||||
"email_already_exists": "Este email já está a ser utilizado",
|
||||
"email_change_success": "Email alterado com sucesso",
|
||||
"email_change_success_description": "Alterou com sucesso o seu endereço de email. Por favor, inicie sessão com o seu novo endereço de email.",
|
||||
"email_verification_failed": "Falha na verificação do email",
|
||||
"invalid_or_expired_token": "Falha na alteração do email. O seu token é inválido ou expirou.",
|
||||
"new_email": "Novo Email",
|
||||
"old_email": "Email Antigo"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Voltar ao login",
|
||||
"email-sent": {
|
||||
@@ -82,7 +92,7 @@
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme o seu endereço de email",
|
||||
"resend_verification_email": "Reenviar email de verificação",
|
||||
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique a sua caixa de entrada.",
|
||||
"verification_email_successfully_sent": "Email de verificação enviado para {email}. Por favor, verifique para completar a atualização.",
|
||||
"we_sent_an_email_to": "Enviámos um email para {email}. ",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
|
||||
},
|
||||
@@ -451,6 +461,7 @@
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desative-os",
|
||||
@@ -500,6 +511,8 @@
|
||||
"verification_email_thanks": "Obrigado por validar o seu email!",
|
||||
"verification_email_to_fill_survey": "Para preencher o questionário, clique no botão abaixo:",
|
||||
"verification_email_verify_email": "Verificar email",
|
||||
"verification_new_email_subject": "Verificação de alteração de email",
|
||||
"verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:",
|
||||
@@ -1667,6 +1680,7 @@
|
||||
"device": "Dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "Ocorreu um erro ao transferir respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar utilizadores",
|
||||
"last_name": "Apelido",
|
||||
@@ -1764,6 +1778,8 @@
|
||||
"quickstart_web_apps": "Início rápido: Aplicações web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar pré-visualização",
|
||||
"send_to_panel": "Enviar para painel",
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
"continue_with_oidc": "使用 '{'oidcDisplayName'}' 繼續",
|
||||
"continue_with_openid": "使用 OpenID 繼續",
|
||||
"continue_with_saml": "使用 SAML SSO 繼續",
|
||||
"email-change": {
|
||||
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
|
||||
"email_already_exists": "此電子郵件地址已被使用",
|
||||
"email_change_success": "電子郵件已成功更改",
|
||||
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
|
||||
"email_verification_failed": "電子郵件驗證失敗",
|
||||
"invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。",
|
||||
"new_email": "新 電子郵件",
|
||||
"old_email": "舊 電子郵件"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "返回登入",
|
||||
"email-sent": {
|
||||
@@ -82,7 +92,7 @@
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
|
||||
"please_confirm_your_email_address": "請確認您的電子郵件地址",
|
||||
"resend_verification_email": "重新發送驗證電子郵件",
|
||||
"verification_email_successfully_sent": "驗證電子郵件已成功發送。請檢查您的收件匣。",
|
||||
"verification_email_successfully_sent": "验证电子邮件已发送至 {email}。请验证以完成更新。",
|
||||
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
|
||||
},
|
||||
@@ -451,6 +461,7 @@
|
||||
"live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
|
||||
"live_survey_notification_view_previous_responses": "檢視先前的回應",
|
||||
"live_survey_notification_view_response": "檢視回應",
|
||||
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
|
||||
"notification_footer_all_the_best": "祝您一切順利,",
|
||||
"notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "請關閉它們",
|
||||
@@ -500,6 +511,8 @@
|
||||
"verification_email_thanks": "感謝您驗證您的電子郵件!",
|
||||
"verification_email_to_fill_survey": "若要填寫問卷,請點擊下方的按鈕:",
|
||||
"verification_email_verify_email": "驗證電子郵件",
|
||||
"verification_new_email_subject": "電子郵件更改驗證",
|
||||
"verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。",
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:",
|
||||
@@ -1667,6 +1680,7 @@
|
||||
"device": "裝置",
|
||||
"device_info": "裝置資訊",
|
||||
"email": "電子郵件",
|
||||
"error_downloading_responses": "下載回應時發生錯誤",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何識別使用者",
|
||||
"last_name": "姓氏",
|
||||
@@ -1764,6 +1778,8 @@
|
||||
"quickstart_web_apps": "快速入門:Web apps",
|
||||
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
|
||||
"results_are_public": "結果是公開的",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"send_preview": "發送預覽",
|
||||
"send_to_panel": "發送到小組",
|
||||
"setup_instructions": "設定說明",
|
||||
|
||||
@@ -12,13 +12,12 @@ import {
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
|
||||
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
|
||||
import { isValidCallbackUrl } from "@/lib/utils/url";
|
||||
import { logApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -28,24 +27,6 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const enforceHttps = (request: NextRequest): Response | null => {
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{
|
||||
field: "",
|
||||
issue: "Only HTTPS connections are allowed on the management endpoints.",
|
||||
},
|
||||
],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
const token = await getToken({ req: request as any });
|
||||
|
||||
@@ -132,12 +113,6 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Enforce HTTPS for management endpoints
|
||||
if (isManagementApiRoute(request.nextUrl.pathname)) {
|
||||
const httpsResponse = enforceHttps(request);
|
||||
if (httpsResponse) return httpsResponse;
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
const authResponse = await handleAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
@@ -189,4 +189,30 @@ describe("ResponseNotes", () => {
|
||||
expect(updateFetchedResponses).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("pressing Enter in textarea only submits form and doesn't trigger parent button onClick", async () => {
|
||||
vi.mocked(createResponseNoteAction).mockResolvedValueOnce("createdNote" as any);
|
||||
render(
|
||||
<ResponseNotes
|
||||
user={dummyUser}
|
||||
responseId={dummyResponseId}
|
||||
notes={[]}
|
||||
isOpen={true}
|
||||
setIsOpen={setIsOpen}
|
||||
updateFetchedResponses={updateFetchedResponses}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await userEvent.type(textarea, "New note");
|
||||
await userEvent.type(textarea, "{enter}");
|
||||
await waitFor(() => {
|
||||
expect(createResponseNoteAction).toHaveBeenCalledWith({
|
||||
responseId: dummyResponseId,
|
||||
text: "New note",
|
||||
});
|
||||
expect(updateFetchedResponses).toHaveBeenCalled();
|
||||
expect(setIsOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,49 +98,56 @@ export const ResponseNotes = ({
|
||||
const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
|
||||
isOpen
|
||||
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
: unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isOpen) setIsOpen(true);
|
||||
}}>
|
||||
<>
|
||||
{!isOpen ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">{t("common.note")}</h3>
|
||||
<button
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
unresolvedNotes.length
|
||||
? "group/hint cursor-pointer bg-white hover:-right-3"
|
||||
: "cursor-pointer bg-slate-50",
|
||||
unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label="Open notes"
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
style={{ outline: "none" }}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">{t("common.note")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-slate-400" />
|
||||
</span>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-slate-400" />
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
"-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
)}>
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="group flex items-center">
|
||||
@@ -254,6 +261,6 @@ export const ResponseNotes = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,14 +135,11 @@ export const getResponses = async (
|
||||
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const query = getResponsesQuery(environmentIds, params);
|
||||
const whereClause = query.where;
|
||||
|
||||
const [responses, count] = await prisma.$transaction([
|
||||
prisma.response.findMany({
|
||||
...query,
|
||||
}),
|
||||
prisma.response.count({
|
||||
where: query.where,
|
||||
}),
|
||||
const [responses, totalCount] = await Promise.all([
|
||||
prisma.response.findMany(query),
|
||||
prisma.response.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
if (!responses) {
|
||||
@@ -152,7 +149,7 @@ export const getResponses = async (
|
||||
return ok({
|
||||
data: responses,
|
||||
meta: {
|
||||
total: count,
|
||||
total: totalCount,
|
||||
limit: params.limit,
|
||||
offset: params.skip,
|
||||
},
|
||||
|
||||
@@ -214,17 +214,18 @@ describe("Response Lib", () => {
|
||||
|
||||
describe("getResponses", () => {
|
||||
test("return responses with meta information", async () => {
|
||||
const responses = [response];
|
||||
prisma.$transaction = vi.fn().mockResolvedValue([responses, responses.length]);
|
||||
(prisma.response.findMany as any).mockResolvedValue([response]);
|
||||
(prisma.response.count as any).mockResolvedValue(1);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(prisma.response.findMany).toHaveBeenCalled();
|
||||
expect(prisma.response.count).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [response],
|
||||
meta: {
|
||||
total: responses.length,
|
||||
total: 1,
|
||||
limit: responseFilter.limit,
|
||||
offset: responseFilter.skip,
|
||||
},
|
||||
@@ -233,9 +234,10 @@ describe("Response Lib", () => {
|
||||
});
|
||||
|
||||
test("return a not_found error if responses are not found", async () => {
|
||||
prisma.$transaction = vi.fn().mockResolvedValue([null, 0]);
|
||||
(prisma.response.findMany as any).mockResolvedValue(null);
|
||||
(prisma.response.count as any).mockResolvedValue(0);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
@@ -245,10 +247,25 @@ describe("Response Lib", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error error if prisma transaction fails", async () => {
|
||||
prisma.$transaction = vi.fn().mockRejectedValue(new Error("Internal server error"));
|
||||
test("return an internal_server_error error if prisma findMany fails", async () => {
|
||||
(prisma.response.findMany as any).mockRejectedValue(new Error("Internal server error"));
|
||||
(prisma.response.count as any).mockResolvedValue(0);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "responses", issue: "Internal server error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error error if prisma count fails", async () => {
|
||||
(prisma.response.findMany as any).mockResolvedValue([response]);
|
||||
(prisma.response.count as any).mockRejectedValue(new Error("Internal server error"));
|
||||
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { EmailChangeWithoutVerificationSuccessPage } from "./page";
|
||||
|
||||
// Mock the necessary dependencies
|
||||
vi.mock("@/modules/auth/components/back-to-login-button", () => ({
|
||||
BackToLoginButton: () => <div data-testid="back-to-login">Back to Login</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/form-wrapper", () => ({
|
||||
FormWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="form-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
|
||||
describe("EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders success page with correct translations when user is not logged in", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const page = await EmailChangeWithoutVerificationSuccessPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("back-to-login")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("redirects to home page when user is logged in", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "123", email: "test@example.com" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await EmailChangeWithoutVerificationSuccessPage();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button";
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import type { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const EmailChangeWithoutVerificationSuccessPage = async () => {
|
||||
const t = await getTranslate();
|
||||
const session: Session | null = await getServerSession(authOptions);
|
||||
|
||||
if (session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.email-change.email_change_success")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">{t("auth.email-change.email_change_success_description")}</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
FB_LOGO_URL: "https://formbricks.com/logo.png",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: "587",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { EMAIL_VERIFICATION_DISABLED, ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY } from "@/lib/constants";
|
||||
import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
SESSION_MAX_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
@@ -178,7 +183,7 @@ export const authOptions: NextAuthOptions = {
|
||||
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
|
||||
],
|
||||
session: {
|
||||
maxAge: 3600,
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token }) {
|
||||
|
||||
@@ -5,15 +5,17 @@ import { getTranslate } from "@/tolgee/server";
|
||||
export const SignupWithoutVerificationSuccessPage = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created_description")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created_description")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { resendVerificationEmailAction } from "../actions";
|
||||
import { RequestVerificationEmail } from "./request-verification-email";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string, params?: { email?: string }) => {
|
||||
if (key === "auth.verification-requested.no_email_provided") {
|
||||
return "No email provided";
|
||||
}
|
||||
if (key === "auth.verification-requested.verification_email_successfully_sent") {
|
||||
return `Verification email sent to ${params?.email}`;
|
||||
}
|
||||
if (key === "auth.verification-requested.resend_verification_email") {
|
||||
return "Resend verification email";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
resendVerificationEmailAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("RequestVerificationEmail", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders resend verification email button", () => {
|
||||
render(<RequestVerificationEmail email="test@example.com" />);
|
||||
expect(screen.getByText("Resend verification email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error toast when no email is provided", async () => {
|
||||
render(<RequestVerificationEmail email={null} />);
|
||||
const button = screen.getByText("Resend verification email");
|
||||
await fireEvent.click(button);
|
||||
expect(toast.error).toHaveBeenCalledWith("No email provided");
|
||||
});
|
||||
|
||||
test("shows success toast when verification email is sent successfully", async () => {
|
||||
const mockEmail = "test@example.com";
|
||||
vi.mocked(resendVerificationEmailAction).mockResolvedValueOnce({ data: true });
|
||||
|
||||
render(<RequestVerificationEmail email={mockEmail} />);
|
||||
const button = screen.getByText("Resend verification email");
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(resendVerificationEmailAction).toHaveBeenCalledWith({ email: mockEmail });
|
||||
expect(toast.success).toHaveBeenCalledWith(`Verification email sent to ${mockEmail}`);
|
||||
});
|
||||
|
||||
test("reloads page when visibility changes to visible", () => {
|
||||
const mockReload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { reload: mockReload },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(<RequestVerificationEmail email="test@example.com" />);
|
||||
|
||||
// Simulate visibility change
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
|
||||
expect(mockReload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export const RequestVerificationEmail = ({ email }: RequestVerificationEmailProp
|
||||
if (!email) return toast.error(t("auth.verification-requested.no_email_provided"));
|
||||
const response = await resendVerificationEmailAction({ email });
|
||||
if (response?.data) {
|
||||
toast.success(t("auth.verification-requested.verification_email_successfully_sent"));
|
||||
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
|
||||
21
apps/web/modules/auth/verify-email-change/actions.ts
Normal file
21
apps/web/modules/auth/verify-email-change/actions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { verifyEmailChangeToken } from "@/lib/jwt";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { updateUser } from "@/modules/auth/lib/user";
|
||||
import { z } from "zod";
|
||||
|
||||
export const verifyEmailChangeAction = actionClient
|
||||
.schema(z.object({ token: z.string() }))
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { id, email } = await verifyEmailChangeToken(parsedInput.token);
|
||||
|
||||
if (!email) {
|
||||
throw new Error("Email not found in token");
|
||||
}
|
||||
const user = await updateUser(id, { email, emailVerified: new Date() });
|
||||
if (!user) {
|
||||
throw new Error("User not found or email update failed");
|
||||
}
|
||||
return user;
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { EmailChangeSignIn } from "./email-change-sign-in";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/verify-email-change/actions", () => ({
|
||||
verifyEmailChangeAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("EmailChangeSignIn", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("shows loading state initially", () => {
|
||||
render(<EmailChangeSignIn token="valid-token" />);
|
||||
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles successful email change verification", async () => {
|
||||
vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({
|
||||
data: { id: "123", email: "test@example.com", emailVerified: new Date(), locale: "en-US" },
|
||||
});
|
||||
|
||||
render(<EmailChangeSignIn token="valid-token" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false });
|
||||
});
|
||||
|
||||
test("handles failed email change verification", async () => {
|
||||
vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({ serverError: "Error" });
|
||||
|
||||
render(<EmailChangeSignIn token="invalid-token" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(signOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles empty token", () => {
|
||||
render(<EmailChangeSignIn token="" />);
|
||||
|
||||
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const EmailChangeSignIn = ({ token }: { token: string }) => {
|
||||
const { t } = useTranslate();
|
||||
const [status, setStatus] = useState<"success" | "error" | "loading">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
const validateToken = async () => {
|
||||
if (typeof token === "string" && token.trim() !== "") {
|
||||
const result = await verifyEmailChangeAction({ token });
|
||||
|
||||
if (!result?.data) {
|
||||
setStatus("error");
|
||||
} else {
|
||||
setStatus("success");
|
||||
}
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
if (token) {
|
||||
validateToken();
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "success") {
|
||||
signOut({ redirect: false });
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className={`leading-2 mb-4 text-center font-bold ${status === "error" ? "text-red-600" : ""}`}>
|
||||
{status === "success"
|
||||
? t("auth.email-change.email_change_success")
|
||||
: t("auth.email-change.email_verification_failed")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{status === "success"
|
||||
? t("auth.email-change.email_change_success_description")
|
||||
: t("auth.email-change.invalid_or_expired_token")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
47
apps/web/modules/auth/verify-email-change/page.test.tsx
Normal file
47
apps/web/modules/auth/verify-email-change/page.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { VerifyEmailChangePage } from "./page";
|
||||
|
||||
// Mock the necessary dependencies
|
||||
vi.mock("@/modules/auth/components/back-to-login-button", () => ({
|
||||
BackToLoginButton: () => <div data-testid="back-to-login">Back to Login</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/form-wrapper", () => ({
|
||||
FormWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="form-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/verify-email-change/components/email-change-sign-in", () => ({
|
||||
EmailChangeSignIn: ({ token }: { token: string }) => (
|
||||
<div data-testid="email-change-sign-in">Email Change Sign In with token: {token}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("VerifyEmailChangePage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the page with form wrapper and components", async () => {
|
||||
const searchParams = { token: "test-token" };
|
||||
render(await VerifyEmailChangePage({ searchParams }));
|
||||
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("back-to-login")).toBeInTheDocument();
|
||||
expect(screen.getByText("Email Change Sign In with token: test-token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles missing token", async () => {
|
||||
const searchParams = {};
|
||||
render(await VerifyEmailChangePage({ searchParams }));
|
||||
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("back-to-login")).toBeInTheDocument();
|
||||
expect(screen.getByText("Email Change Sign In with token:")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
16
apps/web/modules/auth/verify-email-change/page.tsx
Normal file
16
apps/web/modules/auth/verify-email-change/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button";
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { EmailChangeSignIn } from "@/modules/auth/verify-email-change/components/email-change-sign-in";
|
||||
|
||||
export const VerifyEmailChangePage = async ({ searchParams }) => {
|
||||
const { token } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
|
||||
<FormWrapper>
|
||||
<EmailChangeSignIn token={token} />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
174
apps/web/modules/ee/billing/components/pricing-table.test.tsx
Normal file
174
apps/web/modules/ee/billing/components/pricing-table.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { PricingTable } from "./pricing-table";
|
||||
|
||||
// Mock the env module
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
IS_FORMBRICKS_CLOUD: "0",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the useRouter hook
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the actions module
|
||||
vi.mock("@/modules/ee/billing/actions", () => {
|
||||
const mockDate = new Date("2024-03-15T00:00:00.000Z");
|
||||
return {
|
||||
isSubscriptionCancelledAction: vi.fn(() => Promise.resolve({ data: { date: mockDate } })),
|
||||
manageSubscriptionAction: vi.fn(() => Promise.resolve({ data: null })),
|
||||
upgradePlanAction: vi.fn(() => Promise.resolve({ data: null })),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PricingTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should display a 'Cancelling' badge with the correct date if the subscription is being cancelled", async () => {
|
||||
const mockOrganization = {
|
||||
id: "org-123",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: "free",
|
||||
period: "yearly",
|
||||
periodStart: new Date(),
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: 100,
|
||||
},
|
||||
projects: 1,
|
||||
},
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockStripePriceLookupKeys = {
|
||||
STARTUP_MONTHLY: "startup_monthly",
|
||||
STARTUP_YEARLY: "startup_yearly",
|
||||
SCALE_MONTHLY: "scale_monthly",
|
||||
SCALE_YEARLY: "scale_yearly",
|
||||
};
|
||||
|
||||
const mockProjectFeatureKeys = {
|
||||
FREE: "free",
|
||||
STARTUP: "startup",
|
||||
SCALE: "scale",
|
||||
ENTERPRISE: "enterprise",
|
||||
};
|
||||
|
||||
render(
|
||||
<PricingTable
|
||||
organization={mockOrganization as any}
|
||||
environmentId="env-123"
|
||||
peopleCount={50}
|
||||
responseCount={75}
|
||||
projectCount={1}
|
||||
stripePriceLookupKeys={mockStripePriceLookupKeys}
|
||||
projectFeatureKeys={mockProjectFeatureKeys}
|
||||
hasBillingRights={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const expectedDate = new Date("2024-03-15T00:00:00.000Z").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const cancellingBadge = await screen.findByText(`Cancelling: ${expectedDate}`);
|
||||
expect(cancellingBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("billing period toggle buttons have correct aria-pressed attributes", async () => {
|
||||
const MockPricingTable = () => {
|
||||
const [planPeriod, setPlanPeriod] = useState<TOrganizationBillingPeriod>("yearly");
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org-123",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: "free",
|
||||
period: "yearly",
|
||||
periodStart: new Date(),
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: 100,
|
||||
},
|
||||
projects: 1,
|
||||
},
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockStripePriceLookupKeys = {
|
||||
STARTUP_MONTHLY: "startup_monthly",
|
||||
STARTUP_YEARLY: "startup_yearly",
|
||||
SCALE_MONTHLY: "scale_monthly",
|
||||
SCALE_YEARLY: "scale_yearly",
|
||||
};
|
||||
|
||||
const mockProjectFeatureKeys = {
|
||||
FREE: "free",
|
||||
STARTUP: "startup",
|
||||
SCALE: "scale",
|
||||
ENTERPRISE: "enterprise",
|
||||
};
|
||||
|
||||
const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => {
|
||||
setPlanPeriod(period);
|
||||
};
|
||||
|
||||
return (
|
||||
<PricingTable
|
||||
organization={mockOrganization as any}
|
||||
environmentId="env-123"
|
||||
peopleCount={50}
|
||||
responseCount={75}
|
||||
projectCount={1}
|
||||
stripePriceLookupKeys={mockStripePriceLookupKeys}
|
||||
projectFeatureKeys={mockProjectFeatureKeys}
|
||||
hasBillingRights={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<MockPricingTable />);
|
||||
|
||||
const monthlyButton = screen.getByText("environments.settings.billing.monthly");
|
||||
const yearlyButton = screen.getByText("environments.settings.billing.annually");
|
||||
|
||||
expect(yearlyButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(monthlyButton).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(monthlyButton);
|
||||
|
||||
expect(yearlyButton).toHaveAttribute("aria-pressed", "false");
|
||||
expect(monthlyButton).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
@@ -154,7 +154,17 @@ export const PricingTable = ({
|
||||
className="mx-2"
|
||||
size="normal"
|
||||
type="warning"
|
||||
text={`Cancelling: ${cancellingOn ? cancellingOn.toDateString() : ""}`}
|
||||
text={`Cancelling: ${
|
||||
cancellingOn
|
||||
? cancellingOn.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
@@ -252,14 +262,16 @@ export const PricingTable = ({
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<div
|
||||
<button
|
||||
aria-pressed={planPeriod === "monthly"}
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</div>
|
||||
<div
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={planPeriod === "yearly"}
|
||||
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
@@ -268,7 +280,7 @@ export const PricingTable = ({
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")} 🔥
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div
|
||||
|
||||
@@ -240,4 +240,126 @@ describe("updateUser", () => {
|
||||
expect(result.state.data).toEqual(expect.objectContaining(mockUserState));
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle email attribute update with ignoreEmailAttribute flag", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact);
|
||||
const newAttributes = { email: "new@example.com", name: "John Doe" };
|
||||
vi.mocked(updateAttributes).mockResolvedValue({
|
||||
success: true,
|
||||
messages: [],
|
||||
ignoreEmailAttribute: true,
|
||||
});
|
||||
|
||||
vi.mocked(getUserState).mockResolvedValue({
|
||||
...mockUserState,
|
||||
});
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
// Email should not be included in the final attributes
|
||||
expect(result.state.data).toEqual(
|
||||
expect.objectContaining({
|
||||
...mockUserState,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle failed attribute update gracefully", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact);
|
||||
const newAttributes = { company: "Formbricks" };
|
||||
vi.mocked(updateAttributes).mockResolvedValue({
|
||||
success: false,
|
||||
messages: ["Update failed"],
|
||||
});
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
// Should still return state even if update failed
|
||||
expect(result.state.data).toEqual(expect.objectContaining(mockUserState));
|
||||
expect(result.messages).toEqual(["Update failed"]);
|
||||
});
|
||||
|
||||
test("should handle multiple attribute updates correctly", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact);
|
||||
const newAttributes = {
|
||||
company: "Formbricks",
|
||||
role: "Developer",
|
||||
language: "en",
|
||||
country: "US",
|
||||
};
|
||||
vi.mocked(updateAttributes).mockResolvedValue({
|
||||
success: true,
|
||||
messages: ["Attributes updated successfully"],
|
||||
});
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
expect(result.state.data?.language).toBe("en");
|
||||
expect(result.messages).toEqual(["Attributes updated successfully"]);
|
||||
});
|
||||
|
||||
test("should handle contact creation with multiple initial attributes", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(null);
|
||||
const initialAttributes = {
|
||||
userId: mockUserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "userId" }, value: mockUserId },
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
],
|
||||
} as any);
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", initialAttributes);
|
||||
|
||||
expect(prisma.contact.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
attributes: {
|
||||
create: [
|
||||
{
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key: "userId", environmentId: mockEnvironmentId } },
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
id: mockContactId,
|
||||
});
|
||||
expect(result.state.data).toEqual(expect.objectContaining(mockUserState));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,20 +85,26 @@ export const updateUser = async (
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
const { success, messages: updateAttrMessages } = await updateAttributes(
|
||||
contact.id,
|
||||
userId,
|
||||
environmentId,
|
||||
attributes
|
||||
);
|
||||
const {
|
||||
success,
|
||||
messages: updateAttrMessages,
|
||||
ignoreEmailAttribute,
|
||||
} = await updateAttributes(contact.id, userId, environmentId, attributes);
|
||||
|
||||
messages = updateAttrMessages ?? [];
|
||||
|
||||
// If the attributes update was successful and the language attribute was provided, set the language
|
||||
if (success) {
|
||||
let attributesToUpdate = { ...attributes };
|
||||
|
||||
if (ignoreEmailAttribute) {
|
||||
const { email, ...rest } = attributes;
|
||||
attributesToUpdate = rest;
|
||||
}
|
||||
|
||||
contactAttributes = {
|
||||
...contactAttributes,
|
||||
...attributes,
|
||||
...attributesToUpdate,
|
||||
};
|
||||
|
||||
if (attributes.language) {
|
||||
|
||||
@@ -236,7 +236,7 @@ export const ContactsTable = ({
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRows={deleteContacts}
|
||||
deleteRowsAction={deleteContacts}
|
||||
type="contact"
|
||||
deleteAction={deleteContact}
|
||||
refreshContacts={refreshContacts}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const updateAttributes = async (
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
contactAttributesParam: TContactAttributes
|
||||
): Promise<{ success: boolean; messages?: string[] }> => {
|
||||
): Promise<{ success: boolean; messages?: string[]; ignoreEmailAttribute?: boolean }> => {
|
||||
validateInputs(
|
||||
[contactId, ZId],
|
||||
[userId, ZString],
|
||||
@@ -21,6 +21,8 @@ export const updateAttributes = async (
|
||||
[contactAttributesParam, ZContactAttributes]
|
||||
);
|
||||
|
||||
let ignoreEmailAttribute = false;
|
||||
|
||||
// Fetch contact attribute keys and email check in parallel
|
||||
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
|
||||
getContactAttributeKeys(environmentId),
|
||||
@@ -58,6 +60,10 @@ export const updateAttributes = async (
|
||||
? ["The email already exists for this environment and was not updated."]
|
||||
: [];
|
||||
|
||||
if (emailExists) {
|
||||
ignoreEmailAttribute = true;
|
||||
}
|
||||
|
||||
// First, update all existing attributes
|
||||
if (existingAttributes.length > 0) {
|
||||
await prisma.$transaction(
|
||||
@@ -124,5 +130,6 @@ export const updateAttributes = async (
|
||||
return {
|
||||
success: true,
|
||||
messages,
|
||||
ignoreEmailAttribute,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,8 +8,28 @@ import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open }: { children: React.ReactNode; open: boolean }) => {
|
||||
return open ? <div>{children}</div> : null; // NOSONAR // This is a mock
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
closeOnOutsideClick,
|
||||
setOpen,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
closeOnOutsideClick?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
}) => {
|
||||
return open ? ( // NOSONAR // This is a mock
|
||||
<button
|
||||
data-testid="modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (closeOnOutsideClick && e.target === e.currentTarget && setOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}>
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
</button>
|
||||
) : null; // NOSONAR // This is a mock
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -280,7 +300,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Person (userId) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("userId"),
|
||||
() => screen.getByTestId("filter-btn-person-userId"),
|
||||
"person",
|
||||
{ personIdentifier: "userId" },
|
||||
"equals",
|
||||
@@ -290,7 +310,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Email Address) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Email Address"),
|
||||
() => screen.getByTestId("filter-btn-attribute-email"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "email" },
|
||||
"equals",
|
||||
@@ -300,7 +320,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Plan Type) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Plan Type"),
|
||||
() => screen.getByTestId("filter-btn-attribute-plan"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "plan" },
|
||||
"equals",
|
||||
@@ -310,7 +330,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Active Users) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Active Users"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg1"),
|
||||
"segment",
|
||||
{ segmentId: "seg1" },
|
||||
"userIsIn",
|
||||
@@ -320,7 +340,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Paying Customers) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Paying Customers"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg2"),
|
||||
"segment",
|
||||
{ segmentId: "seg2" },
|
||||
"userIsIn",
|
||||
@@ -330,7 +350,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Phone) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.phone"),
|
||||
() => screen.getByTestId("filter-btn-device-phone"),
|
||||
"device",
|
||||
{ deviceType: "phone" },
|
||||
"equals",
|
||||
@@ -340,7 +360,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Desktop) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.desktop"),
|
||||
() => screen.getByTestId("filter-btn-device-desktop"),
|
||||
"device",
|
||||
{ deviceType: "desktop" },
|
||||
"equals",
|
||||
@@ -366,7 +386,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Person (userId) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByTestId("person-filter-item"), // Use testid from component
|
||||
() => screen.getByTestId("filter-btn-person-userId"),
|
||||
"person",
|
||||
{ personIdentifier: "userId" },
|
||||
"equals",
|
||||
@@ -376,7 +396,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Email Address) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Email Address"),
|
||||
() => screen.getByTestId("filter-btn-attribute-email"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "email" },
|
||||
"equals",
|
||||
@@ -386,7 +406,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Plan Type) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Plan Type"),
|
||||
() => screen.getByTestId("filter-btn-attribute-plan"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "plan" },
|
||||
"equals",
|
||||
@@ -412,7 +432,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Active Users) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Active Users"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg1"),
|
||||
"segment",
|
||||
{ segmentId: "seg1" },
|
||||
"userIsIn",
|
||||
@@ -422,7 +442,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Paying Customers) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Paying Customers"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg2"),
|
||||
"segment",
|
||||
{ segmentId: "seg2" },
|
||||
"userIsIn",
|
||||
@@ -448,7 +468,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Phone) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.phone"),
|
||||
() => screen.getByTestId("filter-btn-device-phone"),
|
||||
"device",
|
||||
{ deviceType: "phone" },
|
||||
"equals",
|
||||
@@ -458,7 +478,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Desktop) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.desktop"),
|
||||
() => screen.getByTestId("filter-btn-device-desktop"),
|
||||
"device",
|
||||
{ deviceType: "desktop" },
|
||||
"equals",
|
||||
@@ -510,4 +530,86 @@ describe("AddFilterModal", () => {
|
||||
await user.type(searchInput, "nonexistentfilter");
|
||||
expect(await screen.findByText("environments.segments.no_filters_yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("verifies keyboard navigation through filter buttons", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
|
||||
// Get the search input to start tabbing from
|
||||
const searchInput = screen.getByPlaceholderText("Browse filters...");
|
||||
searchInput.focus();
|
||||
|
||||
// Tab to the first tab button ("all")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/common\.all/);
|
||||
|
||||
// Tab to the second tab button ("attributes")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/person_and_attributes/);
|
||||
|
||||
// Tab to the third tab button ("segments")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/common\.segments/);
|
||||
|
||||
// Tab to the fourth tab button ("devices")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/environments\.segments\.devices/);
|
||||
|
||||
// Tab to the first filter button ("Email Address")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent("Email Address");
|
||||
|
||||
// Tab to the second filter button ("Plan Type")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent("Plan Type");
|
||||
|
||||
// Tab to the third filter button ("userId")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent("userId");
|
||||
});
|
||||
|
||||
test("button elements are accessible to screen readers", () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThan(0); // Verify buttons exist
|
||||
|
||||
// Check that buttons are focusable (they should be by default)
|
||||
buttons.forEach((button) => {
|
||||
expect(button).not.toHaveAttribute("aria-hidden", "true");
|
||||
expect(button).not.toHaveAttribute("tabIndex", "-1"); // Should not be unfocusable
|
||||
});
|
||||
});
|
||||
|
||||
test("closes the modal when clicking outside the content area", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
|
||||
const modalOverlay = screen.getByTestId("modal-overlay");
|
||||
await user.click(modalOverlay);
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentPersonFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import AttributeTabContent from "./attribute-tab-content";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
interface TAddFilterModalProps {
|
||||
open: boolean;
|
||||
@@ -26,7 +28,7 @@ interface TAddFilterModalProps {
|
||||
|
||||
type TFilterType = "attribute" | "segment" | "device" | "person";
|
||||
|
||||
const handleAddFilter = ({
|
||||
export const handleAddFilter = ({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
@@ -132,92 +134,8 @@ const handleAddFilter = ({
|
||||
}
|
||||
};
|
||||
|
||||
interface AttributeTabContentProps {
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function AttributeTabContent({ contactAttributeKeys, onAddFilter, setOpen }: AttributeTabContentProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.person")}</h2>
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
tabIndex={0}
|
||||
data-testid="person-filter-item"
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<FingerprintIcon className="h-4 w-4" />
|
||||
<p>{t("common.user_id")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-2" />
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.attributes")}</h2>
|
||||
</div>
|
||||
{contactAttributeKeys.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_attributes_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{contactAttributeKeys.map((attributeKey) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={attributeKey.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeKey.name ?? attributeKey.key}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddFilterModal({
|
||||
// NOSONAR // the read-only attribute doesn't work as expected yet
|
||||
onAddFilter,
|
||||
open,
|
||||
setOpen,
|
||||
@@ -315,161 +233,68 @@ export function AddFilterModal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{allFiltersFiltered.map((filters, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
{filters.attributes.map((attributeKey) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={attributeKey.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeKey.name ?? attributeKey.key}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.contactAttributeFiltered.map((personAttribute) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={personAttribute.name}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<FingerprintIcon className="h-4 w-4" />
|
||||
<p>{personAttribute.name}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.segments.map((segment) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={segment.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.devices.map((deviceType) => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={deviceType.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
{allFiltersFiltered.map((filters, index) => (
|
||||
<div key={index}>
|
||||
{filters.attributes.map((attributeKey) => (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<MonitorSmartphoneIcon className="h-4 w-4" />
|
||||
<span>{deviceType.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
const getAttributesTabContent = () => {
|
||||
return (
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={contactAttributeKeysFiltered}
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
);
|
||||
};
|
||||
{filters.contactAttributeFiltered.map((personAttribute) => (
|
||||
<FilterButton
|
||||
key={personAttribute.name}
|
||||
data-testid={`filter-btn-person-${personAttribute.name}`}
|
||||
icon={<FingerprintIcon className="h-4 w-4" />}
|
||||
label={personAttribute.name}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
const getSegmentsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{segmentsFiltered.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_segments_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{segmentsFiltered
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
{filters.segments.map((segment) => (
|
||||
<FilterButton
|
||||
key={segment.id}
|
||||
tabIndex={0}
|
||||
data-testid={`filter-btn-segment-${segment.id}`}
|
||||
icon={<Users2Icon className="h-4 w-4" />}
|
||||
label={segment.title}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
@@ -488,12 +313,91 @@ export function AddFilterModal({
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filters.devices.map((deviceType) => (
|
||||
<FilterButton
|
||||
key={deviceType.id}
|
||||
data-testid={`filter-btn-device-${deviceType.id}`}
|
||||
icon={<MonitorSmartphoneIcon className="h-4 w-4" />}
|
||||
label={deviceType.name}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getAttributesTabContent = () => {
|
||||
return (
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={contactAttributeKeysFiltered}
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getSegmentsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{segmentsFiltered.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_segments_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{segmentsFiltered
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => (
|
||||
<FilterButton
|
||||
key={segment.id}
|
||||
data-testid={`filter-btn-segment-${segment.id}`}
|
||||
icon={<Users2Icon className="h-4 w-4" />}
|
||||
label={segment.title}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -502,10 +406,11 @@ export function AddFilterModal({
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{deviceTypesFiltered.map((deviceType) => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
<FilterButton
|
||||
key={deviceType.id}
|
||||
tabIndex={0}
|
||||
data-testid={`filter-btn-device-${deviceType.id}`}
|
||||
icon={<MonitorSmartphoneIcon className="h-4 w-4" />}
|
||||
label={deviceType.name}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
@@ -524,10 +429,8 @@ export function AddFilterModal({
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<MonitorSmartphoneIcon className="h-4 w-4" />
|
||||
<span>{deviceType.name}</span>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import AttributeTabContent from "./attribute-tab-content";
|
||||
|
||||
describe("AttributeTabContent", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockContactAttributeKeys: TContactAttributeKey[] = [
|
||||
{ id: "attr1", key: "email", name: "Email Address", environmentId: "env1" } as TContactAttributeKey,
|
||||
{ id: "attr2", key: "plan", name: "Plan Type", environmentId: "env1" } as TContactAttributeKey,
|
||||
];
|
||||
|
||||
test("renders person and attribute buttons", () => {
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("filter-btn-person-userId")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("filter-btn-attribute-email")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("filter-btn-attribute-plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows empty state when no attributes", () => {
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={[]}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/no_attributes_yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls handleAddFilter with correct args for person", async () => {
|
||||
const handleAddFilter = vi.fn();
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("filter-btn-person-userId"));
|
||||
expect(handleAddFilter).toHaveBeenCalledWith(expect.objectContaining({ type: "person" }));
|
||||
});
|
||||
|
||||
test("calls handleAddFilter with correct args for attribute", async () => {
|
||||
const handleAddFilter = vi.fn();
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("filter-btn-attribute-email"));
|
||||
expect(handleAddFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "attribute", contactAttributeKey: "email" })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FingerprintIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter } from "@formbricks/types/segment";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
interface AttributeTabContentProps {
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
handleAddFilter: (args: {
|
||||
type: "attribute" | "person";
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// Helper component to render a FilterButton with common handlers
|
||||
function FilterButtonWithHandler({
|
||||
dataTestId,
|
||||
icon,
|
||||
label,
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
handleAddFilter,
|
||||
contactAttributeKey,
|
||||
}: {
|
||||
dataTestId: string;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
type: "attribute" | "person";
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
handleAddFilter: (args: {
|
||||
type: "attribute" | "person";
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
}) => void;
|
||||
contactAttributeKey?: string;
|
||||
}) {
|
||||
return (
|
||||
<FilterButton
|
||||
data-testid={dataTestId}
|
||||
icon={icon}
|
||||
label={label}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttributeTabContent({
|
||||
contactAttributeKeys,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
handleAddFilter,
|
||||
}: AttributeTabContentProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.person")}</h2>
|
||||
<div>
|
||||
<FilterButtonWithHandler
|
||||
dataTestId="filter-btn-person-userId"
|
||||
icon={<FingerprintIcon className="h-4 w-4" />}
|
||||
label={t("common.user_id")}
|
||||
type="person"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-2" />
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.attributes")}</h2>
|
||||
</div>
|
||||
{contactAttributeKeys.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_attributes_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{contactAttributeKeys.map((attributeKey) => (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeTabContent;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
describe("FilterButton", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders icon and label", () => {
|
||||
render(
|
||||
<FilterButton icon={<span data-testid="icon">icon</span>} label="Test Label" onClick={() => {}} />
|
||||
);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<FilterButton icon={<span />} label="Click Me" onClick={onClick} />);
|
||||
const button = screen.getByRole("button");
|
||||
await userEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls onKeyDown when Enter or Space is pressed", async () => {
|
||||
const onKeyDown = vi.fn();
|
||||
render(<FilterButton icon={<span />} label="Key Test" onClick={() => {}} onKeyDown={onKeyDown} />);
|
||||
const button = screen.getByRole("button");
|
||||
button.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
onKeyDown.mockClear();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
function FilterButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
tabIndex = 0,
|
||||
className = "",
|
||||
...props
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
onClick: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
tabIndex?: number;
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50 ${className}`}
|
||||
tabIndex={tabIndex}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterButton;
|
||||
@@ -385,4 +385,113 @@ describe("SegmentEditor", () => {
|
||||
|
||||
// Dropdown menu trigger is disabled, so no need to test clicking items inside
|
||||
});
|
||||
|
||||
test("connector button is focusable and activates on Enter/Space", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
// Focus the button directly instead of tabbing to it
|
||||
connectorButton.focus();
|
||||
|
||||
// Simulate pressing Enter
|
||||
await user.keyboard("[Enter]");
|
||||
expect(segmentUtils.toggleGroupConnector).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
groupResource1.id,
|
||||
"or"
|
||||
);
|
||||
|
||||
vi.mocked(segmentUtils.toggleGroupConnector).mockClear(); // Clear mock for next assertion
|
||||
|
||||
// Simulate pressing Space
|
||||
await user.keyboard(" ");
|
||||
expect(segmentUtils.toggleGroupConnector).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
groupResource1.id,
|
||||
"or"
|
||||
);
|
||||
});
|
||||
|
||||
test("connector button has accessibility attributes", () => {
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorElement = screen.getByText("and");
|
||||
expect(connectorElement.tagName.toLowerCase()).toBe("button");
|
||||
});
|
||||
|
||||
test("connector button and add filter button are both keyboard focusable and reachable via tabbing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
const addFilterButton = screen.getByTestId("add-filter-button");
|
||||
|
||||
// Tab through the page and collect focusable elements
|
||||
const focusable: (Element | null)[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Arbitrary upper bound to avoid infinite loop
|
||||
await user.tab();
|
||||
focusable.push(document.activeElement);
|
||||
if (document.activeElement === document.body) break;
|
||||
}
|
||||
|
||||
// Filter out nulls for the assertion
|
||||
const nonNullFocusable = focusable.filter((el): el is Element => el !== null);
|
||||
expect(nonNullFocusable).toContain(connectorButton);
|
||||
expect(nonNullFocusable).toContain(addFilterButton);
|
||||
});
|
||||
|
||||
test("connector button and add filter button can be focused independently", () => {
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
const addFilterButton = screen.getByTestId("add-filter-button");
|
||||
|
||||
connectorButton.focus();
|
||||
expect(document.activeElement).toBe(connectorButton);
|
||||
|
||||
addFilterButton.focus();
|
||||
expect(document.activeElement).toBe(addFilterButton);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ export function SegmentEditor({
|
||||
<div key={groupId}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-auto" key={connector}>
|
||||
<span
|
||||
<button
|
||||
className={cn(
|
||||
Boolean(connector) && "cursor-pointer underline",
|
||||
"text-sm",
|
||||
@@ -159,8 +159,8 @@ export function SegmentEditor({
|
||||
if (viewOnly) return;
|
||||
onConnectorChange(groupId, connector);
|
||||
}}>
|
||||
{connector ? connector : t("environments.segments.where")}
|
||||
</span>
|
||||
{connector ?? t("environments.segments.where")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-slate-300 bg-white p-4">
|
||||
@@ -176,6 +176,7 @@ export function SegmentEditor({
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
data-testid="add-filter-button"
|
||||
disabled={viewOnly}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SegmentFilter } from "@/modules/ee/contacts/segments/components/segment-filter";
|
||||
import * as segmentUtils from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
// Added fireEvent
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
@@ -127,6 +126,16 @@ const segments: TSegment[] = [
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment,
|
||||
{
|
||||
id: "seg3",
|
||||
environmentId,
|
||||
title: "Third Segment",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
surveys: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment,
|
||||
];
|
||||
const contactAttributeKeys: TContactAttributeKey[] = [
|
||||
{
|
||||
@@ -178,6 +187,226 @@ describe("SegmentFilter", () => {
|
||||
// vi.clearAllMocks() in afterEach handles mock reset.
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector displays correct connector value or default text", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
expect(screen.getByText("and")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={attributeFilterResource} />);
|
||||
expect(screen.getByText("environments.segments.where")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector applies correct CSS classes based on props", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
|
||||
// Test case 1: connector is "and", viewOnly is false
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const connectorButton1 = screen.getByText("and").closest("button");
|
||||
expect(connectorButton1).toHaveClass("cursor-pointer");
|
||||
expect(connectorButton1).toHaveClass("underline");
|
||||
expect(connectorButton1).not.toHaveClass("cursor-not-allowed");
|
||||
|
||||
cleanup();
|
||||
|
||||
// Test case 2: connector is null, viewOnly is false
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={attributeFilterResource} />);
|
||||
const connectorButton2 = screen.getByText("environments.segments.where").closest("button");
|
||||
expect(connectorButton2).not.toHaveClass("cursor-pointer");
|
||||
expect(connectorButton2).not.toHaveClass("underline");
|
||||
expect(connectorButton2).not.toHaveClass("cursor-not-allowed");
|
||||
|
||||
cleanup();
|
||||
|
||||
// Test case 3: connector is "and", viewOnly is true
|
||||
render(
|
||||
<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} viewOnly={true} />
|
||||
);
|
||||
const connectorButton3 = screen.getByText("and").closest("button");
|
||||
expect(connectorButton3).not.toHaveClass("cursor-pointer");
|
||||
expect(connectorButton3).toHaveClass("underline");
|
||||
expect(connectorButton3).toHaveClass("cursor-not-allowed");
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector applies cursor-not-allowed class when viewOnly is true", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter, viewOnly: true };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const connectorButton = screen.getByText("and");
|
||||
expect(connectorButton).toHaveClass("cursor-not-allowed");
|
||||
});
|
||||
|
||||
test("toggles connector on Enter key press", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: { type: "attribute", contactAttributeKey: "email" },
|
||||
qualifier: { operator: "equals" },
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithAttributeFilter) };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const connectorButton = screen.getByText("and");
|
||||
connectorButton.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
expect(vi.mocked(segmentUtils.toggleFilterConnector)).toHaveBeenCalledWith(
|
||||
currentProps.segment.filters,
|
||||
attributeFilterResource.id,
|
||||
"or"
|
||||
);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector button shows a visible focus indicator when focused via keyboard navigation", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
await userEvent.tab();
|
||||
expect(connectorButton).toHaveFocus();
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector button has aria-label for screen readers", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const andButton = screen.getByRole("button", { name: "and" });
|
||||
expect(andButton).toHaveAttribute("aria-label", "and");
|
||||
|
||||
cleanup();
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={attributeFilterResource} />);
|
||||
const orButton = screen.getByRole("button", { name: "or" });
|
||||
expect(orButton).toHaveAttribute("aria-label", "or");
|
||||
|
||||
cleanup();
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={attributeFilterResource} />);
|
||||
const whereButton = screen.getByRole("button", { name: "environments.segments.where" });
|
||||
expect(whereButton).toHaveAttribute("aria-label", "environments.segments.where");
|
||||
});
|
||||
|
||||
describe("Attribute Filter", () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
@@ -270,6 +499,138 @@ describe("SegmentFilter", () => {
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
|
||||
expect(screen.getByTestId("trash-icon").closest("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("displays error message for non-numeric input with arithmetic operator", async () => {
|
||||
const arithmeticFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-arithmetic-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "greaterThan",
|
||||
},
|
||||
value: "10",
|
||||
};
|
||||
|
||||
const segmentWithArithmeticFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: arithmeticFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithArithmeticFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={arithmeticFilterResource} />);
|
||||
|
||||
const valueInput = screen.getByDisplayValue("10");
|
||||
await userEvent.clear(valueInput);
|
||||
fireEvent.change(valueInput, { target: { value: "abc" } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("environments.segments.value_must_be_a_number")).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("navigates with tab key", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
const connectorButton = screen.getByText("and").closest("button");
|
||||
const attributeSelect = screen.getByText("Email").closest("button");
|
||||
const operatorSelect = screen.getByText("equals").closest("button");
|
||||
const valueInput = screen.getByDisplayValue("test@example.com");
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-trigger");
|
||||
const trashButton = screen.getByTestId("trash-icon").closest("button");
|
||||
|
||||
// Set focus on the first element (connector button)
|
||||
connectorButton?.focus();
|
||||
await waitFor(() => expect(connectorButton).toHaveFocus());
|
||||
|
||||
// Tab to attribute select
|
||||
await userEvent.tab();
|
||||
if (!attributeSelect) throw new Error("attributeSelect is null");
|
||||
await waitFor(() => expect(attributeSelect).toHaveFocus());
|
||||
|
||||
// Tab to operator select
|
||||
await userEvent.tab();
|
||||
if (!operatorSelect) throw new Error("operatorSelect is null");
|
||||
await waitFor(() => expect(operatorSelect).toHaveFocus());
|
||||
|
||||
// Tab to value input
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(valueInput).toHaveFocus());
|
||||
|
||||
// Tab to dropdown trigger
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(dropdownTrigger).toHaveFocus());
|
||||
|
||||
// Tab through dropdown menu items (4 items)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await userEvent.tab();
|
||||
}
|
||||
|
||||
// Tab to trash button
|
||||
await userEvent.tab();
|
||||
if (!trashButton) throw new Error("trashButton is null");
|
||||
await waitFor(() => expect(trashButton).toHaveFocus());
|
||||
});
|
||||
|
||||
test("interactive buttons have type='button' attribute", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
const connectorButton = await screen.findByText("and");
|
||||
expect(connectorButton.closest("button")).toHaveAttribute("type", "button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Person Filter", () => {
|
||||
@@ -327,6 +688,126 @@ describe("SegmentFilter", () => {
|
||||
// Ensure the state update function was called
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("displays error message for non-numeric input with arithmetic operator", async () => {
|
||||
const personFilterResourceWithArithmeticOperator: TSegmentPersonFilter = {
|
||||
id: "filter-person-2",
|
||||
root: { type: "person", personIdentifier: "userId" },
|
||||
qualifier: { operator: "greaterThan" },
|
||||
value: "10",
|
||||
};
|
||||
|
||||
const segmentWithPersonFilterArithmetic: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-2", connector: "and", resource: personFilterResourceWithArithmeticOperator }],
|
||||
};
|
||||
|
||||
const currentProps = {
|
||||
...baseProps,
|
||||
segment: structuredClone(segmentWithPersonFilterArithmetic),
|
||||
setSegment: mockSetSegment,
|
||||
};
|
||||
|
||||
render(
|
||||
<SegmentFilter
|
||||
{...currentProps}
|
||||
connector="or"
|
||||
resource={personFilterResourceWithArithmeticOperator}
|
||||
/>
|
||||
);
|
||||
const valueInput = screen.getByDisplayValue("10");
|
||||
|
||||
await userEvent.clear(valueInput);
|
||||
fireEvent.change(valueInput, { target: { value: "abc" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("environments.segments.value_must_be_a_number")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles empty value input", async () => {
|
||||
const initialSegment = structuredClone(segmentWithPersonFilter);
|
||||
const currentProps = { ...baseProps, segment: initialSegment, setSegment: mockSetSegment };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={personFilterResource} />);
|
||||
const valueInput = screen.getByDisplayValue("person123");
|
||||
|
||||
// Clear the input
|
||||
await userEvent.clear(valueInput);
|
||||
// Fire a single change event with the final value
|
||||
fireEvent.change(valueInput, { target: { value: "" } });
|
||||
|
||||
// Check the call to the update function (might be called once or twice by checkValueAndUpdate)
|
||||
await waitFor(() => {
|
||||
// Check if it was called AT LEAST once with the correct final value
|
||||
expect(vi.mocked(segmentUtils.updateFilterValue)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
personFilterResource.id,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
const errorMessage = await screen.findByText("environments.segments.value_cannot_be_empty");
|
||||
expect(errorMessage).toBeVisible();
|
||||
|
||||
// Ensure the state update function was called
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("is keyboard accessible", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithPersonFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={personFilterResource} />);
|
||||
|
||||
// Tab to the connector button
|
||||
await userEvent.tab();
|
||||
expect(screen.getByText("or")).toHaveFocus();
|
||||
|
||||
// Tab to the person identifier select
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(screen.getByText("userId").closest("button")).toHaveFocus());
|
||||
|
||||
// Tab to the operator select
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toHaveFocus());
|
||||
|
||||
// Tab to the value input
|
||||
await userEvent.tab();
|
||||
expect(screen.getByDisplayValue("person123")).toHaveFocus();
|
||||
|
||||
// Tab to the context menu trigger
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(screen.getByTestId("dropdown-trigger")).toHaveFocus());
|
||||
});
|
||||
|
||||
describe("Person Filter - Multiple Identifiers", () => {
|
||||
const personFilterResourceWithMultipleIdentifiers: TSegmentPersonFilter = {
|
||||
id: "filter-person-multi-1",
|
||||
root: { type: "person", personIdentifier: "userId" }, // Even though it's a single value, the component should handle the possibility of multiple
|
||||
qualifier: { operator: "equals" },
|
||||
value: "person123",
|
||||
};
|
||||
const segmentWithPersonFilterWithMultipleIdentifiers: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{ id: "group-multi-1", connector: "and", resource: personFilterResourceWithMultipleIdentifiers },
|
||||
],
|
||||
};
|
||||
|
||||
test("renders correctly with multiple person identifiers", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithPersonFilterWithMultipleIdentifiers };
|
||||
render(
|
||||
<SegmentFilter
|
||||
{...currentProps}
|
||||
connector="or"
|
||||
resource={personFilterResourceWithMultipleIdentifiers}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("or")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("userId").closest("button")).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("person123")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Segment Filter", () => {
|
||||
@@ -357,6 +838,44 @@ describe("SegmentFilter", () => {
|
||||
expect(vi.mocked(segmentUtils.updateSegmentIdInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates the segment ID in the filter when a new segment is selected", async () => {
|
||||
const segmentFilterResource = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
const currentProps = {
|
||||
...baseProps,
|
||||
segment: structuredClone(segmentWithSegmentFilter),
|
||||
setSegment: mockSetSegment,
|
||||
};
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Mock the updateSegmentIdInFilter function call directly
|
||||
// This simulates what would happen when a segment is selected
|
||||
vi.mocked(segmentUtils.updateSegmentIdInFilter).mockImplementationOnce(() => {});
|
||||
|
||||
// Directly call the mocked function with the expected arguments
|
||||
segmentUtils.updateSegmentIdInFilter(currentProps.segment.filters, "filter-segment-1", "seg3");
|
||||
|
||||
// Verify the function was called with the correct arguments
|
||||
expect(vi.mocked(segmentUtils.updateSegmentIdInFilter)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"filter-segment-1",
|
||||
"seg3"
|
||||
);
|
||||
|
||||
// Call the setSegment function to simulate the state update
|
||||
mockSetSegment(currentProps.segment);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Device Filter", () => {
|
||||
@@ -464,4 +983,216 @@ describe("SegmentFilter", () => {
|
||||
expect(vi.mocked(segmentUtils.toggleFilterConnector)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Segment Filter - Empty Segments", () => {
|
||||
const segmentFilterResource = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
test("renders correctly when segments array is empty", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter, segments: [] };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Find the combobox element
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
// Verify it has the empty placeholder attribute
|
||||
expect(selectElement).toHaveAttribute("data-placeholder", "");
|
||||
});
|
||||
|
||||
test("renders correctly when segments array contains only private segments", async () => {
|
||||
const privateSegments: TSegment[] = [
|
||||
{
|
||||
id: "seg3",
|
||||
environmentId,
|
||||
title: "Private Segment",
|
||||
isPrivate: true,
|
||||
filters: [],
|
||||
surveys: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment,
|
||||
];
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter, segments: privateSegments };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Find the combobox element
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
// Verify it has the empty placeholder attribute
|
||||
expect(selectElement).toHaveAttribute("data-placeholder", "");
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes the entire group when deleting the last SegmentSegmentFilter", async () => {
|
||||
const segmentFilterResource: TSegmentSegmentFilter = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
const deleteButton = screen.getByTestId("trash-icon").closest("button");
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
if (!deleteButton) throw new Error("deleteButton is null");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
expect(mockOnDeleteFilter).toHaveBeenCalledWith("filter-segment-1");
|
||||
});
|
||||
|
||||
describe("SegmentSegmentFilter", () => {
|
||||
const segmentFilterResource = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
test("operator toggle button has accessible name", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Find the operator button by its text content
|
||||
const operatorButton = screen.getByText("userIsIn");
|
||||
|
||||
// Check that the button is accessible by its visible name
|
||||
const operatorToggleButton = operatorButton.closest("button");
|
||||
expect(operatorToggleButton).toHaveAccessibleName("userIsIn");
|
||||
});
|
||||
});
|
||||
|
||||
test("renders AttributeSegmentFilter in viewOnly mode with disabled interactive elements and accessibility attributes", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter, viewOnly: true };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
// Check if the connector button is disabled and has the correct class
|
||||
const connectorButton = screen.getByText("and");
|
||||
expect(connectorButton).toHaveClass("cursor-not-allowed");
|
||||
|
||||
// Check if the attribute key select is disabled
|
||||
const attributeKeySelect = await screen.findByRole("combobox", {
|
||||
name: (content, element) => {
|
||||
return element.textContent?.toLowerCase().includes("email") ?? false;
|
||||
},
|
||||
});
|
||||
expect(attributeKeySelect).toBeDisabled();
|
||||
|
||||
// Check if the operator select is disabled
|
||||
const operatorSelect = await screen.findByRole("combobox", {
|
||||
name: (content, element) => {
|
||||
return element.textContent?.toLowerCase().includes("equals") ?? false;
|
||||
},
|
||||
});
|
||||
expect(operatorSelect).toBeDisabled();
|
||||
|
||||
// Check if the value input is disabled
|
||||
const valueInput = screen.getByDisplayValue("test@example.com");
|
||||
expect(valueInput).toBeDisabled();
|
||||
|
||||
// Check if the context menu trigger is disabled
|
||||
const contextMenuTrigger = screen.getByTestId("dropdown-trigger");
|
||||
expect(contextMenuTrigger).toBeDisabled();
|
||||
|
||||
// Check if the delete button is disabled
|
||||
const deleteButton = screen.getByTestId("trash-icon").closest("button");
|
||||
expect(deleteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("handles complex nested structures without error", async () => {
|
||||
const nestedAttributeFilter: TSegmentAttributeFilter = {
|
||||
id: "nested-filter",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "plan",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "premium",
|
||||
};
|
||||
|
||||
const complexAttributeFilter: TSegmentAttributeFilter = {
|
||||
id: "complex-filter",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "contains",
|
||||
},
|
||||
value: "example",
|
||||
};
|
||||
|
||||
const deeplyNestedSegment: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: [
|
||||
{
|
||||
id: "group-2",
|
||||
connector: "or",
|
||||
resource: [
|
||||
{
|
||||
id: "group-3",
|
||||
connector: "and",
|
||||
resource: complexAttributeFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "group-4",
|
||||
connector: "and",
|
||||
resource: nestedAttributeFilter,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: deeplyNestedSegment };
|
||||
|
||||
// Act & Assert: Render the component and expect no error to be thrown
|
||||
expect(() => {
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={complexAttributeFilter} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,14 +116,16 @@ function SegmentFilterItemConnector({
|
||||
|
||||
return (
|
||||
<div className="w-[40px]">
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
aria-label={connector ?? t("environments.segments.where")}
|
||||
className={cn(Boolean(connector) && "cursor-pointer underline", viewOnly && "cursor-not-allowed")}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
onConnectorChange();
|
||||
}}>
|
||||
{connector ? connector : t("environments.segments.where")}
|
||||
</span>
|
||||
{connector ?? t("environments.segments.where")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -626,14 +628,16 @@ function SegmentSegmentFilter({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
aria-label={operatorText}
|
||||
className={cn("cursor-pointer underline", viewOnly && "cursor-not-allowed")}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
toggleSegmentOperator();
|
||||
}}>
|
||||
{operatorText}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("SegmentTableDataRow", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("div.grid");
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("button.grid");
|
||||
expect(row).toBeInTheDocument();
|
||||
|
||||
// Initially modal should not be called with open: true
|
||||
@@ -117,7 +117,7 @@ describe("SegmentTableDataRow", () => {
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("div.grid");
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("button.grid");
|
||||
await user.click(row!);
|
||||
|
||||
// Check second call (open: true)
|
||||
@@ -130,4 +130,23 @@ describe("SegmentTableDataRow", () => {
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
});
|
||||
|
||||
test("has focus styling for keyboard navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SegmentTableDataRow
|
||||
currentSegment={mockCurrentSegment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
isContactsEnabled={mockIsContactsEnabled}
|
||||
isReadOnly={mockIsReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("button.grid");
|
||||
expect(row).toBeInTheDocument();
|
||||
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(row);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,9 @@ export const SegmentTableDataRow = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
key={id}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100"
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-7 content-center rounded-lg p-2 text-left transition-colors ease-in-out hover:bg-slate-100"
|
||||
onClick={() => setIsEditSegmentModalOpen(true)}>
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -55,7 +55,7 @@ export const SegmentTableDataRow = ({
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<EditSegmentModal
|
||||
environmentId={environmentId}
|
||||
|
||||
@@ -78,14 +78,14 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
/>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
className="block cursor-pointer rounded-md px-4 py-2 text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||
<button
|
||||
className="block w-full cursor-pointer rounded-md px-4 py-2 text-left text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleOptionSelect(item);
|
||||
}}>
|
||||
{item.label[locale]}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,14 +15,14 @@ import { AuthenticationError, OperationNotAllowedError, ValidationError } from "
|
||||
|
||||
// Mock constants with getter functions to allow overriding in tests
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockDisableUserManagement = false;
|
||||
let mockUserManagementMinimumRole = "owner";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
get DISABLE_USER_MANAGEMENT() {
|
||||
return mockDisableUserManagement;
|
||||
get USER_MANAGEMENT_MINIMUM_ROLE() {
|
||||
return mockUserManagementMinimumRole;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("Role Management Actions", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockIsFormbricksCloud = false;
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
});
|
||||
|
||||
describe("checkRoleManagementPermission", () => {
|
||||
@@ -220,7 +220,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("throws error if user management is disabled", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = true;
|
||||
mockUserManagementMinimumRole = "disabled";
|
||||
|
||||
await expect(
|
||||
updateMembershipAction({
|
||||
@@ -231,12 +231,12 @@ describe("Role Management Actions", () => {
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(new OperationNotAllowedError("User management is disabled"));
|
||||
).rejects.toThrow(new OperationNotAllowedError("User management is not allowed for your role"));
|
||||
});
|
||||
|
||||
test("throws error if billing role is not allowed in self-hosted", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
@@ -253,7 +253,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("allows billing role in cloud environment", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
@@ -274,7 +274,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("throws error if manager tries to assign a role other than member", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "manager";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
@@ -291,7 +291,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("allows manager to assign member role", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "manager";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
@@ -312,7 +312,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("successful membership update as owner", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserManagementAccess } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
@@ -87,8 +88,13 @@ export const updateMembershipAction = authenticatedActionClient
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
if (DISABLE_USER_MANAGEMENT) {
|
||||
throw new OperationNotAllowedError("User management is disabled");
|
||||
const hasUserManagementAccess = getUserManagementAccess(
|
||||
currentUserMembership.role,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE
|
||||
);
|
||||
|
||||
if (!hasUserManagementAccess) {
|
||||
throw new OperationNotAllowedError("User management is not allowed for your role");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
|
||||
import { Session } from "next-auth";
|
||||
import { TMembership, TMembershipUpdateInput } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
// Common mock IDs
|
||||
export const mockOrganizationId = "cblt7dwr7d0hvdifl4iw6d5x";
|
||||
export const mockUserId = "wl43gybf3pxmqqx3fcmsk8eb";
|
||||
export const mockInviteId = "dc0b6ea6-bb65-4a22-88e1-847df2e85af4";
|
||||
export const mockTargetUserId = "vevt9qm7sqmh44e3za6a2vzd";
|
||||
|
||||
// Mock user
|
||||
export const mockUser: TUser = {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
identityProvider: "email",
|
||||
twoFactorEnabled: false,
|
||||
objective: null,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
},
|
||||
locale: "en-US",
|
||||
imageUrl: null,
|
||||
role: null,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Mock session
|
||||
export const mockSession: Session = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
},
|
||||
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
// Mock organizations
|
||||
export const createMockOrganization = (plan: TOrganizationBillingPlan): TOrganization => ({
|
||||
id: mockOrganizationId,
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan,
|
||||
period: "monthly",
|
||||
periodStart: new Date(),
|
||||
limits: {
|
||||
projects: plan === "free" ? 3 : null,
|
||||
monthly: {
|
||||
responses: plan === "free" ? 1500 : null,
|
||||
miu: plan === "free" ? 2000 : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockOrganizationFree = createMockOrganization("free");
|
||||
export const mockOrganizationStartup = createMockOrganization("startup");
|
||||
export const mockOrganizationScale = createMockOrganization("scale");
|
||||
|
||||
// Mock membership data
|
||||
export const createMockMembership = (role: TMembership["role"]): TMembership => ({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
role,
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
export const mockMembershipMember = createMockMembership("member");
|
||||
export const mockMembershipManager = createMockMembership("manager");
|
||||
export const mockMembershipOwner = createMockMembership("owner");
|
||||
|
||||
// Mock data payloads
|
||||
export const mockInviteDataMember: TInviteUpdateInput = { role: "member" };
|
||||
export const mockInviteDataOwner: TInviteUpdateInput = { role: "owner" };
|
||||
export const mockInviteDataBilling: TInviteUpdateInput = { role: "billing" };
|
||||
|
||||
export const mockMembershipUpdateMember: TMembershipUpdateInput = { role: "member" };
|
||||
export const mockMembershipUpdateOwner: TMembershipUpdateInput = { role: "owner" };
|
||||
export const mockMembershipUpdateBilling: TMembershipUpdateInput = { role: "billing" };
|
||||
|
||||
// Mock input objects for actions
|
||||
export const mockUpdateInviteInput = {
|
||||
inviteId: mockInviteId,
|
||||
organizationId: mockOrganizationId,
|
||||
data: mockInviteDataMember,
|
||||
};
|
||||
|
||||
export const mockUpdateMembershipInput = {
|
||||
userId: mockTargetUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
data: mockMembershipUpdateMember,
|
||||
};
|
||||
|
||||
// Mock responses
|
||||
export const mockUpdatedMembership: TMembership = {
|
||||
userId: mockTargetUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
role: "member",
|
||||
accepted: true,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user