mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-03 19:40:08 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c092f005b6 | |||
| 25266e4566 | |||
| b960cfd2a1 | |||
| 9e1d1c1dc2 | |||
| 8c63a9f7af | |||
| fff0a7f052 | |||
| 0ecc8aabff | |||
| 01cc0ab64d | |||
| 1d125bdac2 |
@@ -4835,7 +4835,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
segment: null,
|
segment: null,
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: "cltxxaa6x0000g8hacxdxeje1",
|
||||||
name: "Block 1",
|
name: "Block 1",
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
@@ -4857,7 +4857,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: "cltxxaa6x0000g8hacxdxeje2",
|
||||||
name: "Block 2",
|
name: "Block 2",
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1588,6 +1588,8 @@
|
|||||||
"upload_at_least_2_images": "Upload at least 2 images",
|
"upload_at_least_2_images": "Upload at least 2 images",
|
||||||
"upper_label": "Upper Label",
|
"upper_label": "Upper Label",
|
||||||
"url_filters": "URL Filters",
|
"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",
|
"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_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",
|
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
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";
|
import { updateAttributes } from "./attributes";
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
@@ -20,6 +24,7 @@ vi.mock("@/modules/ee/contacts/lib/contact-attributes", async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
getContactAttributes: vi.fn(),
|
getContactAttributes: vi.fn(),
|
||||||
hasEmailAttribute: vi.fn(),
|
hasEmailAttribute: vi.fn(),
|
||||||
|
hasUserIdAttribute: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
@@ -75,6 +80,7 @@ describe("updateAttributes", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Set default mock return values - these will be overridden in individual tests
|
// Set default mock return values - these will be overridden in individual tests
|
||||||
vi.mocked(getContactAttributes).mockResolvedValue({});
|
vi.mocked(getContactAttributes).mockResolvedValue({});
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
@@ -83,19 +89,21 @@ describe("updateAttributes", () => {
|
|||||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||||
const attributes = { name: "John", email: "john@example.com" };
|
const attributes = { name: "John", email: "john@example.com" };
|
||||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||||
expect(prisma.$transaction).toHaveBeenCalled();
|
expect(prisma.$transaction).toHaveBeenCalled();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.messages).toEqual([]);
|
expect(result.messages).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips updating email if it already exists", async () => {
|
test("skips updating email if it already exists", async () => {
|
||||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||||
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||||
const attributes = { name: "John", email: "john@example.com" };
|
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.");
|
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates new attributes if under limit", async () => {
|
test("skips updating userId if it already exists", async () => {
|
||||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0]]);
|
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
||||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane" });
|
...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(hasEmailAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
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);
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||||
expect(prisma.$transaction).toHaveBeenCalled();
|
expect(prisma.$transaction).toHaveBeenCalled();
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
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 () => {
|
test("does not create new attributes if over the limit", async () => {
|
||||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
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);
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
|
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns success with no attributes to update or create", async () => {
|
test("returns success with only email attribute", async () => {
|
||||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([]);
|
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
|
||||||
vi.mocked(getContactAttributes).mockResolvedValue({});
|
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" });
|
||||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||||
const attributes = {};
|
const attributes = { email: "updated@example.com" };
|
||||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||||
expect(result.success).toBe(true);
|
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
|
// Reset mocks explicitly for this test
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
||||||
|
|
||||||
@@ -156,27 +266,19 @@ describe("updateAttributes", () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
|
|
||||||
const attributes = { name: "John", email: "john@example.com" };
|
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);
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||||
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
|
// deleteMany should NOT be called since we're merging, not replacing
|
||||||
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
|
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
||||||
where: {
|
|
||||||
contactId,
|
|
||||||
attributeKeyId: {
|
|
||||||
in: ["key-3"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
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
|
// Reset mocks explicitly for this test
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
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")
|
// Note: DEFAULT_ATTRIBUTES includes: email, userId, firstName, lastName (not "name")
|
||||||
const attributeKeysWithDefaults: TContactAttributeKey[] = [
|
const attributeKeysWithDefaults: TContactAttributeKey[] = [
|
||||||
{
|
{
|
||||||
@@ -231,13 +333,105 @@ describe("updateAttributes", () => {
|
|||||||
firstName: "John",
|
firstName: "John",
|
||||||
});
|
});
|
||||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||||
const attributes = { customAttr: "value" };
|
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
|
// Should not delete default attributes (email, userId, firstName) - deleteMany should not be called
|
||||||
// since all current attributes are default attributes
|
// since all current attributes are default attributes
|
||||||
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
||||||
expect(result.success).toBe(true);
|
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."
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { getContactAttributes, hasEmailAttribute } from "./contact-attributes";
|
import { TContactAttribute } from "@formbricks/types/contact-attribute";
|
||||||
|
import { getContactAttributes, hasEmailAttribute, hasUserIdAttribute } from "./contact-attributes";
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
@@ -16,11 +17,12 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
|
|||||||
const contactId = "contact-1";
|
const contactId = "contact-1";
|
||||||
const environmentId = "env-1";
|
const environmentId = "env-1";
|
||||||
const email = "john@example.com";
|
const email = "john@example.com";
|
||||||
|
const userId = "user-123";
|
||||||
|
|
||||||
const mockAttributes = [
|
const mockAttributes = [
|
||||||
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
|
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||||
{ value: "John", attributeKey: { key: "name", name: "Name" } },
|
{ value: "John", attributeKey: { key: "name", name: "Name" } },
|
||||||
];
|
] as unknown as TContactAttribute[];
|
||||||
|
|
||||||
describe("getContactAttributes", () => {
|
describe("getContactAttributes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -50,7 +52,9 @@ describe("hasEmailAttribute", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("returns true if email attribute exists", async () => {
|
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);
|
const result = await hasEmailAttribute(email, environmentId, contactId);
|
||||||
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
|
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
@@ -67,3 +71,29 @@ describe("hasEmailAttribute", () => {
|
|||||||
expect(result).toBe(false);
|
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,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { updateAttributes } from "./attributes";
|
import { updateAttributes } from "./attributes";
|
||||||
import { getContactAttributeKeys } from "./contact-attribute-keys";
|
import { getContactAttributeKeys } from "./contact-attribute-keys";
|
||||||
import { getContactAttributes } from "./contact-attributes";
|
import { getContactAttributes } from "./contact-attributes";
|
||||||
@@ -16,7 +16,7 @@ describe("updateContactAttributes", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update contact attributes successfully", async () => {
|
test("should update contact attributes with deleteRemovedAttributes: true", async () => {
|
||||||
const contactId = "contact123";
|
const contactId = "contact123";
|
||||||
const environmentId = "env123";
|
const environmentId = "env123";
|
||||||
const userId = "user123";
|
const userId = "user123";
|
||||||
@@ -91,13 +91,14 @@ describe("updateContactAttributes", () => {
|
|||||||
|
|
||||||
expect(getContact).toHaveBeenCalledWith(contactId);
|
expect(getContact).toHaveBeenCalledWith(contactId);
|
||||||
expect(getContactAttributeKeys).toHaveBeenCalledWith(environmentId);
|
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(getContactAttributes).toHaveBeenCalledWith(contactId);
|
||||||
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
|
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
|
||||||
expect(result.updatedAttributeKeys).toBeUndefined();
|
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 contactId = "contact123";
|
||||||
const environmentId = "env123";
|
const environmentId = "env123";
|
||||||
const userId = "user123";
|
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 contactId = "contact123";
|
||||||
const environmentId = "env123";
|
const environmentId = "env123";
|
||||||
const attributes = {
|
const attributes = {
|
||||||
@@ -226,13 +227,13 @@ describe("updateContactAttributes", () => {
|
|||||||
|
|
||||||
const result = await updateContactAttributes(contactId, attributes);
|
const result = await updateContactAttributes(contactId, attributes);
|
||||||
|
|
||||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes);
|
// When userId is not in attributes, pass empty string to updateAttributes
|
||||||
expect(result.messages).toContain(
|
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes, true);
|
||||||
"Warning: userId attribute is missing. Some operations may not work correctly."
|
// 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 contactId = "contact123";
|
||||||
const environmentId = "env123";
|
const environmentId = "env123";
|
||||||
const userId = "user123";
|
const userId = "user123";
|
||||||
@@ -279,7 +280,7 @@ describe("updateContactAttributes", () => {
|
|||||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
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 contactId = "contact123";
|
||||||
const attributes = {
|
const attributes = {
|
||||||
firstName: "John",
|
firstName: "John",
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import { TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
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 { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||||
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
|
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
|
||||||
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
|
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 { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
@@ -398,6 +400,19 @@ export const MultipleChoiceElementForm = ({
|
|||||||
surveyLanguageCodes={surveyLanguageCodes}
|
surveyLanguageCodes={surveyLanguageCodes}
|
||||||
locale={locale}
|
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>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|||||||
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
||||||
import { JSX, useEffect, useState } from "react";
|
import { JSX, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
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 { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
@@ -245,6 +251,17 @@ export const OpenElementForm = ({
|
|||||||
customContainerClass="p-0"
|
customContainerClass="p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Rules Editor */}
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.OpenText}
|
||||||
|
validationRules={element.validationRules ?? []}
|
||||||
|
onUpdateRules={(rules: TValidationRule[]) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validationRules: rules,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 isProduction = process.env.NODE_ENV === "production";
|
||||||
const scriptSrcUnsafeEval = isProduction ? "" : " 'unsafe-eval'";
|
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 [
|
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/).*)",
|
source: "/((?!s/|c/).*)",
|
||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
key: "X-Frame-Options",
|
key: "X-Frame-Options",
|
||||||
value: "SAMEORIGIN",
|
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",
|
key: "X-Content-Type-Options",
|
||||||
value: "nosniff",
|
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",
|
key: "Strict-Transport-Security",
|
||||||
value: "max-age=63072000; includeSubDomains; preload",
|
value: "max-age=63072000; includeSubDomains; preload",
|
||||||
@@ -458,7 +471,5 @@ const sentryOptions = {
|
|||||||
// Runtime Sentry reporting still depends on DSN being set via environment variables
|
// Runtime Sentry reporting still depends on DSN being set via environment variables
|
||||||
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
|
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
|
||||||
|
|
||||||
console.log("BASE PATH", nextConfig.basePath);
|
|
||||||
|
|
||||||
|
|
||||||
export default exportConfig;
|
export default exportConfig;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"db:migrate:deploy": "turbo run db:migrate:deploy",
|
"db:migrate:deploy": "turbo run db:migrate:deploy",
|
||||||
"db:start": "turbo run db:start",
|
"db:start": "turbo run db:start",
|
||||||
"db:push": "turbo run db:push",
|
"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:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
"go": "pnpm db:up && turbo run go --concurrency 20",
|
"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
|
- Generates new `migration.sql` in the custom directory
|
||||||
- Copies migration to Prisma's internal directory
|
- Copies migration to Prisma's internal directory
|
||||||
- Applies all pending migrations to the database
|
- 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
|
### Package Level Commands
|
||||||
|
|
||||||
@@ -92,6 +98,8 @@ Run these commands from the `packages/database` directory:
|
|||||||
- Creates new subdirectory with appropriate timestamp
|
- Creates new subdirectory with appropriate timestamp
|
||||||
- Generates `migration.ts` file with pre-configured ID and name
|
- 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
|
- **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
|
### Available Scripts
|
||||||
|
|
||||||
@@ -102,13 +110,41 @@ Run these commands from the `packages/database` directory:
|
|||||||
"db:migrate:deploy": "Apply migrations in production",
|
"db:migrate:deploy": "Apply migrations in production",
|
||||||
"db:migrate:dev": "Apply migrations in development",
|
"db:migrate:dev": "Apply migrations in development",
|
||||||
"db:push": "prisma db push --accept-data-loss",
|
"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",
|
"dev": "vite build --watch",
|
||||||
"generate": "prisma generate",
|
"generate": "prisma generate",
|
||||||
"generate-data-migration": "Create new data migration"
|
"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
|
## Migration Workflow
|
||||||
|
|
||||||
### Adding a Schema Migration
|
### Adding a Schema Migration
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
},
|
},
|
||||||
"./zod/*": {
|
"./zod/*": {
|
||||||
"import": "./zod/*.ts"
|
"import": "./zod/*.ts"
|
||||||
|
},
|
||||||
|
"./seed/constants": {
|
||||||
|
"import": "./src/seed/constants.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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: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:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
|
||||||
"db:push": "prisma db push --accept-data-loss",
|
"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",
|
"db:start": "pnpm db:setup",
|
||||||
"format": "prisma format",
|
"format": "prisma format",
|
||||||
"generate": "prisma generate",
|
"generate": "prisma generate",
|
||||||
@@ -45,17 +50,20 @@
|
|||||||
"@formbricks/logger": "workspace:*",
|
"@formbricks/logger": "workspace:*",
|
||||||
"@paralleldrive/cuid2": "2.2.2",
|
"@paralleldrive/cuid2": "2.2.2",
|
||||||
"@prisma/client": "6.14.0",
|
"@prisma/client": "6.14.0",
|
||||||
|
"bcryptjs": "2.4.3",
|
||||||
"zod": "3.24.4",
|
"zod": "3.24.4",
|
||||||
"zod-openapi": "4.2.4"
|
"zod-openapi": "4.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formbricks/config-typescript": "workspace:*",
|
"@formbricks/config-typescript": "workspace:*",
|
||||||
"@formbricks/eslint-config": "workspace:*",
|
"@formbricks/eslint-config": "workspace:*",
|
||||||
|
"@types/bcryptjs": "2.4.6",
|
||||||
"dotenv-cli": "8.0.0",
|
"dotenv-cli": "8.0.0",
|
||||||
"glob": "11.1.0",
|
"glob": "11.1.0",
|
||||||
"prisma": "6.14.0",
|
"prisma": "6.14.0",
|
||||||
"prisma-json-types-generator": "3.5.4",
|
"prisma-json-types-generator": "3.5.4",
|
||||||
"ts-node": "10.9.2",
|
"ts-node": "10.9.2",
|
||||||
|
"tsx": "4.19.2",
|
||||||
"vite": "6.4.1",
|
"vite": "6.4.1",
|
||||||
"vite-plugin-dts": "4.5.3"
|
"vite-plugin-dts": "4.5.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -44,8 +44,15 @@
|
|||||||
},
|
},
|
||||||
"invalid_device_error": {
|
"invalid_device_error": {
|
||||||
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
"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_book_an_appointment": "Please book an appointment",
|
||||||
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
||||||
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface MultipleChoiceMultiElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string; // Validation error from centralized validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultipleChoiceMultiElement({
|
export function MultipleChoiceMultiElement({
|
||||||
@@ -28,10 +29,10 @@ export function MultipleChoiceMultiElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<MultipleChoiceMultiElementProps>) {
|
}: Readonly<MultipleChoiceMultiElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [otherValue, setOtherValue] = useState("");
|
const [otherValue, setOtherValue] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
@@ -173,22 +174,9 @@ export function MultipleChoiceMultiElement({
|
|||||||
onChange({ [element.id]: nextValue });
|
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) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (!validateRequired()) return;
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -228,7 +216,6 @@ export function MultipleChoiceMultiElement({
|
|||||||
|
|
||||||
// Handle selection changes - store labels directly instead of IDs
|
// Handle selection changes - store labels directly instead of IDs
|
||||||
const handleMultiSelectChange = (selectedIds: string[]) => {
|
const handleMultiSelectChange = (selectedIds: string[]) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
const nextLabels: string[] = [];
|
const nextLabels: string[] = [];
|
||||||
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);
|
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { OpenText } from "@formbricks/survey-ui";
|
import { OpenText } from "@formbricks/survey-ui";
|
||||||
import { ZEmail, ZUrl } from "@formbricks/types/common";
|
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
@@ -18,6 +16,7 @@ interface OpenTextElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string; // Validation error from centralized validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenTextElement({
|
export function OpenTextElement({
|
||||||
@@ -29,76 +28,19 @@ export function OpenTextElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<OpenTextElementProps>) {
|
}: Readonly<OpenTextElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleChange = (inputValue: string) => {
|
const handleChange = (inputValue: string) => {
|
||||||
// Clear error when user starts typing
|
|
||||||
setErrorMessage(undefined);
|
|
||||||
onChange({ [element.id]: inputValue });
|
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) => {
|
const handleOnSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
|
|
||||||
if (!validateRequired()) return;
|
|
||||||
if (!validateInput()) return;
|
|
||||||
|
|
||||||
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtc);
|
setTtc(updatedTtc);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||||
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||||
@@ -9,12 +10,14 @@ import {
|
|||||||
TSurveyMatrixElement,
|
TSurveyMatrixElement,
|
||||||
TSurveyRankingElement,
|
TSurveyRankingElement,
|
||||||
} from "@formbricks/types/surveys/elements";
|
} from "@formbricks/types/surveys/elements";
|
||||||
|
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
|
||||||
import { BackButton } from "@/components/buttons/back-button";
|
import { BackButton } from "@/components/buttons/back-button";
|
||||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||||
import { ElementConditional } from "@/components/general/element-conditional";
|
import { ElementConditional } from "@/components/general/element-conditional";
|
||||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { validateBlockResponses, getFirstErrorMessage } from "@/lib/validation";
|
||||||
|
|
||||||
interface BlockConditionalProps {
|
interface BlockConditionalProps {
|
||||||
block: TSurveyBlock;
|
block: TSurveyBlock;
|
||||||
@@ -59,9 +62,14 @@ export function BlockConditional({
|
|||||||
dir,
|
dir,
|
||||||
fullSizeCards,
|
fullSizeCards,
|
||||||
}: BlockConditionalProps) {
|
}: BlockConditionalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Track the current element being filled (for TTC tracking)
|
// Track the current element being filled (for TTC tracking)
|
||||||
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
|
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
|
// Refs to store form elements for each element so we can trigger their validation
|
||||||
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
||||||
|
|
||||||
@@ -74,6 +82,14 @@ export function BlockConditional({
|
|||||||
if (elementId !== currentElementId) {
|
if (elementId !== currentElementId) {
|
||||||
setCurrentElementId(elementId);
|
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
|
// Merge with existing block data to preserve other element values
|
||||||
onChange({ ...value, ...responseData });
|
onChange({ ...value, ...responseData });
|
||||||
};
|
};
|
||||||
@@ -263,15 +279,34 @@ export function BlockConditional({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all forms and check for custom validation rules
|
// Run centralized validation for elements that support it (OpenText, MultiSelect)
|
||||||
const firstInvalidForm = findFirstInvalidForm();
|
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) {
|
if (firstInvalidForm) {
|
||||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any previous errors
|
||||||
|
setElementErrors({});
|
||||||
|
|
||||||
// Collect TTC and responses, then submit
|
// Collect TTC and responses, then submit
|
||||||
const blockTtc = collectTtcValues();
|
const blockTtc = collectTtcValues();
|
||||||
const blockResponses = collectBlockResponses();
|
const blockResponses = collectBlockResponses();
|
||||||
@@ -310,6 +345,7 @@ export function BlockConditional({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTtcCollect={handleTtcCollect}
|
onTtcCollect={handleTtcCollect}
|
||||||
|
errorMessage={getFirstErrorMessage(elementErrors, element.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface ElementConditionalProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
||||||
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
||||||
|
errorMessage?: string; // Validation error message from centralized validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElementConditional({
|
export function ElementConditional({
|
||||||
@@ -56,6 +57,7 @@ export function ElementConditional({
|
|||||||
dir,
|
dir,
|
||||||
formRef,
|
formRef,
|
||||||
onTtcCollect,
|
onTtcCollect,
|
||||||
|
errorMessage,
|
||||||
}: ElementConditionalProps) {
|
}: ElementConditionalProps) {
|
||||||
// Ref to the container div, used to find and expose the form element inside
|
// Ref to the container div, used to find and expose the form element inside
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -124,6 +126,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||||
@@ -154,6 +157,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.NPS:
|
case TSurveyElementTypeEnum.NPS:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { validateElementResponse, validateBlockResponses, getFirstErrorMessage } from "./evaluator";
|
||||||
|
export { validators } from "./validators";
|
||||||
|
export type { TValidator, TValidatorCheckResult } from "./validators";
|
||||||
|
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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";
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 { ZI18nString } from "../i18n";
|
||||||
import { ZAllowedFileExtension } from "../storage";
|
import { ZAllowedFileExtension } from "../storage";
|
||||||
import { FORBIDDEN_IDS } from "./validation";
|
import { FORBIDDEN_IDS } from "./validation";
|
||||||
|
import { ZValidationRules } from "./validation-rules";
|
||||||
|
|
||||||
// Element Type Enum (same as question types)
|
// Element Type Enum (same as question types)
|
||||||
export enum TSurveyElementTypeEnum {
|
export enum TSurveyElementTypeEnum {
|
||||||
@@ -61,6 +62,7 @@ export const ZSurveyElementBase = z.object({
|
|||||||
scale: z.enum(["number", "smiley", "star"]).optional(),
|
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(),
|
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
|
||||||
isDraft: z.boolean().optional(),
|
isDraft: z.boolean().optional(),
|
||||||
|
validationRules: ZValidationRules.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// OpenText Element
|
// OpenText Element
|
||||||
@@ -162,7 +164,7 @@ export const ZSurveyCTAElement = ZSurveyElementBase.extend({
|
|||||||
buttonUrl: z.string().optional(),
|
buttonUrl: z.string().optional(),
|
||||||
ctaButtonLabel: ZI18nString.optional(),
|
ctaButtonLabel: ZI18nString.optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).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.buttonExternal) {
|
||||||
if (!data.buttonUrl || data.buttonUrl.trim() === "") {
|
if (!data.buttonUrl || data.buttonUrl.trim() === "") {
|
||||||
ctx.addIssue({
|
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"],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
|
||||||
Generated
+671
-361
File diff suppressed because it is too large
Load Diff
@@ -274,6 +274,7 @@
|
|||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
"db:seed": {
|
"db:seed": {
|
||||||
|
"env": ["ALLOW_SEED"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
"db:setup": {
|
"db:setup": {
|
||||||
|
|||||||
Reference in New Issue
Block a user