feat: add blocks model to support multi-question pages (schema only)

This commit is contained in:
pandeymangg
2025-10-30 00:00:53 +05:30
parent 26292ecf39
commit 6772ac7c20
18 changed files with 2131 additions and 289 deletions

View File

@@ -3668,6 +3668,7 @@ export const previewSurvey = (projectName: string, t: TFunction) => {
isDraft: true,
},
],
blocks: [],
endings: [
{
id: "cltyqp5ng000108l9dmxw6nde",

View File

@@ -261,6 +261,7 @@ export const mockSyncSurveyOutput: SurveyMock = {
variables: [],
showLanguageSwitch: null,
metadata: {},
blocks: [],
};
export const mockSurveyOutput: SurveyMock = {
@@ -282,6 +283,7 @@ export const mockSurveyOutput: SurveyMock = {
languages: mockSurveyLanguages,
followUps: [],
variables: [],
blocks: [],
showLanguageSwitch: null,
...baseSurveyProperties,
};
@@ -311,6 +313,7 @@ export const updateSurveyInput: TSurvey = {
variables: [],
followUps: [],
metadata: {},
blocks: [],
...commonMockProperties,
...baseSurveyProperties,
};

View File

@@ -37,6 +37,7 @@ export const selectSurvey = {
status: true,
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,

View File

@@ -33,6 +33,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
type: true,
environmentId: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,

View File

@@ -16,6 +16,7 @@ export const selectSurvey = {
status: true,
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,

View File

@@ -30,6 +30,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
// Survey configuration
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,

View File

@@ -249,6 +249,7 @@ const getExistingSurvey = async (surveyId: string) => {
},
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
variables: true,
hiddenFields: true,

View File

@@ -18,6 +18,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
displayLimit: null,
welcomeCard: getDefaultWelcomeCard(t),
questions: [],
blocks: [],
endings: [getDefaultEndingCard([], t)],
hiddenFields: {
enabled: false,

View File

@@ -8,6 +8,7 @@ import { type TProjectConfig, type TProjectStyling } from "../types/project";
import type { TSurveyQuotaLogic } from "../types/quota";
import { type TResponseContactAttributes, type TResponseData, type TResponseMeta } from "../types/responses";
import { type TBaseFilters } from "../types/segment";
import { type TSurveyBlocks } from "../types/surveys/blocks";
import {
type TSurveyClosedMessage,
type TSurveyEnding,
@@ -35,6 +36,7 @@ declare global {
export type ResponseContactAttributes = TResponseContactAttributes;
export type SurveyWelcomeCard = TSurveyWelcomeCard;
export type SurveyQuestions = TSurveyQuestions;
export type SurveyBlocks = TSurveyBlocks;
export type SurveyEnding = TSurveyEnding;
export type SurveyHiddenFields = TSurveyHiddenFields;
export type SurveyVariables = TSurveyVariables;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Survey" ADD COLUMN "blocks" JSONB[] DEFAULT ARRAY[]::JSONB[];

View File

@@ -340,6 +340,8 @@ model Survey {
welcomeCard Json @default("{\"enabled\": false}")
/// [SurveyQuestions]
questions Json @default("[]")
/// [SurveyBlocks]
blocks Json[] @default([])
/// [SurveyEnding]
endings Json[] @default([])
/// [SurveyHiddenFields]

View File

@@ -1,7 +1,8 @@
/* eslint-disable import/no-relative-packages -- Need to import from parent package */
import { SurveyStatus, SurveyType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
import { ZSurveyBlocks } from "../../types/surveys/blocks";
import {
ZSurveyEnding,
ZSurveyMetadata,
@@ -96,6 +97,9 @@ const ZSurveyBase = z.object({
questions: z.array(ZSurveyQuestion).openapi({
description: "The questions of the survey",
}),
blocks: ZSurveyBlocks.default([]).openapi({
description: "The blocks of the survey",
}),
endings: z.array(ZSurveyEnding).default([]).openapi({
description: "The endings of the survey",
}),

View File

@@ -0,0 +1,64 @@
import type { TActionJumpToBlock, TSurveyBlock, TSurveyBlockId, TSurveyBlockLogicAction } from "./blocks";
export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): TSurveyBlockId[] => {
const visited: Record<string, boolean> = {};
const recStack: Record<string, boolean> = {};
const cyclicBlocks = new Set<TSurveyBlockId>();
const checkForCyclicLogic = (blockId: TSurveyBlockId): boolean => {
if (!visited[blockId]) {
visited[blockId] = true;
recStack[blockId] = true;
const block = blocks.find((b) => b.id === blockId);
if (block?.logic && block.logic.length > 0) {
for (const logic of block.logic) {
const jumpActions = findJumpToBlockActions(logic.actions);
for (const jumpAction of jumpActions) {
const destination = jumpAction.target;
if (!visited[destination] && checkForCyclicLogic(destination)) {
cyclicBlocks.add(blockId);
return true;
} else if (recStack[destination]) {
cyclicBlocks.add(blockId);
return true;
}
}
}
}
// Check fallback logic
if (block?.logicFallback) {
const fallbackBlockId = block.logicFallback;
if (!visited[fallbackBlockId] && checkForCyclicLogic(fallbackBlockId)) {
cyclicBlocks.add(blockId);
return true;
} else if (recStack[fallbackBlockId]) {
cyclicBlocks.add(blockId);
return true;
}
}
// Handle default behavior: move to the next block if no jump actions or fallback logic is defined
const nextBlockIndex = blocks.findIndex((b) => b.id === blockId) + 1;
const nextBlock = blocks[nextBlockIndex] as TSurveyBlock | undefined;
if (nextBlock && !visited[nextBlock.id] && checkForCyclicLogic(nextBlock.id)) {
return true;
}
}
recStack[blockId] = false;
return false;
};
for (const block of blocks) {
checkForCyclicLogic(block.id);
}
return Array.from(cyclicBlocks);
};
// Helper function to find all "jumpToBlock" actions in the logic
const findJumpToBlockActions = (actions: TSurveyBlockLogicAction[]): TActionJumpToBlock[] => {
return actions.filter((action): action is TActionJumpToBlock => action.objective === "jumpToBlock");
};

View File

@@ -0,0 +1,232 @@
/* eslint-disable import/no-cycle -- Required for circular dependency between types, blocks, and elements */
import { z } from "zod";
import { ZId } from "../common";
import { ZSurveyElementId, ZSurveyElements } from "./elements";
import type { TSurveyLogicConditionsOperator } from "./types";
import {
ZActionNumberVariableCalculateOperator,
ZActionTextVariableCalculateOperator,
ZConnector,
ZI18nString,
ZSurveyLogicConditionsOperator,
} from "./types";
// Block ID - CUID (system-generated, NOT user-editable)
export const ZSurveyBlockId = z.string().cuid2();
export type TSurveyBlockId = z.infer<typeof ZSurveyBlockId>;
// Copy condition types from types.ts for block logic
const ZDynamicQuestion = z.object({
type: z.literal("question"),
value: z.string().min(1, "Conditional Logic: Question id cannot be empty"),
meta: z.record(z.string()).optional(),
});
const ZDynamicVariable = z.object({
type: z.literal("variable"),
value: z
.string()
.cuid2({ message: "Conditional Logic: Variable id must be a valid cuid" })
.min(1, "Conditional Logic: Variable id cannot be empty"),
});
const ZDynamicHiddenField = z.object({
type: z.literal("hiddenField"),
value: z.string().min(1, "Conditional Logic: Hidden field id cannot be empty"),
});
const ZDynamicLogicFieldValue = z.union([ZDynamicQuestion, ZDynamicVariable, ZDynamicHiddenField], {
message: "Conditional Logic: Invalid dynamic field value",
});
const ZLeftOperand = ZDynamicLogicFieldValue;
export type TLeftOperand = z.infer<typeof ZLeftOperand>;
export const ZRightOperandStatic = z.object({
type: z.literal("static"),
value: z.union([z.string(), z.number(), z.array(z.string())]),
});
export const ZRightOperand = z.union([ZRightOperandStatic, ZDynamicLogicFieldValue]);
export type TRightOperand = z.infer<typeof ZRightOperand>;
const operatorsWithoutRightOperand: readonly TSurveyLogicConditionsOperator[] = [
ZSurveyLogicConditionsOperator.Enum.isSubmitted,
ZSurveyLogicConditionsOperator.Enum.isSkipped,
ZSurveyLogicConditionsOperator.Enum.isClicked,
ZSurveyLogicConditionsOperator.Enum.isAccepted,
ZSurveyLogicConditionsOperator.Enum.isBooked,
ZSurveyLogicConditionsOperator.Enum.isPartiallySubmitted,
ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted,
ZSurveyLogicConditionsOperator.Enum.isSet,
ZSurveyLogicConditionsOperator.Enum.isNotSet,
ZSurveyLogicConditionsOperator.Enum.isEmpty,
ZSurveyLogicConditionsOperator.Enum.isNotEmpty,
];
export const ZSingleCondition = z
.object({
id: ZId,
leftOperand: ZLeftOperand,
operator: ZSurveyLogicConditionsOperator,
rightOperand: ZRightOperand.optional(),
})
.and(
z.object({
connector: z.undefined(),
})
)
.superRefine((val, ctx) => {
if (!operatorsWithoutRightOperand.includes(val.operator)) {
if (val.rightOperand === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Conditional Logic: right operand is required for operator "${val.operator}"`,
path: ["rightOperand"],
});
} else if (val.rightOperand.type === "static" && val.rightOperand.value === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Conditional Logic: right operand value cannot be empty for operator "${val.operator}"`,
});
}
} else if (val.rightOperand !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Conditional Logic: right operand should not be present for operator "${val.operator}"`,
path: ["rightOperand"],
});
}
});
export type TSingleCondition = z.infer<typeof ZSingleCondition>;
export interface TConditionGroup {
id: string;
connector: z.infer<typeof ZConnector>;
conditions: (TSingleCondition | TConditionGroup)[];
}
const ZConditionGroup: z.ZodType<TConditionGroup> = z.lazy(() =>
z.object({
id: ZId,
connector: ZConnector,
conditions: z.array(z.union([ZSingleCondition, ZConditionGroup])),
})
);
// Block Logic - Actions
const ZActionCalculateBase = z.object({
id: ZId,
objective: z.literal("calculate"),
variableId: z.string(),
});
export const ZActionCalculateText = ZActionCalculateBase.extend({
operator: ZActionTextVariableCalculateOperator,
value: z.union([
z.object({
type: z.literal("static"),
value: z
.string({ message: "Conditional Logic: Value must be a string for text variable" })
.min(1, "Conditional Logic: Please enter a value in logic field"),
}),
ZDynamicLogicFieldValue,
]),
});
export const ZActionCalculateNumber = ZActionCalculateBase.extend({
operator: ZActionNumberVariableCalculateOperator,
value: z.union([
z.object({
type: z.literal("static"),
value: z.number({ message: "Conditional Logic: Value must be a number for number variable" }),
}),
ZDynamicLogicFieldValue,
]),
}).superRefine((val, ctx) => {
if (val.operator === "divide" && val.value.type === "static" && val.value.value === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Conditional Logic: Cannot divide by zero",
path: ["value", "value"],
});
}
});
export const ZActionCalculate = z.union([ZActionCalculateText, ZActionCalculateNumber]);
export type TActionCalculate = z.infer<typeof ZActionCalculate>;
// RequireAnswer action - targets element IDs
export const ZActionRequireAnswer = z.object({
id: ZId,
objective: z.literal("requireAnswer"),
target: ZSurveyElementId, // Targets elements, validated to be outside current block
});
export type TActionRequireAnswer = z.infer<typeof ZActionRequireAnswer>;
// JumpToBlock action - targets block IDs (CUIDs)
export const ZActionJumpToBlock = z.object({
id: ZId,
objective: z.literal("jumpToBlock"),
target: ZSurveyBlockId, // Must be a valid CUID
});
export type TActionJumpToBlock = z.infer<typeof ZActionJumpToBlock>;
// Block logic actions
export const ZSurveyBlockLogicAction = z.union([ZActionCalculate, ZActionRequireAnswer, ZActionJumpToBlock]);
export type TSurveyBlockLogicAction = z.infer<typeof ZSurveyBlockLogicAction>;
const ZSurveyBlockLogicActions = z.array(ZSurveyBlockLogicAction);
export type TSurveyBlockLogicActions = z.infer<typeof ZSurveyBlockLogicActions>;
// Block Logic
export const ZSurveyBlockLogic = z.object({
id: ZId,
conditions: ZConditionGroup,
actions: ZSurveyBlockLogicActions,
});
export type TSurveyBlockLogic = z.infer<typeof ZSurveyBlockLogic>;
// Block definition
export const ZSurveyBlock = z
.object({
id: ZSurveyBlockId, // CUID
name: z.string().min(1, { message: "Block name is required" }), // REQUIRED for editor
elements: ZSurveyElements.min(1, { message: "Block must have at least one element" }),
logic: z.array(ZSurveyBlockLogic).optional(),
logicFallback: ZSurveyBlockId.optional(),
buttonLabel: ZI18nString.optional(),
backButtonLabel: ZI18nString.optional(),
isDraft: z.boolean().optional(),
})
.superRefine((block, ctx) => {
// Validate element IDs are unique within block
const elementIds = block.elements.map((e) => e.id);
const uniqueElementIds = new Set(elementIds);
if (uniqueElementIds.size !== elementIds.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Element IDs must be unique within a block",
path: [elementIds.findIndex((id, index) => elementIds.indexOf(id) !== index), "id"],
});
}
});
export type TSurveyBlock = z.infer<typeof ZSurveyBlock>;
export const ZSurveyBlocks = z.array(ZSurveyBlock);
export type TSurveyBlocks = z.infer<typeof ZSurveyBlocks>;

View File

@@ -0,0 +1,114 @@
import { z } from "zod";
import type { TI18nString, TSurveyLanguage } from "./types";
import { getTextContent } from "./validation";
const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => {
if (!surveyLanguages) return [];
return surveyLanguages.map((surveyLanguage) =>
surveyLanguage.default ? "default" : surveyLanguage.language.code
);
};
const validateLabelForAllLanguages = (label: TI18nString, surveyLanguages: TSurveyLanguage[]): string[] => {
const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled);
const languageCodes = extractLanguageCodes(enabledLanguages);
const languages = !languageCodes.length ? ["default"] : languageCodes;
const invalidLanguageCodes = languages.filter((language) => {
// Check if label exists and is not undefined
if (!label[language]) return true;
// Use getTextContent to extract text from HTML or plain text
const textContent = getTextContent(label[language]);
return textContent.length === 0;
});
return invalidLanguageCodes.map((invalidLanguageCode) => {
if (invalidLanguageCode === "default") {
return surveyLanguages.find((lang) => lang.default)?.language.code ?? "default";
}
return invalidLanguageCode;
});
};
// Map for element field names to user-friendly labels
const ELEMENT_FIELD_TO_LABEL_MAP: Record<string, string> = {
headline: "question",
subheader: "description",
placeholder: "placeholder",
upperLabel: "upper label",
lowerLabel: "lower label",
"consent.label": "checkbox label",
dismissButtonLabel: "dismiss button label",
html: "description",
};
export const validateElementLabels = (
field: string,
fieldLabel: TI18nString,
languages: TSurveyLanguage[],
blockIndex: number,
elementIndex: number,
skipArticle = false
): z.IssueData | null => {
// fieldLabel should contain all the keys present in languages
for (const language of languages) {
if (
!language.default &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- could be undefined
fieldLabel[language.language.code] === undefined
) {
return {
code: z.ZodIssueCode.custom,
message: `The ${field} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)} is not present for the following languages: ${language.language.code}`,
path: ["blocks", blockIndex, "elements", elementIndex, field],
};
}
}
const invalidLanguageCodes = validateLabelForAllLanguages(fieldLabel, languages);
const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default";
const messagePrefix = skipArticle ? "" : "The ";
const messageField = ELEMENT_FIELD_TO_LABEL_MAP[field] ? ELEMENT_FIELD_TO_LABEL_MAP[field] : field;
const messageSuffix = isDefaultOnly ? " is missing" : " is missing for the following languages: ";
const message = isDefaultOnly
? `${messagePrefix}${messageField} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix}`
: `${messagePrefix}${messageField} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix} -fLang- ${invalidLanguageCodes.join()}`;
if (invalidLanguageCodes.length) {
return {
code: z.ZodIssueCode.custom,
message,
path: ["blocks", blockIndex, "elements", elementIndex, field],
params: isDefaultOnly ? undefined : { invalidLanguageCodes },
};
}
return null;
};
export const findLanguageCodesForDuplicateLabels = (
labels: TI18nString[],
surveyLanguages: TSurveyLanguage[]
): string[] => {
const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled);
const languageCodes = extractLanguageCodes(enabledLanguages);
const languagesToCheck = languageCodes.length === 0 ? ["default"] : languageCodes;
const duplicateLabels = new Set<string>();
for (const language of languagesToCheck) {
const labelTexts = labels.map((label) => label[language].trim()).filter(Boolean);
const uniqueLabels = new Set(labelTexts);
if (uniqueLabels.size !== labelTexts.length) {
duplicateLabels.add(language);
}
}
return Array.from(duplicateLabels);
};

View File

@@ -0,0 +1,308 @@
/* eslint-disable import/no-cycle -- Required for circular dependency between types, blocks, and elements */
import { z } from "zod";
import { ZAllowedFileExtension } from "../storage";
import { ZI18nString } from "./types";
import { FORBIDDEN_IDS } from "./validation";
// Element Type Enum (same as question types)
export enum TSurveyElementTypeEnum {
FileUpload = "fileUpload",
OpenText = "openText",
MultipleChoiceSingle = "multipleChoiceSingle",
MultipleChoiceMulti = "multipleChoiceMulti",
NPS = "nps",
CTA = "cta",
Rating = "rating",
Consent = "consent",
PictureSelection = "pictureSelection",
Cal = "cal",
Date = "date",
Matrix = "matrix",
Address = "address",
Ranking = "ranking",
ContactInfo = "contactInfo",
}
// Element ID validation (same rules as questions - USER EDITABLE)
export const ZSurveyElementId = z.string().superRefine((id, ctx) => {
if (FORBIDDEN_IDS.includes(id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Element id is not allowed`,
});
}
if (id.includes(" ")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Element id not allowed, avoid using spaces.",
});
}
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Element id not allowed, use only alphanumeric characters, hyphens, or underscores.",
});
}
});
export type TSurveyElementId = z.infer<typeof ZSurveyElementId>;
// Base element (like ZSurveyQuestionBase but WITHOUT logic, buttonLabel, backButtonLabel)
export const ZSurveyElementBase = z.object({
id: ZSurveyElementId,
type: z.string(),
headline: ZI18nString,
subheader: ZI18nString.optional(),
imageUrl: z.string().optional(),
videoUrl: z.string().optional(),
required: z.boolean(),
scale: z.enum(["number", "smiley", "star"]).optional(),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
isDraft: z.boolean().optional(),
});
// OpenText Element
export const ZSurveyOpenTextElementInputType = z.enum(["text", "email", "url", "number", "phone"]);
export type TSurveyOpenTextElementInputType = z.infer<typeof ZSurveyOpenTextElementInputType>;
export const ZSurveyOpenTextElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.OpenText),
placeholder: ZI18nString.optional(),
longAnswer: z.boolean().optional(),
inputType: ZSurveyOpenTextElementInputType.optional().default("text"),
insightsEnabled: z.boolean().default(false).optional(),
charLimit: z
.object({
enabled: z.boolean().default(false).optional(),
min: z.number().optional(),
max: z.number().optional(),
})
.default({ enabled: false }),
}).superRefine((data, ctx) => {
if (data.charLimit.enabled && data.charLimit.min === undefined && data.charLimit.max === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter the values for either minimum or maximum field",
});
}
if (
(data.charLimit.min !== undefined && data.charLimit.min < 0) ||
(data.charLimit.max !== undefined && data.charLimit.max < 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "The character limit values should be positive",
});
}
if (
data.charLimit.min !== undefined &&
data.charLimit.max !== undefined &&
data.charLimit.min > data.charLimit.max
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Minimum value cannot be greater than the maximum value",
});
}
});
export type TSurveyOpenTextElement = z.infer<typeof ZSurveyOpenTextElement>;
// Consent Element
export const ZSurveyConsentElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Consent),
label: ZI18nString,
});
export type TSurveyConsentElement = z.infer<typeof ZSurveyConsentElement>;
// Multiple Choice Elements
export const ZSurveyElementChoice = z.object({
id: z.string(),
label: ZI18nString,
});
export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]);
export type TShuffleOption = z.infer<typeof ZShuffleOption>;
export const ZSurveyMultipleChoiceElement = ZSurveyElementBase.extend({
type: z.union([
z.literal(TSurveyElementTypeEnum.MultipleChoiceSingle),
z.literal(TSurveyElementTypeEnum.MultipleChoiceMulti),
]),
choices: z
.array(ZSurveyElementChoice)
.min(2, { message: "Multiple Choice Element must have at least two choices" }),
shuffleOption: ZShuffleOption.optional(),
otherOptionPlaceholder: ZI18nString.optional(),
});
export type TSurveyMultipleChoiceElement = z.infer<typeof ZSurveyMultipleChoiceElement>;
// NPS Element
export const ZSurveyNPSElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.NPS),
lowerLabel: ZI18nString.optional(),
upperLabel: ZI18nString.optional(),
isColorCodingEnabled: z.boolean().optional().default(false),
});
export type TSurveyNPSElement = z.infer<typeof ZSurveyNPSElement>;
// CTA Element
export const ZSurveyCTAElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.CTA),
buttonUrl: z.string().optional(),
buttonExternal: z.boolean(),
dismissButtonLabel: ZI18nString.optional(),
});
export type TSurveyCTAElement = z.infer<typeof ZSurveyCTAElement>;
// Rating Element
export const ZSurveyRatingElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Rating),
scale: z.enum(["number", "smiley", "star"]),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(6), z.literal(7), z.literal(10)]),
lowerLabel: ZI18nString.optional(),
upperLabel: ZI18nString.optional(),
isColorCodingEnabled: z.boolean().optional().default(false),
});
export type TSurveyRatingElement = z.infer<typeof ZSurveyRatingElement>;
// Picture Selection Element
export const ZSurveyPictureChoice = z.object({
id: z.string(),
imageUrl: z.string(),
});
export type TSurveyPictureChoice = z.infer<typeof ZSurveyPictureChoice>;
export const ZSurveyPictureSelectionElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.PictureSelection),
allowMulti: z.boolean().optional().default(false),
choices: z
.array(ZSurveyPictureChoice)
.min(2, { message: "Picture Selection element must have atleast 2 choices" }),
});
export type TSurveyPictureSelectionElement = z.infer<typeof ZSurveyPictureSelectionElement>;
// Date Element
export const ZSurveyDateElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Date),
html: ZI18nString.optional(),
format: z.enum(["M-d-y", "d-M-y", "y-M-d"]),
});
export type TSurveyDateElement = z.infer<typeof ZSurveyDateElement>;
// File Upload Element
export const ZSurveyFileUploadElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.FileUpload),
allowMultipleFiles: z.boolean(),
maxSizeInMB: z.number().optional(),
allowedFileExtensions: z.array(ZAllowedFileExtension).optional(),
});
export type TSurveyFileUploadElement = z.infer<typeof ZSurveyFileUploadElement>;
// Cal Element
export const ZSurveyCalElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Cal),
calUserName: z.string().min(1, { message: "Cal user name is required" }),
calHost: z.string().optional(),
});
export type TSurveyCalElement = z.infer<typeof ZSurveyCalElement>;
// Matrix Element
export const ZSurveyMatrixElementChoice = z.object({
id: z.string(),
label: ZI18nString,
});
export type TSurveyMatrixElementChoice = z.infer<typeof ZSurveyMatrixElementChoice>;
export const ZSurveyMatrixElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Matrix),
rows: z.array(ZSurveyMatrixElementChoice),
columns: z.array(ZSurveyMatrixElementChoice),
shuffleOption: ZShuffleOption.optional().default("none"),
});
export type TSurveyMatrixElement = z.infer<typeof ZSurveyMatrixElement>;
// Address Element
const ZToggleInputConfig = z.object({
show: z.boolean(),
required: z.boolean(),
placeholder: ZI18nString,
});
export type TInputFieldConfig = z.infer<typeof ZToggleInputConfig>;
export const ZSurveyAddressElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Address),
addressLine1: ZToggleInputConfig,
addressLine2: ZToggleInputConfig,
city: ZToggleInputConfig,
state: ZToggleInputConfig,
zip: ZToggleInputConfig,
country: ZToggleInputConfig,
});
export type TSurveyAddressElement = z.infer<typeof ZSurveyAddressElement>;
// Ranking Element
export const ZSurveyRankingElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Ranking),
choices: z
.array(ZSurveyElementChoice)
.min(2, { message: "Ranking Element must have at least two options" })
.max(25, { message: "Ranking Element can have at most 25 options" }),
otherOptionPlaceholder: ZI18nString.optional(),
shuffleOption: ZShuffleOption.optional(),
});
export type TSurveyRankingElement = z.infer<typeof ZSurveyRankingElement>;
// Contact Info Element
export const ZSurveyContactInfoElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.ContactInfo),
firstName: ZToggleInputConfig,
lastName: ZToggleInputConfig,
email: ZToggleInputConfig,
phone: ZToggleInputConfig,
company: ZToggleInputConfig,
});
export type TSurveyContactInfoElement = z.infer<typeof ZSurveyContactInfoElement>;
// Union of all element types
export const ZSurveyElement = z.union([
ZSurveyOpenTextElement,
ZSurveyConsentElement,
ZSurveyMultipleChoiceElement,
ZSurveyNPSElement,
ZSurveyCTAElement,
ZSurveyRatingElement,
ZSurveyPictureSelectionElement,
ZSurveyDateElement,
ZSurveyFileUploadElement,
ZSurveyCalElement,
ZSurveyMatrixElement,
ZSurveyAddressElement,
ZSurveyRankingElement,
ZSurveyContactInfoElement,
]);
export type TSurveyElement = z.infer<typeof ZSurveyElement>;
export const ZSurveyElements = z.array(ZSurveyElement);
export type TSurveyElements = z.infer<typeof ZSurveyElements>;

File diff suppressed because it is too large Load Diff

View File

@@ -299,22 +299,28 @@ const findJumpToQuestionActions = (actions: TSurveyLogicAction[]): TActionJumpTo
return actions.filter((action): action is TActionJumpToQuestion => action.objective === "jumpToQuestion");
};
// function to validate hidden field or question id
// function to validate hidden field or question id or element id
export const validateId = (
type: "Hidden field" | "Question",
type: "Hidden field" | "Question" | "Element",
field: string,
existingQuestionIds: string[],
existingEndingCardIds: string[],
existingHiddenFieldIds: string[]
existingHiddenFieldIds: string[],
existingElementIds?: string[]
): string | null => {
if (field.trim() === "") {
return `Please enter a ${type} Id.`;
}
const combinedIds = [...existingQuestionIds, ...existingHiddenFieldIds, ...existingEndingCardIds];
const combinedIds = [
...existingQuestionIds,
...existingHiddenFieldIds,
...existingEndingCardIds,
...(existingElementIds ?? []),
];
if (combinedIds.findIndex((id) => id.toLowerCase() === field.toLowerCase()) !== -1) {
return `${type} ID already exists in questions or hidden fields.`;
return `${type} ID already exists in questions, hidden fields, or elements.`;
}
if (FORBIDDEN_IDS.includes(field)) {