mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
Compare commits
7 Commits
fix/click-
...
cursor/upd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71832183e | ||
|
|
1c1cd99510 | ||
|
|
b0a7e212dd | ||
|
|
0c1f6f3c3a | ||
|
|
9399b526b8 | ||
|
|
cd60032bc9 | ||
|
|
a941f994ea |
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
72
SCHEMA_CHANGES_SUMMARY.md
Normal file
72
SCHEMA_CHANGES_SUMMARY.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# User Schema Changes: Separate First and Last Name Fields
|
||||
|
||||
## Overview
|
||||
Successfully updated the User model in the formbricks-poc project to use separate `firstName` and `lastName` fields instead of a single `name` field.
|
||||
|
||||
## Schema Changes
|
||||
|
||||
### 1. Database Schema (packages/database/schema.prisma)
|
||||
- **Changed User model fields:**
|
||||
- Removed: `name String`
|
||||
- Added: `firstName String` and `lastName String`
|
||||
- **Updated documentation comments** to reflect the new field structure
|
||||
|
||||
### 2. Type Definitions (packages/types/user.ts)
|
||||
- **Updated ZUser schema:** Changed from `name: ZUserName` to `firstName: ZUserName, lastName: ZUserName`
|
||||
- **Updated ZUserUpdateInput schema:** Changed from `name: ZUserName.optional()` to `firstName: ZUserName.optional(), lastName: ZUserName.optional()`
|
||||
- **Updated ZUserCreateInput schema:** Changed from `name: ZUserName` to `firstName: ZUserName, lastName: ZUserName`
|
||||
|
||||
## Application Code Updates
|
||||
|
||||
### 3. Core User Functions (apps/web/modules/auth/lib/user.ts)
|
||||
- **Updated createUser function:** Modified select statement to return `firstName` and `lastName` instead of `name`
|
||||
|
||||
### 4. Database ZOD Schema (packages/database/zod/users.ts)
|
||||
- **Updated ZUser description** to reflect that `name` field now represents the full name (concatenated firstName + lastName)
|
||||
|
||||
### 5. User Authentication & Signup (apps/web/modules/auth/signup/actions.ts)
|
||||
- **Updated ZCreatedUser type** to use `firstName` and `lastName`
|
||||
- **Updated ZCreateUserAction schema** to accept `firstName` and `lastName`
|
||||
- **Modified all functions** to handle the new field structure:
|
||||
- `verifyTurnstileIfConfigured()` - now accepts firstName and lastName parameters
|
||||
- `createUserSafely()` - now accepts firstName and lastName parameters
|
||||
- `handleInviteAcceptance()` - concatenates firstName and lastName for display
|
||||
- `handleOrganizationCreation()` - concatenates firstName and lastName for organization name
|
||||
|
||||
### 6. API Endpoints (apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts)
|
||||
- **Updated getUsers function:** Returns concatenated firstName + lastName as `name` field
|
||||
- **Updated createUser function:** Splits input `name` into firstName and lastName for storage
|
||||
- **Updated updateUser function:** Splits input `name` into firstName and lastName for updates
|
||||
- **Maintained backward compatibility:** API still accepts/returns `name` field for external consumers
|
||||
|
||||
### 7. Team Management (apps/web/modules/organization/settings/teams/lib/membership.ts)
|
||||
- **Updated select statements** to fetch `firstName` and `lastName` instead of `name`
|
||||
- **Updated mapping functions** to concatenate firstName and lastName for display
|
||||
|
||||
### 8. Organization & Team Actions
|
||||
- **Updated sendInviteMemberEmail calls** to use concatenated firstName + lastName
|
||||
- **Updated creator name references** to use concatenated firstName + lastName
|
||||
- **Updated user context references** to use concatenated firstName + lastName
|
||||
|
||||
### 9. Additional Files Updated
|
||||
- **Two-factor authentication** (apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts)
|
||||
- **Email customization** (apps/web/modules/ee/whitelabel/email-customization/actions.ts)
|
||||
- **Team management** (apps/web/modules/ee/teams/team-list/lib/team.ts)
|
||||
- **Response utilities** (apps/web/lib/response/utils.ts)
|
||||
|
||||
## Migration Required
|
||||
A database migration will be needed to apply these schema changes:
|
||||
```bash
|
||||
npx prisma migrate dev --name "separate-user-first-and-last-name"
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
- **API endpoints** continue to work with the existing `name` field format
|
||||
- **Frontend components** that display user names will show the concatenated firstName + lastName
|
||||
- **Database operations** now use the separate firstName and lastName fields internally
|
||||
|
||||
## Notes
|
||||
- All user display names are now formatted as `${firstName} ${lastName}`
|
||||
- Input validation remains the same using the existing `ZUserName` schema
|
||||
- The change maintains data integrity while providing more flexibility for user name management
|
||||
- All existing functionality has been preserved while using the new field structure
|
||||
@@ -169,7 +169,7 @@ export const resetPasswordAction = authenticatedActionClient.action(
|
||||
"user",
|
||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
@@ -145,7 +145,7 @@ export const EditProfileDetailsForm = ({
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(t(errorMessage));
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setIsResettingPassword(false);
|
||||
|
||||
@@ -523,7 +523,7 @@ export const getResponsesJson = (
|
||||
"Survey ID": response.surveyId,
|
||||
"Formbricks ID (internal)": response.contact?.id || "",
|
||||
"User ID": response.contact?.userId || "",
|
||||
Notes: response.notes.map((note) => `${note.user.name}: ${note.text}`).join("\n"),
|
||||
Notes: response.notes.map((note) => `${note.user.firstName} ${note.user.lastName}: ${note.text}`).join("\n"),
|
||||
Tags: response.tags.map((tag) => tag.name).join(", "),
|
||||
});
|
||||
|
||||
|
||||
@@ -143,7 +143,6 @@ export const mockPrismaPerson: Prisma.ContactGetPayload<{
|
||||
include: typeof selectContact;
|
||||
}> = {
|
||||
id: mockId,
|
||||
userId: mockId,
|
||||
attributes: [
|
||||
{
|
||||
value: "de",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Name",
|
||||
"gathering_responses": "Antworten sammeln",
|
||||
"general": "Allgemein",
|
||||
"go_back": "Geh zurück",
|
||||
"go_to_dashboard": "Zum Dashboard gehen",
|
||||
"hidden": "Versteckt",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Wechseln zu {environment}",
|
||||
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
|
||||
"table_settings": "Tabelleinstellungen",
|
||||
"tags": "Tags",
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Teamzugriff",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Full name",
|
||||
"gathering_responses": "Gathering responses",
|
||||
"general": "General",
|
||||
"go_back": "Go Back",
|
||||
"go_to_dashboard": "Go to Dashboard",
|
||||
"hidden": "Hidden",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Switch to {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deleted successfully",
|
||||
"table_settings": "Table settings",
|
||||
"tags": "Tags",
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Team Access",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Version de Formbricks",
|
||||
"full_name": "Nom complet",
|
||||
"gathering_responses": "Collecte des réponses",
|
||||
"general": "Général",
|
||||
"go_back": "Retourner",
|
||||
"go_to_dashboard": "Aller au tableau de bord",
|
||||
"hidden": "Caché",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Passer à {environment}",
|
||||
"table_items_deleted_successfully": "{type}s supprimés avec succès",
|
||||
"table_settings": "Réglages de table",
|
||||
"tags": "Étiquettes",
|
||||
"targeting": "Ciblage",
|
||||
"team": "Équipe",
|
||||
"team_access": "Accès Équipe",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "Recolhendo respostas",
|
||||
"general": "Geral",
|
||||
"go_back": "Voltar",
|
||||
"go_to_dashboard": "Ir para o Painel",
|
||||
"hidden": "Escondido",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deletados com sucesso",
|
||||
"table_settings": "Arrumação da mesa",
|
||||
"tags": "Etiquetas",
|
||||
"targeting": "mirando",
|
||||
"team": "Time",
|
||||
"team_access": "Acesso da equipe",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "A recolher respostas",
|
||||
"general": "Geral",
|
||||
"go_back": "Voltar",
|
||||
"go_to_dashboard": "Ir para o Painel",
|
||||
"hidden": "Oculto",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s eliminados com sucesso",
|
||||
"table_settings": "Configurações da tabela",
|
||||
"tags": "Etiquetas",
|
||||
"targeting": "Segmentação",
|
||||
"team": "Equipa",
|
||||
"team_access": "Acesso da Equipa",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集回應中",
|
||||
"general": "一般",
|
||||
"go_back": "返回",
|
||||
"go_to_dashboard": "前往儀表板",
|
||||
"hidden": "隱藏",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "切換至 '{'environment'}'",
|
||||
"table_items_deleted_successfully": "'{'type'}' 已成功刪除",
|
||||
"table_settings": "表格設定",
|
||||
"tags": "標籤",
|
||||
"targeting": "目標設定",
|
||||
"team": "團隊",
|
||||
"team_access": "團隊存取權限",
|
||||
|
||||
@@ -48,7 +48,7 @@ export const getUsers = async (
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
isActive: user.isActive,
|
||||
role: user.memberships.filter((membership) => membership.organizationId === organizationId)[0].role,
|
||||
@@ -93,8 +93,14 @@ export const createUser = async (
|
||||
}));
|
||||
}
|
||||
|
||||
// Split name into firstName and lastName
|
||||
const nameParts = name.split(" ");
|
||||
const firstName = nameParts[0] || "";
|
||||
const lastName = nameParts.slice(1).join(" ") || "";
|
||||
|
||||
const prismaData: Prisma.UserCreateInput = {
|
||||
name,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
isActive: isActive,
|
||||
memberships: {
|
||||
@@ -133,7 +139,7 @@ export const createUser = async (
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
isActive: user.isActive,
|
||||
role: user.memberships.filter((membership) => membership.organizationId === organizationId)[0].role,
|
||||
@@ -240,8 +246,18 @@ export const updateUser = async (
|
||||
}
|
||||
});
|
||||
|
||||
// Split name into firstName and lastName if provided
|
||||
let firstName: string | undefined;
|
||||
let lastName: string | undefined;
|
||||
if (name) {
|
||||
const nameParts = name.split(" ");
|
||||
firstName = nameParts[0] || "";
|
||||
lastName = nameParts.slice(1).join(" ") || "";
|
||||
}
|
||||
|
||||
const prismaData: Prisma.UserUpdateInput = {
|
||||
name: name ?? undefined,
|
||||
firstName: firstName ?? undefined,
|
||||
lastName: lastName ?? undefined,
|
||||
email: email ?? undefined,
|
||||
isActive: isActive ?? undefined,
|
||||
memberships: {
|
||||
@@ -281,7 +297,7 @@ export const updateUser = async (
|
||||
createdAt: updatedUser.createdAt,
|
||||
updatedAt: updatedUser.updatedAt,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
name: `${updatedUser.firstName} ${updatedUser.lastName}`,
|
||||
lastLoginAt: updatedUser.lastLoginAt,
|
||||
isActive: updatedUser.isActive,
|
||||
role: updatedUser.memberships.find(
|
||||
|
||||
@@ -124,7 +124,8 @@ export const createUser = async (data: TUserCreateInput) => {
|
||||
const user = await prisma.user.create({
|
||||
data: data,
|
||||
select: {
|
||||
name: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
notificationSettings: true,
|
||||
id: true,
|
||||
email: true,
|
||||
|
||||
@@ -19,7 +19,8 @@ import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
|
||||
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
|
||||
const ZCreatedUser = ZUser.pick({
|
||||
name: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
id: true,
|
||||
@@ -29,7 +30,8 @@ const ZCreatedUser = ZUser.pick({
|
||||
type TCreatedUser = z.infer<typeof ZCreatedUser>;
|
||||
|
||||
const ZCreateUserAction = z.object({
|
||||
name: ZUserName,
|
||||
firstName: ZUserName,
|
||||
lastName: ZUserName,
|
||||
email: ZUserEmail,
|
||||
password: ZUserPassword,
|
||||
inviteToken: z.string().optional(),
|
||||
@@ -47,25 +49,27 @@ const ZCreateUserAction = z.object({
|
||||
async function verifyTurnstileIfConfigured(
|
||||
turnstileToken: string | undefined,
|
||||
email: string,
|
||||
name: string
|
||||
firstName: string,
|
||||
lastName: string
|
||||
): Promise<void> {
|
||||
if (!IS_TURNSTILE_CONFIGURED) return;
|
||||
|
||||
if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
|
||||
captureFailedSignup(email, name);
|
||||
captureFailedSignup(email, `${firstName} ${lastName}`);
|
||||
throw new UnknownError("Server configuration error");
|
||||
}
|
||||
|
||||
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
|
||||
if (!isHuman) {
|
||||
captureFailedSignup(email, name);
|
||||
captureFailedSignup(email, `${firstName} ${lastName}`);
|
||||
throw new UnknownError("reCAPTCHA verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserSafely(
|
||||
email: string,
|
||||
name: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
hashedPassword: string,
|
||||
userLocale: z.infer<typeof ZUserLocale> | undefined
|
||||
): Promise<{ user: TCreatedUser | undefined; userAlreadyExisted: boolean }> {
|
||||
@@ -75,7 +79,8 @@ async function createUserSafely(
|
||||
try {
|
||||
user = await createUser({
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
firstName,
|
||||
lastName,
|
||||
password: hashedPassword,
|
||||
locale: userLocale,
|
||||
});
|
||||
@@ -127,7 +132,7 @@ async function handleInviteAcceptance(
|
||||
},
|
||||
});
|
||||
|
||||
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
|
||||
await sendInviteAcceptedEmail(invite.creator.name ?? "", `${user.firstName} ${user.lastName}`, invite.creator.email);
|
||||
await deleteInvite(invite.id);
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) return;
|
||||
|
||||
const organization = await createOrganization({ name: `${user.name}'s Organization` });
|
||||
const organization = await createOrganization({ name: `${user.firstName} ${user.lastName}'s Organization` });
|
||||
ctx.auditLoggingCtx.organizationId = organization.id;
|
||||
|
||||
await createMembership(organization.id, user.id, {
|
||||
@@ -177,12 +182,13 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
"created",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
|
||||
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.firstName, parsedInput.lastName);
|
||||
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const { user, userAlreadyExisted } = await createUserSafely(
|
||||
parsedInput.email,
|
||||
parsedInput.name,
|
||||
parsedInput.firstName,
|
||||
parsedInput.lastName,
|
||||
hashedPassword,
|
||||
parsedInput.userLocale
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ const mockContact = {
|
||||
environmentId: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attributes: [],
|
||||
};
|
||||
|
||||
describe("contact lib", () => {
|
||||
@@ -38,7 +37,9 @@ describe("contact lib", () => {
|
||||
const result = await getContact(mockContactId);
|
||||
|
||||
expect(result).toEqual(mockContact);
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } });
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
@@ -46,7 +47,9 @@ describe("contact lib", () => {
|
||||
const result = await getContact(mockContactId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } });
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError if prisma throws PrismaClientKnownRequestError", async () => {
|
||||
|
||||
@@ -20,18 +20,12 @@ const mockContacts = [
|
||||
{
|
||||
id: "contactId1",
|
||||
environmentId: mockEnvironmentId1,
|
||||
name: "Contact 1",
|
||||
email: "contact1@example.com",
|
||||
attributes: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "contactId2",
|
||||
environmentId: mockEnvironmentId2,
|
||||
name: "Contact 2",
|
||||
email: "contact2@example.com",
|
||||
attributes: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
||||
@@ -219,7 +219,8 @@ export const getTeamDetails = reactCache(async (teamId: string): Promise<TTeamDe
|
||||
role: true,
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -248,7 +249,7 @@ export const getTeamDetails = reactCache(async (teamId: string): Promise<TTeamDe
|
||||
organizationId: team.organizationId,
|
||||
members: team.teamUsers.map((teamUser) => ({
|
||||
userId: teamUser.userId,
|
||||
name: teamUser.user.name,
|
||||
name: `${teamUser.user.firstName} ${teamUser.user.lastName}`,
|
||||
role: teamUser.role,
|
||||
})),
|
||||
projects: team.projectTeams.map((projectTeam) => ({
|
||||
|
||||
@@ -63,7 +63,7 @@ export const setupTwoFactorAuth = async (
|
||||
},
|
||||
});
|
||||
|
||||
const name = user.email || user.name || user.id.toString();
|
||||
const name = user.email || `${user.firstName} ${user.lastName}` || user.id.toString();
|
||||
const keyUri = authenticator.keyuri(name, "Formbricks", secret);
|
||||
const dataUri = await qrcode.toDataURL(keyUri);
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ export const sendTestEmailAction = authenticatedActionClient
|
||||
|
||||
await sendEmailCustomizationPreviewEmail(
|
||||
ctx.user.email,
|
||||
ctx.user.name,
|
||||
`${ctx.user.firstName} ${ctx.user.lastName}`,
|
||||
organization?.whitelabel?.logoUrl || ""
|
||||
);
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
|
||||
await sendInviteMemberEmail(
|
||||
parsedInput.inviteId,
|
||||
updatedInvite.email,
|
||||
invite?.creator?.name ?? "",
|
||||
invite?.creator ? `${invite.creator.firstName} ${invite.creator.lastName}` : "",
|
||||
updatedInvite.name ?? "",
|
||||
undefined,
|
||||
ctx.user.locale
|
||||
@@ -269,7 +269,7 @@ export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserActi
|
||||
await sendInviteMemberEmail(
|
||||
inviteId,
|
||||
parsedInput.email,
|
||||
ctx.user.name ?? "",
|
||||
`${ctx.user.firstName} ${ctx.user.lastName}`,
|
||||
parsedInput.name ?? "",
|
||||
false,
|
||||
undefined
|
||||
|
||||
@@ -20,7 +20,8 @@ export const getMembershipByOrganizationId = reactCache(
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
isActive: true,
|
||||
},
|
||||
@@ -35,7 +36,7 @@ export const getMembershipByOrganizationId = reactCache(
|
||||
|
||||
const members = membersData.map((member) => {
|
||||
return {
|
||||
name: member.user?.name || "",
|
||||
name: member.user ? `${member.user.firstName} ${member.user.lastName}` : "",
|
||||
email: member.user?.email || "",
|
||||
userId: member.userId,
|
||||
accepted: member.accepted,
|
||||
@@ -162,7 +163,8 @@ export const getMembersByOrganizationId = reactCache(
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
role: true,
|
||||
@@ -173,7 +175,7 @@ export const getMembersByOrganizationId = reactCache(
|
||||
const members = membersData.map((member) => {
|
||||
return {
|
||||
id: member.userId,
|
||||
name: member.user?.name || "",
|
||||
name: member.user ? `${member.user.firstName} ${member.user.lastName}` : "",
|
||||
role: member.role,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
|
||||
await sendInviteMemberEmail(
|
||||
invitedUserId,
|
||||
parsedInput.email,
|
||||
ctx.user.name,
|
||||
`${ctx.user.firstName} ${ctx.user.lastName}`,
|
||||
"",
|
||||
false, // is onboarding invite
|
||||
undefined
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
@@ -12,6 +13,16 @@ import {
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixQuestionForm } from "./matrix-question-form";
|
||||
|
||||
// Mock cuid2 to track CUID generation
|
||||
const mockCuids = ["cuid1", "cuid2", "cuid3", "cuid4", "cuid5", "cuid6"];
|
||||
let cuidIndex = 0;
|
||||
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
default: {
|
||||
createId: vi.fn(() => mockCuids[cuidIndex++]),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock window.matchMedia - required for useAutoAnimate
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
@@ -386,4 +397,223 @@ describe("MatrixQuestionForm", () => {
|
||||
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// CUID functionality tests
|
||||
describe("CUID Management", () => {
|
||||
beforeEach(() => {
|
||||
// Reset CUID index before each test
|
||||
cuidIndex = 0;
|
||||
});
|
||||
|
||||
test("generates stable CUIDs for rows and columns on initial render", () => {
|
||||
const { rerender } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Check that CUIDs are generated for initial items
|
||||
expect(cuidIndex).toBe(6); // 3 rows + 3 columns
|
||||
|
||||
// Rerender with the same props - no new CUIDs should be generated
|
||||
rerender(<MatrixQuestionForm {...defaultProps} />);
|
||||
expect(cuidIndex).toBe(6); // Should remain the same
|
||||
});
|
||||
|
||||
test("maintains stable CUIDs across rerenders", () => {
|
||||
const TestComponent = ({ question }: { question: TSurveyMatrixQuestion }) => {
|
||||
return <MatrixQuestionForm {...defaultProps} question={question} />;
|
||||
};
|
||||
|
||||
const { rerender } = render(<TestComponent question={mockMatrixQuestion} />);
|
||||
|
||||
// Check initial CUID count
|
||||
expect(cuidIndex).toBe(6); // 3 rows + 3 columns
|
||||
|
||||
// Rerender multiple times
|
||||
rerender(<TestComponent question={mockMatrixQuestion} />);
|
||||
rerender(<TestComponent question={mockMatrixQuestion} />);
|
||||
rerender(<TestComponent question={mockMatrixQuestion} />);
|
||||
|
||||
// CUIDs should remain stable
|
||||
expect(cuidIndex).toBe(6); // Should not increase
|
||||
});
|
||||
|
||||
test("generates new CUIDs only when rows are added", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a test component that can update its props
|
||||
const TestComponent = () => {
|
||||
const [question, setQuestion] = React.useState(mockMatrixQuestion);
|
||||
|
||||
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
|
||||
setQuestion((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return (
|
||||
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = render(<TestComponent />);
|
||||
|
||||
// Initial render should generate 6 CUIDs (3 rows + 3 columns)
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Add a new row
|
||||
const addRowButton = getByText("environments.surveys.edit.add_row");
|
||||
await user.click(addRowButton);
|
||||
|
||||
// Should generate 1 new CUID for the new row
|
||||
expect(cuidIndex).toBe(7);
|
||||
});
|
||||
|
||||
test("generates new CUIDs only when columns are added", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a test component that can update its props
|
||||
const TestComponent = () => {
|
||||
const [question, setQuestion] = React.useState(mockMatrixQuestion);
|
||||
|
||||
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
|
||||
setQuestion((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return (
|
||||
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = render(<TestComponent />);
|
||||
|
||||
// Initial render should generate 6 CUIDs (3 rows + 3 columns)
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Add a new column
|
||||
const addColumnButton = getByText("environments.surveys.edit.add_column");
|
||||
await user.click(addColumnButton);
|
||||
|
||||
// Should generate 1 new CUID for the new column
|
||||
expect(cuidIndex).toBe(7);
|
||||
});
|
||||
|
||||
test("maintains CUID stability when items are deleted", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId, rerender } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Mock that no items are used in logic
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
|
||||
|
||||
// Initial render: 6 CUIDs generated
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Delete a row
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
// No new CUIDs should be generated for deletion
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Rerender should not generate new CUIDs
|
||||
rerender(<MatrixQuestionForm {...defaultProps} />);
|
||||
expect(cuidIndex).toBe(6);
|
||||
});
|
||||
|
||||
test("handles mixed operations maintaining CUID stability", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a test component that can update its props
|
||||
const TestComponent = () => {
|
||||
const [question, setQuestion] = React.useState(mockMatrixQuestion);
|
||||
|
||||
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
|
||||
setQuestion((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return (
|
||||
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText, findAllByTestId } = render(<TestComponent />);
|
||||
|
||||
// Mock that no items are used in logic
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
|
||||
|
||||
// Initial: 6 CUIDs
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Add a row: +1 CUID
|
||||
const addRowButton = getByText("environments.surveys.edit.add_row");
|
||||
await user.click(addRowButton);
|
||||
expect(cuidIndex).toBe(7);
|
||||
|
||||
// Add a column: +1 CUID
|
||||
const addColumnButton = getByText("environments.surveys.edit.add_column");
|
||||
await user.click(addColumnButton);
|
||||
expect(cuidIndex).toBe(8);
|
||||
|
||||
// Delete a row: no new CUIDs
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
expect(cuidIndex).toBe(8);
|
||||
|
||||
// Delete a column: no new CUIDs
|
||||
const updatedDeleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(updatedDeleteButtons[2].querySelector("button") as HTMLButtonElement);
|
||||
expect(cuidIndex).toBe(8);
|
||||
});
|
||||
|
||||
test("CUID arrays are properly maintained when items are deleted in order", async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithManyRows = {
|
||||
...defaultProps,
|
||||
question: {
|
||||
...mockMatrixQuestion,
|
||||
rows: [
|
||||
createI18nString("Row 1", ["en"]),
|
||||
createI18nString("Row 2", ["en"]),
|
||||
createI18nString("Row 3", ["en"]),
|
||||
createI18nString("Row 4", ["en"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithManyRows} />);
|
||||
|
||||
// Mock that no items are used in logic
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
|
||||
|
||||
// Initial: 7 CUIDs (4 rows + 3 columns)
|
||||
expect(cuidIndex).toBe(7);
|
||||
|
||||
// Delete first row
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
// Verify the correct row was deleted (should be Row 2, Row 3, Row 4 remaining)
|
||||
expect(mockUpdateQuestion).toHaveBeenLastCalledWith(0, {
|
||||
rows: [
|
||||
propsWithManyRows.question.rows[1],
|
||||
propsWithManyRows.question.rows[2],
|
||||
propsWithManyRows.question.rows[3],
|
||||
],
|
||||
});
|
||||
|
||||
// No new CUIDs should be generated
|
||||
expect(cuidIndex).toBe(7);
|
||||
});
|
||||
|
||||
test("CUID generation is consistent across component instances", () => {
|
||||
// Reset CUID index
|
||||
cuidIndex = 0;
|
||||
|
||||
// Render first instance
|
||||
const { unmount } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Unmount and render second instance
|
||||
unmount();
|
||||
render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Should generate 6 more CUIDs for the new instance
|
||||
expect(cuidIndex).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,10 @@ import { Label } from "@/modules/ui/components/label";
|
||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import cuid2 from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { type JSX, useMemo, useRef } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -39,6 +40,45 @@ export const MatrixQuestionForm = ({
|
||||
}: MatrixQuestionFormProps): JSX.Element => {
|
||||
const languageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const { t } = useTranslate();
|
||||
|
||||
// Refs to maintain stable CUIDs across renders
|
||||
const cuidRefs = useRef<{
|
||||
rows: string[];
|
||||
columns: string[];
|
||||
}>({
|
||||
rows: [],
|
||||
columns: [],
|
||||
});
|
||||
|
||||
// Generic function to ensure CUIDs are synchronized with the current state
|
||||
const ensureCuids = (type: "rows" | "columns", currentItems: TI18nString[]) => {
|
||||
const currentCuids = cuidRefs.current[type];
|
||||
if (currentCuids.length !== currentItems.length) {
|
||||
if (currentItems.length > currentCuids.length) {
|
||||
// Add new CUIDs for added items
|
||||
const newCuids = Array(currentItems.length - currentCuids.length)
|
||||
.fill(null)
|
||||
.map(() => cuid2.createId());
|
||||
cuidRefs.current[type] = [...currentCuids, ...newCuids];
|
||||
} else {
|
||||
// Remove CUIDs for deleted items (keep the remaining ones in order)
|
||||
cuidRefs.current[type] = currentCuids.slice(0, currentItems.length);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Generic function to get items with CUIDs
|
||||
const getItemsWithCuid = (type: "rows" | "columns", items: TI18nString[]) => {
|
||||
ensureCuids(type, items);
|
||||
return items.map((item, index) => ({
|
||||
...item,
|
||||
id: cuidRefs.current[type][index],
|
||||
}));
|
||||
};
|
||||
|
||||
const rowsWithCuid = useMemo(() => getItemsWithCuid("rows", question.rows), [question.rows]);
|
||||
const columnsWithCuid = useMemo(() => getItemsWithCuid("columns", question.columns), [question.columns]);
|
||||
|
||||
// Function to add a new Label input field
|
||||
const handleAddLabel = (type: "row" | "column") => {
|
||||
if (type === "row") {
|
||||
@@ -79,6 +119,11 @@ export const MatrixQuestionForm = ({
|
||||
}
|
||||
|
||||
const updatedLabels = labels.filter((_, idx) => idx !== index);
|
||||
|
||||
// Update the CUID arrays when deleting
|
||||
const cuidType = type === "row" ? "rows" : "columns";
|
||||
cuidRefs.current[cuidType] = cuidRefs.current[cuidType].filter((_, idx) => idx !== index);
|
||||
|
||||
if (type === "row") {
|
||||
updateQuestion(questionIdx, { rows: updatedLabels });
|
||||
} else {
|
||||
@@ -182,8 +227,8 @@ export const MatrixQuestionForm = ({
|
||||
{/* Rows section */}
|
||||
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.rows.map((row, index) => (
|
||||
<div className="flex items-center" key={`${row}-${index}`}>
|
||||
{rowsWithCuid.map((row, index) => (
|
||||
<div className="flex items-center" key={row.id}>
|
||||
<QuestionFormInput
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
@@ -232,8 +277,8 @@ export const MatrixQuestionForm = ({
|
||||
{/* Columns section */}
|
||||
<Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.columns.map((column, index) => (
|
||||
<div className="flex items-center" key={`${column}-${index}`}>
|
||||
{columnsWithCuid.map((column, index) => (
|
||||
<div className="flex items-center" key={column.id}>
|
||||
<QuestionFormInput
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
|
||||
@@ -247,6 +247,7 @@ export const SurveyMenuBar = ({
|
||||
if (updatedSurveyResponse?.data) {
|
||||
setLocalSurvey(updatedSurveyResponse.data);
|
||||
toast.success(t("environments.surveys.edit.changes_saved"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
|
||||
@@ -70,6 +70,14 @@ vi.mock("react-hot-toast", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock clipboard API
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("SurveyDropDownMenu", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -78,7 +86,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
test("calls copySurveyLink when copy link is clicked", async () => {
|
||||
const mockRefresh = vi.fn().mockResolvedValue("fakeSingleUseId");
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
const mockDuplicateSurvey = vi.fn();
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
@@ -149,6 +156,135 @@ describe("SurveyDropDownMenu", () => {
|
||||
responseCount: 5,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
describe("clipboard functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("pre-fetches single-use ID when dropdown opens", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
|
||||
// Initially, refreshSingleUseId should not have been called
|
||||
expect(mockRefreshSingleUseId).not.toHaveBeenCalled();
|
||||
|
||||
// Open dropdown
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
// Now it should have been called
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not pre-fetch single-use ID when dropdown is closed", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Don't open dropdown
|
||||
|
||||
// Wait a bit to ensure useEffect doesn't run
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("copies link with pre-fetched single-use ID", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
navigator.clipboard.writeText = mockWriteText;
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
|
||||
// Open dropdown to trigger pre-fetch
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
// Wait for pre-fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click copy link
|
||||
const copyLinkButton = screen.getByTestId("copy-link");
|
||||
await userEvent.click(copyLinkButton);
|
||||
|
||||
// Verify clipboard was called with the correct URL including single-use ID
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith("http://survey.test/s/testSurvey?suId=test-single-use-id");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles copy link with undefined single-use ID", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue(undefined);
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
navigator.clipboard.writeText = mockWriteText;
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
|
||||
// Open dropdown to trigger pre-fetch
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
// Wait for pre-fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click copy link
|
||||
const copyLinkButton = screen.getByTestId("copy-link");
|
||||
await userEvent.click(copyLinkButton);
|
||||
|
||||
// Verify clipboard was called with base URL (no single-use ID)
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith("http://survey.test/s/testSurvey");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("handleEditforActiveSurvey opens EditPublicSurveyAlertDialog for active surveys", async () => {
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
@@ -285,7 +421,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
expect(mockDeleteSurveyAction).toHaveBeenCalledWith({ surveyId: "testSurvey" });
|
||||
expect(mockDeleteSurvey).toHaveBeenCalledWith("testSurvey");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("environments.surveys.survey_deleted_successfully");
|
||||
expect(mockRouterRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -396,7 +531,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
|
||||
// Verify that deleteSurvey callback was not called due to error
|
||||
expect(mockDeleteSurvey).not.toHaveBeenCalled();
|
||||
expect(mockRouterRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call router.refresh or success toast when deleteSurveyAction throws", async () => {
|
||||
@@ -480,7 +614,7 @@ describe("SurveyDropDownMenu", () => {
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success", "router.refresh"]);
|
||||
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { CopySurveyModal } from "./copy-survey-modal";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
@@ -61,18 +62,33 @@ export const SurveyDropDownMenu = ({
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
const [newSingleUseId, setNewSingleUseId] = useState<string | undefined>(undefined);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
|
||||
|
||||
// Pre-fetch single-use ID when dropdown opens to avoid async delay during clipboard operation
|
||||
// This ensures Safari's clipboard API works by maintaining the user gesture context
|
||||
useEffect(() => {
|
||||
if (!isDropDownOpen) return;
|
||||
const fetchNewId = async () => {
|
||||
try {
|
||||
const newId = await refreshSingleUseId();
|
||||
setNewSingleUseId(newId ?? undefined);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
fetchNewId();
|
||||
}, [refreshSingleUseId, isDropDownOpen]);
|
||||
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction({ surveyId });
|
||||
deleteSurvey(surveyId);
|
||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.error_deleting_survey"));
|
||||
} finally {
|
||||
@@ -84,12 +100,11 @@ export const SurveyDropDownMenu = ({
|
||||
try {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
const newId = await refreshSingleUseId();
|
||||
const copiedLink = copySurveyLink(surveyLink, newId);
|
||||
const copiedLink = copySurveyLink(surveyLink, newSingleUseId);
|
||||
navigator.clipboard.writeText(copiedLink);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `userId` on the `Contact` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Contact" DROP COLUMN "userId";
|
||||
@@ -112,14 +112,12 @@ model ContactAttributeKey {
|
||||
/// Contacts are environment-specific and can have multiple attributes and responses.
|
||||
///
|
||||
/// @property id - Unique identifier for the contact
|
||||
/// @property userId - Optional external user identifier
|
||||
/// @property environment - The environment this contact belongs to
|
||||
/// @property responses - Survey responses from this contact
|
||||
/// @property attributes - Custom attributes associated with this contact
|
||||
/// @property displays - Record of surveys shown to this contact
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
@@ -835,7 +833,8 @@ enum Intention {
|
||||
/// Central model for user authentication and profile management.
|
||||
///
|
||||
/// @property id - Unique identifier for the user
|
||||
/// @property name - Display name of the user
|
||||
/// @property firstName - User's first name
|
||||
/// @property lastName - User's last name
|
||||
/// @property email - User's email address
|
||||
/// @property role - User's professional role
|
||||
/// @property objective - User's main goal with Formbricks
|
||||
@@ -846,7 +845,8 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
firstName String
|
||||
lastName String
|
||||
email String @unique
|
||||
emailVerified DateTime? @map(name: "email_verified")
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ZUser = z.object({
|
||||
example: true,
|
||||
}),
|
||||
name: ZUserName.openapi({
|
||||
description: "The name of the user",
|
||||
description: "The full name of the user",
|
||||
example: "John Doe",
|
||||
}),
|
||||
email: ZUserEmail.openapi({
|
||||
|
||||
@@ -50,12 +50,12 @@ export function RenderSurvey(props: SurveyContainerProps) {
|
||||
placement={props.placement}
|
||||
darkOverlay={props.darkOverlay}
|
||||
clickOutside={props.clickOutside}
|
||||
ignorePlacementForClickOutside={props.ignorePlacementForClickOutside}
|
||||
onClose={close}
|
||||
isOpen={isOpen}>
|
||||
{/* @ts-expect-error -- TODO: fix this */}
|
||||
<Survey
|
||||
{...props}
|
||||
clickOutside={props.placement === "center" ? props.clickOutside : true}
|
||||
onClose={close}
|
||||
onFinished={() => {
|
||||
props.onFinished?.();
|
||||
|
||||
@@ -210,36 +210,6 @@ describe("SurveyContainer", () => {
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("triggers clickOutside logic if ignorePlacementForClickOutside is true and placement is not center", () => {
|
||||
render(
|
||||
<SurveyContainer
|
||||
mode="modal"
|
||||
placement="bottomRight"
|
||||
clickOutside={true}
|
||||
onClose={onCloseMock}
|
||||
ignorePlacementForClickOutside={true}>
|
||||
{(<TestChild />) as any}
|
||||
</SurveyContainer>
|
||||
);
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not trigger clickOutside logic if ignorePlacementForClickOutside is true and placement is center", () => {
|
||||
render(
|
||||
<SurveyContainer
|
||||
mode="modal"
|
||||
placement="center"
|
||||
clickOutside={false}
|
||||
onClose={onCloseMock}
|
||||
ignorePlacementForClickOutside={true}>
|
||||
{(<TestChild />) as any}
|
||||
</SurveyContainer>
|
||||
);
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not trigger clickOutside logic if mode is not modal", () => {
|
||||
render(
|
||||
<SurveyContainer mode="inline" placement="center" clickOutside={true} onClose={onCloseMock}>
|
||||
@@ -264,7 +234,6 @@ describe("SurveyContainer", () => {
|
||||
unmount();
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("mousedown", expect.any(Function));
|
||||
});
|
||||
|
||||
test("does not call onClose when modal is not shown (show=false)", () => {
|
||||
render(
|
||||
<SurveyContainer
|
||||
|
||||
@@ -9,7 +9,6 @@ interface SurveyContainerProps {
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
clickOutside?: boolean;
|
||||
ignorePlacementForClickOutside?: boolean;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,7 +19,6 @@ export function SurveyContainer({
|
||||
children,
|
||||
onClose,
|
||||
clickOutside,
|
||||
ignorePlacementForClickOutside,
|
||||
isOpen = true,
|
||||
}: Readonly<SurveyContainerProps>) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -29,11 +27,7 @@ export function SurveyContainer({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModal) return;
|
||||
|
||||
// If the placement is not center and we don't want to ignore center placement for click outside, we will return early
|
||||
if (!ignorePlacementForClickOutside && !isCenter) {
|
||||
return;
|
||||
}
|
||||
if (!isCenter) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
@@ -50,7 +44,7 @@ export function SurveyContainer({
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [clickOutside, onClose, isCenter, isModal, isOpen, ignorePlacementForClickOutside]);
|
||||
}, [clickOutside, onClose, isCenter, isModal, isOpen]);
|
||||
|
||||
const getPlacementStyle = (placement: TPlacement): string => {
|
||||
switch (placement) {
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface SurveyBaseProps {
|
||||
isCardBorderVisible?: boolean;
|
||||
startAtQuestionId?: string;
|
||||
clickOutside?: boolean;
|
||||
ignorePlacementForClickOutside?: boolean;
|
||||
hiddenFieldsRecord?: TResponseHiddenFieldValue;
|
||||
shouldResetQuestionId?: boolean;
|
||||
fullSizeCards?: boolean;
|
||||
|
||||
@@ -46,7 +46,8 @@ const ZUserIdentityProvider = z.enum(["email", "google", "github", "azuread", "o
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: ZUserName,
|
||||
firstName: ZUserName,
|
||||
lastName: ZUserName,
|
||||
email: ZUserEmail,
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
@@ -65,7 +66,8 @@ export const ZUser = z.object({
|
||||
export type TUser = z.infer<typeof ZUser>;
|
||||
|
||||
export const ZUserUpdateInput = z.object({
|
||||
name: ZUserName.optional(),
|
||||
firstName: ZUserName.optional(),
|
||||
lastName: ZUserName.optional(),
|
||||
email: ZUserEmail.optional(),
|
||||
emailVerified: z.date().nullish(),
|
||||
password: ZUserPassword.optional(),
|
||||
@@ -81,7 +83,8 @@ export const ZUserUpdateInput = z.object({
|
||||
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
|
||||
|
||||
export const ZUserCreateInput = z.object({
|
||||
name: ZUserName,
|
||||
firstName: ZUserName,
|
||||
lastName: ZUserName,
|
||||
email: ZUserEmail,
|
||||
password: ZUserPassword.optional(),
|
||||
emailVerified: z.date().optional(),
|
||||
|
||||
Reference in New Issue
Block a user