feat: migrate all templates from questions to blocks structure

This commit is contained in:
Matti Nannt
2025-11-10 16:44:55 +01:00
parent 4277a9dc34
commit 7918523957
5 changed files with 3483 additions and 2007 deletions

View File

@@ -0,0 +1,308 @@
import { createId } from "@paralleldrive/cuid2";
import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TShuffleOption } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceElement = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
shuffleOption,
required,
containsOther = false,
}: {
id?: string;
headline: string;
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
shuffleOption?: TShuffleOption;
required?: boolean;
containsOther?: boolean;
}): TSurveyMultipleChoiceElement => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
let choiceId: string;
if (containsOther && isLastIndex) {
choiceId = "other";
} else if (choiceIds) {
choiceId = choiceIds[index];
} else {
choiceId = createId();
}
return { id: choiceId, label: createI18nString(choice, []) };
}),
shuffleOption: shuffleOption || "none",
required: required ?? false,
};
};
export const buildOpenTextElement = ({
id,
headline,
subheader,
placeholder,
inputType,
required,
longAnswer,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
required?: boolean;
inputType: TSurveyOpenTextElementInputType;
longAnswer?: boolean;
}): TSurveyOpenTextElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
longAnswer,
charLimit: {
enabled: false,
},
};
};
export const buildRatingElement = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
range: TSurveyRatingElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
export const buildConsentElement = ({
id,
headline,
subheader,
label,
required,
}: {
id?: string;
headline: string;
subheader: string;
required?: boolean;
label: string;
}): TSurveyConsentElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
required: required ?? false,
label: createI18nString(label, []),
};
};
export const buildCTAElement = ({
id,
headline,
subheader,
buttonExternal,
required,
dismissButtonLabel,
buttonUrl,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
required?: boolean;
dismissButtonLabel?: string;
buttonUrl?: string;
}): TSurveyCTAElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.CTA,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,
buttonUrl,
};
};
export const buildNPSElement = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
subheader?: string;
lowerLabel?: string;
upperLabel?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyNPSElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
targetBlockId: string,
operator: "isSkipped" | "isSubmitted" | "isClicked"
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: operator,
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Helper function to create block-level jump logic based on choice selection
export const createBlockChoiceJumpLogic = (
sourceElementId: string,
choiceId: string | number,
targetBlockId: string
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: "equals",
rightOperand: {
type: "static",
value: choiceId,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Block builder function
export const buildBlock = ({
id,
name,
elements,
logic,
logicFallback,
buttonLabel,
backButtonLabel,
t,
}: {
id?: string;
name: string;
elements: TSurveyElement[];
logic?: TSurveyBlockLogic[];
logicFallback?: string;
buttonLabel?: string;
backButtonLabel?: string;
t: TFunction;
}): TSurveyBlock => {
return {
id: id ?? createId(),
name,
elements,
logic,
logicFallback,
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
};
};

View File

@@ -1,6 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import {
import type { TFunction } from "i18next";
import type { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import type {
TShuffleOption,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
@@ -14,11 +15,11 @@ import {
TSurveyOpenTextQuestion,
TSurveyOpenTextQuestionInputType,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
@@ -63,8 +64,15 @@ export const buildMultipleChoiceQuestion = ({
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
return { id, label: createI18nString(choice, []) };
let choiceId: string;
if (containsOther && isLastIndex) {
choiceId = "other";
} else if (choiceIds) {
choiceId = choiceIds[index];
} else {
choiceId = createId();
}
return { id: choiceId, label: createI18nString(choice, []) };
}),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
@@ -378,12 +386,13 @@ export const getDefaultSurveyPreset = (t: TFunction): TTemplate["preset"] => {
endings: [getDefaultEndingCard([], t)],
hiddenFields: hiddenFieldsDefault,
questions: [],
blocks: [],
};
};
/**
* Generic builder for survey.
* @param config - The configuration for survey settings and questions.
* @param config - The configuration for survey settings and questions/blocks.
* @param t - The translation function.
*/
export const buildSurvey = (
@@ -393,7 +402,8 @@ export const buildSurvey = (
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
questions?: TSurveyQuestion[];
blocks?: TSurveyBlock[];
endings?: TSurveyEnding[];
hiddenFields?: TSurveyHiddenFields;
},
@@ -409,7 +419,8 @@ export const buildSurvey = (
preset: {
...localSurvey,
name: config.name,
questions: config.questions,
blocks: config.blocks ?? [],
questions: config.questions ?? [],
endings: config.endings ?? localSurvey.endings,
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
},

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import {
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import {
TActionCalculate,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
@@ -626,7 +627,7 @@ const getRightOperandValue = (
export const performActions = (
survey: TJsEnvironmentStateSurvey,
actions: TSurveyBlockLogicAction[],
actions: TSurveyBlockLogicAction[] | TSurveyLogicAction[],
data: TResponseData,
calculationResults: TResponseVariables
): {
@@ -648,6 +649,8 @@ export const performActions = (
requiredQuestionIds.push(action.target);
break;
case "jumpToBlock":
case "jumpToQuestion":
// Backward compatibility: handle old question-level logic (jumpToQuestion)
if (!jumpTarget) {
jumpTarget = action.target;
}

View File

@@ -1,6 +1,7 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import type { TProject } from "@formbricks/types/project";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TSurveyQuestion } from "@formbricks/types/surveys/types";
import type { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -26,12 +27,50 @@ export const replaceQuestionPresetPlaceholders = (
return newQuestion;
};
export const replaceElementPresetPlaceholders = (
element: TSurveyElement,
project: TProject
): TSurveyElement => {
if (!project) return element;
const newElement = structuredClone(element);
const defaultLanguageCode = "default";
if (newElement.headline) {
newElement.headline[defaultLanguageCode] = getLocalizedValue(
newElement.headline,
defaultLanguageCode
).replace("$[projectName]", project.name);
}
if (newElement.subheader) {
newElement.subheader[defaultLanguageCode] = getLocalizedValue(
newElement.subheader,
defaultLanguageCode
)?.replace("$[projectName]", project.name);
}
return newElement;
};
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TTemplate, project: any) => {
const preset = structuredClone(template.preset);
preset.name = preset.name.replace("$[projectName]", project.name);
preset.questions = preset.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
// Handle blocks if present
if (preset.blocks && preset.blocks.length > 0) {
preset.blocks = preset.blocks.map((block) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
}
// Handle questions for backward compatibility
if (preset.questions && preset.questions.length > 0) {
preset.questions = preset.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
}
return { ...template, preset };
};