mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-01 04:49:11 -06:00
Compare commits
9 Commits
fix/attrib
...
validation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c092f005b6 | ||
|
|
25266e4566 | ||
|
|
b960cfd2a1 | ||
|
|
9e1d1c1dc2 | ||
|
|
8c63a9f7af | ||
|
|
fff0a7f052 | ||
|
|
0ecc8aabff | ||
|
|
01cc0ab64d | ||
|
|
1d125bdac2 |
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -34,7 +34,6 @@ export const AnonymousLinksTab = ({
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
|
||||
pendingAction: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
const surveyUrlWithCustomSuid = useMemo(() => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", "CUSTOM-ID");
|
||||
return url.toString();
|
||||
}, [surveyUrl]);
|
||||
|
||||
const resetState = () => {
|
||||
const { singleUse } = survey;
|
||||
const { enabled, isEncrypted } = singleUse ?? {};
|
||||
@@ -177,7 +182,11 @@ export const AnonymousLinksTab = ({
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", singleUseId);
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
// Create content with just the links
|
||||
const csvContent = surveyLinks.join("\n");
|
||||
|
||||
@@ -4835,7 +4835,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
segment: null,
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
id: "cltxxaa6x0000g8hacxdxeje1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
@@ -4857,7 +4857,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
id: "cltxxaa6x0000g8hacxdxeje2",
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
{
|
||||
|
||||
@@ -1826,7 +1826,7 @@ checksums:
|
||||
environments/workspace/general/delete_workspace_settings_description: 411ef100f167fc8fca64e833b6c0d030
|
||||
environments/workspace/general/error_saving_workspace_information: e7b8022785619ef34de1fb1630b3c476
|
||||
environments/workspace/general/only_owners_or_managers_can_delete_workspaces: 58da180cd2610210302d85a9896d80bd
|
||||
environments/workspace/general/recontact_waiting_time: 8977b5160fbf88c456608982b33e246f
|
||||
environments/workspace/general/recontact_waiting_time: 6873c18d51830e2cadef67cce6a2c95c
|
||||
environments/workspace/general/recontact_waiting_time_settings_description: ebd64fddbea9387b12c027a18358db7e
|
||||
environments/workspace/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
|
||||
environments/workspace/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
|
||||
|
||||
@@ -1588,6 +1588,8 @@
|
||||
"upload_at_least_2_images": "Upload at least 2 images",
|
||||
"upper_label": "Upper Label",
|
||||
"url_filters": "URL Filters",
|
||||
"validation_rules": "Validation rules",
|
||||
"validation_rules_description": "Only accept responses that meet the following criteria",
|
||||
"url_not_supported": "URL not supported",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
|
||||
@@ -1935,7 +1937,7 @@
|
||||
"delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.",
|
||||
"error_saving_workspace_information": "Error saving workspace information",
|
||||
"only_owners_or_managers_can_delete_workspaces": "Only owners or managers can delete workspaces",
|
||||
"recontact_waiting_time": "Cooldown Period (scross surveys)",
|
||||
"recontact_waiting_time": "Cooldown Period (across surveys)",
|
||||
"recontact_waiting_time_settings_description": "Control how frequently users can be surveyed across all Website & App Surveys in this workspace.",
|
||||
"this_action_cannot_be_undone": "This action cannot be undone.",
|
||||
"wait_x_days_before_showing_next_survey": "Wait X days before showing next survey:",
|
||||
|
||||
@@ -91,6 +91,13 @@ export const EditContactAttributesModal = ({
|
||||
return allKeyOptions.filter((option) => !selectedKeys.has(String(option.value)));
|
||||
};
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [open, defaultValues, form]);
|
||||
|
||||
// Scroll to first error on validation failure
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { updateAttributes } from "./attributes";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -20,6 +24,7 @@ vi.mock("@/modules/ee/contacts/lib/contact-attributes", async () => {
|
||||
...actual,
|
||||
getContactAttributes: vi.fn(),
|
||||
hasEmailAttribute: vi.fn(),
|
||||
hasUserIdAttribute: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -75,6 +80,7 @@ describe("updateAttributes", () => {
|
||||
vi.clearAllMocks();
|
||||
// Set default mock return values - these will be overridden in individual tests
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({});
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -83,19 +89,21 @@ describe("updateAttributes", () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("skips updating email if it already exists", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
@@ -106,45 +114,147 @@ describe("updateAttributes", () => {
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
});
|
||||
|
||||
test("creates new attributes if under limit", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0]]);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane" });
|
||||
test("skips updating userId if it already exists", async () => {
|
||||
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "old-user-id" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", newAttr: "val" };
|
||||
const attributes = { name: "John", userId: "duplicate-user-id" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
|
||||
test("skips updating both email and userId if both already exist", async () => {
|
||||
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({
|
||||
name: "Jane",
|
||||
email: "old@example.com",
|
||||
userId: "old-user-id",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "duplicate@example.com", userId: "duplicate-user-id" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
|
||||
expect(result.ignoreEmailAttribute).toBe(true);
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
|
||||
test("creates new attributes if under limit", async () => {
|
||||
// Use name and email keys (2 existing keys), MAX is mocked to 2
|
||||
// We update existing attributes, no new ones created
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0], attributeKeys[1]]); // name, email
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not create new attributes if over the limit", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", newAttr: "val" };
|
||||
// Include email to satisfy the "at least one of email or userId" requirement
|
||||
const attributes = { name: "John", email: "john@example.com", newAttr: "val" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
|
||||
});
|
||||
|
||||
test("returns success with no attributes to update or create", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([]);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({});
|
||||
test("returns success with only email attribute", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = {};
|
||||
const attributes = { email: "updated@example.com" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("deletes non-default attributes that are removed from payload", async () => {
|
||||
test("deletes non-default attributes when deleteRemovedAttributes is true", async () => {
|
||||
// Reset mocks explicitly for this test
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
||||
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({
|
||||
name: "Jane",
|
||||
email: "jane@example.com",
|
||||
customAttr: "oldValue",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
// Pass deleteRemovedAttributes: true to enable deletion behavior
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
|
||||
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
|
||||
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
contactId,
|
||||
attributeKeyId: {
|
||||
in: ["key-3"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not delete attributes when deleteRemovedAttributes is false (default behavior)", async () => {
|
||||
// Reset mocks explicitly for this test
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
||||
|
||||
@@ -156,27 +266,19 @@ describe("updateAttributes", () => {
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
// Default behavior (deleteRemovedAttributes: false) should NOT delete existing attributes
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
|
||||
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
contactId,
|
||||
attributeKeyId: {
|
||||
in: ["key-3"],
|
||||
},
|
||||
},
|
||||
});
|
||||
// deleteMany should NOT be called since we're merging, not replacing
|
||||
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not delete default attributes even if removed from payload", async () => {
|
||||
test("does not delete default attributes even when deleteRemovedAttributes is true", async () => {
|
||||
// Reset mocks explicitly for this test
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
||||
|
||||
// Need to include userId and firstName in attributeKeys for this test
|
||||
// Note: DEFAULT_ATTRIBUTES includes: email, userId, firstName, lastName (not "name")
|
||||
const attributeKeysWithDefaults: TContactAttributeKey[] = [
|
||||
{
|
||||
@@ -231,13 +333,105 @@ describe("updateAttributes", () => {
|
||||
firstName: "John",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { customAttr: "value" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
// Pass deleteRemovedAttributes: true to test that default attributes are still preserved
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
|
||||
// Should not delete default attributes (email, userId, firstName) - deleteMany should not be called
|
||||
// since all current attributes are default attributes
|
||||
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves existing email when empty string is submitted", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "existing@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
|
||||
// Attempt to clear email by submitting empty string
|
||||
const attributes = { name: "John", email: "" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
|
||||
// Verify that the transaction was called with the preserved email
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
||||
// The email should be preserved (existing@example.com), not cleared
|
||||
expect(transactionCall).toHaveLength(2); // name and email
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("allows clearing userId when empty string is submitted", async () => {
|
||||
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "existing-user-id" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
|
||||
// Clear userId by submitting empty string - this should be allowed
|
||||
const attributes = { name: "John", userId: "" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
|
||||
// Verify that the transaction was called
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
||||
// Only name and userId (empty) should be in the transaction
|
||||
expect(transactionCall).toHaveLength(2); // name and userId (with empty value)
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves existing values when both email and userId would be cleared", async () => {
|
||||
const attributeKeysWithBoth: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithBoth);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({
|
||||
name: "Jane",
|
||||
email: "existing@example.com",
|
||||
userId: "existing-user-id",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
|
||||
// Attempt to clear both email and userId
|
||||
const attributes = { name: "John", email: "", userId: "" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain(
|
||||
"Either email or userId is required. The existing values were preserved."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,11 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
|
||||
// Default/system attributes that should not be deleted even if missing from payload
|
||||
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
|
||||
@@ -47,12 +51,28 @@ const deleteAttributes = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates or creates contact attributes.
|
||||
*
|
||||
* @param contactId - The ID of the contact to update
|
||||
* @param userId - The user ID of the contact
|
||||
* @param environmentId - The environment ID
|
||||
* @param contactAttributesParam - The attributes to update/create
|
||||
* @param deleteRemovedAttributes - When true, deletes attributes that exist in DB but are not in the payload.
|
||||
* Use this for UI forms where all attributes are submitted. Default is false (merge behavior) for API calls.
|
||||
*/
|
||||
export const updateAttributes = async (
|
||||
contactId: string,
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
contactAttributesParam: TContactAttributes
|
||||
): Promise<{ success: boolean; messages?: string[]; ignoreEmailAttribute?: boolean }> => {
|
||||
contactAttributesParam: TContactAttributes,
|
||||
deleteRemovedAttributes: boolean = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
messages?: string[];
|
||||
ignoreEmailAttribute?: boolean;
|
||||
ignoreUserIdAttribute?: boolean;
|
||||
}> => {
|
||||
validateInputs(
|
||||
[contactId, ZId],
|
||||
[userId, ZString],
|
||||
@@ -61,23 +81,89 @@ export const updateAttributes = async (
|
||||
);
|
||||
|
||||
let ignoreEmailAttribute = false;
|
||||
let ignoreUserIdAttribute = false;
|
||||
const messages: string[] = [];
|
||||
|
||||
// Fetch current attributes, contact attribute keys, and email check in parallel
|
||||
const [currentAttributes, contactAttributeKeys, existingEmailAttribute] = await Promise.all([
|
||||
getContactAttributes(contactId),
|
||||
getContactAttributeKeys(environmentId),
|
||||
contactAttributesParam.email
|
||||
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
// Fetch current attributes, contact attribute keys, and email/userId checks in parallel
|
||||
const [currentAttributes, contactAttributeKeys, existingEmailAttribute, existingUserIdAttribute] =
|
||||
await Promise.all([
|
||||
getContactAttributes(contactId),
|
||||
getContactAttributeKeys(environmentId),
|
||||
contactAttributesParam.email
|
||||
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
|
||||
: Promise.resolve(null),
|
||||
contactAttributesParam.userId
|
||||
? hasUserIdAttribute(contactAttributesParam.userId, environmentId, contactId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Process email existence early
|
||||
const { email, ...remainingAttributes } = contactAttributesParam;
|
||||
const contactAttributes = existingEmailAttribute ? remainingAttributes : contactAttributesParam;
|
||||
// Process email and userId existence early
|
||||
const emailExists = !!existingEmailAttribute;
|
||||
const userIdExists = !!existingUserIdAttribute;
|
||||
|
||||
// Delete attributes that were removed (using the deleteAttributes service)
|
||||
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
|
||||
// Remove email and/or userId from attributes if they already exist on another contact
|
||||
let contactAttributes = { ...contactAttributesParam };
|
||||
|
||||
// Determine what the final email and userId values will be after this update
|
||||
// Only consider a value as "submitted" if it was explicitly included in the attributes
|
||||
const emailWasSubmitted = "email" in contactAttributesParam;
|
||||
const userIdWasSubmitted = "userId" in contactAttributesParam;
|
||||
|
||||
const submittedEmail = emailWasSubmitted ? contactAttributes.email?.trim() || "" : null;
|
||||
const submittedUserId = userIdWasSubmitted ? contactAttributes.userId?.trim() || "" : null;
|
||||
|
||||
const currentEmail = currentAttributes.email || "";
|
||||
const currentUserId = currentAttributes.userId || "";
|
||||
|
||||
// Calculate final values:
|
||||
// - If not submitted, keep current value
|
||||
// - If submitted but duplicate exists, keep current value
|
||||
// - If submitted and no duplicate, use submitted value
|
||||
const getFinalEmail = (): string => {
|
||||
if (submittedEmail === null) return currentEmail;
|
||||
if (emailExists) return currentEmail;
|
||||
return submittedEmail;
|
||||
};
|
||||
|
||||
const getFinalUserId = (): string => {
|
||||
if (submittedUserId === null) return currentUserId;
|
||||
if (userIdExists) return currentUserId;
|
||||
return submittedUserId;
|
||||
};
|
||||
|
||||
const finalEmail = getFinalEmail();
|
||||
const finalUserId = getFinalUserId();
|
||||
|
||||
// Ensure at least one of email or userId will have a value after update
|
||||
if (!finalEmail && !finalUserId) {
|
||||
// If both would be empty, preserve the current values
|
||||
if (currentEmail) {
|
||||
contactAttributes.email = currentEmail;
|
||||
}
|
||||
if (currentUserId) {
|
||||
contactAttributes.userId = currentUserId;
|
||||
}
|
||||
messages.push("Either email or userId is required. The existing values were preserved.");
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
const { email: _email, ...rest } = contactAttributes;
|
||||
contactAttributes = rest;
|
||||
ignoreEmailAttribute = true;
|
||||
}
|
||||
|
||||
if (userIdExists) {
|
||||
const { userId: _userId, ...rest } = contactAttributes;
|
||||
contactAttributes = rest;
|
||||
ignoreUserIdAttribute = true;
|
||||
}
|
||||
|
||||
// Delete attributes that were removed (only when explicitly requested)
|
||||
// This is used by UI forms where all attributes are submitted
|
||||
// For API calls, we want merge behavior by default (only update passed attributes)
|
||||
if (deleteRemovedAttributes) {
|
||||
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
|
||||
}
|
||||
|
||||
// Create lookup map for attribute keys
|
||||
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
|
||||
@@ -99,12 +185,12 @@ export const updateAttributes = async (
|
||||
}
|
||||
);
|
||||
|
||||
let messages: string[] = emailExists
|
||||
? ["The email already exists for this environment and was not updated."]
|
||||
: [];
|
||||
|
||||
if (emailExists) {
|
||||
ignoreEmailAttribute = true;
|
||||
messages.push("The email already exists for this environment and was not updated.");
|
||||
}
|
||||
|
||||
if (userIdExists) {
|
||||
messages.push("The userId already exists for this environment and was not updated.");
|
||||
}
|
||||
|
||||
// Update all existing attributes
|
||||
@@ -159,7 +245,8 @@ export const updateAttributes = async (
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages,
|
||||
messages: messages.length > 0 ? messages : undefined,
|
||||
ignoreEmailAttribute,
|
||||
ignoreUserIdAttribute,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getContactAttributes, hasEmailAttribute } from "./contact-attributes";
|
||||
import { TContactAttribute } from "@formbricks/types/contact-attribute";
|
||||
import { getContactAttributes, hasEmailAttribute, hasUserIdAttribute } from "./contact-attributes";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -16,11 +17,12 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
|
||||
const contactId = "contact-1";
|
||||
const environmentId = "env-1";
|
||||
const email = "john@example.com";
|
||||
const userId = "user-123";
|
||||
|
||||
const mockAttributes = [
|
||||
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||
{ value: "John", attributeKey: { key: "name", name: "Name" } },
|
||||
];
|
||||
] as unknown as TContactAttribute[];
|
||||
|
||||
describe("getContactAttributes", () => {
|
||||
beforeEach(() => {
|
||||
@@ -50,7 +52,9 @@ describe("hasEmailAttribute", () => {
|
||||
});
|
||||
|
||||
test("returns true if email attribute exists", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({ id: "attr-1" });
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
|
||||
id: "attr-1",
|
||||
} as unknown as TContactAttribute);
|
||||
const result = await hasEmailAttribute(email, environmentId, contactId);
|
||||
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -67,3 +71,29 @@ describe("hasEmailAttribute", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUserIdAttribute", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns true if userId attribute exists on another contact", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
|
||||
id: "attr-1",
|
||||
} as unknown as TContactAttribute);
|
||||
const result = await hasUserIdAttribute(userId, environmentId, contactId);
|
||||
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
AND: [{ attributeKey: { key: "userId", environmentId }, value: userId }, { NOT: { contactId } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false if userId attribute does not exist on another contact", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue(null);
|
||||
const result = await hasUserIdAttribute(userId, environmentId, contactId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
@@ -68,3 +68,31 @@ export const hasEmailAttribute = reactCache(
|
||||
return !!contactAttribute;
|
||||
}
|
||||
);
|
||||
|
||||
export const hasUserIdAttribute = reactCache(
|
||||
async (userId: string, environmentId: string, contactId: string): Promise<boolean> => {
|
||||
validateInputs([userId, ZString], [environmentId, ZId], [contactId, ZId]);
|
||||
|
||||
const contactAttribute = await prisma.contactAttribute.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
{
|
||||
NOT: {
|
||||
contactId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return !!contactAttribute;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { updateAttributes } from "./attributes";
|
||||
import { getContactAttributeKeys } from "./contact-attribute-keys";
|
||||
import { getContactAttributes } from "./contact-attributes";
|
||||
@@ -16,7 +16,7 @@ describe("updateContactAttributes", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should update contact attributes successfully", async () => {
|
||||
test("should update contact attributes with deleteRemovedAttributes: true", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const userId = "user123";
|
||||
@@ -91,13 +91,14 @@ describe("updateContactAttributes", () => {
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId);
|
||||
expect(getContactAttributeKeys).toHaveBeenCalledWith(environmentId);
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes);
|
||||
// Should call updateAttributes with deleteRemovedAttributes: true for UI form updates
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes, true);
|
||||
expect(getContactAttributes).toHaveBeenCalledWith(contactId);
|
||||
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
|
||||
expect(result.updatedAttributeKeys).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should detect new attribute keys when created", async () => {
|
||||
test("should detect new attribute keys when created", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const userId = "user123";
|
||||
@@ -184,7 +185,7 @@ describe("updateContactAttributes", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle missing userId with warning message", async () => {
|
||||
test("should handle missing userId gracefully", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const attributes = {
|
||||
@@ -226,13 +227,13 @@ describe("updateContactAttributes", () => {
|
||||
|
||||
const result = await updateContactAttributes(contactId, attributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes);
|
||||
expect(result.messages).toContain(
|
||||
"Warning: userId attribute is missing. Some operations may not work correctly."
|
||||
);
|
||||
// When userId is not in attributes, pass empty string to updateAttributes
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes, true);
|
||||
// No warning message - the backend now gracefully handles missing userId by keeping current value
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should merge messages from updateAttributes", async () => {
|
||||
test("should merge messages from updateAttributes", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const userId = "user123";
|
||||
@@ -279,7 +280,7 @@ describe("updateContactAttributes", () => {
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
});
|
||||
|
||||
it("should throw error if contact not found", async () => {
|
||||
test("should throw error if contact not found", async () => {
|
||||
const contactId = "contact123";
|
||||
const attributes = {
|
||||
firstName: "John",
|
||||
|
||||
@@ -13,11 +13,6 @@ export interface UpdateContactAttributesResult {
|
||||
updatedAttributeKeys?: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates contact attributes for a single contact.
|
||||
* Handles loading contact data, extracting userId, calling updateAttributes,
|
||||
* and detecting if new attribute keys were created.
|
||||
*/
|
||||
export const updateContactAttributes = async (
|
||||
contactId: string,
|
||||
attributes: TContactAttributes
|
||||
@@ -35,16 +30,13 @@ export const updateContactAttributes = async (
|
||||
const userId = attributes.userId ?? "";
|
||||
const messages: string[] = [];
|
||||
|
||||
if (!attributes.userId) {
|
||||
messages.push("Warning: userId attribute is missing. Some operations may not work correctly.");
|
||||
}
|
||||
|
||||
// Get current attribute keys before update to detect new ones
|
||||
const currentAttributeKeys = await getContactAttributeKeys(environmentId);
|
||||
const currentKeysSet = new Set(currentAttributeKeys.map((key) => key.key));
|
||||
|
||||
// Call the existing updateAttributes function
|
||||
const updateResult = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
// Call updateAttributes with deleteRemovedAttributes: true
|
||||
// UI forms submit all attributes, so any missing attribute should be deleted
|
||||
const updateResult = await updateAttributes(contactId, userId, environmentId, attributes, true);
|
||||
|
||||
// Merge any messages from updateAttributes
|
||||
if (updateResult.messages) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactWithAttributes, TTransformPersonInput } from "@/modules/ee/contacts/types/contact";
|
||||
|
||||
export const getContactIdentifier = (contactAttributes: TContactAttributes | null): string => {
|
||||
return contactAttributes?.email ?? contactAttributes?.userId ?? "";
|
||||
return contactAttributes?.email || contactAttributes?.userId || "";
|
||||
};
|
||||
|
||||
export const convertPrismaContactAttributes = (
|
||||
|
||||
@@ -335,7 +335,34 @@ export const ZEditContactAttributesForm = z.object({
|
||||
}
|
||||
});
|
||||
|
||||
// Validate email format if key is "email"
|
||||
// Check that at least one of email or userId has a value
|
||||
const emailAttr = attributes.find((attr) => attr.key === "email");
|
||||
const userIdAttr = attributes.find((attr) => attr.key === "userId");
|
||||
const hasEmail = emailAttr?.value && emailAttr.value.trim() !== "";
|
||||
const hasUserId = userIdAttr?.value && userIdAttr.value.trim() !== "";
|
||||
|
||||
if (!hasEmail && !hasUserId) {
|
||||
// Find the indices to show errors on the relevant fields
|
||||
const emailIndex = attributes.findIndex((attr) => attr.key === "email");
|
||||
const userIdIndex = attributes.findIndex((attr) => attr.key === "userId");
|
||||
|
||||
// When both are empty, show "Either email or userId is required" on both fields
|
||||
if (emailIndex !== -1 && userIdIndex !== -1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Either email or userId is required",
|
||||
path: [emailIndex, "value"],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Either email or userId is required",
|
||||
path: [userIdIndex, "value"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format if key is "email" and has a value
|
||||
attributes.forEach((attr, index) => {
|
||||
if (attr.key === "email" && attr.value && attr.value.trim() !== "") {
|
||||
const emailResult = z.string().email().safeParse(attr.value);
|
||||
|
||||
@@ -11,12 +11,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
|
||||
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
|
||||
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
@@ -398,6 +400,19 @@ export const MultipleChoiceElementForm = ({
|
||||
surveyLanguageCodes={surveyLanguageCodes}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{/* Validation Rules Editor - only for MultipleChoiceMulti */}
|
||||
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && (
|
||||
<ValidationRulesEditor
|
||||
elementType={TSurveyElementTypeEnum.MultipleChoiceMulti}
|
||||
validationRules={element.validationRules ?? []}
|
||||
onUpdateRules={(rules: TValidationRule[]) => {
|
||||
updateElement(elementIdx, {
|
||||
validationRules: rules,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,11 +4,17 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
||||
import { JSX, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyOpenTextElement, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyOpenTextElement,
|
||||
TSurveyOpenTextElementInputType,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -245,6 +251,17 @@ export const OpenElementForm = ({
|
||||
customContainerClass="p-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Rules Editor */}
|
||||
<ValidationRulesEditor
|
||||
elementType={TSurveyElementTypeEnum.OpenText}
|
||||
validationRules={element.validationRules ?? []}
|
||||
onUpdateRules={(rules: TValidationRule[]) => {
|
||||
updateElement(elementIdx, {
|
||||
validationRules: rules,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
APPLICABLE_RULES,
|
||||
TValidationRule,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface ValidationRulesEditorProps {
|
||||
elementType: TSurveyElementTypeEnum;
|
||||
validationRules: TValidationRule[];
|
||||
onUpdateRules: (rules: TValidationRule[]) => void;
|
||||
}
|
||||
|
||||
// Rule type definitions with labels and whether they need a value input
|
||||
const RULE_TYPE_CONFIG: Record<
|
||||
TValidationRuleType,
|
||||
{
|
||||
label: string;
|
||||
needsValue: boolean;
|
||||
valueType?: "number" | "text";
|
||||
valuePlaceholder?: string;
|
||||
unitOptions?: { value: string; label: string }[];
|
||||
}
|
||||
> = {
|
||||
required: {
|
||||
label: "Is not empty",
|
||||
needsValue: false,
|
||||
},
|
||||
minLength: {
|
||||
label: "Is longer than",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "100",
|
||||
unitOptions: [
|
||||
{ value: "characters", label: "characters" },
|
||||
],
|
||||
},
|
||||
maxLength: {
|
||||
label: "Is shorter than",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "500",
|
||||
unitOptions: [
|
||||
{ value: "characters", label: "characters" },
|
||||
],
|
||||
},
|
||||
pattern: {
|
||||
label: "Matches pattern",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
valuePlaceholder: "^[A-Z].*",
|
||||
},
|
||||
email: {
|
||||
label: "Is valid email",
|
||||
needsValue: false,
|
||||
},
|
||||
url: {
|
||||
label: "Is valid URL",
|
||||
needsValue: false,
|
||||
},
|
||||
phone: {
|
||||
label: "Is valid phone",
|
||||
needsValue: false,
|
||||
},
|
||||
minValue: {
|
||||
label: "Is greater than",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "0",
|
||||
},
|
||||
maxValue: {
|
||||
label: "Is less than",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "100",
|
||||
},
|
||||
minSelections: {
|
||||
label: "At least",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "1",
|
||||
unitOptions: [{ value: "options", label: "options selected" }],
|
||||
},
|
||||
maxSelections: {
|
||||
label: "At most",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "3",
|
||||
unitOptions: [{ value: "options", label: "options selected" }],
|
||||
},
|
||||
};
|
||||
|
||||
// Get available rule types for an element type
|
||||
const getAvailableRuleTypes = (
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
existingRules: TValidationRule[]
|
||||
): TValidationRuleType[] => {
|
||||
const elementTypeKey = elementType.toString();
|
||||
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
|
||||
|
||||
// Filter out rules that are already added (for non-repeatable rules)
|
||||
const existingTypes = new Set(existingRules.map((r) => r.params.type));
|
||||
|
||||
return applicable.filter((ruleType) => {
|
||||
// Allow only one of each rule type
|
||||
return !existingTypes.has(ruleType);
|
||||
});
|
||||
};
|
||||
|
||||
// Get the value from rule params based on rule type
|
||||
const getRuleValue = (rule: TValidationRule): number | string | undefined => {
|
||||
const params = rule.params as Record<string, unknown>;
|
||||
if ("min" in params) return params.min as number;
|
||||
if ("max" in params) return params.max as number;
|
||||
if ("pattern" in params) return params.pattern as string;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Create params object from rule type and value
|
||||
const createRuleParams = (
|
||||
ruleType: TValidationRuleType,
|
||||
value?: number | string
|
||||
): TValidationRule["params"] => {
|
||||
switch (ruleType) {
|
||||
case "required":
|
||||
return { type: "required" };
|
||||
case "minLength":
|
||||
return { type: "minLength", min: Number(value) || 0 };
|
||||
case "maxLength":
|
||||
return { type: "maxLength", max: Number(value) || 100 };
|
||||
case "pattern":
|
||||
return { type: "pattern", pattern: String(value) || "" };
|
||||
case "email":
|
||||
return { type: "email" };
|
||||
case "url":
|
||||
return { type: "url" };
|
||||
case "phone":
|
||||
return { type: "phone" };
|
||||
case "minValue":
|
||||
return { type: "minValue", min: Number(value) || 0 };
|
||||
case "maxValue":
|
||||
return { type: "maxValue", max: Number(value) || 100 };
|
||||
case "minSelections":
|
||||
return { type: "minSelections", min: Number(value) || 1 };
|
||||
case "maxSelections":
|
||||
return { type: "maxSelections", max: Number(value) || 3 };
|
||||
default:
|
||||
return { type: "required" };
|
||||
}
|
||||
};
|
||||
|
||||
export const ValidationRulesEditor = ({
|
||||
elementType,
|
||||
validationRules,
|
||||
onUpdateRules,
|
||||
}: ValidationRulesEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isEnabled = validationRules.length > 0;
|
||||
|
||||
const handleEnable = () => {
|
||||
const availableRules = getAvailableRuleTypes(elementType, []);
|
||||
if (availableRules.length > 0) {
|
||||
const defaultRuleType = availableRules[0];
|
||||
const newRule: TValidationRule = {
|
||||
id: createId(),
|
||||
params: createRuleParams(defaultRuleType),
|
||||
enabled: true,
|
||||
};
|
||||
onUpdateRules([newRule]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
onUpdateRules([]);
|
||||
};
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (checked) {
|
||||
handleEnable();
|
||||
} else {
|
||||
handleDisable();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
const availableRules = getAvailableRuleTypes(elementType, validationRules);
|
||||
if (availableRules.length === 0) return;
|
||||
|
||||
const newRuleType = availableRules[0];
|
||||
const newRule: TValidationRule = {
|
||||
id: createId(),
|
||||
params: createRuleParams(newRuleType),
|
||||
enabled: true,
|
||||
};
|
||||
onUpdateRules([...validationRules, newRule]);
|
||||
};
|
||||
|
||||
const handleDeleteRule = (ruleId: string) => {
|
||||
const updated = validationRules.filter((r) => r.id !== ruleId);
|
||||
onUpdateRules(updated);
|
||||
};
|
||||
|
||||
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
params: createRuleParams(newType),
|
||||
};
|
||||
});
|
||||
onUpdateRules(updated);
|
||||
};
|
||||
|
||||
const handleRuleValueChange = (ruleId: string, value: string) => {
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
const ruleType = rule.params.type;
|
||||
const config = RULE_TYPE_CONFIG[ruleType];
|
||||
const parsedValue = config.valueType === "number" ? Number(value) || 0 : value;
|
||||
return {
|
||||
...rule,
|
||||
params: createRuleParams(ruleType, parsedValue),
|
||||
};
|
||||
});
|
||||
onUpdateRules(updated);
|
||||
};
|
||||
|
||||
const availableRulesForAdd = getAvailableRuleTypes(elementType, validationRules);
|
||||
const canAddMore = availableRulesForAdd.length > 0;
|
||||
|
||||
return (
|
||||
<AdvancedOptionToggle
|
||||
isChecked={isEnabled}
|
||||
onToggle={handleToggle}
|
||||
htmlId="validation-rules-toggle"
|
||||
title={t("environments.surveys.edit.validation_rules")}
|
||||
description={t("environments.surveys.edit.validation_rules_description")}
|
||||
customContainerClass="p-0 mt-4"
|
||||
childrenContainerClass="flex-col p-3 gap-2">
|
||||
{validationRules.map((rule, index) => {
|
||||
const ruleType = rule.params.type;
|
||||
const config = RULE_TYPE_CONFIG[ruleType];
|
||||
const currentValue = getRuleValue(rule);
|
||||
|
||||
// Get available types for this rule (current type + unused types, no duplicates)
|
||||
const otherAvailableTypes = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules.filter((r) => r.id !== rule.id)
|
||||
).filter((t) => t !== ruleType);
|
||||
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
|
||||
|
||||
return (
|
||||
<div key={rule.id} className="flex w-full items-center gap-2">
|
||||
{/* Rule Type Selector */}
|
||||
<Select value={ruleType} onValueChange={(value) => handleRuleTypeChange(rule.id, value as TValidationRuleType)}>
|
||||
<SelectTrigger className={config.needsValue ? "w-[160px]" : "flex-1"}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypesForSelect.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{RULE_TYPE_CONFIG[type].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Value Input (if needed) */}
|
||||
{config.needsValue && (
|
||||
<>
|
||||
<Input
|
||||
type={config.valueType === "number" ? "number" : "text"}
|
||||
value={currentValue ?? ""}
|
||||
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
|
||||
placeholder={config.valuePlaceholder}
|
||||
className="w-[80px] bg-white"
|
||||
min={config.valueType === "number" ? 0 : undefined}
|
||||
/>
|
||||
|
||||
{/* Unit selector (if applicable) */}
|
||||
{config.unitOptions && config.unitOptions.length > 0 && (
|
||||
<Select value={config.unitOptions[0].value} disabled>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.unitOptions.map((unit) => (
|
||||
<SelectItem key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="shrink-0">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Add button (only on last row and if can add more) */}
|
||||
{index === validationRules.length - 1 && canAddMore && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleAddRule}
|
||||
className="shrink-0">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</AdvancedOptionToggle>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -133,15 +133,32 @@ const nextConfig = {
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const scriptSrcUnsafeEval = isProduction ? "" : " 'unsafe-eval'";
|
||||
|
||||
const cspBase = `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`;
|
||||
|
||||
return [
|
||||
{
|
||||
// Apply X-Frame-Options to all routes except those starting with /s/ or /c/
|
||||
// Apply X-Frame-Options and restricted frame-ancestors to all routes except those starting with /s/ or /c/
|
||||
source: "/((?!s/|c/).*)",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: `${cspBase}; frame-ancestors 'self'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Allow surveys (/s/*) and contact survey links (/c/*) to be embedded in iframes on any domain
|
||||
// Note: These routes need frame-ancestors * to support embedding surveys in customer websites
|
||||
source: "/(s|c)/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: `${cspBase}; frame-ancestors *`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -179,10 +196,6 @@ const nextConfig = {
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
@@ -458,7 +471,5 @@ const sentryOptions = {
|
||||
// Runtime Sentry reporting still depends on DSN being set via environment variables
|
||||
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
|
||||
|
||||
console.log("BASE PATH", nextConfig.basePath);
|
||||
|
||||
|
||||
export default exportConfig;
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"db:migrate:deploy": "turbo run db:migrate:deploy",
|
||||
"db:start": "turbo run db:start",
|
||||
"db:push": "turbo run db:push",
|
||||
"db:seed": "turbo run db:seed",
|
||||
"db:seed:clear": "turbo run db:seed -- -- --clear",
|
||||
"db:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||
"go": "pnpm db:up && turbo run go --concurrency 20",
|
||||
|
||||
@@ -82,6 +82,12 @@ Run these commands from the root directory of the Formbricks monorepo:
|
||||
- Generates new `migration.sql` in the custom directory
|
||||
- Copies migration to Prisma's internal directory
|
||||
- Applies all pending migrations to the database
|
||||
- **`pnpm db:seed`**: Seed the database with sample data
|
||||
- Upserts base infrastructure (Organization, Project, Environments)
|
||||
- Creates multi-role users (Admin, Manager)
|
||||
- Generates complex surveys and sample responses
|
||||
- **`pnpm db:seed:clear`**: Clear all seeded data and re-seed
|
||||
- **WARNING**: This will delete existing data in the database.
|
||||
|
||||
### Package Level Commands
|
||||
|
||||
@@ -92,6 +98,8 @@ Run these commands from the `packages/database` directory:
|
||||
- Creates new subdirectory with appropriate timestamp
|
||||
- Generates `migration.ts` file with pre-configured ID and name
|
||||
- **Note**: Only use Prisma raw queries in data migrations for better performance and to avoid type errors
|
||||
- **`pnpm db:seed`**: Run the seeding script
|
||||
- **`pnpm db:seed:clear`**: Clear data and run the seeding script
|
||||
|
||||
### Available Scripts
|
||||
|
||||
@@ -102,13 +110,41 @@ Run these commands from the `packages/database` directory:
|
||||
"db:migrate:deploy": "Apply migrations in production",
|
||||
"db:migrate:dev": "Apply migrations in development",
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
|
||||
"db:seed": "Seed the database with sample data",
|
||||
"db:seed:clear": "Clear all data and re-seed",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
|
||||
"dev": "vite build --watch",
|
||||
"generate": "prisma generate",
|
||||
"generate-data-migration": "Create new data migration"
|
||||
}
|
||||
```
|
||||
|
||||
## Database Seeding
|
||||
|
||||
The seeding system provides a quick way to set up a functional environment for development, QA, and testing.
|
||||
|
||||
### Safety Guard
|
||||
|
||||
To prevent accidental data loss in production, seeding is blocked if `NODE_ENV=production`. If you explicitly need to seed a production-like environment (e.g., staging), you must set:
|
||||
|
||||
```bash
|
||||
ALLOW_SEED=true
|
||||
```
|
||||
|
||||
### Seeding Logic
|
||||
|
||||
The `pnpm db:seed` script:
|
||||
1. **Infrastructure**: Upserts a default organization, project, and environments.
|
||||
2. **Users**: Creates default users with the following credentials (passwords are hashed):
|
||||
- **Admin**: `admin@formbricks.com` / `password123`
|
||||
- **Manager**: `manager@formbricks.com` / `password123`
|
||||
3. **Surveys**: Creates complex sample surveys (Kitchen Sink, CSAT, Draft, etc.) in the **Production** environment.
|
||||
4. **Responses**: Generates ~50 realistic responses and displays for each survey.
|
||||
|
||||
### Idempotency
|
||||
|
||||
By default, the seed script uses `upsert` to ensure it can be run multiple times without creating duplicate infrastructure. To perform a clean reset, use `pnpm db:seed:clear`.
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
### Adding a Schema Migration
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
"./zod/*": {
|
||||
"import": "./zod/*.ts"
|
||||
},
|
||||
"./seed/constants": {
|
||||
"import": "./src/seed/constants.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -33,7 +36,9 @@
|
||||
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" node ./dist/scripts/create-saml-database.js",
|
||||
"db:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
|
||||
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
|
||||
"db:seed:clear": "dotenv -e ../../.env -- tsx src/seed.ts --clear",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
|
||||
"db:start": "pnpm db:setup",
|
||||
"format": "prisma format",
|
||||
"generate": "prisma generate",
|
||||
@@ -45,17 +50,20 @@
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@prisma/client": "6.14.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"zod": "3.24.4",
|
||||
"zod-openapi": "4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"dotenv-cli": "8.0.0",
|
||||
"glob": "11.1.0",
|
||||
"prisma": "6.14.0",
|
||||
"prisma-json-types-generator": "3.5.4",
|
||||
"ts-node": "10.9.2",
|
||||
"tsx": "4.19.2",
|
||||
"vite": "6.4.1",
|
||||
"vite-plugin-dts": "4.5.3"
|
||||
}
|
||||
|
||||
596
packages/database/src/seed.ts
Normal file
596
packages/database/src/seed.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { type Prisma, PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { SEED_CREDENTIALS, SEED_IDS } from "./seed/constants";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const allowSeed = process.env.ALLOW_SEED === "true";
|
||||
|
||||
if (isProduction && !allowSeed) {
|
||||
logger.error("ERROR: Seeding blocked in production. Set ALLOW_SEED=true to override.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const clearData = process.argv.includes("--clear");
|
||||
|
||||
// Define local types to avoid resolution issues in seed script
|
||||
type SurveyElementType =
|
||||
| "openText"
|
||||
| "multipleChoiceSingle"
|
||||
| "multipleChoiceMulti"
|
||||
| "nps"
|
||||
| "cta"
|
||||
| "rating"
|
||||
| "consent"
|
||||
| "date"
|
||||
| "matrix"
|
||||
| "address"
|
||||
| "ranking"
|
||||
| "contactInfo";
|
||||
|
||||
interface SurveyQuestion {
|
||||
id: string;
|
||||
type: SurveyElementType;
|
||||
headline: { default: string; [key: string]: string };
|
||||
subheader?: { default: string; [key: string]: string };
|
||||
required?: boolean;
|
||||
placeholder?: { default: string; [key: string]: string };
|
||||
longAnswer?: boolean;
|
||||
choices?: { id: string; label: { default: string }; imageUrl?: string }[];
|
||||
lowerLabel?: { default: string };
|
||||
upperLabel?: { default: string };
|
||||
buttonLabel?: { default: string };
|
||||
buttonUrl?: string;
|
||||
buttonExternal?: boolean;
|
||||
dismissButtonLabel?: { default: string };
|
||||
ctaButtonLabel?: { default: string };
|
||||
scale?: string;
|
||||
range?: number;
|
||||
label?: { default: string };
|
||||
allowMulti?: boolean;
|
||||
format?: string;
|
||||
rows?: { id: string; label: { default: string } }[];
|
||||
columns?: { id: string; label: { default: string } }[];
|
||||
addressLine1?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
addressLine2?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
city?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
state?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
zip?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
country?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
firstName?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
lastName?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
email?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
phone?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
company?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
allowMultipleFiles?: boolean;
|
||||
maxSizeInMB?: number;
|
||||
}
|
||||
|
||||
async function deleteData(): Promise<void> {
|
||||
logger.info("Clearing existing data...");
|
||||
|
||||
const deleteOrder: Prisma.TypeMap["meta"]["modelProps"][] = [
|
||||
"responseQuotaLink",
|
||||
"surveyQuota",
|
||||
"tagsOnResponses",
|
||||
"tag",
|
||||
"surveyFollowUp",
|
||||
"response",
|
||||
"display",
|
||||
"surveyTrigger",
|
||||
"surveyAttributeFilter",
|
||||
"surveyLanguage",
|
||||
"survey",
|
||||
"actionClass",
|
||||
"contactAttribute",
|
||||
"contactAttributeKey",
|
||||
"contact",
|
||||
"apiKeyEnvironment",
|
||||
"apiKey",
|
||||
"segment",
|
||||
"webhook",
|
||||
"integration",
|
||||
"projectTeam",
|
||||
"teamUser",
|
||||
"team",
|
||||
"project",
|
||||
"invite",
|
||||
"membership",
|
||||
"account",
|
||||
"user",
|
||||
"organization",
|
||||
];
|
||||
|
||||
for (const model of deleteOrder) {
|
||||
try {
|
||||
// @ts-expect-error - prisma[model] is not typed correctly
|
||||
await prisma[model].deleteMany();
|
||||
} catch (e: unknown) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`Could not delete data from ${model}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Data cleared.");
|
||||
}
|
||||
|
||||
const KITCHEN_SINK_QUESTIONS: SurveyQuestion[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: "openText",
|
||||
headline: { default: "What do you think of Formbricks?" },
|
||||
subheader: { default: "Please be honest!" },
|
||||
required: true,
|
||||
placeholder: { default: "Your feedback here..." },
|
||||
longAnswer: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "multipleChoiceSingle",
|
||||
headline: { default: "How often do you use Formbricks?" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Daily" } },
|
||||
{ id: createId(), label: { default: "Weekly" } },
|
||||
{ id: createId(), label: { default: "Monthly" } },
|
||||
{ id: createId(), label: { default: "Rarely" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "multipleChoiceMulti",
|
||||
headline: { default: "Which features do you use?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Surveys" } },
|
||||
{ id: createId(), label: { default: "Analytics" } },
|
||||
{ id: createId(), label: { default: "Integrations" } },
|
||||
{ id: createId(), label: { default: "Action Tracking" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "nps",
|
||||
headline: { default: "How likely are you to recommend Formbricks?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "cta",
|
||||
headline: { default: "Check out our documentation!" },
|
||||
required: true,
|
||||
ctaButtonLabel: { default: "Go to Docs" },
|
||||
buttonUrl: "https://formbricks.com/docs",
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "rating",
|
||||
headline: { default: "Rate your overall experience" },
|
||||
required: true,
|
||||
scale: "star",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "consent",
|
||||
headline: { default: "Do you agree to our terms?" },
|
||||
required: true,
|
||||
label: { default: "I agree to the terms and conditions" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "date",
|
||||
headline: { default: "When did you start using Formbricks?" },
|
||||
required: true,
|
||||
format: "M-d-y",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "matrix",
|
||||
headline: { default: "How do you feel about these aspects?" },
|
||||
required: true,
|
||||
rows: [
|
||||
{ id: createId(), label: { default: "UI Design" } },
|
||||
{ id: createId(), label: { default: "Performance" } },
|
||||
{ id: createId(), label: { default: "Documentation" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: createId(), label: { default: "Disappointed" } },
|
||||
{ id: createId(), label: { default: "Neutral" } },
|
||||
{ id: createId(), label: { default: "Satisfied" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "address",
|
||||
headline: { default: "Where are you located?" },
|
||||
required: true,
|
||||
addressLine1: { show: true, required: true, placeholder: { default: "Address Line 1" } },
|
||||
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
|
||||
city: { show: true, required: true, placeholder: { default: "City" } },
|
||||
state: { show: true, required: true, placeholder: { default: "State" } },
|
||||
zip: { show: true, required: true, placeholder: { default: "Zip" } },
|
||||
country: { show: true, required: true, placeholder: { default: "Country" } },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "ranking",
|
||||
headline: { default: "Rank these features" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Feature A" } },
|
||||
{ id: createId(), label: { default: "Feature B" } },
|
||||
{ id: createId(), label: { default: "Feature C" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "contactInfo",
|
||||
headline: { default: "How can we reach you?" },
|
||||
required: true,
|
||||
firstName: { show: true, required: true, placeholder: { default: "First Name" } },
|
||||
lastName: { show: true, required: true, placeholder: { default: "Last Name" } },
|
||||
email: { show: true, required: true, placeholder: { default: "Email" } },
|
||||
phone: { show: true, required: false, placeholder: { default: "Phone" } },
|
||||
company: { show: true, required: false, placeholder: { default: "Company" } },
|
||||
},
|
||||
];
|
||||
|
||||
interface SurveyBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
elements: SurveyQuestion[];
|
||||
}
|
||||
|
||||
type ResponseValue = string | number | string[] | Record<string, string>;
|
||||
|
||||
const generateQuestionResponse = (q: SurveyQuestion, index: number): ResponseValue | undefined => {
|
||||
const responseGenerators: Record<SurveyElementType, () => ResponseValue | undefined> = {
|
||||
openText: () => `Sample response ${String(index)}`,
|
||||
multipleChoiceSingle: () =>
|
||||
q.choices ? q.choices[Math.floor(Math.random() * q.choices.length)].label.default : undefined,
|
||||
multipleChoiceMulti: () =>
|
||||
q.choices ? [q.choices[0].label.default, q.choices[1].label.default] : undefined,
|
||||
nps: () => Math.floor(Math.random() * 11),
|
||||
rating: () => (q.range ? Math.floor(Math.random() * q.range) + 1 : undefined),
|
||||
cta: () => "clicked",
|
||||
consent: () => "accepted",
|
||||
date: () => new Date().toISOString().split("T")[0],
|
||||
matrix: () => {
|
||||
const matrixData: Record<string, string> = {};
|
||||
if (q.rows && q.columns) {
|
||||
for (const row of q.rows) {
|
||||
matrixData[row.label.default] =
|
||||
q.columns[Math.floor(Math.random() * q.columns.length)].label.default;
|
||||
}
|
||||
}
|
||||
return matrixData;
|
||||
},
|
||||
ranking: () =>
|
||||
q.choices ? q.choices.map((c) => c.label.default).sort(() => Math.random() - 0.5) : undefined,
|
||||
address: () => ({
|
||||
addressLine1: "Main St 1",
|
||||
city: "Berlin",
|
||||
state: "Berlin",
|
||||
zip: "10115",
|
||||
country: "Germany",
|
||||
}),
|
||||
contactInfo: () => ({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: `john.doe.${String(index)}@example.com`,
|
||||
}),
|
||||
};
|
||||
|
||||
return responseGenerators[q.type]();
|
||||
};
|
||||
|
||||
async function generateResponses(surveyId: string, count: number): Promise<void> {
|
||||
logger.info(`Generating ${String(count)} responses for survey ${surveyId}...`);
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
});
|
||||
|
||||
if (!survey) return;
|
||||
|
||||
const blocks = survey.blocks as unknown as SurveyBlock[];
|
||||
const questions = blocks.flatMap((block) => block.elements);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const data: Record<string, ResponseValue> = {};
|
||||
for (const q of questions) {
|
||||
const response = generateQuestionResponse(q, i);
|
||||
if (response !== undefined) {
|
||||
data[q.id] = response;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const display = await tx.display.create({
|
||||
data: {
|
||||
surveyId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.response.create({
|
||||
data: {
|
||||
surveyId,
|
||||
finished: true,
|
||||
// @ts-expect-error - data is not typed correctly
|
||||
data: data as unknown as Prisma.InputJsonValue,
|
||||
displayId: display.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Generate some displays without responses (e.g., 30% more)
|
||||
const extraDisplays = Math.floor(count * 0.3);
|
||||
logger.info(`Generating ${String(extraDisplays)} extra displays for survey ${surveyId}...`);
|
||||
|
||||
for (let i = 0; i < extraDisplays; i++) {
|
||||
await prisma.display.create({
|
||||
data: {
|
||||
surveyId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (clearData) {
|
||||
await deleteData();
|
||||
}
|
||||
|
||||
logger.info("Seeding base infrastructure...");
|
||||
|
||||
// Organization
|
||||
const organization = await prisma.organization.upsert({
|
||||
where: { id: SEED_IDS.ORGANIZATION },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.ORGANIZATION,
|
||||
name: "Seed Organization",
|
||||
billing: {
|
||||
plan: "free",
|
||||
limits: { projects: 3, monthly: { responses: 1500, miu: 2000 } },
|
||||
stripeCustomerId: null,
|
||||
periodStart: new Date(),
|
||||
period: "monthly",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Users
|
||||
const passwordHash = await bcrypt.hash(SEED_CREDENTIALS.ADMIN.password, 10);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.USER_ADMIN },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.USER_ADMIN,
|
||||
name: "Admin User",
|
||||
email: SEED_CREDENTIALS.ADMIN.email,
|
||||
password: passwordHash,
|
||||
emailVerified: new Date(),
|
||||
memberships: {
|
||||
create: {
|
||||
organizationId: organization.id,
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.USER_MANAGER },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.USER_MANAGER,
|
||||
name: "Manager User",
|
||||
email: SEED_CREDENTIALS.MANAGER.email,
|
||||
password: passwordHash,
|
||||
emailVerified: new Date(),
|
||||
memberships: {
|
||||
create: {
|
||||
organizationId: organization.id,
|
||||
role: "manager",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.USER_MEMBER },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.USER_MEMBER,
|
||||
name: "Member User",
|
||||
email: SEED_CREDENTIALS.MEMBER.email,
|
||||
password: passwordHash,
|
||||
emailVerified: new Date(),
|
||||
memberships: {
|
||||
create: {
|
||||
organizationId: organization.id,
|
||||
role: "member",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Project
|
||||
const project = await prisma.project.upsert({
|
||||
where: { id: SEED_IDS.PROJECT },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.PROJECT,
|
||||
name: "Seed Project",
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Environments
|
||||
await prisma.environment.upsert({
|
||||
where: { id: SEED_IDS.ENV_DEV },
|
||||
update: { appSetupCompleted: false },
|
||||
create: {
|
||||
id: SEED_IDS.ENV_DEV,
|
||||
type: "development",
|
||||
projectId: project.id,
|
||||
appSetupCompleted: false,
|
||||
attributeKeys: {
|
||||
create: [
|
||||
{ name: "Email", key: "email", isUnique: true, type: "default" },
|
||||
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
|
||||
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
|
||||
{ name: "userId", key: "userId", isUnique: true, type: "default" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const prodEnv = await prisma.environment.upsert({
|
||||
where: { id: SEED_IDS.ENV_PROD },
|
||||
update: { appSetupCompleted: false },
|
||||
create: {
|
||||
id: SEED_IDS.ENV_PROD,
|
||||
type: "production",
|
||||
projectId: project.id,
|
||||
appSetupCompleted: false,
|
||||
attributeKeys: {
|
||||
create: [
|
||||
{ name: "Email", key: "email", isUnique: true, type: "default" },
|
||||
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
|
||||
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
|
||||
{ name: "userId", key: "userId", isUnique: true, type: "default" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Seeding surveys...");
|
||||
|
||||
const createSurveyWithBlocks = async (
|
||||
id: string,
|
||||
name: string,
|
||||
environmentId: string,
|
||||
status: "inProgress" | "draft" | "completed",
|
||||
questions: SurveyQuestion[]
|
||||
): Promise<void> => {
|
||||
const blocks = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Main Block",
|
||||
elements: questions,
|
||||
},
|
||||
];
|
||||
|
||||
await prisma.survey.upsert({
|
||||
where: { id },
|
||||
update: {
|
||||
environmentId,
|
||||
type: "link",
|
||||
// @ts-expect-error - blocks is not typed correctly
|
||||
blocks: blocks as unknown as Prisma.InputJsonValue[],
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
name,
|
||||
environmentId,
|
||||
status,
|
||||
type: "link",
|
||||
// @ts-expect-error - blocks is not typed correctly
|
||||
blocks: blocks as unknown as Prisma.InputJsonValue[],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Kitchen Sink Survey
|
||||
await createSurveyWithBlocks(
|
||||
SEED_IDS.SURVEY_KITCHEN_SINK,
|
||||
"Kitchen Sink Survey",
|
||||
prodEnv.id,
|
||||
"inProgress",
|
||||
KITCHEN_SINK_QUESTIONS
|
||||
);
|
||||
|
||||
// CSAT Survey
|
||||
await createSurveyWithBlocks(SEED_IDS.SURVEY_CSAT, "CSAT Survey", prodEnv.id, "inProgress", [
|
||||
{
|
||||
id: createId(),
|
||||
type: "rating",
|
||||
headline: { default: "How satisfied are you with our product?" },
|
||||
required: true,
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
},
|
||||
]);
|
||||
|
||||
// Draft Survey
|
||||
await createSurveyWithBlocks(SEED_IDS.SURVEY_DRAFT, "Draft Survey", prodEnv.id, "draft", [
|
||||
{
|
||||
id: createId(),
|
||||
type: "openText",
|
||||
headline: { default: "Coming soon..." },
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Completed Survey
|
||||
await createSurveyWithBlocks(SEED_IDS.SURVEY_COMPLETED, "Exit Survey", prodEnv.id, "completed", [
|
||||
{
|
||||
id: createId(),
|
||||
type: "multipleChoiceSingle",
|
||||
headline: { default: "Why are you leaving?" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Too expensive" } },
|
||||
{ id: createId(), label: { default: "Found a better alternative" } },
|
||||
{ id: createId(), label: { default: "Missing features" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
logger.info("Generating responses...");
|
||||
|
||||
await generateResponses(SEED_IDS.SURVEY_KITCHEN_SINK, 50);
|
||||
await generateResponses(SEED_IDS.SURVEY_CSAT, 50);
|
||||
await generateResponses(SEED_IDS.SURVEY_COMPLETED, 50);
|
||||
|
||||
logger.info(`\n${"=".repeat(50)}`);
|
||||
logger.info("🚀 SEEDING COMPLETED SUCCESSFULLY");
|
||||
logger.info("=".repeat(50));
|
||||
logger.info("\nLog in with the following credentials:");
|
||||
logger.info(`\n Admin (Owner):`);
|
||||
logger.info(` Email: ${SEED_CREDENTIALS.ADMIN.email}`);
|
||||
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
|
||||
logger.info(`\n Manager:`);
|
||||
logger.info(` Email: ${SEED_CREDENTIALS.MANAGER.email}`);
|
||||
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
|
||||
logger.info(`\n Member:`);
|
||||
logger.info(` Email: ${SEED_CREDENTIALS.MEMBER.email}`);
|
||||
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
|
||||
logger.info(`\n${"=".repeat(50)}\n`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e: unknown) => {
|
||||
logger.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch((e: unknown) => {
|
||||
logger.error(e, "Error disconnecting prisma");
|
||||
});
|
||||
});
|
||||
19
packages/database/src/seed/constants.ts
Normal file
19
packages/database/src/seed/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const SEED_IDS = {
|
||||
USER_ADMIN: "clseedadmin000000000000",
|
||||
USER_MANAGER: "clseedmanager0000000000",
|
||||
USER_MEMBER: "clseedmember00000000000",
|
||||
ORGANIZATION: "clseedorg0000000000000",
|
||||
PROJECT: "clseedproject000000000",
|
||||
ENV_DEV: "clseedenvdev0000000000",
|
||||
ENV_PROD: "clseedenvprod000000000",
|
||||
SURVEY_KITCHEN_SINK: "clseedsurveykitchen00",
|
||||
SURVEY_CSAT: "clseedsurveycsat000000",
|
||||
SURVEY_DRAFT: "clseedsurveydraft00000",
|
||||
SURVEY_COMPLETED: "clseedsurveycomplete00",
|
||||
} as const;
|
||||
|
||||
export const SEED_CREDENTIALS = {
|
||||
ADMIN: { email: "admin@formbricks.com", password: "Password#123" },
|
||||
MANAGER: { email: "manager@formbricks.com", password: "Password#123" },
|
||||
MEMBER: { email: "member@formbricks.com", password: "Password#123" },
|
||||
} as const;
|
||||
@@ -172,7 +172,7 @@ function DateElement({
|
||||
onSelect={handleDateSelect}
|
||||
locale={dateLocale}
|
||||
required={required}
|
||||
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
|
||||
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto h-[stretch] w-full max-w-[25rem] border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,8 +44,15 @@
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
||||
"title": "This device doesn’t support spam protection."
|
||||
"title": "This device doesn't support spam protection."
|
||||
},
|
||||
"invalid_format": "Please enter a valid format",
|
||||
"max_length": "Please enter no more than {max} characters",
|
||||
"max_selections": "Please select no more than {max} options",
|
||||
"max_value": "Please enter a value no greater than {max}",
|
||||
"min_length": "Please enter at least {min} characters",
|
||||
"min_selections": "Please select at least {min} options",
|
||||
"min_value": "Please enter a value of at least {min}",
|
||||
"please_book_an_appointment": "Please book an appointment",
|
||||
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
||||
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
||||
|
||||
@@ -17,6 +17,7 @@ interface MultipleChoiceMultiElementProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
errorMessage?: string; // Validation error from centralized validation
|
||||
}
|
||||
|
||||
export function MultipleChoiceMultiElement({
|
||||
@@ -28,10 +29,10 @@ export function MultipleChoiceMultiElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
errorMessage,
|
||||
}: Readonly<MultipleChoiceMultiElementProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [otherValue, setOtherValue] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
||||
const isCurrent = element.id === currentElementId;
|
||||
const { t } = useTranslation();
|
||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
@@ -173,22 +174,9 @@ export function MultipleChoiceMultiElement({
|
||||
onChange({ [element.id]: nextValue });
|
||||
};
|
||||
|
||||
const validateRequired = (): boolean => {
|
||||
if (element.required && (!Array.isArray(value) || value.length === 0)) {
|
||||
setErrorMessage(t("errors.please_select_an_option"));
|
||||
return false;
|
||||
}
|
||||
if (element.required && isOtherSelected && !otherValue.trim()) {
|
||||
setErrorMessage(t("errors.please_fill_out_this_field"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(undefined);
|
||||
if (!validateRequired()) return;
|
||||
// Update TTC when form is submitted (for TTC collection)
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
};
|
||||
@@ -228,7 +216,6 @@ export function MultipleChoiceMultiElement({
|
||||
|
||||
// Handle selection changes - store labels directly instead of IDs
|
||||
const handleMultiSelectChange = (selectedIds: string[]) => {
|
||||
setErrorMessage(undefined);
|
||||
const nextLabels: string[] = [];
|
||||
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OpenText } from "@formbricks/survey-ui";
|
||||
import { ZEmail, ZUrl } from "@formbricks/types/common";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
@@ -18,6 +16,7 @@ interface OpenTextElementProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
errorMessage?: string; // Validation error from centralized validation
|
||||
}
|
||||
|
||||
export function OpenTextElement({
|
||||
@@ -29,76 +28,19 @@ export function OpenTextElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
errorMessage,
|
||||
}: Readonly<OpenTextElementProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
||||
const isCurrent = element.id === currentElementId;
|
||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (inputValue: string) => {
|
||||
// Clear error when user starts typing
|
||||
setErrorMessage(undefined);
|
||||
onChange({ [element.id]: inputValue });
|
||||
};
|
||||
|
||||
const validateRequired = (): boolean => {
|
||||
if (element.required && (!value || value.trim() === "")) {
|
||||
setErrorMessage(t("errors.please_fill_out_this_field"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateEmail = (): boolean => {
|
||||
if (!ZEmail.safeParse(value).success) {
|
||||
setErrorMessage(t("errors.please_enter_a_valid_email_address"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateUrl = (): boolean => {
|
||||
if (!ZUrl.safeParse(value).success) {
|
||||
setErrorMessage(t("errors.please_enter_a_valid_url"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validatePhone = (): boolean => {
|
||||
// Match the same pattern: must start with digit or +, end with digit
|
||||
// Allows digits, +, -, and spaces in between
|
||||
const phoneRegex = /^[0-9+][0-9+\- ]*[0-9]$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
setErrorMessage(t("errors.please_enter_a_valid_phone_number"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateInput = (): boolean => {
|
||||
if (!value || value.trim() === "") return true;
|
||||
|
||||
if (element.inputType === "email") {
|
||||
return validateEmail();
|
||||
}
|
||||
if (element.inputType === "url") {
|
||||
return validateUrl();
|
||||
}
|
||||
if (element.inputType === "phone") {
|
||||
return validatePhone();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleOnSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(undefined);
|
||||
|
||||
if (!validateRequired()) return;
|
||||
if (!validateInput()) return;
|
||||
|
||||
// Update TTC when form is submitted (for TTC collection)
|
||||
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtc);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
TSurveyMatrixElement,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
|
||||
import { BackButton } from "@/components/buttons/back-button";
|
||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { ElementConditional } from "@/components/general/element-conditional";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { validateBlockResponses, getFirstErrorMessage } from "@/lib/validation";
|
||||
|
||||
interface BlockConditionalProps {
|
||||
block: TSurveyBlock;
|
||||
@@ -59,9 +62,14 @@ export function BlockConditional({
|
||||
dir,
|
||||
fullSizeCards,
|
||||
}: BlockConditionalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Track the current element being filled (for TTC tracking)
|
||||
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
|
||||
|
||||
// State to store validation errors from centralized validation
|
||||
const [elementErrors, setElementErrors] = useState<TValidationErrorMap>({});
|
||||
|
||||
// Refs to store form elements for each element so we can trigger their validation
|
||||
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
||||
|
||||
@@ -74,6 +82,14 @@ export function BlockConditional({
|
||||
if (elementId !== currentElementId) {
|
||||
setCurrentElementId(elementId);
|
||||
}
|
||||
// Clear error for this element when user makes a change
|
||||
if (elementErrors[elementId]) {
|
||||
setElementErrors((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[elementId];
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
// Merge with existing block data to preserve other element values
|
||||
onChange({ ...value, ...responseData });
|
||||
};
|
||||
@@ -263,15 +279,34 @@ export function BlockConditional({
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Validate all forms and check for custom validation rules
|
||||
const firstInvalidForm = findFirstInvalidForm();
|
||||
// Run centralized validation for elements that support it (OpenText, MultiSelect)
|
||||
const errorMap = validateBlockResponses(block.elements, value, languageCode, t);
|
||||
|
||||
// If any form is invalid, scroll to it and stop
|
||||
// Check if there are any validation errors from centralized validation
|
||||
const hasValidationErrors = Object.keys(errorMap).length > 0;
|
||||
|
||||
if (hasValidationErrors) {
|
||||
setElementErrors(errorMap);
|
||||
|
||||
// Find the first element with an error and scroll to it
|
||||
const firstErrorElementId = Object.keys(errorMap)[0];
|
||||
const form = elementFormRefs.current.get(firstErrorElementId);
|
||||
if (form) {
|
||||
form.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Also run legacy validation for elements not yet migrated to centralized validation
|
||||
const firstInvalidForm = findFirstInvalidForm();
|
||||
if (firstInvalidForm) {
|
||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any previous errors
|
||||
setElementErrors({});
|
||||
|
||||
// Collect TTC and responses, then submit
|
||||
const blockTtc = collectTtcValues();
|
||||
const blockResponses = collectBlockResponses();
|
||||
@@ -310,6 +345,7 @@ export function BlockConditional({
|
||||
}
|
||||
}}
|
||||
onTtcCollect={handleTtcCollect}
|
||||
errorMessage={getFirstErrorMessage(elementErrors, element.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ interface ElementConditionalProps {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
||||
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
||||
errorMessage?: string; // Validation error message from centralized validation
|
||||
}
|
||||
|
||||
export function ElementConditional({
|
||||
@@ -56,6 +57,7 @@ export function ElementConditional({
|
||||
dir,
|
||||
formRef,
|
||||
onTtcCollect,
|
||||
errorMessage,
|
||||
}: ElementConditionalProps) {
|
||||
// Ref to the container div, used to find and expose the form element inside
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -124,6 +126,7 @@ export function ElementConditional({
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
@@ -154,6 +157,7 @@ export function ElementConditional({
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
|
||||
151
packages/surveys/src/lib/validation/evaluator.ts
Normal file
151
packages/surveys/src/lib/validation/evaluator.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type {
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyOpenTextElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import type {
|
||||
TValidationError,
|
||||
TValidationErrorMap,
|
||||
TValidationResult,
|
||||
TValidationRule,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { validators } from "./validators";
|
||||
|
||||
/**
|
||||
* Check if an element is an OpenText element with inputType
|
||||
*/
|
||||
const isOpenTextElement = (element: TSurveyElement): element is TSurveyOpenTextElement => {
|
||||
return element.type === ("openText" as TSurveyElementTypeEnum);
|
||||
};
|
||||
|
||||
/**
|
||||
* Single entrypoint for validating an element's response value.
|
||||
* Called by block-conditional.tsx during form submission.
|
||||
*
|
||||
* @param element - The survey element being validated
|
||||
* @param value - The response value for this element
|
||||
* @param languageCode - Current language code for error messages
|
||||
* @param t - i18next translation function
|
||||
* @returns Validation result with valid flag and array of errors
|
||||
*/
|
||||
export const validateElementResponse = (
|
||||
element: TSurveyElement,
|
||||
value: TResponseDataValue,
|
||||
languageCode: string,
|
||||
t: TFunction
|
||||
): TValidationResult => {
|
||||
const errors: TValidationError[] = [];
|
||||
const rules: TValidationRule[] = [...(element.validationRules ?? [])];
|
||||
|
||||
// Handle legacy `required` field for backwards compatibility
|
||||
// If element.required is true and no explicit "required" rule exists, add one
|
||||
if (element.required && !rules.some((r) => r.params.type === "required")) {
|
||||
const legacyRequiredRule: TValidationRule = {
|
||||
id: "__legacy_required__",
|
||||
params: { type: "required" },
|
||||
enabled: true,
|
||||
};
|
||||
rules.unshift(legacyRequiredRule);
|
||||
}
|
||||
|
||||
// Handle legacy `inputType` field for OpenText elements
|
||||
// If inputType is email/url/phone and no explicit rule exists, add one
|
||||
if (isOpenTextElement(element) && element.inputType) {
|
||||
const inputTypeToRuleType: Record<string, TValidationRuleType> = {
|
||||
email: "email",
|
||||
url: "url",
|
||||
phone: "phone",
|
||||
};
|
||||
|
||||
const ruleType = inputTypeToRuleType[element.inputType];
|
||||
if (ruleType && !rules.some((r) => r.params.type === ruleType)) {
|
||||
const legacyInputTypeRule: TValidationRule = {
|
||||
id: `__legacy_${element.inputType}__`,
|
||||
params: { type: ruleType } as TValidationRule["params"],
|
||||
enabled: true,
|
||||
};
|
||||
rules.push(legacyInputTypeRule);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
// Skip disabled rules
|
||||
if (rule.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ruleType = rule.params.type as TValidationRuleType;
|
||||
const validator = validators[ruleType];
|
||||
|
||||
if (!validator) {
|
||||
console.warn(`Unknown validation rule type: ${ruleType}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const checkResult = validator.check(value, rule.params, element);
|
||||
|
||||
if (!checkResult.valid) {
|
||||
// Use custom error message if provided, otherwise use default
|
||||
const message =
|
||||
rule.customErrorMessage?.[languageCode] ??
|
||||
rule.customErrorMessage?.default ??
|
||||
validator.getDefaultMessage(rule.params, t);
|
||||
|
||||
errors.push({
|
||||
ruleId: rule.id,
|
||||
ruleType,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate all elements in a block, returning an error map.
|
||||
*
|
||||
* @param elements - Array of elements to validate
|
||||
* @param responses - Response data keyed by element ID
|
||||
* @param languageCode - Current language code for error messages
|
||||
* @param t - i18next translation function
|
||||
* @returns Map of element IDs to their validation errors
|
||||
*/
|
||||
export const validateBlockResponses = (
|
||||
elements: TSurveyElement[],
|
||||
responses: TResponseData,
|
||||
languageCode: string,
|
||||
t: TFunction
|
||||
): TValidationErrorMap => {
|
||||
const errorMap: TValidationErrorMap = {};
|
||||
|
||||
for (const element of elements) {
|
||||
const result = validateElementResponse(element, responses[element.id], languageCode, t);
|
||||
if (!result.valid) {
|
||||
errorMap[element.id] = result.errors;
|
||||
}
|
||||
}
|
||||
|
||||
return errorMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the first error message for an element from the error map.
|
||||
* Useful for UI components that only display one error at a time.
|
||||
*
|
||||
* @param errorMap - The validation error map
|
||||
* @param elementId - The element ID to get error for
|
||||
* @returns The first error message or undefined
|
||||
*/
|
||||
export const getFirstErrorMessage = (
|
||||
errorMap: TValidationErrorMap,
|
||||
elementId: string
|
||||
): string | undefined => {
|
||||
const errors = errorMap[elementId];
|
||||
return errors?.[0]?.message;
|
||||
};
|
||||
|
||||
|
||||
5
packages/surveys/src/lib/validation/index.ts
Normal file
5
packages/surveys/src/lib/validation/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { validateElementResponse, validateBlockResponses, getFirstErrorMessage } from "./evaluator";
|
||||
export { validators } from "./validators";
|
||||
export type { TValidator, TValidatorCheckResult } from "./validators";
|
||||
|
||||
|
||||
22
packages/surveys/src/lib/validation/validators/email.ts
Normal file
22
packages/surveys/src/lib/validation/validators/email.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { ZEmail } from "@formbricks/types/common";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsEmail } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const emailValidator: TValidator<TValidationRuleParamsEmail> = {
|
||||
check: (value: TResponseDataValue, _params: TValidationRuleParamsEmail, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is empty (let required handle empty)
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: ZEmail.safeParse(value).success };
|
||||
},
|
||||
|
||||
getDefaultMessage: (_params: TValidationRuleParamsEmail, t: TFunction): string => {
|
||||
return t("errors.please_enter_a_valid_email_address");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
34
packages/surveys/src/lib/validation/validators/index.ts
Normal file
34
packages/surveys/src/lib/validation/validators/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator } from "./types";
|
||||
import { requiredValidator } from "./required";
|
||||
import { minLengthValidator } from "./min-length";
|
||||
import { maxLengthValidator } from "./max-length";
|
||||
import { emailValidator } from "./email";
|
||||
import { urlValidator } from "./url";
|
||||
import { phoneValidator } from "./phone";
|
||||
import { patternValidator } from "./pattern";
|
||||
import { minValueValidator } from "./min-value";
|
||||
import { maxValueValidator } from "./max-value";
|
||||
import { minSelectionsValidator } from "./min-selections";
|
||||
import { maxSelectionsValidator } from "./max-selections";
|
||||
|
||||
/**
|
||||
* Registry of all validators, keyed by rule type.
|
||||
* Each validator implements the TValidator interface.
|
||||
* We use `as TValidator` to work around TypeScript's strict generics for the discriminated union.
|
||||
*/
|
||||
export const validators: Record<TValidationRuleType, TValidator> = {
|
||||
required: requiredValidator as TValidator,
|
||||
minLength: minLengthValidator as TValidator,
|
||||
maxLength: maxLengthValidator as TValidator,
|
||||
email: emailValidator as TValidator,
|
||||
url: urlValidator as TValidator,
|
||||
phone: phoneValidator as TValidator,
|
||||
pattern: patternValidator as TValidator,
|
||||
minValue: minValueValidator as TValidator,
|
||||
maxValue: maxValueValidator as TValidator,
|
||||
minSelections: minSelectionsValidator as TValidator,
|
||||
maxSelections: maxSelectionsValidator as TValidator,
|
||||
};
|
||||
|
||||
export type { TValidator, TValidatorCheckResult } from "./types";
|
||||
21
packages/surveys/src/lib/validation/validators/max-length.ts
Normal file
21
packages/surveys/src/lib/validation/validators/max-length.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsMaxLength } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const maxLengthValidator: TValidator<TValidationRuleParamsMaxLength> = {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxLength, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is not a string
|
||||
if (typeof value !== "string") {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: value.length <= params.max };
|
||||
},
|
||||
|
||||
getDefaultMessage: (params: TValidationRuleParamsMaxLength, t: TFunction): string => {
|
||||
return t("errors.max_length", { max: params.max });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsMaxSelections } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
import { countSelections } from "./selection-utils";
|
||||
|
||||
export const maxSelectionsValidator: TValidator<TValidationRuleParamsMaxSelections> = {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxSelections, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// If value is not an array, rule doesn't apply (graceful)
|
||||
if (!Array.isArray(value)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const selectionCount = countSelections(value);
|
||||
return { valid: selectionCount <= params.max };
|
||||
},
|
||||
|
||||
getDefaultMessage: (params: TValidationRuleParamsMaxSelections, t: TFunction): string => {
|
||||
return t("errors.max_selections", { max: params.max });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/max-value.ts
Normal file
29
packages/surveys/src/lib/validation/validators/max-value.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsMaxValue } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const maxValueValidator: TValidator<TValidationRuleParamsMaxValue> = {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxValue, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is empty (let required handle empty)
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Handle string numbers (from OpenText with inputType=number)
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : typeof value === "number" ? value : NaN;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return { valid: true }; // Let pattern/type validation handle non-numeric
|
||||
}
|
||||
|
||||
return { valid: numValue <= params.max };
|
||||
},
|
||||
|
||||
getDefaultMessage: (params: TValidationRuleParamsMaxValue, t: TFunction): string => {
|
||||
return t("errors.max_value", { max: params.max });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
21
packages/surveys/src/lib/validation/validators/min-length.ts
Normal file
21
packages/surveys/src/lib/validation/validators/min-length.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsMinLength } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const minLengthValidator: TValidator<TValidationRuleParamsMinLength> = {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParamsMinLength, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is not a string or is empty (let required handle empty)
|
||||
if (typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: value.length >= params.min };
|
||||
},
|
||||
|
||||
getDefaultMessage: (params: TValidationRuleParamsMinLength, t: TFunction): string => {
|
||||
return t("errors.min_length", { min: params.min });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsMinSelections } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
import { countSelections } from "./selection-utils";
|
||||
|
||||
export const minSelectionsValidator: TValidator<TValidationRuleParamsMinSelections> = {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParamsMinSelections, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// If value is not an array, check fails (need selections)
|
||||
if (!Array.isArray(value)) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const selectionCount = countSelections(value);
|
||||
return { valid: selectionCount >= params.min };
|
||||
},
|
||||
|
||||
getDefaultMessage: (params: TValidationRuleParamsMinSelections, t: TFunction): string => {
|
||||
return t("errors.min_selections", { min: params.min });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/min-value.ts
Normal file
29
packages/surveys/src/lib/validation/validators/min-value.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsMinValue } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const minValueValidator: TValidator<TValidationRuleParamsMinValue> = {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParamsMinValue, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is empty (let required handle empty)
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Handle string numbers (from OpenText with inputType=number)
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : typeof value === "number" ? value : NaN;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return { valid: true }; // Let pattern/type validation handle non-numeric
|
||||
}
|
||||
|
||||
return { valid: numValue >= params.min };
|
||||
},
|
||||
|
||||
getDefaultMessage: (params: TValidationRuleParamsMinValue, t: TFunction): string => {
|
||||
return t("errors.min_value", { min: params.min });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/pattern.ts
Normal file
29
packages/surveys/src/lib/validation/validators/pattern.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsPattern } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const patternValidator: TValidator<TValidationRuleParamsPattern> = {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParamsPattern, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is empty (let required handle empty)
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(params.pattern, params.flags);
|
||||
return { valid: regex.test(value) };
|
||||
} catch {
|
||||
// If regex is invalid, consider it valid (design-time should catch this)
|
||||
console.warn(`Invalid regex pattern: ${params.pattern}`);
|
||||
return { valid: true };
|
||||
}
|
||||
},
|
||||
|
||||
getDefaultMessage: (_params: TValidationRuleParamsPattern, t: TFunction): string => {
|
||||
return t("errors.invalid_format");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
25
packages/surveys/src/lib/validation/validators/phone.ts
Normal file
25
packages/surveys/src/lib/validation/validators/phone.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsPhone } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
// Phone regex: must start with digit or +, end with digit
|
||||
// Allows digits, +, -, and spaces in between
|
||||
const PHONE_REGEX = /^[0-9+][0-9+\- ]*[0-9]$/;
|
||||
|
||||
export const phoneValidator: TValidator<TValidationRuleParamsPhone> = {
|
||||
check: (value: TResponseDataValue, _params: TValidationRuleParamsPhone, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is empty (let required handle empty)
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: PHONE_REGEX.test(value) };
|
||||
},
|
||||
|
||||
getDefaultMessage: (_params: TValidationRuleParamsPhone, t: TFunction): string => {
|
||||
return t("errors.please_enter_a_valid_phone_number");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
24
packages/surveys/src/lib/validation/validators/required.ts
Normal file
24
packages/surveys/src/lib/validation/validators/required.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsRequired } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const requiredValidator: TValidator<TValidationRuleParamsRequired> = {
|
||||
check: (value: TResponseDataValue, _params: TValidationRuleParamsRequired, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0);
|
||||
|
||||
return { valid: !isEmpty };
|
||||
},
|
||||
|
||||
getDefaultMessage: (_params: TValidationRuleParamsRequired, t: TFunction): string => {
|
||||
return t("errors.please_fill_out_this_field");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Count the number of actual selections in a multi-select value array.
|
||||
*
|
||||
* The value array format for MultiSelect:
|
||||
* - Regular options: ["Label1", "Label2"]
|
||||
* - With "other" option: ["Label1", "", "custom other text"]
|
||||
* - The "" sentinel indicates "other" is selected
|
||||
* - The text following it is the custom value
|
||||
*
|
||||
* This function counts logical selections, not array length.
|
||||
*/
|
||||
export const countSelections = (value: unknown[]): number => {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const hasOtherSentinel = value.includes("");
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const item = value[i];
|
||||
|
||||
// Skip empty sentinel
|
||||
if (item === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip the value immediately after empty sentinel (it's the "other" custom text)
|
||||
if (i > 0 && value[i - 1] === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
// Add 1 for "other" if it's selected (the sentinel + optional text count as 1 selection)
|
||||
if (hasOtherSentinel) {
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/types.ts
Normal file
29
packages/surveys/src/lib/validation/validators/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParams } from "@formbricks/types/surveys/validation-rules";
|
||||
|
||||
/**
|
||||
* Result of a validator check
|
||||
*/
|
||||
export interface TValidatorCheckResult {
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic validator interface
|
||||
* P = the specific params type for this validator
|
||||
*/
|
||||
export interface TValidator<P extends TValidationRuleParams = TValidationRuleParams> {
|
||||
/**
|
||||
* Check if the value passes validation
|
||||
*/
|
||||
check: (value: TResponseDataValue, params: P, element: TSurveyElement) => TValidatorCheckResult;
|
||||
|
||||
/**
|
||||
* Get the default error message for this rule
|
||||
*/
|
||||
getDefaultMessage: (params: P, t: TFunction) => string;
|
||||
}
|
||||
|
||||
|
||||
22
packages/surveys/src/lib/validation/validators/url.ts
Normal file
22
packages/surveys/src/lib/validation/validators/url.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { ZUrl } from "@formbricks/types/common";
|
||||
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type { TValidationRuleParamsUrl } from "@formbricks/types/surveys/validation-rules";
|
||||
import type { TValidator, TValidatorCheckResult } from "./types";
|
||||
|
||||
export const urlValidator: TValidator<TValidationRuleParamsUrl> = {
|
||||
check: (value: TResponseDataValue, _params: TValidationRuleParamsUrl, _element: TSurveyElement): TValidatorCheckResult => {
|
||||
// Skip validation if value is empty (let required handle empty)
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: ZUrl.safeParse(value).success };
|
||||
},
|
||||
|
||||
getDefaultMessage: (_params: TValidationRuleParamsUrl, t: TFunction): string => {
|
||||
return t("errors.please_enter_a_valid_url");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ZUrl } from "../common";
|
||||
import { ZI18nString } from "../i18n";
|
||||
import { ZAllowedFileExtension } from "../storage";
|
||||
import { FORBIDDEN_IDS } from "./validation";
|
||||
import { ZValidationRules } from "./validation-rules";
|
||||
|
||||
// Element Type Enum (same as question types)
|
||||
export enum TSurveyElementTypeEnum {
|
||||
@@ -61,6 +62,7 @@ export const ZSurveyElementBase = z.object({
|
||||
scale: z.enum(["number", "smiley", "star"]).optional(),
|
||||
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
|
||||
isDraft: z.boolean().optional(),
|
||||
validationRules: ZValidationRules.optional(),
|
||||
});
|
||||
|
||||
// OpenText Element
|
||||
@@ -162,7 +164,7 @@ export const ZSurveyCTAElement = ZSurveyElementBase.extend({
|
||||
buttonUrl: z.string().optional(),
|
||||
ctaButtonLabel: ZI18nString.optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
// When buttonExternal is true, buttonUrl is required and must be valid
|
||||
// When buttonExternal is true, buttonUrl and ctaButtonLabel are required
|
||||
if (data.buttonExternal) {
|
||||
if (!data.buttonUrl || data.buttonUrl.trim() === "") {
|
||||
ctx.addIssue({
|
||||
@@ -181,6 +183,14 @@ export const ZSurveyCTAElement = ZSurveyElementBase.extend({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.ctaButtonLabel?.default || data.ctaButtonLabel.default.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Button label is required when external button is enabled",
|
||||
path: ["ctaButtonLabel"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
159
packages/types/surveys/validation-rules.ts
Normal file
159
packages/types/surveys/validation-rules.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { z } from "zod";
|
||||
import { ZI18nString } from "../i18n";
|
||||
|
||||
// Validation rule type enum - extensible for future rule types
|
||||
export const ZValidationRuleType = z.enum([
|
||||
// Universal rules
|
||||
"required",
|
||||
|
||||
// Text/OpenText rules
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"email",
|
||||
"url",
|
||||
"phone",
|
||||
|
||||
// Numeric rules (for OpenText inputType=number)
|
||||
"minValue",
|
||||
"maxValue",
|
||||
|
||||
// Selection rules (MultiSelect, PictureSelection)
|
||||
"minSelections",
|
||||
"maxSelections",
|
||||
]);
|
||||
|
||||
export type TValidationRuleType = z.infer<typeof ZValidationRuleType>;
|
||||
|
||||
// Rule params - discriminated union for type-safe params per rule type
|
||||
export const ZValidationRuleParamsRequired = z.object({
|
||||
type: z.literal("required"),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsMinLength = z.object({
|
||||
type: z.literal("minLength"),
|
||||
min: z.number().min(0),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsMaxLength = z.object({
|
||||
type: z.literal("maxLength"),
|
||||
max: z.number().min(1),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsPattern = z.object({
|
||||
type: z.literal("pattern"),
|
||||
pattern: z.string().min(1),
|
||||
flags: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsEmail = z.object({
|
||||
type: z.literal("email"),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsUrl = z.object({
|
||||
type: z.literal("url"),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsPhone = z.object({
|
||||
type: z.literal("phone"),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsMinValue = z.object({
|
||||
type: z.literal("minValue"),
|
||||
min: z.number(),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsMaxValue = z.object({
|
||||
type: z.literal("maxValue"),
|
||||
max: z.number(),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsMinSelections = z.object({
|
||||
type: z.literal("minSelections"),
|
||||
min: z.number().min(1),
|
||||
});
|
||||
|
||||
export const ZValidationRuleParamsMaxSelections = z.object({
|
||||
type: z.literal("maxSelections"),
|
||||
max: z.number().min(1),
|
||||
});
|
||||
|
||||
// Union of all params types
|
||||
export const ZValidationRuleParams = z.discriminatedUnion("type", [
|
||||
ZValidationRuleParamsRequired,
|
||||
ZValidationRuleParamsMinLength,
|
||||
ZValidationRuleParamsMaxLength,
|
||||
ZValidationRuleParamsPattern,
|
||||
ZValidationRuleParamsEmail,
|
||||
ZValidationRuleParamsUrl,
|
||||
ZValidationRuleParamsPhone,
|
||||
ZValidationRuleParamsMinValue,
|
||||
ZValidationRuleParamsMaxValue,
|
||||
ZValidationRuleParamsMinSelections,
|
||||
ZValidationRuleParamsMaxSelections,
|
||||
]);
|
||||
|
||||
export type TValidationRuleParams = z.infer<typeof ZValidationRuleParams>;
|
||||
|
||||
// Extract specific param types for validators
|
||||
export type TValidationRuleParamsRequired = z.infer<typeof ZValidationRuleParamsRequired>;
|
||||
export type TValidationRuleParamsMinLength = z.infer<typeof ZValidationRuleParamsMinLength>;
|
||||
export type TValidationRuleParamsMaxLength = z.infer<typeof ZValidationRuleParamsMaxLength>;
|
||||
export type TValidationRuleParamsPattern = z.infer<typeof ZValidationRuleParamsPattern>;
|
||||
export type TValidationRuleParamsEmail = z.infer<typeof ZValidationRuleParamsEmail>;
|
||||
export type TValidationRuleParamsUrl = z.infer<typeof ZValidationRuleParamsUrl>;
|
||||
export type TValidationRuleParamsPhone = z.infer<typeof ZValidationRuleParamsPhone>;
|
||||
export type TValidationRuleParamsMinValue = z.infer<typeof ZValidationRuleParamsMinValue>;
|
||||
export type TValidationRuleParamsMaxValue = z.infer<typeof ZValidationRuleParamsMaxValue>;
|
||||
export type TValidationRuleParamsMinSelections = z.infer<typeof ZValidationRuleParamsMinSelections>;
|
||||
export type TValidationRuleParamsMaxSelections = z.infer<typeof ZValidationRuleParamsMaxSelections>;
|
||||
|
||||
// Validation rule stored on element
|
||||
export const ZValidationRule = z.object({
|
||||
id: z.string(),
|
||||
params: ZValidationRuleParams,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type TValidationRule = z.infer<typeof ZValidationRule>;
|
||||
|
||||
// Array of validation rules
|
||||
export const ZValidationRules = z.array(ZValidationRule);
|
||||
export type TValidationRules = z.infer<typeof ZValidationRules>;
|
||||
|
||||
// Validation error returned by evaluator
|
||||
export interface TValidationError {
|
||||
ruleId: string;
|
||||
ruleType: TValidationRuleType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Validation result for a single element
|
||||
export interface TValidationResult {
|
||||
valid: boolean;
|
||||
errors: TValidationError[];
|
||||
}
|
||||
|
||||
// Error map for block-level validation (keyed by elementId)
|
||||
export type TValidationErrorMap = Record<string, TValidationError[]>;
|
||||
|
||||
// Applicable rules per element type
|
||||
export const APPLICABLE_RULES: Record<string, TValidationRuleType[]> = {
|
||||
openText: ["required", "minLength", "maxLength", "pattern", "email", "url", "phone", "minValue", "maxValue"],
|
||||
multipleChoiceSingle: ["required"],
|
||||
multipleChoiceMulti: ["required", "minSelections", "maxSelections"],
|
||||
rating: ["required"],
|
||||
nps: ["required"],
|
||||
date: ["required"],
|
||||
consent: ["required"],
|
||||
matrix: ["required"],
|
||||
ranking: ["required"],
|
||||
fileUpload: ["required"],
|
||||
pictureSelection: ["required", "minSelections", "maxSelections"],
|
||||
address: ["required"],
|
||||
contactInfo: ["required"],
|
||||
cal: ["required"],
|
||||
cta: [], // CTA never validates
|
||||
};
|
||||
|
||||
1032
pnpm-lock.yaml
generated
1032
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -274,6 +274,7 @@
|
||||
"outputs": []
|
||||
},
|
||||
"db:seed": {
|
||||
"env": ["ALLOW_SEED"],
|
||||
"outputs": []
|
||||
},
|
||||
"db:setup": {
|
||||
|
||||
Reference in New Issue
Block a user