mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-19 19:21:15 -05:00
survey mqp survey editor logic
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFunction } from "i18next";
|
||||
import { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum, TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
@@ -3598,19 +3599,26 @@ export const customSurveyTemplate = (t: TFunction): TTemplate => {
|
||||
preset: {
|
||||
...getDefaultSurveyPreset(t),
|
||||
name: t("templates.custom_survey_name"),
|
||||
questions: [
|
||||
questions: [],
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: createI18nString(t("templates.custom_survey_question_1_headline"), []),
|
||||
placeholder: createI18nString(t("templates.custom_survey_question_1_placeholder"), []),
|
||||
buttonLabel: createI18nString(t("templates.next"), []),
|
||||
required: true,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
} as TSurveyOpenTextQuestion,
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: createI18nString(t("templates.custom_survey_question_1_headline"), []),
|
||||
placeholder: createI18nString(t("templates.custom_survey_question_1_placeholder"), []),
|
||||
buttonLabel: createI18nString(t("templates.next"), []),
|
||||
required: true,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
} as TSurveyOpenTextElement,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1352,6 +1352,7 @@ checksums:
|
||||
environments/surveys/edit/is_skipped: 9fb90b6578f603cca37d4e6c912bb401
|
||||
environments/surveys/edit/is_submitted: 13e774a97ad5f5609555e6f99514e70f
|
||||
environments/surveys/edit/italic: 555c60fb1d12ae305136202afa6deb3d
|
||||
environments/surveys/edit/jump_to_block: 2fc00bd725c44f98861051c57bb2c392
|
||||
environments/surveys/edit/jump_to_question: 742aabed8845190825418aa429f01b2d
|
||||
environments/surveys/edit/keep_current_order: a7c944ad6b3515f2c4f83a2c81f8fc26
|
||||
environments/surveys/edit/keep_showing_while_conditions_match: 2574802d87bd6da151c9145aacce7281
|
||||
@@ -1532,6 +1533,7 @@ checksums:
|
||||
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
|
||||
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
|
||||
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
|
||||
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
|
||||
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
|
||||
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
|
||||
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyBlockLogic,
|
||||
TSurveyBlockLogicAction,
|
||||
TSurveyBlockLogicActionObjective,
|
||||
} from "@formbricks/types/surveys/blocks";
|
||||
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import {
|
||||
TActionCalculate,
|
||||
TActionObjective,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
@@ -19,7 +21,7 @@ export const isConditionGroup = (condition: TCondition): condition is TCondition
|
||||
return (condition as TConditionGroup).connector !== undefined;
|
||||
};
|
||||
|
||||
export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
|
||||
export const duplicateLogicItem = (logicItem: TSurveyBlockLogic): TSurveyBlockLogic => {
|
||||
const duplicateConditionGroup = (group: TConditionGroup): TConditionGroup => {
|
||||
return {
|
||||
...group,
|
||||
@@ -41,7 +43,7 @@ export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
|
||||
};
|
||||
};
|
||||
|
||||
const duplicateAction = (action: TSurveyLogicAction): TSurveyLogicAction => {
|
||||
const duplicateAction = (action: TSurveyBlockLogicAction): TSurveyBlockLogicAction => {
|
||||
return {
|
||||
...action,
|
||||
id: createId(),
|
||||
@@ -197,9 +199,9 @@ export const updateCondition = (
|
||||
};
|
||||
|
||||
export const getUpdatedActionBody = (
|
||||
action: TSurveyLogicAction,
|
||||
objective: TActionObjective
|
||||
): TSurveyLogicAction => {
|
||||
action: TSurveyBlockLogicAction,
|
||||
objective: TSurveyBlockLogicActionObjective
|
||||
): TSurveyBlockLogicAction => {
|
||||
if (objective === action.objective) return action;
|
||||
switch (objective) {
|
||||
case "calculate":
|
||||
@@ -216,12 +218,14 @@ export const getUpdatedActionBody = (
|
||||
objective: "requireAnswer",
|
||||
target: "",
|
||||
};
|
||||
case "jumpToQuestion":
|
||||
case "jumpToBlock":
|
||||
return {
|
||||
id: action.id,
|
||||
objective: "jumpToQuestion",
|
||||
objective: "jumpToBlock",
|
||||
target: "",
|
||||
};
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -622,7 +626,7 @@ const getRightOperandValue = (
|
||||
|
||||
export const performActions = (
|
||||
survey: TJsEnvironmentStateSurvey,
|
||||
actions: TSurveyLogicAction[],
|
||||
actions: TSurveyBlockLogicAction[],
|
||||
data: TResponseData,
|
||||
calculationResults: TResponseVariables
|
||||
): {
|
||||
@@ -643,7 +647,7 @@ export const performActions = (
|
||||
case "requireAnswer":
|
||||
requiredQuestionIds.push(action.target);
|
||||
break;
|
||||
case "jumpToQuestion":
|
||||
case "jumpToBlock":
|
||||
if (!jumpTarget) {
|
||||
jumpTarget = action.target;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -60,7 +61,8 @@ const getRecallItemLabel = <T extends TSurvey>(
|
||||
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
|
||||
if (isHiddenField) return recallItemId;
|
||||
|
||||
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
const questions = survey.blocks.flatMap((b) => b.elements);
|
||||
const surveyQuestion = questions.find((question) => question.id === recallItemId);
|
||||
if (surveyQuestion) {
|
||||
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
|
||||
// Strip HTML tags to prevent raw HTML from showing in nested recalls
|
||||
@@ -123,13 +125,14 @@ export const replaceRecallInfoWithUnderline = (label: string): string => {
|
||||
};
|
||||
|
||||
// Checks for survey questions with a "recall" pattern but no fallback value.
|
||||
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
|
||||
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyElement | null => {
|
||||
const doesTextHaveRecall = (text: string) => {
|
||||
const recalls = text.match(/#recall:[^ ]+/g);
|
||||
return recalls?.some((recall) => !extractFallbackValue(recall));
|
||||
};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const questions = survey.blocks.flatMap((b) => b.elements);
|
||||
for (const question of questions) {
|
||||
if (
|
||||
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
|
||||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
|
||||
@@ -157,7 +160,8 @@ export const getRecallItems = (text: string, survey: TSurvey, languageCode: stri
|
||||
let recallItems: TSurveyRecallItem[] = [];
|
||||
ids.forEach((recallItemId) => {
|
||||
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
|
||||
const isSurveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
const questions = survey.blocks.flatMap((b) => b.elements);
|
||||
const isSurveyQuestion = questions.find((question) => question.id === recallItemId);
|
||||
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);
|
||||
|
||||
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode);
|
||||
|
||||
@@ -62,6 +62,11 @@ export function LocalizedEditor({
|
||||
autoFocus,
|
||||
isExternalUrlsAllowed,
|
||||
}: Readonly<LocalizedEditorProps>) {
|
||||
// Derive questions from blocks for migrated surveys
|
||||
const questions = useMemo(
|
||||
() => localSurvey.blocks.flatMap((block) => block.elements),
|
||||
[localSurvey.blocks]
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isInComplete = useMemo(
|
||||
@@ -99,12 +104,12 @@ export function LocalizedEditor({
|
||||
}
|
||||
|
||||
// Check if the question still exists before updating
|
||||
const currentQuestion = localSurvey.questions[questionIdx];
|
||||
const currentQuestion = questions[questionIdx];
|
||||
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = questionIdx === -1;
|
||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
||||
const isEndingCard = questionIdx >= questions.length;
|
||||
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Language } from "@prisma/client";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { LanguageToggle } from "./language-toggle";
|
||||
|
||||
@@ -10,7 +11,7 @@ interface SecondaryLanguageSelectProps {
|
||||
projectLanguages: Language[];
|
||||
defaultLanguage: Language;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId) => void;
|
||||
setActiveQuestionId: (questionId: string) => void;
|
||||
localSurvey: TSurvey;
|
||||
updateSurveyLanguages: (language: Language) => void;
|
||||
locale: TUserLocale;
|
||||
@@ -32,6 +33,10 @@ export function SecondaryLanguageSelect({
|
||||
);
|
||||
};
|
||||
|
||||
const questions = useMemo(() => {
|
||||
return localSurvey.blocks.flatMap((block) => block.elements);
|
||||
}, [localSurvey.blocks]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
@@ -46,7 +51,7 @@ export function SecondaryLanguageSelect({
|
||||
language={language}
|
||||
onEdit={() => {
|
||||
setSelectedLanguageCode(language.code);
|
||||
setActiveQuestionId(localSurvey.questions[0]?.id);
|
||||
setActiveQuestionId(questions[0]?.id);
|
||||
}}
|
||||
onToggle={() => {
|
||||
updateSurveyLanguages(language);
|
||||
|
||||
@@ -80,7 +80,14 @@ export const QuotaModal = ({
|
||||
const { t } = useTranslation();
|
||||
const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
|
||||
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
|
||||
|
||||
// Derive questions from blocks (with fallback to legacy questions)
|
||||
const questions = useMemo(() => {
|
||||
return survey.blocks.flatMap((block) => block.elements);
|
||||
}, [survey.blocks]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const firstQuestion = questions[0];
|
||||
return {
|
||||
name: quota?.name || "",
|
||||
limit: quota?.limit || 1,
|
||||
@@ -89,8 +96,8 @@ export const QuotaModal = ({
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: { type: "question", value: survey.questions[0]?.id },
|
||||
operator: getDefaultOperatorForQuestion(survey.questions[0], t),
|
||||
leftOperand: { type: "question", value: firstQuestion?.id },
|
||||
operator: firstQuestion ? getDefaultOperatorForQuestion(firstQuestion, t) : "equals",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -99,7 +106,7 @@ export const QuotaModal = ({
|
||||
countPartialSubmissions: quota?.countPartialSubmissions || false,
|
||||
surveyId: survey.id,
|
||||
};
|
||||
}, [quota, survey]);
|
||||
}, [quota, survey, questions, t]);
|
||||
|
||||
const form = useForm<TSurveyQuotaInput>({
|
||||
defaultValues,
|
||||
|
||||
@@ -13,7 +13,8 @@ import { render } from "@react-email/render";
|
||||
import { TFunction } from "i18next";
|
||||
import { CalendarDaysIcon, UploadIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -77,13 +78,26 @@ export async function PreviewEmailTemplate({
|
||||
const url = `${surveyUrl}?preview=true`;
|
||||
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
|
||||
const defaultLanguageCode = "default";
|
||||
const firstQuestion = survey.questions[0];
|
||||
// Derive questions from blocks
|
||||
const questions = survey.blocks.flatMap((block) => block.elements);
|
||||
const firstQuestion = questions[0] as TSurveyElement;
|
||||
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
|
||||
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
|
||||
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
|
||||
|
||||
const getButtonLabel = (survey: TSurvey, defaultLanguageCode: string) => {
|
||||
const ctaQuestionBlock = survey.blocks.find((block) =>
|
||||
block.elements.some((element) => element.type === TSurveyElementTypeEnum.CTA)
|
||||
);
|
||||
if (ctaQuestionBlock) {
|
||||
return getLocalizedValue(ctaQuestionBlock.buttonLabel, defaultLanguageCode);
|
||||
}
|
||||
|
||||
return t("common.next");
|
||||
};
|
||||
|
||||
switch (firstQuestion.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -91,7 +105,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -120,7 +134,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Section className="w-full justify-center">
|
||||
@@ -169,7 +183,7 @@ export async function PreviewEmailTemplate({
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -187,13 +201,13 @@ export async function PreviewEmailTemplate({
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}>
|
||||
{getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)}
|
||||
{getButtonLabel(survey, defaultLanguageCode)}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Section className="w-full">
|
||||
@@ -246,7 +260,7 @@ export async function PreviewEmailTemplate({
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -262,7 +276,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -278,7 +292,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -295,7 +309,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -321,7 +335,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Cal:
|
||||
case TSurveyElementTypeEnum.Cal:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Container>
|
||||
@@ -337,7 +351,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Date:
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -350,7 +364,7 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -391,8 +405,8 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Address:
|
||||
case TSurveyQuestionTypeEnum.ContactInfo:
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
case TSurveyElementTypeEnum.ContactInfo:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
@@ -407,7 +421,7 @@ export async function PreviewEmailTemplate({
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
case TSurveyElementTypeEnum.FileUpload:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
|
||||
@@ -15,13 +15,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyRecallItem,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement, TSurveyElementId, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyHiddenFields, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -46,7 +41,7 @@ const questionIconMapping = {
|
||||
|
||||
interface RecallItemSelectProps {
|
||||
localSurvey: TSurvey;
|
||||
questionId: TSurveyQuestionId;
|
||||
questionId: TSurveyElementId;
|
||||
addRecallItem: (question: TSurveyRecallItem) => void;
|
||||
setShowRecallItemSelect: (show: boolean) => void;
|
||||
recallItems: TSurveyRecallItem[];
|
||||
@@ -64,14 +59,14 @@ export const RecallItemSelect = ({
|
||||
}: RecallItemSelectProps) => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const isNotAllowedQuestionType = (question: TSurveyQuestion): boolean => {
|
||||
const isNotAllowedQuestionType = (question: TSurveyElement): boolean => {
|
||||
return (
|
||||
question.type === "fileUpload" ||
|
||||
question.type === "cta" ||
|
||||
question.type === "consent" ||
|
||||
question.type === "pictureSelection" ||
|
||||
question.type === "cal" ||
|
||||
question.type === "matrix"
|
||||
question.type === TSurveyElementTypeEnum.FileUpload ||
|
||||
question.type === TSurveyElementTypeEnum.CTA ||
|
||||
question.type === TSurveyElementTypeEnum.Consent ||
|
||||
question.type === TSurveyElementTypeEnum.PictureSelection ||
|
||||
question.type === TSurveyElementTypeEnum.Cal ||
|
||||
question.type === TSurveyElementTypeEnum.Matrix
|
||||
);
|
||||
};
|
||||
|
||||
@@ -114,11 +109,14 @@ export const RecallItemSelect = ({
|
||||
const isWelcomeCard = questionId === "start";
|
||||
if (isWelcomeCard) return [];
|
||||
|
||||
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
|
||||
// Derive questions from blocks or fall back to legacy questions array
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
|
||||
const isEndingCard = !questions.map((question) => question.id).includes(questionId);
|
||||
const idx = isEndingCard
|
||||
? localSurvey.questions.length
|
||||
: localSurvey.questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
|
||||
const filteredQuestions = localSurvey.questions
|
||||
? questions.length
|
||||
: questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
|
||||
const filteredQuestions = questions
|
||||
.filter((question, index) => {
|
||||
const notAllowed = isNotAllowedQuestionType(question);
|
||||
return (
|
||||
@@ -130,7 +128,7 @@ export const RecallItemSelect = ({
|
||||
});
|
||||
|
||||
return filteredQuestions;
|
||||
}, [localSurvey.questions, questionId, recallItemIds, selectedLanguageCode]);
|
||||
}, [localSurvey.blocks, questionId, recallItemIds, selectedLanguageCode]);
|
||||
|
||||
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
|
||||
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
|
||||
@@ -146,7 +144,9 @@ export const RecallItemSelect = ({
|
||||
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
||||
switch (recallItem.type) {
|
||||
case "question":
|
||||
const question = localSurvey.questions.find((question) => question.id === recallItem.id);
|
||||
// Derive questions from blocks or fall back to legacy questions array
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
const question = questions.find((question) => question.id === recallItem.id);
|
||||
if (question) {
|
||||
return questionIconMapping[question?.type as keyof typeof questionIconMapping];
|
||||
}
|
||||
|
||||
@@ -189,7 +189,8 @@ export const RecallWrapper = ({
|
||||
const info = extractRecallInfo(recallItem.label);
|
||||
if (info) {
|
||||
const recallItemId = extractId(info);
|
||||
const recallQuestion = localSurvey.questions.find((q) => q.id === recallItemId);
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
const recallQuestion = questions.find((q) => q.id === recallItemId);
|
||||
if (recallQuestion) {
|
||||
// replace nested recall with "___"
|
||||
return [recallItem.label.replace(info, "___")];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ImagePlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
@@ -92,7 +93,9 @@ export const QuestionFormInput = ({
|
||||
const defaultLanguageCode =
|
||||
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
|
||||
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
|
||||
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
|
||||
const questions = localSurvey.blocks?.flatMap((block) => block.elements) ?? localSurvey.questions;
|
||||
|
||||
const question: TSurveyElement = questions[questionIdx];
|
||||
const isChoice = id.includes("choice");
|
||||
const isMatrixLabelRow = id.includes("row");
|
||||
const isMatrixLabelColumn = id.includes("column");
|
||||
@@ -100,7 +103,7 @@ export const QuestionFormInput = ({
|
||||
return isChoice || isMatrixLabelColumn || isMatrixLabelRow ? id.split("-")[0] : id;
|
||||
}, [id, isChoice, isMatrixLabelColumn, isMatrixLabelRow]);
|
||||
|
||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
||||
const isEndingCard = questionIdx >= questions.length;
|
||||
const isWelcomeCard = questionIdx === -1;
|
||||
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
|
||||
|
||||
@@ -108,7 +111,7 @@ export const QuestionFormInput = ({
|
||||
return isWelcomeCard
|
||||
? "start"
|
||||
: isEndingCard
|
||||
? localSurvey.endings[questionIdx - localSurvey.questions.length].id
|
||||
? localSurvey.endings[questionIdx - questions.length].id
|
||||
: question.id;
|
||||
//eslint-disable-next-line
|
||||
}, [isWelcomeCard, isEndingCard, question?.id]);
|
||||
@@ -133,7 +136,7 @@ export const QuestionFormInput = ({
|
||||
}
|
||||
|
||||
if (isEndingCard) {
|
||||
return getEndingCardText(localSurvey, id, surveyLanguageCodes, questionIdx);
|
||||
return getEndingCardText(localSurvey, questions, id, surveyLanguageCodes, questionIdx);
|
||||
}
|
||||
|
||||
if ((isMatrixLabelColumn || isMatrixLabelRow) && typeof index === "number") {
|
||||
@@ -160,6 +163,7 @@ export const QuestionFormInput = ({
|
||||
localSurvey,
|
||||
question,
|
||||
questionIdx,
|
||||
questions,
|
||||
surveyLanguageCodes,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyMatrixQuestion,
|
||||
@@ -51,11 +52,12 @@ export const getWelcomeCardText = (
|
||||
|
||||
export const getEndingCardText = (
|
||||
survey: TSurvey,
|
||||
questions: TSurveyElement[],
|
||||
id: string,
|
||||
surveyLanguageCodes: string[],
|
||||
questionIdx: number
|
||||
): TI18nString => {
|
||||
const endingCardIndex = questionIdx - survey.questions.length;
|
||||
const endingCardIndex = questionIdx - questions.length;
|
||||
const card = survey.endings[endingCardIndex];
|
||||
if (card.type === "endScreen") {
|
||||
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);
|
||||
@@ -69,8 +71,9 @@ export const determineImageUploaderVisibility = (questionIdx: number, localSurve
|
||||
case -1: // Welcome Card
|
||||
return false;
|
||||
default:
|
||||
// Regular Survey Question
|
||||
const question = localSurvey.questions[questionIdx];
|
||||
// Regular Survey Question - derive questions from blocks
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
const question = questions[questionIdx];
|
||||
return (!!question && !!question.imageUrl) || (!!question && !!question.videoUrl);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { selectSurvey } from "@/modules/survey/lib/survey";
|
||||
@@ -63,7 +63,10 @@ export const createSurvey = async (
|
||||
delete data.followUps;
|
||||
}
|
||||
|
||||
if (data.questions) checkForInvalidImagesInQuestions(data.questions);
|
||||
// Validate and prepare blocks
|
||||
if (data.blocks && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { ConditionalLogic } from "@/modules/survey/editor/components/conditional-logic";
|
||||
import { OptionIds } from "@/modules/survey/editor/components/option-ids";
|
||||
import { UpdateQuestionId } from "@/modules/survey/editor/components/update-question-id";
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
question: TSurveyQuestion;
|
||||
question: TSurveyElement;
|
||||
questionIdx: number;
|
||||
localSurvey: TSurvey;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
selectedLanguageCode: string;
|
||||
}
|
||||
|
||||
@@ -16,19 +20,23 @@ export const AdvancedSettings = ({
|
||||
questionIdx,
|
||||
localSurvey,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
selectedLanguageCode,
|
||||
}: AdvancedSettingsProps) => {
|
||||
const showOptionIds =
|
||||
question.type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
question.type === TSurveyQuestionTypeEnum.Ranking;
|
||||
question.type === TSurveyElementTypeEnum.PictureSelection ||
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
question.type === TSurveyElementTypeEnum.Ranking;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConditionalLogic
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
|
||||
@@ -33,8 +35,10 @@ import { Label } from "@/modules/ui/components/label";
|
||||
interface ConditionalLogicProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
question: TSurveyElement;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
}
|
||||
|
||||
export function ConditionalLogic({
|
||||
@@ -42,6 +46,8 @@ export function ConditionalLogic({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
}: ConditionalLogicProps) {
|
||||
const { t } = useTranslation();
|
||||
const transformedSurvey = useMemo(() => {
|
||||
@@ -51,10 +57,18 @@ export function ConditionalLogic({
|
||||
return modifiedSurvey;
|
||||
}, [localSurvey]);
|
||||
|
||||
// Find the parent block for this question/element to get its logic
|
||||
const parentBlock = localSurvey.blocks.find((block) =>
|
||||
block.elements.some((element) => element.id === question.id)
|
||||
);
|
||||
|
||||
const blockLogic = useMemo(() => parentBlock?.logic ?? [], [parentBlock?.logic]);
|
||||
const blockLogicFallback = parentBlock?.logicFallback;
|
||||
|
||||
const addLogic = () => {
|
||||
const operator = getDefaultOperatorForQuestion(question, t);
|
||||
|
||||
const initialCondition: TSurveyLogic = {
|
||||
const initialCondition: TSurveyBlockLogic = {
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
@@ -73,55 +87,49 @@ export function ConditionalLogic({
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToQuestion",
|
||||
objective: "jumpToBlock",
|
||||
target: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: [...(question?.logic ?? []), initialCondition],
|
||||
});
|
||||
updateBlockLogic(questionIdx, [...blockLogic, initialCondition]);
|
||||
};
|
||||
|
||||
const handleRemoveLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(question.logic ?? []);
|
||||
const logicCopy = structuredClone(blockLogic);
|
||||
const isLast = logicCopy.length === 1;
|
||||
logicCopy.splice(logicItemIdx, 1);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
logicFallback: isLast ? undefined : question.logicFallback,
|
||||
});
|
||||
updateBlockLogic(questionIdx, logicCopy);
|
||||
if (isLast) {
|
||||
updateBlockLogicFallback(questionIdx, undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const moveLogic = (from: number, to: number) => {
|
||||
const logicCopy = structuredClone(question.logic ?? []);
|
||||
const logicCopy = structuredClone(blockLogic);
|
||||
const [movedItem] = logicCopy.splice(from, 1);
|
||||
logicCopy.splice(to, 0, movedItem);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
updateBlockLogic(questionIdx, logicCopy);
|
||||
};
|
||||
|
||||
const duplicateLogic = (logicItemIdx: number) => {
|
||||
const logicCopy = structuredClone(question.logic ?? []);
|
||||
const logicCopy = structuredClone(blockLogic);
|
||||
const logicItem = logicCopy[logicItemIdx];
|
||||
const newLogicItem = duplicateLogicItem(logicItem);
|
||||
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
updateBlockLogic(questionIdx, logicCopy);
|
||||
};
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
useEffect(() => {
|
||||
if (question.logic?.length === 0 && question.logicFallback) {
|
||||
updateQuestion(questionIdx, { logicFallback: undefined });
|
||||
if (blockLogic.length === 0 && blockLogicFallback) {
|
||||
updateBlockLogicFallback(questionIdx, undefined);
|
||||
}
|
||||
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
|
||||
}, [blockLogic, questionIdx, blockLogicFallback, updateBlockLogicFallback]);
|
||||
|
||||
return (
|
||||
<div className="mt-4" ref={parent}>
|
||||
@@ -130,20 +138,22 @@ export function ConditionalLogic({
|
||||
<SplitIcon className="h-4 w-4 rotate-90" />
|
||||
</Label>
|
||||
|
||||
{question.logic && question.logic.length > 0 && (
|
||||
{blockLogic.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-4" ref={parent}>
|
||||
{question.logic.map((logicItem, logicItemIdx) => (
|
||||
{blockLogic.map((logicItem, logicItemIdx) => (
|
||||
<div
|
||||
key={logicItem.id}
|
||||
className="relative flex w-full grow items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<LogicEditor
|
||||
localSurvey={transformedSurvey}
|
||||
logicItem={logicItem}
|
||||
logicItem={logicItem as TSurveyBlockLogic}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
logicIdx={logicItemIdx}
|
||||
isLast={logicItemIdx === (question.logic ?? []).length - 1}
|
||||
isLast={logicItemIdx === blockLogic.length - 1}
|
||||
/>
|
||||
|
||||
{logicItem.conditions.conditions.length > 1 && (
|
||||
@@ -173,7 +183,7 @@ export function ConditionalLogic({
|
||||
{t("common.move_up")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={logicItemIdx === (question.logic ?? []).length - 1}
|
||||
disabled={logicItemIdx === blockLogic.length - 1}
|
||||
onClick={() => {
|
||||
moveLogic(logicItemIdx, logicItemIdx + 1);
|
||||
}}
|
||||
|
||||
@@ -70,10 +70,9 @@ export const EditorCardMenu = ({
|
||||
|
||||
return undefined;
|
||||
});
|
||||
const questions = survey.blocks.flatMap((block) => block.elements);
|
||||
const isDeleteDisabled =
|
||||
cardType === "question"
|
||||
? survey.questions.length === 1
|
||||
: survey.type === "link" && survey.endings.length === 1;
|
||||
cardType === "question" ? questions.length === 1 : survey.type === "link" && survey.endings.length === 1;
|
||||
|
||||
const availableQuestionTypes = isCxMode ? getCXQuestionNameMap(t) : getQuestionNameMap(t);
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ export const EndScreenForm = ({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
|
||||
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
|
||||
endingCard.type === "endScreen" &&
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
@@ -55,7 +57,7 @@ export const EndScreenForm = ({
|
||||
label={t("common.note") + "*"}
|
||||
value={endingCard.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
questionIdx={questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
@@ -73,7 +75,7 @@ export const EndScreenForm = ({
|
||||
value={endingCard.subheader}
|
||||
label={t("common.description")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
questionIdx={questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
@@ -142,7 +144,7 @@ export const EndScreenForm = ({
|
||||
className="rounded-md"
|
||||
value={endingCard.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
questionIdx={questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
|
||||
@@ -45,27 +45,31 @@ export const HiddenFieldsCard = ({
|
||||
};
|
||||
|
||||
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
|
||||
// Remove recall info from question headlines
|
||||
// Remove recall info from question/element headlines
|
||||
if (currentFieldId) {
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${currentFieldId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const updatedElement = { ...element };
|
||||
for (const [languageCode, headline] of Object.entries(element.headline)) {
|
||||
if (headline.includes(`recall:${currentFieldId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
updatedElement.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return updatedElement;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
questions,
|
||||
...updatedSurvey,
|
||||
hiddenFields: {
|
||||
...localSurvey.hiddenFields,
|
||||
...updatedSurvey.hiddenFields,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
@@ -93,7 +97,8 @@ export const HiddenFieldsCard = ({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (recallQuestionIdx === localSurvey.questions.length) {
|
||||
const totalQuestions = localSurvey.blocks.flatMap((b) => b.elements).length;
|
||||
if (recallQuestionIdx === totalQuestions) {
|
||||
toast.error(
|
||||
t("environments.surveys.edit.hidden_field_used_in_recall_ending_card", { hiddenField: fieldId })
|
||||
);
|
||||
@@ -191,7 +196,8 @@ export const HiddenFieldsCard = ({
|
||||
className="mt-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const existingQuestionIds = localSurvey.questions.map((question) => question.id);
|
||||
const existingElements = localSurvey.blocks.flatMap((b) => b.elements);
|
||||
const existingQuestionIds = existingElements.map((question) => question.id);
|
||||
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
|
||||
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
|
||||
const validateIdError = validateId(
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { CopyIcon, CornerDownRightIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TSurveyBlockLogic,
|
||||
TSurveyBlockLogicAction,
|
||||
TSurveyBlockLogicActionObjective,
|
||||
} from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TActionNumberVariableCalculateOperator,
|
||||
TActionTextVariableCalculateOperator,
|
||||
} from "@formbricks/types/surveys/logic";
|
||||
import {
|
||||
TActionObjective,
|
||||
TActionVariableValueType,
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TActionVariableValueType, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getUpdatedActionBody } from "@/lib/surveyLogic/utils";
|
||||
import {
|
||||
getActionObjectiveOptions,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
getActionTargetOptions,
|
||||
getActionValueOptions,
|
||||
getActionVariableOptions,
|
||||
hasJumpToQuestionAction,
|
||||
hasJumpToBlockAction,
|
||||
} from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -36,10 +35,11 @@ import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
interface LogicEditorActions {
|
||||
localSurvey: TSurvey;
|
||||
logicItem: TSurveyLogic;
|
||||
logicItem: TSurveyBlockLogic;
|
||||
logicIdx: number;
|
||||
question: TSurveyQuestion;
|
||||
question: TSurveyElement;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
questionIdx: number;
|
||||
}
|
||||
|
||||
@@ -48,17 +48,24 @@ export function LogicEditorActions({
|
||||
logicItem,
|
||||
logicIdx,
|
||||
question,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
questionIdx,
|
||||
}: LogicEditorActions) {
|
||||
const actions = logicItem.actions;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Find the parent block for this question/element to get its logic
|
||||
const parentBlock = localSurvey.blocks?.find((block) =>
|
||||
block.elements.some((element) => element.id === question.id)
|
||||
);
|
||||
const blockLogic = parentBlock?.logic ?? [];
|
||||
|
||||
const handleActionsChange = (
|
||||
operation: "remove" | "addBelow" | "duplicate" | "update",
|
||||
actionIdx: number,
|
||||
action?: TSurveyLogicAction
|
||||
action?: TSurveyBlockLogicAction
|
||||
) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicCopy = structuredClone(blockLogic);
|
||||
const currentLogicItem = logicCopy[logicIdx];
|
||||
const actionsClone = currentLogicItem.actions;
|
||||
|
||||
@@ -69,7 +76,7 @@ export function LogicEditorActions({
|
||||
case "addBelow":
|
||||
actionsClone.splice(actionIdx + 1, 0, {
|
||||
id: createId(),
|
||||
objective: hasJumpToQuestionAction(logicItem.actions) ? "requireAnswer" : "jumpToQuestion",
|
||||
objective: hasJumpToBlockAction(logicItem.actions) ? "requireAnswer" : "jumpToBlock",
|
||||
target: "",
|
||||
});
|
||||
break;
|
||||
@@ -82,28 +89,26 @@ export function LogicEditorActions({
|
||||
break;
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
logic: logicCopy,
|
||||
});
|
||||
updateBlockLogic(questionIdx, logicCopy);
|
||||
};
|
||||
|
||||
const handleObjectiveChange = (actionIdx: number, objective: TActionObjective) => {
|
||||
const handleObjectiveChange = (actionIdx: number, objective: TSurveyBlockLogicActionObjective) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = getUpdatedActionBody(action, objective);
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
};
|
||||
|
||||
const handleValuesChange = (actionIdx: number, values: Partial<TSurveyLogicAction>) => {
|
||||
const handleValuesChange = (actionIdx: number, values: Partial<TSurveyBlockLogicAction>) => {
|
||||
const action = actions[actionIdx];
|
||||
const actionBody = { ...action, ...values } as TSurveyLogicAction;
|
||||
const actionBody = { ...action, ...values } as TSurveyBlockLogicAction;
|
||||
handleActionsChange("update", actionIdx, actionBody);
|
||||
};
|
||||
|
||||
const filteredObjectiveOptions = getActionObjectiveOptions(t).filter(
|
||||
(option) => option.value !== "jumpToQuestion"
|
||||
(option) => option.value !== "jumpToBlock"
|
||||
);
|
||||
|
||||
const jumpToQuestionActionIdx = actions.findIndex((action) => action.objective === "jumpToQuestion");
|
||||
const jumpToBlockActionIdx = actions.findIndex((action) => action.objective === "jumpToBlock");
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-2">
|
||||
@@ -129,12 +134,12 @@ export function LogicEditorActions({
|
||||
key={`objective-${action.id}`}
|
||||
showSearch={false}
|
||||
options={
|
||||
jumpToQuestionActionIdx === -1 || idx === jumpToQuestionActionIdx
|
||||
jumpToBlockActionIdx === -1 || idx === jumpToBlockActionIdx
|
||||
? getActionObjectiveOptions(t)
|
||||
: filteredObjectiveOptions
|
||||
}
|
||||
value={action.objective}
|
||||
onChangeValue={(val: TActionObjective) => {
|
||||
onChangeValue={(val: TSurveyBlockLogicActionObjective) => {
|
||||
handleObjectiveChange(idx, val);
|
||||
}}
|
||||
comboboxClasses="grow"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TConditionGroup } from "@formbricks/types/surveys/logic";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
|
||||
@@ -10,7 +12,8 @@ import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
|
||||
interface LogicEditorConditionsProps {
|
||||
conditions: TConditionGroup;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyQuestion>) => void;
|
||||
question: TSurveyQuestion;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
question: TSurveyElement;
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
@@ -23,11 +26,17 @@ export function LogicEditorConditions({
|
||||
question,
|
||||
localSurvey,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
depth = 0,
|
||||
}: LogicEditorConditionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Find the parent block for this question/element to get its logic
|
||||
const parentBlock = localSurvey.blocks?.find((block) =>
|
||||
block.elements.some((element) => element.id === question.id)
|
||||
);
|
||||
const blockLogic = parentBlock?.logic ?? [];
|
||||
|
||||
const { config, callbacks } = createSharedConditionsFactory(
|
||||
{
|
||||
survey: localSurvey,
|
||||
@@ -38,7 +47,7 @@ export function LogicEditorConditions({
|
||||
},
|
||||
{
|
||||
onConditionsChange: (updater) => {
|
||||
const logicCopy = structuredClone(question.logic) ?? [];
|
||||
const logicCopy = structuredClone(blockLogic);
|
||||
const logicItem = logicCopy[logicIdx];
|
||||
if (!logicItem) return;
|
||||
logicItem.conditions = updater(logicItem.conditions);
|
||||
@@ -47,7 +56,7 @@ export function LogicEditorConditions({
|
||||
logicCopy.splice(logicIdx, 1);
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, { logic: logicCopy });
|
||||
updateBlockLogic(questionIdx, logicCopy);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
@@ -18,9 +21,11 @@ import {
|
||||
|
||||
interface LogicEditorProps {
|
||||
localSurvey: TSurvey;
|
||||
logicItem: TSurveyLogic;
|
||||
logicItem: TSurveyBlockLogic;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
question: TSurveyQuestion;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
question: TSurveyElement;
|
||||
questionIdx: number;
|
||||
logicIdx: number;
|
||||
isLast: boolean;
|
||||
@@ -30,6 +35,8 @@ export function LogicEditor({
|
||||
localSurvey,
|
||||
logicItem,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
question,
|
||||
questionIdx,
|
||||
logicIdx,
|
||||
@@ -37,6 +44,13 @@ export function LogicEditor({
|
||||
}: LogicEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
|
||||
// Find the parent block for this question/element to get its logicFallback
|
||||
const parentBlock = localSurvey.blocks?.find((block) =>
|
||||
block.elements.some((element) => element.id === question.id)
|
||||
);
|
||||
const blockLogicFallback = parentBlock?.logicFallback;
|
||||
|
||||
const fallbackOptions = useMemo(() => {
|
||||
let options: {
|
||||
icon?: ReactElement;
|
||||
@@ -44,12 +58,19 @@ export function LogicEditor({
|
||||
value: string;
|
||||
}[] = [];
|
||||
|
||||
for (let i = questionIdx + 1; i < localSurvey.questions.length; i++) {
|
||||
const ques = localSurvey.questions[i];
|
||||
// Derive questions from blocks
|
||||
const allQuestions = localSurvey.blocks.flatMap((b) => b.elements);
|
||||
const blocks = localSurvey.blocks;
|
||||
|
||||
for (let i = questionIdx + 1; i < allQuestions.length; i++) {
|
||||
const ques = allQuestions[i];
|
||||
// Find block ID for this question
|
||||
const block = blocks.find((b) => b.elements.some((e) => e.id === ques.id));
|
||||
|
||||
options.push({
|
||||
icon: QUESTIONS_ICON_MAP[ques.type],
|
||||
label: getLocalizedValue(ques.headline, "default"),
|
||||
value: ques.id,
|
||||
label: getTextContent(recallToHeadline(ques.headline, localSurvey, false, "default").default ?? ""),
|
||||
value: block?.id ?? ques.id, // Block ID if blocks exist, otherwise question ID
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,20 +78,24 @@ export function LogicEditor({
|
||||
options.push({
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
|
||||
? getTextContent(
|
||||
recallToHeadline(ending.headline ?? { default: "" }, localSurvey, false, "default").default ??
|
||||
""
|
||||
) || t("environments.surveys.edit.end_screen_card")
|
||||
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
||||
value: ending.id,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [localSurvey.questions, localSurvey.endings, question.id, t]);
|
||||
}, [localSurvey, questionIdx, QUESTIONS_ICON_MAP, t]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
|
||||
<LogicEditorConditions
|
||||
conditions={logicItem.conditions}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
@@ -81,6 +106,7 @@ export function LogicEditor({
|
||||
logicIdx={logicIdx}
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
/>
|
||||
@@ -95,11 +121,9 @@ export function LogicEditor({
|
||||
</p>
|
||||
<Select
|
||||
autoComplete="true"
|
||||
defaultValue={question.logicFallback || "defaultSelection"}
|
||||
defaultValue={blockLogicFallback || "defaultSelection"}
|
||||
onValueChange={(val) => {
|
||||
updateQuestion(questionIdx, {
|
||||
logicFallback: val === "defaultSelection" ? undefined : val,
|
||||
});
|
||||
updateBlockLogicFallback(questionIdx, val === "defaultSelection" ? undefined : val);
|
||||
}}>
|
||||
<SelectTrigger className="w-auto bg-white">
|
||||
<SelectValue />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface OptionIdsProps {
|
||||
question: TSurveyQuestion;
|
||||
question: TSurveyElement;
|
||||
selectedLanguageCode: string;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) =>
|
||||
|
||||
const renderChoiceIds = () => {
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{question.choices.map((choice) => (
|
||||
@@ -28,7 +28,7 @@ export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) =>
|
||||
</div>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{question.choices.map((choice) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
@@ -36,6 +38,7 @@ import { OpenQuestionForm } from "@/modules/survey/editor/components/open-questi
|
||||
import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form";
|
||||
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
|
||||
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
|
||||
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
@@ -49,6 +52,13 @@ interface QuestionCardProps {
|
||||
questionIdx: number;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockButtonLabel: (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString | undefined
|
||||
) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
@@ -74,6 +84,9 @@ export const QuestionCard = ({
|
||||
questionIdx,
|
||||
moveQuestion,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
updateBlockButtonLabel,
|
||||
duplicateQuestion,
|
||||
deleteQuestion,
|
||||
activeQuestionId,
|
||||
@@ -97,19 +110,30 @@ export const QuestionCard = ({
|
||||
const { t } = useTranslation();
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
const open = activeQuestionId === question.id;
|
||||
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
|
||||
|
||||
// Find the parent block for this question/element to get its logic
|
||||
const { blockIndex: parentBlockIndex } = findElementLocation(localSurvey, question.id);
|
||||
const parentBlock = parentBlockIndex !== -1 ? localSurvey.blocks[parentBlockIndex] : undefined;
|
||||
const blockLogic = parentBlock?.logic ?? [];
|
||||
|
||||
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Get button labels from the parent block (not from element)
|
||||
const blockButtonLabel = parentBlock?.buttonLabel;
|
||||
const blockBackButtonLabel = parentBlock?.backButtonLabel;
|
||||
|
||||
const updateEmptyButtonLabels = (
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString,
|
||||
skipIndex: number
|
||||
skipBlockIndex: number
|
||||
) => {
|
||||
localSurvey.questions.forEach((q, index) => {
|
||||
if (index === skipIndex) return;
|
||||
const currentLabel = q[labelKey];
|
||||
// Update button labels for all blocks except the one at skipBlockIndex
|
||||
localSurvey.blocks.forEach((block, index) => {
|
||||
if (index === skipBlockIndex) return;
|
||||
const currentLabel = block[labelKey];
|
||||
if (!currentLabel || currentLabel[selectedLanguageCode]?.trim() === "") {
|
||||
updateQuestion(index, { [labelKey]: labelValue });
|
||||
updateBlockButtonLabel(index, labelKey, labelValue);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -169,7 +193,11 @@ export const QuestionCard = ({
|
||||
const handleRequiredToggle = () => {
|
||||
// Fix for NPS and Rating questions having missing translations when buttonLabel is not removed
|
||||
if (!question.required && (question.type === "nps" || question.type === "rating")) {
|
||||
updateQuestion(questionIdx, { required: true, buttonLabel: undefined });
|
||||
// Remove buttonLabel from the block when making NPS/Rating required
|
||||
if (parentBlockIndex !== -1) {
|
||||
updateBlockButtonLabel(parentBlockIndex, "buttonLabel", undefined);
|
||||
}
|
||||
updateQuestion(questionIdx, { required: true });
|
||||
} else {
|
||||
updateQuestion(questionIdx, { required: !question.required });
|
||||
}
|
||||
@@ -510,7 +538,7 @@ export const QuestionCard = ({
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
value={blockBackButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
@@ -522,12 +550,22 @@ export const QuestionCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!question.backButtonLabel) return;
|
||||
if (!blockBackButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...question.backButtonLabel,
|
||||
...blockBackButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
|
||||
if (parentBlockIndex === -1) return;
|
||||
updateBlockButtonLabel(
|
||||
parentBlockIndex,
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel
|
||||
);
|
||||
updateEmptyButtonLabels(
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel,
|
||||
parentBlockIndex
|
||||
);
|
||||
}}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
@@ -535,7 +573,7 @@ export const QuestionCard = ({
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
value={blockButtonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
@@ -546,18 +584,18 @@ export const QuestionCard = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
onBlur={(e) => {
|
||||
if (!question.buttonLabel) return;
|
||||
if (!blockButtonLabel) return;
|
||||
let translatedNextButtonLabel = {
|
||||
...question.buttonLabel,
|
||||
...blockButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
|
||||
if (questionIdx === localSurvey.questions.length - 1) return;
|
||||
updateEmptyButtonLabels(
|
||||
"buttonLabel",
|
||||
translatedNextButtonLabel,
|
||||
localSurvey.questions.length - 1
|
||||
);
|
||||
if (parentBlockIndex === -1) return;
|
||||
updateBlockButtonLabel(parentBlockIndex, "buttonLabel", translatedNextButtonLabel);
|
||||
// Don't propagate to last block
|
||||
const lastBlockIndex = localSurvey.blocks.length - 1;
|
||||
if (parentBlockIndex !== lastBlockIndex) {
|
||||
updateEmptyButtonLabels("buttonLabel", translatedNextButtonLabel, lastBlockIndex);
|
||||
}
|
||||
}}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
@@ -571,7 +609,7 @@ export const QuestionCard = ({
|
||||
<div className="mt-4">
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
value={blockBackButtonLabel}
|
||||
label={`"Back" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
@@ -582,16 +620,37 @@ export const QuestionCard = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!blockBackButtonLabel) return;
|
||||
const translatedBackButtonLabel = {
|
||||
...blockBackButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
if (parentBlockIndex === -1) return;
|
||||
updateBlockButtonLabel(
|
||||
parentBlockIndex,
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel
|
||||
);
|
||||
updateEmptyButtonLabels(
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel,
|
||||
parentBlockIndex
|
||||
);
|
||||
}}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdvancedSettings
|
||||
question={question}
|
||||
// TODO -- We should remove this when we can confirm that everything works fine with the survey editor, not changing this right now in this file because it would require changing the question type to the respective element type in all the question forms.
|
||||
question={question as unknown as TSurveyElement}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
/>
|
||||
</Collapsible.CollapsibleContent>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { useMemo } from "react";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
|
||||
|
||||
@@ -10,6 +13,13 @@ interface QuestionsDraggableProps {
|
||||
project: Project;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockButtonLabel: (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString | undefined
|
||||
) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
@@ -39,6 +49,9 @@ export const QuestionsDroppable = ({
|
||||
setActiveQuestionId,
|
||||
setSelectedLanguageCode,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
updateBlockButtonLabel,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
@@ -50,25 +63,33 @@ export const QuestionsDroppable = ({
|
||||
}: QuestionsDraggableProps) => {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Derive questions from blocks for display
|
||||
const questions = useMemo(() => {
|
||||
return localSurvey.blocks.flatMap((block) => block.elements);
|
||||
}, [localSurvey.blocks]);
|
||||
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5" ref={parent}>
|
||||
<SortableContext items={localSurvey.questions} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.questions.map((question, questionIdx) => (
|
||||
<SortableContext items={questions} strategy={verticalListSortingStrategy}>
|
||||
{questions.map((question, questionIdx) => (
|
||||
<QuestionCard
|
||||
key={question.id}
|
||||
localSurvey={localSurvey}
|
||||
project={project}
|
||||
question={question}
|
||||
question={question as unknown as TSurveyQuestion}
|
||||
questionIdx={questionIdx}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
updateBlockButtonLabel={updateBlockButtonLabel}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
deleteQuestion={deleteQuestion}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={questionIdx === localSurvey.questions.length - 1}
|
||||
lastQuestion={questionIdx === questions.length - 1}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -15,16 +15,12 @@ import { Language, Project } from "@prisma/client";
|
||||
import React, { SetStateAction, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSurveyBlock, TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { findBlocksWithCyclicLogic } from "@formbricks/types/surveys/blocks-validation";
|
||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
@@ -39,6 +35,13 @@ import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome
|
||||
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
|
||||
import { QuestionsDroppable } from "@/modules/survey/editor/components/questions-droppable";
|
||||
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
|
||||
import {
|
||||
addBlock,
|
||||
deleteBlock,
|
||||
duplicateBlock,
|
||||
moveBlock,
|
||||
updateElementInBlock,
|
||||
} from "@/modules/survey/editor/lib/blocks";
|
||||
import { findQuestionUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
|
||||
import {
|
||||
isEndingCardValid,
|
||||
@@ -91,15 +94,41 @@ export const QuestionsView = ({
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionsViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Derive questions from blocks for display
|
||||
const questions = useMemo(() => {
|
||||
return localSurvey.blocks.flatMap((block) => block.elements);
|
||||
}, [localSurvey.blocks]);
|
||||
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
return questions.reduce((acc, question) => {
|
||||
acc[question.id] = createId();
|
||||
return acc;
|
||||
}, {});
|
||||
}, [localSurvey.questions]);
|
||||
}, [questions]);
|
||||
|
||||
const surveyLanguages = localSurvey.languages;
|
||||
|
||||
const findElementLocation = (elementId: string) => {
|
||||
const blocks = localSurvey.blocks;
|
||||
|
||||
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
||||
const block = blocks[blockIndex];
|
||||
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
|
||||
if (elementIndex !== -1) {
|
||||
return { blockId: block.id, blockIndex, elementIndex };
|
||||
}
|
||||
}
|
||||
|
||||
return { blockId: null, blockIndex: -1, elementIndex: -1 };
|
||||
};
|
||||
|
||||
const getQuestionIdFromBlockId = (block: TSurveyBlock): string => block.elements[0].id;
|
||||
|
||||
const getBlockName = (index: number): string => {
|
||||
return `Block ${index + 1}`;
|
||||
};
|
||||
|
||||
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
|
||||
const updateConditions = (conditions: TConditionGroup): TConditionGroup => {
|
||||
return {
|
||||
@@ -128,11 +157,12 @@ export const QuestionsView = ({
|
||||
return updatedCondition;
|
||||
};
|
||||
|
||||
const updateActions = (actions: TSurveyLogicAction[]): TSurveyLogicAction[] => {
|
||||
const updateActions = (actions: TSurveyBlockLogicAction[]): TSurveyBlockLogicAction[] => {
|
||||
return actions.map((action) => {
|
||||
let updatedAction = { ...action };
|
||||
|
||||
if (updatedAction.objective === "jumpToQuestion" && updatedAction.target === compareId) {
|
||||
// Handle jumpToBlock actions (blocks model)
|
||||
if (updatedAction.objective === "jumpToBlock" && updatedAction.target === compareId) {
|
||||
updatedAction.target = updatedId;
|
||||
}
|
||||
|
||||
@@ -144,29 +174,43 @@ export const QuestionsView = ({
|
||||
});
|
||||
};
|
||||
|
||||
const updatedBlocks = survey.blocks.map((block) => {
|
||||
const updatedElements = block.elements.map((element) => {
|
||||
let updatedElement = { ...element };
|
||||
|
||||
if (element.headline[selectedLanguageCode]?.includes(`recall:${compareId}`)) {
|
||||
updatedElement.headline = {
|
||||
...element.headline,
|
||||
[selectedLanguageCode]: element.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return updatedElement;
|
||||
});
|
||||
|
||||
// Update block-level logic
|
||||
let updatedLogic = block.logic;
|
||||
if (block.logic) {
|
||||
updatedLogic = block.logic.map((logicRule: TSurveyBlockLogic) => ({
|
||||
...logicRule,
|
||||
conditions: updateConditions(logicRule.conditions),
|
||||
actions: updateActions(logicRule.actions),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
elements: updatedElements,
|
||||
logic: updatedLogic,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...survey,
|
||||
questions: survey.questions.map((question) => {
|
||||
let updatedQuestion = { ...question };
|
||||
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
|
||||
`recall:${compareId}`,
|
||||
`recall:${updatedId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update advanced logic
|
||||
if (question.logic) {
|
||||
updatedQuestion.logic = question.logic.map((logicRule: TSurveyLogic) => ({
|
||||
...logicRule,
|
||||
conditions: updateConditions(logicRule.conditions),
|
||||
actions: updateActions(logicRule.actions),
|
||||
}));
|
||||
}
|
||||
|
||||
return updatedQuestion;
|
||||
}),
|
||||
blocks: updatedBlocks,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -206,15 +250,21 @@ export const QuestionsView = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstQuestion = question.id === localSurvey.questions[0].id;
|
||||
const firstElement = localSurvey.blocks?.[0]?.elements[0];
|
||||
const isFirstQuestion = firstElement ? question.id === firstElement.id : false;
|
||||
|
||||
if (validateQuestion(question, surveyLanguages, isFirstQuestion)) {
|
||||
// If question is valid, we now check for cyclic logic
|
||||
const questionsWithCyclicLogic = findQuestionsWithCyclicLogic(localSurvey.questions);
|
||||
if (validateQuestion(question as unknown as TSurveyQuestion, surveyLanguages, isFirstQuestion)) {
|
||||
const blocksWithCyclicLogic = findBlocksWithCyclicLogic(localSurvey.blocks);
|
||||
|
||||
if (questionsWithCyclicLogic.includes(question.id) && !invalidQuestions.includes(question.id)) {
|
||||
setInvalidQuestions([...invalidQuestions, question.id]);
|
||||
return;
|
||||
for (const blockId of blocksWithCyclicLogic) {
|
||||
const block = localSurvey.blocks.find((b) => b.id === blockId);
|
||||
if (block) {
|
||||
const questionId = getQuestionIdFromBlockId(block);
|
||||
if (questionId === question.id) {
|
||||
setInvalidQuestions([...invalidQuestions, question.id]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInvalidQuestions(invalidQuestions.filter((id) => id !== question.id));
|
||||
@@ -226,47 +276,147 @@ export const QuestionsView = ({
|
||||
};
|
||||
|
||||
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
|
||||
const question = questions[questionIdx];
|
||||
if (!question) return;
|
||||
|
||||
const { blockId, blockIndex } = findElementLocation(question.id);
|
||||
if (!blockId || blockIndex === -1) return;
|
||||
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
if ("id" in updatedAttributes) {
|
||||
|
||||
// Handle block-level attributes (logic, logicFallback, buttonLabel, backButtonLabel) separately
|
||||
const blockLevelAttributes: any = {};
|
||||
const elementLevelAttributes: any = {};
|
||||
|
||||
Object.keys(updatedAttributes).forEach((key) => {
|
||||
if (key === "logic" || key === "logicFallback" || key === "buttonLabel" || key === "backButtonLabel") {
|
||||
blockLevelAttributes[key] = updatedAttributes[key];
|
||||
} else {
|
||||
elementLevelAttributes[key] = updatedAttributes[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Update block-level attributes if any
|
||||
if (Object.keys(blockLevelAttributes).length > 0) {
|
||||
const blocks = [...(updatedSurvey.blocks ?? [])];
|
||||
blocks[blockIndex] = {
|
||||
...blocks[blockIndex],
|
||||
...blockLevelAttributes,
|
||||
};
|
||||
updatedSurvey = { ...updatedSurvey, blocks };
|
||||
}
|
||||
|
||||
// Handle element ID changes
|
||||
if ("id" in elementLevelAttributes) {
|
||||
// if the survey question whose id is to be changed is linked to logic of any other survey then changing it
|
||||
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
|
||||
const initialQuestionId = question.id;
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, elementLevelAttributes.id);
|
||||
if (invalidQuestions?.includes(initialQuestionId)) {
|
||||
setInvalidQuestions(
|
||||
invalidQuestions.map((id) => (id === initialQuestionId ? updatedAttributes.id : id))
|
||||
invalidQuestions.map((id) => (id === initialQuestionId ? elementLevelAttributes.id : id))
|
||||
);
|
||||
}
|
||||
|
||||
// relink the question to internal Id
|
||||
internalQuestionIdMap[updatedAttributes.id] =
|
||||
internalQuestionIdMap[localSurvey.questions[questionIdx].id];
|
||||
delete internalQuestionIdMap[localSurvey.questions[questionIdx].id];
|
||||
setActiveQuestionId(updatedAttributes.id);
|
||||
internalQuestionIdMap[elementLevelAttributes.id] = internalQuestionIdMap[question.id];
|
||||
delete internalQuestionIdMap[question.id];
|
||||
setActiveQuestionId(elementLevelAttributes.id);
|
||||
}
|
||||
|
||||
updatedSurvey.questions[questionIdx] = {
|
||||
...updatedSurvey.questions[questionIdx],
|
||||
...updatedAttributes,
|
||||
// Update element-level attributes if any
|
||||
if (Object.keys(elementLevelAttributes).length > 0) {
|
||||
const attributesToCheck = ["upperLabel", "lowerLabel"];
|
||||
|
||||
// If the value of upperLabel or lowerLabel is equal to {default:""}, then delete the key
|
||||
const cleanedAttributes = { ...elementLevelAttributes };
|
||||
attributesToCheck.forEach((attribute) => {
|
||||
if (Object.keys(cleanedAttributes).includes(attribute)) {
|
||||
const currentLabel = cleanedAttributes[attribute];
|
||||
if (
|
||||
currentLabel &&
|
||||
Object.keys(currentLabel).length === 1 &&
|
||||
currentLabel["default"].trim() === ""
|
||||
) {
|
||||
delete cleanedAttributes[attribute];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = updateElementInBlock(updatedSurvey, blockId, question.id, cleanedAttributes);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
updatedSurvey = result.data;
|
||||
|
||||
// Validate the updated question
|
||||
const updatedQuestion = updatedSurvey.blocks
|
||||
?.flatMap((b) => b.elements)
|
||||
.find((q) => q.id === (cleanedAttributes.id ?? question.id));
|
||||
if (updatedQuestion) {
|
||||
validateSurveyQuestion(updatedQuestion as unknown as TSurveyQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
// Update block logic (block-level property)
|
||||
const updateBlockLogic = (questionIdx: number, logic: TSurveyBlockLogic[]) => {
|
||||
const question = questions[questionIdx];
|
||||
if (!question) return;
|
||||
|
||||
const { blockIndex } = findElementLocation(question.id);
|
||||
if (blockIndex === -1) return;
|
||||
|
||||
const blocks = [...(localSurvey.blocks ?? [])];
|
||||
blocks[blockIndex] = {
|
||||
...blocks[blockIndex],
|
||||
logic,
|
||||
};
|
||||
|
||||
const attributesToCheck = ["buttonLabel", "upperLabel", "lowerLabel"];
|
||||
setLocalSurvey({ ...localSurvey, blocks });
|
||||
};
|
||||
|
||||
// If the value of buttonLabel, lowerLabel or upperLabel is equal to {default:""}, then delete buttonLabel key
|
||||
attributesToCheck.forEach((attribute) => {
|
||||
if (Object.keys(updatedAttributes).includes(attribute)) {
|
||||
const currentLabel = updatedSurvey.questions[questionIdx][attribute];
|
||||
if (currentLabel && Object.keys(currentLabel).length === 1 && currentLabel["default"].trim() === "") {
|
||||
delete updatedSurvey.questions[questionIdx][attribute];
|
||||
}
|
||||
}
|
||||
});
|
||||
setLocalSurvey(updatedSurvey);
|
||||
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
|
||||
// Update block logic fallback (block-level property)
|
||||
const updateBlockLogicFallback = (questionIdx: number, logicFallback: string | undefined) => {
|
||||
const question = questions[questionIdx];
|
||||
if (!question) return;
|
||||
|
||||
const { blockIndex } = findElementLocation(question.id);
|
||||
if (blockIndex === -1) return;
|
||||
|
||||
const blocks = [...(localSurvey.blocks ?? [])];
|
||||
blocks[blockIndex] = {
|
||||
...blocks[blockIndex],
|
||||
logicFallback,
|
||||
};
|
||||
|
||||
setLocalSurvey({ ...localSurvey, blocks });
|
||||
};
|
||||
|
||||
// Update block button label (block-level property)
|
||||
const updateBlockButtonLabel = (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString | undefined
|
||||
) => {
|
||||
const blocks = [...(localSurvey.blocks ?? [])];
|
||||
blocks[blockIndex] = {
|
||||
...blocks[blockIndex],
|
||||
[labelKey]: labelValue,
|
||||
};
|
||||
setLocalSurvey({ ...localSurvey, blocks });
|
||||
};
|
||||
|
||||
const deleteQuestion = (questionIdx: number) => {
|
||||
const questionId = localSurvey.questions[questionIdx].id;
|
||||
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
|
||||
const question = questions[questionIdx];
|
||||
if (!question) return;
|
||||
|
||||
const questionId = question.id;
|
||||
const activeQuestionIdTemp = activeQuestionId ?? questions[0]?.id;
|
||||
let updatedSurvey: TSurvey = { ...localSurvey };
|
||||
|
||||
// checking if this question is used in logic of any other question
|
||||
@@ -277,7 +427,7 @@ export const QuestionsView = ({
|
||||
}
|
||||
|
||||
const recallQuestionIdx = isUsedInRecall(localSurvey, questionId);
|
||||
if (recallQuestionIdx === localSurvey.questions.length) {
|
||||
if (recallQuestionIdx === questions.length) {
|
||||
toast.error(t("environments.surveys.edit.question_used_in_recall_ending_card"));
|
||||
return;
|
||||
}
|
||||
@@ -300,26 +450,43 @@ export const QuestionsView = ({
|
||||
}
|
||||
|
||||
// check if we are recalling from this question for every language
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${questionId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
updatedSurvey.blocks = (updatedSurvey.blocks ?? []).map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const updatedElement = { ...element };
|
||||
for (const [languageCode, headline] of Object.entries(element.headline)) {
|
||||
if (headline.includes(`recall:${questionId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
updatedElement.headline = {
|
||||
...updatedElement.headline,
|
||||
[languageCode]: headline.replace(recallInfo, ""),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return updatedElement;
|
||||
}),
|
||||
}));
|
||||
|
||||
updatedSurvey.questions.splice(questionIdx, 1);
|
||||
// Find and delete the block containing this question
|
||||
const { blockId } = findElementLocation(questionId);
|
||||
if (!blockId) return;
|
||||
|
||||
const result = deleteBlock(updatedSurvey, blockId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstEndingCard = localSurvey.endings[0];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
setLocalSurvey(result.data);
|
||||
delete internalQuestionIdMap[questionId];
|
||||
|
||||
if (questionId === activeQuestionIdTemp) {
|
||||
if (questionIdx <= localSurvey.questions.length && localSurvey.questions.length > 0) {
|
||||
setActiveQuestionId(localSurvey.questions[questionIdx % localSurvey.questions.length].id);
|
||||
const newQuestions = result.data.blocks?.flatMap((b) => b.elements) ?? [];
|
||||
if (questionIdx <= newQuestions.length && newQuestions.length > 0) {
|
||||
setActiveQuestionId(newQuestions[questionIdx % newQuestions.length].id);
|
||||
} else if (firstEndingCard) {
|
||||
setActiveQuestionId(firstEndingCard.id);
|
||||
}
|
||||
@@ -329,42 +496,52 @@ export const QuestionsView = ({
|
||||
};
|
||||
|
||||
const duplicateQuestion = (questionIdx: number) => {
|
||||
const questionToDuplicate = structuredClone(localSurvey.questions[questionIdx]);
|
||||
const question = questions[questionIdx];
|
||||
if (!question) return;
|
||||
|
||||
const newQuestionId = createId();
|
||||
const { blockId } = findElementLocation(question.id);
|
||||
if (!blockId) return;
|
||||
|
||||
// create a copy of the question with a new id
|
||||
const duplicatedQuestion = {
|
||||
...questionToDuplicate,
|
||||
id: newQuestionId,
|
||||
};
|
||||
const result = duplicateBlock(localSurvey, blockId);
|
||||
|
||||
// insert the new question right after the original one
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
updatedSurvey.questions.splice(questionIdx + 1, 0, duplicatedQuestion);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
setActiveQuestionId(newQuestionId);
|
||||
internalQuestionIdMap[newQuestionId] = createId();
|
||||
// The duplicated block has new element IDs, find the first one
|
||||
const allBlocks = result.data.blocks ?? [];
|
||||
const { blockIndex } = findElementLocation(question.id);
|
||||
const duplicatedBlock = allBlocks[blockIndex + 1];
|
||||
const newElementId = duplicatedBlock?.elements[0]?.id;
|
||||
|
||||
if (newElementId) {
|
||||
setActiveQuestionId(newElementId);
|
||||
internalQuestionIdMap[newElementId] = createId();
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
toast.success(t("environments.surveys.edit.question_duplicated"));
|
||||
};
|
||||
|
||||
const addQuestion = (question: TSurveyQuestion, index?: number) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
const newQuestions = [...localSurvey.questions];
|
||||
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
|
||||
|
||||
if (index !== undefined) {
|
||||
newQuestions.splice(index, 0, { ...updatedQuestion, isDraft: true });
|
||||
} else {
|
||||
newQuestions.push({ ...updatedQuestion, isDraft: true });
|
||||
}
|
||||
updatedSurvey.questions = newQuestions;
|
||||
const blockName = getBlockName(index ?? questions.length);
|
||||
const newBlock = {
|
||||
name: blockName,
|
||||
elements: [{ ...updatedQuestion, isDraft: true }],
|
||||
};
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
const result = addBlock(t, localSurvey, newBlock, index);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
setActiveQuestionId(question.id);
|
||||
internalQuestionIdMap[question.id] = createId();
|
||||
};
|
||||
@@ -380,12 +557,21 @@ export const QuestionsView = ({
|
||||
};
|
||||
|
||||
const moveQuestion = (questionIndex: number, up: boolean) => {
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
|
||||
const destinationIndex = up ? questionIndex - 1 : questionIndex + 1;
|
||||
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
|
||||
const updatedSurvey = { ...localSurvey, questions: newQuestions };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
const question = questions[questionIndex];
|
||||
if (!question) return;
|
||||
|
||||
const { blockId } = findElementLocation(question.id);
|
||||
if (!blockId) return;
|
||||
|
||||
const direction = up ? "up" : "down";
|
||||
const result = moveBlock(localSurvey, blockId, direction);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
};
|
||||
|
||||
//useEffect to validate survey when changes are made to languages
|
||||
@@ -393,9 +579,9 @@ export const QuestionsView = ({
|
||||
if (!invalidQuestions) return;
|
||||
let updatedInvalidQuestions: string[] = invalidQuestions;
|
||||
// Validate each question
|
||||
localSurvey.questions.forEach((question, index) => {
|
||||
questions.forEach((question, index) => {
|
||||
updatedInvalidQuestions = validateSurveyQuestionsInBatch(
|
||||
question,
|
||||
question as unknown as TSurveyQuestion,
|
||||
updatedInvalidQuestions,
|
||||
surveyLanguages,
|
||||
index === 0
|
||||
@@ -405,7 +591,7 @@ export const QuestionsView = ({
|
||||
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
||||
setInvalidQuestions(updatedInvalidQuestions);
|
||||
}
|
||||
}, [localSurvey.questions, surveyLanguages, invalidQuestions, setInvalidQuestions]);
|
||||
}, [questions, surveyLanguages, invalidQuestions, setInvalidQuestions]);
|
||||
|
||||
useEffect(() => {
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
||||
@@ -429,13 +615,24 @@ export const QuestionsView = ({
|
||||
const onQuestionCardDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const sourceIndex = newQuestions.findIndex((question) => question.id === active.id);
|
||||
const destinationIndex = newQuestions.findIndex((question) => question.id === over?.id);
|
||||
const [reorderedQuestion] = newQuestions.splice(sourceIndex, 1);
|
||||
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
|
||||
const updatedSurvey = { ...localSurvey, questions: newQuestions };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
// Find source and destination block indices
|
||||
const sourceQuestion = questions.find((q) => q.id === active.id);
|
||||
const destQuestion = questions.find((q) => q.id === over?.id);
|
||||
|
||||
if (!sourceQuestion || !destQuestion) return;
|
||||
|
||||
const { blockIndex: sourceBlockIndex } = findElementLocation(sourceQuestion.id);
|
||||
const { blockIndex: destBlockIndex } = findElementLocation(destQuestion.id);
|
||||
|
||||
if (sourceBlockIndex === -1 || destBlockIndex === -1) return;
|
||||
if (sourceBlockIndex === destBlockIndex) return; // No move needed
|
||||
|
||||
// Reorder blocks
|
||||
const blocks = [...(localSurvey.blocks ?? [])];
|
||||
const [movedBlock] = blocks.splice(sourceBlockIndex, 1);
|
||||
blocks.splice(destBlockIndex, 0, movedBlock);
|
||||
|
||||
setLocalSurvey({ ...localSurvey, blocks });
|
||||
};
|
||||
|
||||
const onEndingCardDragEnd = (event: DragEndEvent) => {
|
||||
@@ -480,6 +677,9 @@ export const QuestionsView = ({
|
||||
project={project}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
updateBlockButtonLabel={updateBlockButtonLabel}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
|
||||
@@ -109,8 +109,10 @@ export const SurveyEditor = ({
|
||||
const surveyClone = structuredClone(survey);
|
||||
setLocalSurvey(surveyClone);
|
||||
|
||||
if (survey.questions.length > 0) {
|
||||
setActiveQuestionId(survey.questions[0].id);
|
||||
// Set first element from first block, or first question for legacy surveys
|
||||
const firstBlock = survey.blocks[0];
|
||||
if (firstBlock) {
|
||||
setActiveQuestionId(firstBlock.elements?.[0]?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,11 +139,12 @@ export const SurveyEditor = ({
|
||||
|
||||
// when the survey type changes, we need to reset the active question id to the first question
|
||||
useEffect(() => {
|
||||
if (localSurvey?.questions?.length && localSurvey.questions.length > 0) {
|
||||
setActiveQuestionId(localSurvey.questions[0].id);
|
||||
const firstBlock = localSurvey?.blocks[0];
|
||||
if (firstBlock) {
|
||||
setActiveQuestionId(firstBlock.elements[0]?.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSurvey?.type, survey?.questions]);
|
||||
}, [localSurvey?.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localSurvey?.languages) return;
|
||||
|
||||
@@ -12,7 +12,6 @@ import { TSegment } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEditorTabs,
|
||||
TSurveyQuestion,
|
||||
ZSurvey,
|
||||
ZSurveyEndScreenCard,
|
||||
ZSurveyRedirectUrlCard,
|
||||
@@ -171,12 +170,14 @@ export const SurveyMenuBar = ({
|
||||
if (!localSurveyValidation.success) {
|
||||
const currentError = localSurveyValidation.error.errors[0];
|
||||
|
||||
if (currentError.path[0] === "questions") {
|
||||
const questionIdx = currentError.path[1];
|
||||
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
|
||||
if (question) {
|
||||
if (currentError.path[0] === "blocks") {
|
||||
const blockIdx = currentError.path[1];
|
||||
const elementIdx = currentError.path[3]; // path is ["blocks", blockIdx, "elements", elementIdx, ...]
|
||||
const block = localSurvey.blocks?.[blockIdx];
|
||||
const element = block?.elements[elementIdx];
|
||||
if (element) {
|
||||
setInvalidQuestions((prevInvalidQuestions) =>
|
||||
prevInvalidQuestions ? [...prevInvalidQuestions, question.id] : [question.id]
|
||||
prevInvalidQuestions ? [...prevInvalidQuestions, element.id] : [element.id]
|
||||
);
|
||||
}
|
||||
} else if (currentError.path[0] === "welcomeCard") {
|
||||
@@ -235,10 +236,19 @@ export const SurveyMenuBar = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
localSurvey.questions = localSurvey.questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
});
|
||||
// Clean up blocks by removing isDraft from elements
|
||||
if (localSurvey.blocks) {
|
||||
localSurvey.blocks = localSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const { isDraft, ...rest } = element;
|
||||
return rest;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Set questions to empty array for blocks-based surveys
|
||||
localSurvey.questions = [];
|
||||
|
||||
localSurvey.endings = localSurvey.endings.map((ending) => {
|
||||
if (ending.type === "redirectToUrl") {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const SurveyVariablesCardItem = ({
|
||||
// Removed auto-submit effect
|
||||
|
||||
const onVariableDelete = (variableToDelete: TSurveyVariable) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
const quesIdx = findVariableUsedInLogic(localSurvey, variableToDelete.id);
|
||||
|
||||
if (quesIdx !== -1) {
|
||||
@@ -101,7 +101,7 @@ export const SurveyVariablesCardItem = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (recallQuestionIdx === localSurvey.questions.length) {
|
||||
if (recallQuestionIdx === questions.length) {
|
||||
toast.error(
|
||||
t("environments.surveys.edit.variable_used_in_recall_ending_card", {
|
||||
variable: variableToDelete.name,
|
||||
@@ -131,21 +131,27 @@ export const SurveyVariablesCardItem = ({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// remove recall references
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${variableToDelete.id}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// remove recall references from blocks
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedBlocks = prevSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const updatedHeadline = { ...element.headline };
|
||||
for (const [languageCode, headline] of Object.entries(element.headline)) {
|
||||
if (headline.includes(`recall:${variableToDelete.id}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
updatedHeadline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...element, headline: updatedHeadline };
|
||||
}),
|
||||
}));
|
||||
|
||||
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variableToDelete.id);
|
||||
return { ...prevSurvey, variables: updatedVariables, questions };
|
||||
return { ...prevSurvey, variables: updatedVariables, blocks: updatedBlocks };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { validateId } from "@formbricks/types/surveys/validation";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -11,7 +12,7 @@ import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface UpdateQuestionIdProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyQuestion;
|
||||
question: TSurveyElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
}
|
||||
@@ -35,7 +36,8 @@ export const UpdateQuestionId = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const questionIds = localSurvey.questions.map((q) => q.id);
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
const questionIds = questions.map((q) => q.id);
|
||||
const endingCardIds = localSurvey.endings.map((e) => e.id);
|
||||
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
|
||||
|
||||
|
||||
@@ -388,3 +388,26 @@ export const duplicateElementInBlock = (
|
||||
blocks,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the location of an element within the survey blocks
|
||||
* @param survey - The survey object
|
||||
* @param elementId - The ID of the element to find
|
||||
* @returns Object containing blockId, blockIndex, and elementIndex
|
||||
*/
|
||||
export const findElementLocation = (
|
||||
survey: TSurvey,
|
||||
elementId: string
|
||||
): { blockId: string | null; blockIndex: number; elementIndex: number } => {
|
||||
const blocks = survey.blocks;
|
||||
|
||||
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
||||
const block = blocks[blockIndex];
|
||||
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
|
||||
if (elementIndex !== -1) {
|
||||
return { blockId: block.id, blockIndex, elementIndex };
|
||||
}
|
||||
}
|
||||
|
||||
return { blockId: null, blockIndex: -1, elementIndex: -1 };
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFunction } from "i18next";
|
||||
import { TSurveyQuotaLogic } from "@formbricks/types/quota";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TSingleCondition,
|
||||
TSurveyLogicConditionsOperator,
|
||||
} from "@formbricks/types/surveys/logic";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
addConditionBelow,
|
||||
createGroupFromResource,
|
||||
@@ -54,13 +55,16 @@ export function createSharedConditionsFactory(
|
||||
const { survey, t, questionIdx, getDefaultOperator, includeCreateGroup = false } = params;
|
||||
const { onConditionsChange } = updateCallbacks;
|
||||
|
||||
// Derive questions from blocks
|
||||
const questions = survey.blocks.flatMap((block) => block.elements.map((element) => element));
|
||||
|
||||
// Handles special update logic for matrix questions, setting appropriate operators and metadata
|
||||
const handleMatrixQuestionUpdate = (resourceId: string, updates: Partial<TSingleCondition>): boolean => {
|
||||
if (updates.leftOperand && updates.leftOperand.type === "question") {
|
||||
const [questionId, rowId] = updates.leftOperand.value.split(".");
|
||||
const questionEntity = survey.questions.find((q) => q.id === questionId);
|
||||
const questionEntity = questions.find((q) => q.id === questionId);
|
||||
|
||||
if (questionEntity && questionEntity.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
if (questionEntity && questionEntity.type === TSurveyElementTypeEnum.Matrix) {
|
||||
if (updates.leftOperand.value.includes(".")) {
|
||||
// Matrix question with rowId is selected
|
||||
onConditionsChange((conditions) => {
|
||||
@@ -103,12 +107,11 @@ export function createSharedConditionsFactory(
|
||||
// Creates and adds a new empty condition below the specified condition
|
||||
onAddConditionBelow: (resourceId: string) => {
|
||||
// When adding a condition in the context of a specific question, default to that question
|
||||
const defaultLeftOperandValue =
|
||||
questionIdx !== undefined ? survey.questions[questionIdx].id : survey.questions[0].id;
|
||||
const defaultLeftOperandValue = questionIdx !== undefined ? questions[questionIdx].id : questions[0].id;
|
||||
const defaultOperator =
|
||||
questionIdx !== undefined
|
||||
? getDefaultOperatorForQuestion(survey.questions[questionIdx], t)
|
||||
: getDefaultOperatorForQuestion(survey.questions[0], t);
|
||||
? getDefaultOperatorForQuestion(questions[questionIdx], t)
|
||||
: getDefaultOperatorForQuestion(questions[0], t);
|
||||
const newCondition: TSingleCondition = {
|
||||
id: createId(),
|
||||
leftOperand: { value: defaultLeftOperandValue, type: "question" },
|
||||
@@ -150,7 +153,7 @@ export function createSharedConditionsFactory(
|
||||
// Check if the operator is correct for the question
|
||||
if (updates.leftOperand?.type === "question" && updates.operator) {
|
||||
const questionId = updates.leftOperand.value.split(".")[0];
|
||||
const question = survey.questions.find((q) => q.id === questionId);
|
||||
const question = questions.find((q) => q.id === questionId);
|
||||
|
||||
if (question) {
|
||||
const operatorOptions = getQuestionOperatorOptions(question, t);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { checkForInvalidImagesInQuestions, validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
|
||||
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
@@ -25,8 +25,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
|
||||
updatedSurvey;
|
||||
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
|
||||
// Validate and prepare blocks for persistence
|
||||
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(updatedSurvey.blocks);
|
||||
@@ -234,11 +232,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
};
|
||||
}
|
||||
|
||||
data.questions = questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const organization = await getOrganizationAIKeys(organizationId);
|
||||
if (!organization) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
|
||||
import { HTMLInputTypeAttribute, JSX } from "react";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TConditionGroup,
|
||||
TLeftOperand,
|
||||
@@ -13,12 +15,8 @@ import {
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndings,
|
||||
TSurveyLogic,
|
||||
TSurveyLogicAction,
|
||||
TSurveyLogicActions,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -113,7 +111,8 @@ export const getConditionValueOptions = (
|
||||
): TComboboxGroupedOption[] => {
|
||||
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
const variables = localSurvey.variables ?? [];
|
||||
const questions = localSurvey.questions;
|
||||
// Derive questions from blocks
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
|
||||
|
||||
const groupedOptions: TComboboxGroupedOption[] = [];
|
||||
const questionOptions: TComboboxOption[] = [];
|
||||
@@ -121,18 +120,22 @@ export const getConditionValueOptions = (
|
||||
questions
|
||||
.filter((_, idx) => (typeof currQuestionIdx === "undefined" ? true : idx <= currQuestionIdx))
|
||||
.forEach((question) => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
if (question.type === TSurveyElementTypeEnum.Matrix) {
|
||||
// Rows submenu
|
||||
const questionHeadline = getTextContent(getLocalizedValue(question.headline, "default"));
|
||||
const rows = question.rows.map((row, rowIdx) => ({
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: `${getLocalizedValue(row.label, "default")} (${questionHeadline})`,
|
||||
value: `${question.id}.${rowIdx}`,
|
||||
meta: {
|
||||
type: "question",
|
||||
rowIdx: rowIdx.toString(),
|
||||
},
|
||||
}));
|
||||
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
|
||||
const questionHeadline = getTextContent(processedHeadline.default ?? "");
|
||||
const rows = question.rows.map((row, rowIdx) => {
|
||||
const processedLabel = recallToHeadline(row.label, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: `${getTextContent(processedLabel.default ?? "")} (${questionHeadline})`,
|
||||
value: `${question.id}.${rowIdx}`,
|
||||
meta: {
|
||||
type: "question",
|
||||
rowIdx: rowIdx.toString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
questionOptions.push({
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
@@ -159,7 +162,9 @@ export const getConditionValueOptions = (
|
||||
} else {
|
||||
questionOptions.push({
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getTextContent(
|
||||
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
|
||||
),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -230,15 +235,15 @@ export const replaceEndingCardHeadlineRecall = (survey: TSurvey, language: strin
|
||||
export const getActionObjectiveOptions = (t: TFunction): TComboboxOption[] => [
|
||||
{ label: t("environments.surveys.edit.calculate"), value: "calculate" },
|
||||
{ label: t("environments.surveys.edit.require_answer"), value: "requireAnswer" },
|
||||
{ label: t("environments.surveys.edit.jump_to_question"), value: "jumpToQuestion" },
|
||||
{ label: t("environments.surveys.edit.jump_to_question"), value: "jumpToBlock" },
|
||||
];
|
||||
|
||||
export const hasJumpToQuestionAction = (actions: TSurveyLogicActions): boolean => {
|
||||
return actions.some((action) => action.objective === "jumpToQuestion");
|
||||
export const hasJumpToBlockAction = (actions: TSurveyBlockLogicAction[]): boolean => {
|
||||
return actions.some((action) => action.objective === "jumpToBlock");
|
||||
};
|
||||
|
||||
export const getQuestionOperatorOptions = (
|
||||
question: TSurveyQuestion,
|
||||
question: TSurveyElement,
|
||||
t: TFunction,
|
||||
condition?: TSingleCondition
|
||||
): TComboboxOption[] => {
|
||||
@@ -247,7 +252,7 @@ export const getQuestionOperatorOptions = (
|
||||
if (question.type === "openText") {
|
||||
const inputType = question.inputType === "number" ? "number" : "text";
|
||||
options = getLogicRules(t).question[`openText.${inputType}`].options;
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.Matrix && condition) {
|
||||
} else if (question.type === TSurveyElementTypeEnum.Matrix && condition) {
|
||||
const isMatrixRow =
|
||||
condition.leftOperand.type === "question" && condition.leftOperand?.meta?.row !== undefined;
|
||||
options = getLogicRules(t).question[`matrix${isMatrixRow ? ".row" : ""}`].options;
|
||||
@@ -263,7 +268,7 @@ export const getQuestionOperatorOptions = (
|
||||
};
|
||||
|
||||
export const getDefaultOperatorForQuestion = (
|
||||
question: TSurveyQuestion,
|
||||
question: TSurveyElement,
|
||||
t: TFunction
|
||||
): TSurveyLogicConditionsOperator => {
|
||||
const options = getQuestionOperatorOptions(question, t);
|
||||
@@ -273,8 +278,9 @@ export const getDefaultOperatorForQuestion = (
|
||||
|
||||
export const getFormatLeftOperandValue = (condition: TSingleCondition, localSurvey: TSurvey): string => {
|
||||
if (condition.leftOperand.type === "question") {
|
||||
const questionEntity = localSurvey.questions.find((q) => q.id === condition.leftOperand.value);
|
||||
if (questionEntity && questionEntity.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
|
||||
const question = questions.find((q) => q.id === condition.leftOperand.value);
|
||||
if (question && question.type === TSurveyElementTypeEnum.Matrix) {
|
||||
if (condition.leftOperand?.meta?.row !== undefined) {
|
||||
return `${condition.leftOperand.value}.${condition.leftOperand.meta.row}`;
|
||||
}
|
||||
@@ -296,10 +302,11 @@ export const getConditionOperatorOptions = (
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
return getLogicRules(t).hiddenField.options;
|
||||
} else if (condition.leftOperand.type === "question") {
|
||||
const questions = localSurvey.questions ?? [];
|
||||
// Derive questions from blocks
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
|
||||
const question = questions.find((question) => {
|
||||
let leftOperandQuestionId = condition.leftOperand.value;
|
||||
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
if (question.type === TSurveyElementTypeEnum.Matrix) {
|
||||
leftOperandQuestionId = condition.leftOperand.value.split(".")[0];
|
||||
}
|
||||
return question.id === leftOperandQuestionId;
|
||||
@@ -341,7 +348,9 @@ export const getMatchValueProps = (
|
||||
return { show: false, options: [] };
|
||||
}
|
||||
|
||||
let questions = localSurvey.questions.filter((_, idx) =>
|
||||
// Derive questions from blocks
|
||||
const allQuestions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
|
||||
let questions = allQuestions.filter((_, idx) =>
|
||||
typeof questionIdx === "undefined" ? true : idx <= questionIdx
|
||||
);
|
||||
let variables = localSurvey.variables ?? [];
|
||||
@@ -359,19 +368,19 @@ export const getMatchValueProps = (
|
||||
}
|
||||
|
||||
if (condition.leftOperand.type === "question") {
|
||||
if (selectedQuestion?.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
const allowedQuestionTypes = [TSurveyQuestionTypeEnum.OpenText];
|
||||
if (selectedQuestion?.type === TSurveyElementTypeEnum.OpenText) {
|
||||
const allowedQuestionTypes = [TSurveyElementTypeEnum.OpenText];
|
||||
|
||||
if (selectedQuestion.inputType === "number") {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS);
|
||||
allowedQuestionTypes.push(TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS);
|
||||
}
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
if (selectedQuestion.inputType !== "number") {
|
||||
allowedQuestionTypes.push(
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
TSurveyElementTypeEnum.Date,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -381,7 +390,9 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getTextContent(
|
||||
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
|
||||
),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -447,8 +458,8 @@ export const getMatchValueProps = (
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
selectedQuestion?.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
selectedQuestion?.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
selectedQuestion?.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
) {
|
||||
const operatorsToFilterNone = [
|
||||
"includesOneOf",
|
||||
@@ -457,7 +468,7 @@ export const getMatchValueProps = (
|
||||
"doesNotIncludeAllOf",
|
||||
];
|
||||
const shouldFilterNone =
|
||||
selectedQuestion.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
selectedQuestion.type === TSurveyElementTypeEnum.MultipleChoiceMulti &&
|
||||
operatorsToFilterNone.includes(condition.operator);
|
||||
|
||||
const choices = selectedQuestion.choices
|
||||
@@ -477,7 +488,7 @@ export const getMatchValueProps = (
|
||||
showInput: false,
|
||||
options: [{ label: t("common.choices"), value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||
const choices = selectedQuestion.choices.map((choice, idx) => {
|
||||
return {
|
||||
imgSrc: choice.imageUrl,
|
||||
@@ -494,7 +505,7 @@ export const getMatchValueProps = (
|
||||
showInput: false,
|
||||
options: [{ label: t("common.choices"), value: "choices", options: choices }],
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Rating) {
|
||||
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Rating) {
|
||||
const choices = Array.from({ length: selectedQuestion.range }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx + 1}`,
|
||||
@@ -541,7 +552,7 @@ export const getMatchValueProps = (
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.NPS) {
|
||||
const choices = Array.from({ length: 11 }, (_, idx) => {
|
||||
return {
|
||||
label: `${idx}`,
|
||||
@@ -588,15 +599,17 @@ export const getMatchValueProps = (
|
||||
showInput: false,
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Date) {
|
||||
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Date) {
|
||||
const openTextQuestions = questions.filter((question) =>
|
||||
[TSurveyQuestionTypeEnum.OpenText, TSurveyQuestionTypeEnum.Date].includes(question.type)
|
||||
[TSurveyElementTypeEnum.OpenText, TSurveyElementTypeEnum.Date].includes(question.type)
|
||||
);
|
||||
|
||||
const questionOptions = openTextQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
label: getTextContent(
|
||||
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
|
||||
),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -660,7 +673,7 @@ export const getMatchValueProps = (
|
||||
inputType: "date",
|
||||
options: groupedOptions,
|
||||
};
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Matrix) {
|
||||
const choices = selectedQuestion.columns.map((column, colIdx) => {
|
||||
return {
|
||||
label: getLocalizedValue(column.label, "default"),
|
||||
@@ -680,12 +693,12 @@ export const getMatchValueProps = (
|
||||
} else if (condition.leftOperand.type === "variable") {
|
||||
if (selectedVariable?.type === "text") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
allowedQuestionTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
@@ -760,8 +773,8 @@ export const getMatchValueProps = (
|
||||
} else if (selectedVariable?.type === "number") {
|
||||
const allowedQuestions = questions.filter(
|
||||
(question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type) ||
|
||||
(question.type === TSurveyQuestionTypeEnum.OpenText && question.inputType === "number")
|
||||
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(question.type) ||
|
||||
(question.type === TSurveyElementTypeEnum.OpenText && question.inputType === "number")
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
@@ -834,12 +847,12 @@ export const getMatchValueProps = (
|
||||
}
|
||||
} else if (condition.leftOperand.type === "hiddenField") {
|
||||
const allowedQuestionTypes = [
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
];
|
||||
|
||||
if (["equals", "doesNotEqual"].includes(condition.operator)) {
|
||||
allowedQuestionTypes.push(TSurveyQuestionTypeEnum.MultipleChoiceMulti, TSurveyQuestionTypeEnum.Date);
|
||||
allowedQuestionTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
|
||||
}
|
||||
|
||||
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
|
||||
@@ -917,36 +930,61 @@ export const getMatchValueProps = (
|
||||
};
|
||||
|
||||
export const getActionTargetOptions = (
|
||||
action: TSurveyLogicAction,
|
||||
action: TSurveyBlockLogicAction,
|
||||
localSurvey: TSurvey,
|
||||
currQuestionIdx: number,
|
||||
t: TFunction
|
||||
): TComboboxOption[] => {
|
||||
let questions = localSurvey.questions.filter((_, idx) => idx > currQuestionIdx);
|
||||
// Derive questions from blocks
|
||||
const allQuestions = localSurvey.blocks?.flatMap((b) => b.elements) ?? [];
|
||||
let questions = allQuestions.filter((_, idx) => idx > currQuestionIdx);
|
||||
|
||||
if (action.objective === "requireAnswer") {
|
||||
questions = questions.filter((question) => !question.required);
|
||||
// Return question IDs (elements) for requireAnswer
|
||||
return questions.map((question) => {
|
||||
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(processedHeadline.default ?? ""),
|
||||
value: question.id, // Element ID
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// For jumpToBlock, we need block IDs
|
||||
const blocks = localSurvey.blocks ?? [];
|
||||
const questionOptions = questions.map((question) => {
|
||||
// Find which block this question belongs to
|
||||
const block = blocks.find((b) => b.elements.some((e) => e.id === question.id));
|
||||
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
value: question.id,
|
||||
label: getTextContent(processedHeadline.default ?? ""),
|
||||
value: block?.id ?? question.id, // Block ID for jumpToBlock
|
||||
};
|
||||
});
|
||||
|
||||
if (action.objective === "requireAnswer") return questionOptions;
|
||||
|
||||
// Ending cards
|
||||
const endingCardOptions = localSurvey.endings.map((ending) => {
|
||||
return {
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
|
||||
t("environments.surveys.edit.end_screen_card")
|
||||
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
||||
value: ending.id,
|
||||
};
|
||||
if (ending.type === "endScreen") {
|
||||
const processedHeadline = recallToHeadline(
|
||||
ending.headline ?? { default: "" },
|
||||
localSurvey,
|
||||
false,
|
||||
"default"
|
||||
);
|
||||
return {
|
||||
label:
|
||||
getTextContent(processedHeadline.default ?? "") || t("environments.surveys.edit.end_screen_card"),
|
||||
value: ending.id,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
||||
value: ending.id,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return [...questionOptions, ...endingCardOptions];
|
||||
@@ -1015,9 +1053,10 @@ export const getActionValueOptions = (
|
||||
questionIdx: number,
|
||||
t: TFunction
|
||||
): TComboboxGroupedOption[] => {
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
|
||||
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
let variables = localSurvey.variables ?? [];
|
||||
const questions = localSurvey.questions.filter((_, idx) => idx <= questionIdx);
|
||||
const filteredQuestions = questions.filter((_, idx) => idx <= questionIdx);
|
||||
|
||||
const hiddenFieldsOptions = hiddenFields.map((field) => {
|
||||
return {
|
||||
@@ -1037,13 +1076,13 @@ export const getActionValueOptions = (
|
||||
if (!selectedVariable) return [];
|
||||
|
||||
if (selectedVariable.type === "text") {
|
||||
const allowedQuestions = questions.filter((question) =>
|
||||
const allowedQuestions = filteredQuestions.filter((question) =>
|
||||
[
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.Rating,
|
||||
TSurveyQuestionTypeEnum.NPS,
|
||||
TSurveyQuestionTypeEnum.Date,
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.Rating,
|
||||
TSurveyElementTypeEnum.NPS,
|
||||
TSurveyElementTypeEnum.Date,
|
||||
].includes(question.type)
|
||||
);
|
||||
|
||||
@@ -1099,10 +1138,10 @@ export const getActionValueOptions = (
|
||||
|
||||
return groupedOptions;
|
||||
} else if (selectedVariable.type === "number") {
|
||||
const allowedQuestions = questions.filter(
|
||||
const allowedQuestions = filteredQuestions.filter(
|
||||
(question) =>
|
||||
[TSurveyQuestionTypeEnum.Rating, TSurveyQuestionTypeEnum.NPS].includes(question.type) ||
|
||||
(question.type === TSurveyQuestionTypeEnum.OpenText && question.inputType === "number")
|
||||
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(question.type) ||
|
||||
(question.type === TSurveyElementTypeEnum.OpenText && question.inputType === "number")
|
||||
);
|
||||
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
@@ -1209,21 +1248,25 @@ export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQues
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
|
||||
return (
|
||||
(action.objective === "jumpToQuestion" && action.target === questionId) ||
|
||||
(action.objective === "requireAnswer" && action.target === questionId)
|
||||
// Note: jumpToBlock targets block IDs, not question IDs, so we only check requireAnswer
|
||||
action.objective === "requireAnswer" && action.target === questionId
|
||||
);
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex(
|
||||
// Derive questions from blocks (cast as questions to access logic properties)
|
||||
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
|
||||
|
||||
return questions.findIndex(
|
||||
(question) =>
|
||||
question.logicFallback === questionId ||
|
||||
(question.id !== questionId && question.logic?.some(isUsedInLogicRule))
|
||||
(question.id !== questionId &&
|
||||
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1322,17 +1365,18 @@ export const isUsedInRecall = (survey: TSurvey, id: string): number => {
|
||||
return -2; // Special index for welcome card
|
||||
}
|
||||
|
||||
// Derive questions from blocks (cast as questions to access logic properties)
|
||||
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
|
||||
|
||||
// Check questions
|
||||
const questionIndex = survey.questions.findIndex((question) =>
|
||||
checkQuestionForRecall(question, recallPattern)
|
||||
);
|
||||
const questionIndex = questions.findIndex((question) => checkQuestionForRecall(question, recallPattern));
|
||||
if (questionIndex !== -1) {
|
||||
return questionIndex;
|
||||
}
|
||||
|
||||
// Check ending cards
|
||||
if (checkEndingCardsForRecall(survey.endings, recallPattern)) {
|
||||
return survey.questions.length; // Special index for ending cards
|
||||
return questions.length; // Special index for ending cards
|
||||
}
|
||||
|
||||
return -1; // Not found
|
||||
@@ -1375,11 +1419,16 @@ export const findOptionUsedInLogic = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
// Derive questions from blocks (cast as questions to access logic properties)
|
||||
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
|
||||
|
||||
return questions.findIndex((question) =>
|
||||
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
|
||||
);
|
||||
};
|
||||
|
||||
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
|
||||
@@ -1396,15 +1445,20 @@ export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): nu
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
|
||||
return action.objective === "calculate" && action.variableId === variableId;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions) || logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
// Derive questions from blocks (cast as questions to access logic properties)
|
||||
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
|
||||
|
||||
return questions.findIndex((question) =>
|
||||
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
|
||||
);
|
||||
};
|
||||
|
||||
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
|
||||
@@ -1422,11 +1476,16 @@ export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: strin
|
||||
}
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
|
||||
return isUsedInCondition(logicRule.conditions);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
// Derive questions from blocks (cast as questions to access logic properties)
|
||||
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
|
||||
|
||||
return questions.findIndex((question) =>
|
||||
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
|
||||
);
|
||||
};
|
||||
|
||||
export const getSurveyFollowUpActionDefaultBody = (t: TFunction): string => {
|
||||
@@ -1436,15 +1495,21 @@ export const getSurveyFollowUpActionDefaultBody = (t: TFunction): string => {
|
||||
};
|
||||
|
||||
export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string): number => {
|
||||
const isUsedInAction = (action: TSurveyLogicAction): boolean => {
|
||||
return action.objective === "jumpToQuestion" && action.target === endingCardId;
|
||||
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
|
||||
// jumpToBlock can target ending card IDs as well as block IDs
|
||||
return action.objective === "jumpToBlock" && action.target === endingCardId;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyLogic): boolean => {
|
||||
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
|
||||
return logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
return survey.questions.findIndex(
|
||||
(question) => question.logicFallback === endingCardId || question.logic?.some(isUsedInLogicRule)
|
||||
// Derive questions from blocks (cast as questions to access logic properties)
|
||||
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
|
||||
|
||||
return questions.findIndex(
|
||||
(question) =>
|
||||
question.logicFallback === endingCardId ||
|
||||
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@ import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -100,14 +101,15 @@ export const FollowUpModal = ({
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
const emailSendToOptions: EmailSendToOption[] = useMemo(() => {
|
||||
const { questions } = localSurvey;
|
||||
// Derive questions from blocks
|
||||
const questions = localSurvey.blocks.flatMap((block) => block.elements);
|
||||
|
||||
const openTextAndContactQuestions = questions.filter((question) => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
if (question.type === TSurveyElementTypeEnum.ContactInfo) {
|
||||
return question.email.show;
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
if (question.type === TSurveyElementTypeEnum.OpenText) {
|
||||
if (question.inputType === "email") {
|
||||
return true;
|
||||
}
|
||||
@@ -145,7 +147,7 @@ export const FollowUpModal = ({
|
||||
],
|
||||
id: question.id,
|
||||
type:
|
||||
question.type === TSurveyQuestionTypeEnum.OpenText
|
||||
question.type === TSurveyElementTypeEnum.OpenText
|
||||
? "openTextQuestion"
|
||||
: ("contactInfoQuestion" as EmailSendToOption["type"]),
|
||||
})),
|
||||
|
||||
@@ -27,7 +27,9 @@ export const TemplateContainerWithPreview = ({
|
||||
const { t } = useTranslation();
|
||||
const initialTemplate = customSurveyTemplate(t);
|
||||
const [activeTemplate, setActiveTemplate] = useState<TTemplate>(initialTemplate);
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string>(initialTemplate.preset.questions[0].id);
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string>(
|
||||
initialTemplate.preset.questions[0]?.id || initialTemplate.preset.blocks[0]?.elements[0]?.id || ""
|
||||
);
|
||||
const [templateSearch, setTemplateSearch] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
@@ -56,7 +58,9 @@ export const TemplateContainerWithPreview = ({
|
||||
userId={userId}
|
||||
templateSearch={templateSearch ?? ""}
|
||||
onTemplateClick={(template) => {
|
||||
setActiveQuestionId(template.preset.questions[0].id);
|
||||
setActiveQuestionId(
|
||||
template.preset.questions[0]?.id || template.preset.blocks[0]?.elements[0]?.id || ""
|
||||
);
|
||||
setActiveTemplate(template);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TActionCalculate, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import {
|
||||
TActionCalculate,
|
||||
TSurveyLogicAction,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyVariable,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
|
||||
const getVariableValue = (
|
||||
@@ -51,7 +46,7 @@ export const evaluateLogic = (
|
||||
|
||||
export const performActions = (
|
||||
survey: TJsEnvironmentStateSurvey,
|
||||
actions: TSurveyLogicAction[],
|
||||
actions: TSurveyBlockLogicAction[],
|
||||
data: TResponseData,
|
||||
calculationResults: TResponseVariables
|
||||
): {
|
||||
@@ -72,7 +67,7 @@ export const performActions = (
|
||||
case "requireAnswer":
|
||||
requiredQuestionIds.push(action.target);
|
||||
break;
|
||||
case "jumpToQuestion":
|
||||
case "jumpToBlock":
|
||||
if (!jumpTarget) {
|
||||
jumpTarget = action.target;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): string[] => {
|
||||
const jumpActions = findJumpToBlockActions(logic.actions);
|
||||
for (const jumpAction of jumpActions) {
|
||||
const destination = jumpAction.target;
|
||||
|
||||
// Skip if destination is not a valid block ID (it's an ending card)
|
||||
if (!blocks.find((b) => b.id === destination)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!visited[destination] && checkForCyclicLogic(destination)) {
|
||||
cyclicBlocks.add(blockId);
|
||||
recStack[blockId] = false;
|
||||
@@ -32,14 +38,18 @@ export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): string[] => {
|
||||
// Check fallback logic
|
||||
if (block?.logicFallback) {
|
||||
const fallbackBlockId = block.logicFallback;
|
||||
if (!visited[fallbackBlockId] && checkForCyclicLogic(fallbackBlockId)) {
|
||||
cyclicBlocks.add(blockId);
|
||||
recStack[blockId] = false;
|
||||
return true;
|
||||
} else if (recStack[fallbackBlockId]) {
|
||||
cyclicBlocks.add(blockId);
|
||||
recStack[blockId] = false;
|
||||
return true;
|
||||
|
||||
// Skip if fallback is not a valid block (it's an ending card)
|
||||
if (blocks.find((b) => b.id === fallbackBlockId)) {
|
||||
if (!visited[fallbackBlockId] && checkForCyclicLogic(fallbackBlockId)) {
|
||||
cyclicBlocks.add(blockId);
|
||||
recStack[blockId] = false;
|
||||
return true;
|
||||
} else if (recStack[fallbackBlockId]) {
|
||||
cyclicBlocks.add(blockId);
|
||||
recStack[blockId] = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ export const ZActionCalculate = z.union([ZActionCalculateText, ZActionCalculateN
|
||||
|
||||
export type TActionCalculate = z.infer<typeof ZActionCalculate>;
|
||||
|
||||
export type TSurveyBlockLogicActionObjective = "calculate" | "requireAnswer" | "jumpToBlock";
|
||||
|
||||
// RequireAnswer action - targets element IDs
|
||||
|
||||
export const ZActionRequireAnswer = z.object({
|
||||
@@ -80,15 +82,12 @@ export const ZSurveyBlockLogicAction = z.union([ZActionCalculate, ZActionRequire
|
||||
|
||||
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,
|
||||
actions: z.array(ZSurveyBlockLogicAction),
|
||||
});
|
||||
|
||||
export type TSurveyBlockLogic = z.infer<typeof ZSurveyBlockLogic>;
|
||||
|
||||
@@ -658,9 +658,8 @@ export const ZSurvey = z
|
||||
recontactDays: z.number().nullable(),
|
||||
displayLimit: z.number().nullable(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
questions: ZSurveyQuestions.min(1, {
|
||||
message: "Survey must have at least one question",
|
||||
}).superRefine((questions, ctx) => {
|
||||
// TODO: Remove this once blocks are the single source of truth
|
||||
questions: ZSurveyQuestions.default([]).superRefine((questions, ctx) => {
|
||||
const questionIds = questions.map((q) => q.id);
|
||||
const uniqueQuestionIds = new Set(questionIds);
|
||||
if (uniqueQuestionIds.size !== questionIds.length) {
|
||||
@@ -742,14 +741,14 @@ export const ZSurvey = z
|
||||
.superRefine((survey, ctx) => {
|
||||
const { questions, blocks, languages, welcomeCard, endings, isBackButtonHidden } = survey;
|
||||
|
||||
// Validate: must have questions OR blocks, not both
|
||||
// Validate: must have questions OR blocks with elements, not both
|
||||
const hasQuestions = questions.length > 0;
|
||||
const hasBlocks = blocks.length > 0;
|
||||
const hasBlocks = blocks.length > 0 && blocks.some((b) => b.elements.length > 0);
|
||||
|
||||
if (!hasQuestions && !hasBlocks) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Survey must have either questions or blocks",
|
||||
message: "Survey must have either questions or blocks with elements",
|
||||
path: ["questions"],
|
||||
});
|
||||
}
|
||||
@@ -1599,11 +1598,13 @@ export const ZSurvey = z
|
||||
if (blocksWithCyclicLogic.length > 0) {
|
||||
blocksWithCyclicLogic.forEach((blockId) => {
|
||||
const blockIndex = blocks.findIndex((b) => b.id === blockId);
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Cyclic logic detected in block ${String(blockIndex + 1)} (${blocks[blockIndex].name}).`,
|
||||
path: ["blocks", blockIndex, "logic"],
|
||||
});
|
||||
if (blockIndex !== -1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Cyclic logic detected in block ${String(blockIndex + 1)} (${blocks[blockIndex].name}).`,
|
||||
path: ["blocks", blockIndex, "logic"],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3496,8 +3497,8 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType())
|
||||
})
|
||||
.extend({
|
||||
name: z.string(), // Keep name required
|
||||
questions: ZSurvey.innerType().shape.questions, // Keep questions required and with its original validation
|
||||
blocks: ZSurveyBlocks.default([]),
|
||||
questions: ZSurvey.innerType().shape.questions,
|
||||
blocks: ZSurvey.innerType().shape.blocks,
|
||||
languages: z.array(ZSurveyLanguage).default([]),
|
||||
welcomeCard: ZSurveyWelcomeCard.default({
|
||||
enabled: false,
|
||||
@@ -3522,8 +3523,8 @@ export const ZSurveyCreateInputWithEnvironmentId = makeSchemaOptional(ZSurvey.in
|
||||
.extend({
|
||||
name: z.string(), // Keep name required
|
||||
environmentId: z.string(),
|
||||
questions: ZSurvey.innerType().shape.questions, // Keep questions required and with its original validation
|
||||
blocks: ZSurveyBlocks.default([]),
|
||||
questions: ZSurvey.innerType().shape.questions,
|
||||
blocks: ZSurvey.innerType().shape.blocks,
|
||||
languages: z.array(ZSurveyLanguage).default([]),
|
||||
welcomeCard: ZSurveyWelcomeCard.default({
|
||||
enabled: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { ZProjectConfigChannel, ZProjectConfigIndustry } from "./project";
|
||||
import { ZSurveyBlocks } from "./surveys/blocks";
|
||||
import {
|
||||
ZSurveyEndings,
|
||||
ZSurveyHiddenFields,
|
||||
@@ -27,7 +28,8 @@ export const ZTemplate = z.object({
|
||||
preset: z.object({
|
||||
name: z.string(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
questions: ZSurveyQuestions,
|
||||
blocks: ZSurveyBlocks.default([]),
|
||||
questions: ZSurveyQuestions.default([]),
|
||||
endings: ZSurveyEndings,
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user