mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
feat: add blocks model to support multi-question pages (schema only)
This commit is contained in:
@@ -3668,6 +3668,7 @@ export const previewSurvey = (projectName: string, t: TFunction) => {
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
endings: [
|
||||
{
|
||||
id: "cltyqp5ng000108l9dmxw6nde",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ export const selectSurvey = {
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
|
||||
@@ -33,6 +33,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
|
||||
type: true,
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const selectSurvey = {
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -249,6 +249,7 @@ const getExistingSurvey = async (surveyId: string) => {
|
||||
},
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
endings: true,
|
||||
variables: true,
|
||||
hiddenFields: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
displayLimit: null,
|
||||
welcomeCard: getDefaultWelcomeCard(t),
|
||||
questions: [],
|
||||
blocks: [],
|
||||
endings: [getDefaultEndingCard([], t)],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Survey" ADD COLUMN "blocks" JSONB[] DEFAULT ARRAY[]::JSONB[];
|
||||
@@ -340,6 +340,8 @@ model Survey {
|
||||
welcomeCard Json @default("{\"enabled\": false}")
|
||||
/// [SurveyQuestions]
|
||||
questions Json @default("[]")
|
||||
/// [SurveyBlocks]
|
||||
blocks Json[] @default([])
|
||||
/// [SurveyEnding]
|
||||
endings Json[] @default([])
|
||||
/// [SurveyHiddenFields]
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
64
packages/types/surveys/blocks-validation.ts
Normal file
64
packages/types/surveys/blocks-validation.ts
Normal 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");
|
||||
};
|
||||
232
packages/types/surveys/blocks.ts
Normal file
232
packages/types/surveys/blocks.ts
Normal 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>;
|
||||
114
packages/types/surveys/elements-validation.ts
Normal file
114
packages/types/surveys/elements-validation.ts
Normal 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);
|
||||
};
|
||||
308
packages/types/surveys/elements.ts
Normal file
308
packages/types/surveys/elements.ts
Normal 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
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user